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
KeyRound ,
Layers ,
2026-05-19 22:42:38 +08:00
Loader2 ,
LogOut ,
2026-05-08 15:31:52 +00:00
Moon ,
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 ,
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-04-25 18:05:06 +00:00
import { Input } from "@/components/ui/input" ;
2026-05-09 06:48:26 +00:00
import {
fetchSettings ,
2026-05-19 22:42:38 +08:00
updateImageGenerationSettings ,
2026-05-09 06:48:26 +00:00
updateProviderSettings ,
updateSettings ,
updateWebSearchSettings ,
} from "@/lib/api" ;
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 {
ImageGenerationSettingsUpdate ,
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"
| "providers"
| "image"
| "web"
| "runtime"
| "advanced" ;
type LocalDensity = "comfortable" | "compact" ;
type LocalActivityMode = "auto" | "expanded" ;
interface LocalPreferences {
density : LocalDensity ;
activityMode : LocalActivityMode ;
codeWrap : boolean ;
}
interface AgentSettingsDraft {
model : string ;
provider : string ;
modelPreset : string ;
timezone : string ;
botName : string ;
botIcon : string ;
toolHintMaxLength : number ;
}
type PendingRestartSection = "runtime" | "web" | "image" ;
type PendingRestartSections = Record < PendingRestartSection , boolean >;
const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences" ;
const DEFAULT_LOCAL_PREFS : LocalPreferences = {
density : "comfortable" ,
activityMode : "auto" ,
codeWrap : true ,
};
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-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 ,
};
} 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 );
const [ loading , setLoading ] = useState ( true );
const [ saving , setSaving ] = useState ( false );
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-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-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 ]);
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-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-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 }
onSelectSection = { setActiveSection }
/>
);
case "appearance" :
return (
< AppearanceSettings
theme = { theme }
onToggleTheme = { onToggleTheme }
localPrefs = { localPrefs }
onChangeLocalPrefs = { setLocalPrefs }
/>
);
case "models" :
return (
< ModelsSettings
form = { form }
setForm = { setForm }
settings = { settings }
dirty = { modelDirty }
saving = { saving }
onSave = { saveModelSettings }
onOpenProviders = {() => setActiveSection ( "providers" )}
/>
);
case "providers" :
return (
< ProvidersSettings
settings = { settings }
expandedProvider = { expandedProvider }
providerForms = { providerForms }
visibleProviderKeys = { visibleProviderKeys }
editingProviderKeys = { editingProviderKeys }
providerSaving = { providerSaving }
query = { providerQuery }
onQueryChange = { setProviderQuery }
onToggleProvider = { handleToggleProvider }
onToggleProviderKey = { toggleProviderKeyVisibility }
onToggleProviderKeyEditing = { toggleProviderKeyEditing }
onChangeProviderForm = {( provider , value ) =>
setProviderForms (( prev ) => ({
... prev ,
[ provider ] : {
apiKey : prev [ provider ] ? . apiKey ?? "" ,
apiBase : prev [ provider ] ? . apiBase ?? "" ,
... value ,
},
}))
}
onSaveProvider = { saveProvider }
onResetProviderDraft = { resetProviderDraft }
imageProviderRestartPending = { pendingRestartSections . image }
onRestart = { onRestart }
isRestarting = { isRestarting }
/>
);
case "image" :
return (
< ImageGenerationSettings
settings = { settings }
form = { imageGenerationForm }
dirty = { imageGenerationDirty }
saving = { imageGenerationSaving }
onChangeForm = { setImageGenerationForm }
onSave = { saveImageGenerationSettings }
onOpenProviders = {() => setActiveSection ( "providers" )}
onRestart = { onRestart }
isRestarting = { isRestarting }
requiresRestartPending = { pendingRestartSections . image }
/>
);
case "web" :
return (
< WebSettings
settings = { settings }
form = { webSearchForm }
keyVisible = { webSearchKeyVisible }
keyEditing = { webSearchKeyEditing }
saving = { webSearchSaving }
onChangeForm = { setWebSearchForm }
onChangeProvider = { handleWebSearchProviderChange }
onToggleKey = {() => setWebSearchKeyVisible (( visible ) => ! visible )}
onToggleKeyEditing = {() => {
setWebSearchKeyEditing (( editing ) => ! editing );
setWebSearchKeyVisible ( false );
setWebSearchForm (( prev ) => ({ ... prev , apiKey : "" }));
}}
onReset = { resetWebSearchDraft }
onSave = { saveWebSearch }
onRestart = { onRestart }
isRestarting = { isRestarting }
requiresRestartPending = { pendingRestartSections . web }
/>
);
case "runtime" :
return (
< RuntimeSettings
form = { form }
setForm = { setForm }
settings = { settings }
dirty = { runtimeDirty }
saving = { saving }
onSave = { saveRuntimeSettings }
onRestart = { onRestart }
isRestarting = { isRestarting }
requiresRestartPending = { pendingRestartSections . runtime }
/>
);
case "advanced" :
return < AdvancedSettings settings = { settings } />;
default :
return null ;
}
};
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-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 : "providers" , icon : KeyRound , fallback : "Providers" },
{ key : "image" , icon : ImageIcon , fallback : "Image" },
{ key : "web" , icon : Globe2 , fallback : "Web" },
{ key : "runtime" , icon : Server , fallback : "Runtime" },
{ key : "advanced" , icon : ShieldCheck , fallback : "Advanced" },
];
function titleForSection ( section : SettingsSectionKey ) : string {
return SETTINGS_NAV_ITEMS . find (( item ) => item . key === section ) ? . fallback ?? "Settings" ;
}
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 ,
} : {
settings : SettingsPayload ;
requiresRestart : boolean ;
onRestart ?: () => void ;
isRestarting? : boolean ;
onSelectSection : ( section : SettingsSectionKey ) => void ;
}) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
const configuredCount = settings . providers . filter (( provider ) => provider . configured ). length ;
const activePreset = settings . agent . model_preset || "default" ;
const activeProvider = settings . agent . resolved_provider ?? settings . agent . provider ;
const webStatus = settings . web . enable
? tx ( "settings.values.enabled" , "Enabled" )
: tx ( "settings.values.disabled" , "Disabled" );
const imageStatus = settings . image_generation . enabled
? tx ( "settings.values.enabled" , "Enabled" )
: tx ( "settings.values.disabled" , "Disabled" );
const imageCaption = ` ${ providerLabel ( settings . image_generation . providers , settings . image_generation . provider ) } · ${
settings . image_generation . provider_configured
? tx ( "settings.values.configured" , "Configured" )
: tx ( "settings.values.notConfigured" , "Not configured" )
} ` ;
return (
< div className = "space-y-7" >
< section >
< div className = "overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.075)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.24)]" >
< div className = "flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between" >
< div className = "flex min-w-0 items-center gap-3" >
< span className = "grid h-12 w-12 shrink-0 place-items-center rounded-[16px] bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70" >
< Bot className = "h-6 w-6" aria - hidden />
</ span >
< div className = "min-w-0" >
< div className = "text-[12px] font-medium text-muted-foreground" > nanobot </ div >
< div className = "mt-0.5 truncate text-[18px] font-semibold leading-6 text-foreground" >
{ settings . agent . model }
</ div >
< div className = "mt-0.5 truncate text-[13px] leading-5 text-muted-foreground" >
{ activeProvider } · { activePreset }
</ div >
</ div >
</ div >
< div className = "flex flex-wrap items-center gap-2 sm:justify-end" >
< StatusPill tone = { requiresRestart ? "neutral" : "success" }>
{ requiresRestart
? tx ( "settings.values.restartPending" , "Restart pending" )
: tx ( "settings.values.ready" , "Ready" )}
</ StatusPill >
{ requiresRestart && onRestart ? (
< Button
size = "sm"
variant = "ghost"
onClick = { onRestart }
disabled = { isRestarting }
className = "rounded-full"
>
{ isRestarting ? (
< Loader2 className = "mr-1.5 h-3.5 w-3.5 animate-spin" aria - hidden />
) : (
< RotateCcw className = "mr-1.5 h-3.5 w-3.5" aria - hidden />
)}
{ isRestarting ? t ( "app.system.restarting" ) : t ( "app.system.restart" )}
</ Button >
) : null }
</ div >
</ div >
</ div >
</ section >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.ai" , "AI" )}</ SettingsSectionTitle >
< SettingsGroup >
< OverviewListRow
icon = { Bot }
title = { tx ( "settings.overview.model" , "Current model" )}
value = { settings . agent . model }
caption = { ` ${ activeProvider } · ${ activePreset } ` }
onClick = {() => onSelectSection ( "models" )}
/>
< OverviewListRow
icon = { KeyRound }
title = { tx ( "settings.overview.providers" , "Providers" )}
value = { tx ( "settings.overview.configuredCount" , "{{count}} configured" ). replace (
"{{count}}" ,
String ( configuredCount ),
)}
caption = { tx ( "settings.overview.totalProviders" , "{{count}} available" ). replace (
"{{count}}" ,
String ( settings . providers . length ),
)}
onClick = {() => onSelectSection ( "providers" )}
/>
</ SettingsGroup >
</ section >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.capabilities" , "Capabilities" )}</ SettingsSectionTitle >
< SettingsGroup >
< OverviewListRow
icon = { Globe2 }
title = { tx ( "settings.overview.webSearch" , "Web search" )}
value = { providerLabel ( settings . web_search . providers , settings . web_search . provider )}
caption = { webStatus }
onClick = {() => onSelectSection ( "web" )}
/>
< OverviewListRow
icon = { ImageIcon }
title = { tx ( "settings.overview.imageGeneration" , "Image generation" )}
value = { imageStatus }
caption = { imageCaption }
onClick = {() => onSelectSection ( "image" )}
/>
</ SettingsGroup >
</ section >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.system" , "System" )}</ SettingsSectionTitle >
< SettingsGroup >
< OverviewListRow
icon = { Server }
title = { tx ( "settings.rows.gateway" , "Gateway" )}
value = { ` ${ settings . runtime . gateway_host } : ${ settings . runtime . gateway_port } ` }
caption = {
requiresRestart
? tx ( "settings.values.restartPending" , "Restart pending" )
: tx ( "settings.values.ready" , "Ready" )
}
onClick = {() => onSelectSection ( "runtime" )}
/>
< OverviewListRow
icon = { HardDrive }
title = { tx ( "settings.overview.workspace" , "Workspace" )}
value = { settings . runtime . workspace_path }
caption = { settings . runtime . config_path }
onClick = {() => onSelectSection ( "runtime" )}
/>
</ SettingsGroup >
</ section >
</ div >
);
}
function AppearanceSettings ({
theme ,
onToggleTheme ,
localPrefs ,
onChangeLocalPrefs ,
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 }))}
label = { localPrefs . codeWrap ? tx ( "settings.values.on" , "On" ) : tx ( "settings.values.off" , "Off" )}
/>
</ SettingsRow >
</ SettingsGroup >
</ section >
</ div >
);
}
function ModelsSettings ({
form ,
setForm ,
settings ,
dirty ,
saving ,
onSave ,
onOpenProviders ,
} : {
form : AgentSettingsDraft ;
setForm : Dispatch < SetStateAction < AgentSettingsDraft >>;
settings : SettingsPayload ;
dirty : boolean ;
saving : boolean ;
onSave : () => void ;
onOpenProviders : () => void ;
}) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
const configuredProviders = settings . providers . filter (( provider ) => provider . configured );
const showAutoProvider = defaultPreset ( settings ) ? . provider === "auto" || form . provider === "auto" ;
const providerOptions = showAutoProvider
? [{ name : "auto" , label : tx ( "settings.values.auto" , "Auto" ) }, ... configuredProviders ]
: configuredProviders ;
const providerValue = providerOptions . some (( provider ) => provider . name === form . provider )
? form . provider
: "" ;
const selectedPreset = settings . model_presets . find (( preset ) => preset . name === form . modelPreset );
return (
< div className = "space-y-7" >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.presets" , "Presets" )}</ SettingsSectionTitle >
< div className = "grid gap-3 sm:grid-cols-2" >
{ settings . model_presets . map (( preset ) => (
< button
key = { preset . name }
type = "button"
onClick = {() => setForm (( prev ) => ({ ... prev , modelPreset : preset.name }))}
className = { cn (
"rounded-[22px] border px-4 py-4 text-left transition-colors" ,
form . modelPreset === preset . name
? "border-primary/35 bg-primary/5 text-foreground"
: "border-border/45 bg-card/82 hover:bg-muted/30" ,
)}
>
< span className = "flex items-center justify-between gap-2" >
< span className = "truncate text-[15px] font-semibold" >{ preset . label }</ span >
{ form . modelPreset === preset . name ? < Check className = "h-4 w-4" aria - hidden /> : null }
</ span >
< span className = "mt-2 block truncate text-[12px] text-muted-foreground" >{ preset . model }</ span >
< span className = "mt-1 block text-[12px] text-muted-foreground" >
{ preset . provider } · { preset . max_tokens } tokens
</ span >
</ button >
))}
</ div >
</ section >
2026-05-08 15:31:52 +00:00
< section >
< SettingsSectionTitle >{ t ( "settings.sections.ai" )}</ SettingsSectionTitle >
< SettingsGroup >
< SettingsRow
2026-05-19 22:42:38 +08:00
title = { tx ( "settings.rows.selectedPreset" , "Selected preset" )}
description = { tx ( "settings.help.selectedPreset" , "Named presets are read-only here; edit them in config.json." )}
2026-05-08 15:31:52 +00:00
>
2026-05-19 22:42:38 +08:00
< StatusPill >{ selectedPreset ? . label ?? form . modelPreset }</ StatusPill >
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" )}
onChange = {( provider ) => setForm (( prev ) => ({ ... prev , provider }))}
/>
</ SettingsRow >
< SettingsRow
title = { t ( "settings.rows.model" )}
description = { t ( "settings.help.model" )}
>
< Input
value = { form . model }
onChange = {( event ) => setForm (( prev ) => ({ ... prev , model : event.target.value }))}
className = "h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
/>
</ SettingsRow >
</>
) : (
< SettingsRow
title = { tx ( "settings.rows.presetModel" , "Preset model" )}
description = { tx ( "settings.help.presetModel" , "Switch to Default to edit model and provider from the WebUI." )}
>
< span className = "max-w-[280px] truncate text-right text-[13px] text-muted-foreground" >
{ selectedPreset ? . model ?? settings . agent . model }
</ span >
</ SettingsRow >
)}
< SettingsFooter
dirty = { dirty }
saving = { saving }
saved = { false }
onSave = { onSave }
/>
2026-05-08 15:31:52 +00:00
{ configuredProviders . length === 0 ? (
< SettingsRow title = { t ( "settings.byok.configureFirst" )}>
2026-05-19 22:42:38 +08:00
< Button size = "sm" variant = "outline" onClick = { onOpenProviders } className = "rounded-full" >
2026-05-08 15:31:52 +00:00
{ t ( "settings.byok.openByok" )}
</ Button >
</ SettingsRow >
) : null }
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 ,
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 ;
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" >
< ProviderIcon provider = { provider . name } />
< 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"
>
{ saving ? t ( "settings.actions.saving" ) : t ( "settings.actions.save" )}
</ Button >
</ div >
</ div >
) : null }
</ div >
);
};
return (
< div className = "space-y-6" >
< p className = "max-w-[42rem] text-[13px] leading-6 text-muted-foreground" >
{ t ( "settings.byok.description" )}
</ p >
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 >
</ div >
);
}
function ImageGenerationSettings ({
settings ,
form ,
dirty ,
saving ,
onChangeForm ,
onSave ,
onOpenProviders ,
onRestart ,
isRestarting ,
requiresRestartPending ,
} : {
settings : SettingsPayload ;
form : ImageGenerationSettingsUpdate ;
dirty : boolean ;
saving : boolean ;
onChangeForm : Dispatch < SetStateAction < ImageGenerationSettingsUpdate >>;
onSave : () => void ;
onOpenProviders : () => void ;
onRestart ?: () => void ;
isRestarting? : boolean ;
requiresRestartPending : boolean ;
}) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
const selectedProvider =
settings . image_generation . providers . find (( provider ) => provider . name === form . provider ) ??
settings . image_generation . providers [ 0 ];
const providerConfigured = !! selectedProvider ? . configured ;
const missingCredential = form . enabled && ! providerConfigured ;
const aspectOptions = optionRowsWithCurrent (
IMAGE_ASPECT_RATIO_OPTIONS . map (( value ) => ({ name : value , label : value })),
form . defaultAspectRatio ,
);
const sizeOptions = optionRowsWithCurrent (
IMAGE_SIZE_OPTIONS . map (( value ) => ({ name : value , label : value })),
form . defaultImageSize ,
);
return (
< div className = "space-y-7" >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.imageGeneration" , "Image generation" )}</ SettingsSectionTitle >
< SettingsGroup >
< SettingsRow
title = { tx ( "settings.rows.imageGeneration" , "Image generation" )}
description = { tx ( "settings.help.imageGeneration" , "Expose generate_image in chats when a configured image provider is available." )}
>
< ToggleButton
checked = { form . enabled }
onChange = {( enabled ) => onChangeForm (( prev ) => ({ ... prev , enabled }))}
label = { form . enabled ? tx ( "settings.values.on" , "On" ) : tx ( "settings.values.off" , "Off" )}
/>
</ SettingsRow >
< SettingsRow
title = { tx ( "settings.rows.imageProvider" , "Image provider" )}
description = { tx ( "settings.help.imageProvider" , "Choose the registry provider used by generate_image." )}
>
< ProviderPicker
providers = { settings . image_generation . providers }
value = { form . provider }
emptyLabel = { tx ( "settings.image.selectProvider" , "Select provider" )}
onChange = {( provider ) => onChangeForm (( prev ) => ({ ... prev , provider }))}
/>
</ SettingsRow >
< SettingsRow
title = { tx ( "settings.rows.imageProviderStatus" , "Provider status" )}
description = { tx ( "settings.help.imageProviderStatus" , "Image generation reuses provider credentials from Providers." )}
>
< div className = "flex flex-wrap items-center justify-end gap-2" >
< StatusPill tone = { providerConfigured ? "success" : "neutral" }>
{ providerConfigured
? tx ( "settings.values.configured" , "Configured" )
: tx ( "settings.values.notConfigured" , "Not configured" )}
</ StatusPill >
{ ! providerConfigured ? (
< Button size = "sm" variant = "outline" onClick = { onOpenProviders } className = "rounded-full" >
{ tx ( "settings.image.configureProvider" , "Configure provider" )}
</ Button >
) : null }
</ div >
</ SettingsRow >
< SettingsRow title = { tx ( "settings.rows.imageProviderBase" , "Provider base" )}>
< span className = "max-w-[320px] truncate text-right text-[13px] text-muted-foreground" >
{ selectedProvider ? . api_base || selectedProvider ? . default_api_base || selectedProvider ? . name || tx ( "settings.values.notAvailable" , "Not available" )}
</ span >
</ SettingsRow >
</ SettingsGroup >
</ section >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.imageDefaults" , "Defaults" )}</ SettingsSectionTitle >
< SettingsGroup >
< SettingsRow
title = { tx ( "settings.rows.imageModel" , "Image model" )}
description = { tx ( "settings.help.imageModel" , "Model name sent to the selected image provider." )}
>
< Input
value = { form . model }
onChange = {( event ) => onChangeForm (( prev ) => ({ ... prev , model : event.target.value }))}
className = "h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
/>
</ SettingsRow >
< SettingsRow
title = { tx ( "settings.rows.defaultAspectRatio" , "Default aspect" )}
description = { tx ( "settings.help.defaultAspectRatio" , "Used when the prompt does not choose an aspect ratio." )}
>
< ProviderPicker
providers = { aspectOptions }
value = { form . defaultAspectRatio }
emptyLabel = { tx ( "settings.image.selectAspect" , "Select aspect" )}
onChange = {( defaultAspectRatio ) =>
onChangeForm (( prev ) => ({ ... prev , defaultAspectRatio }))
}
/>
</ SettingsRow >
< SettingsRow
title = { tx ( "settings.rows.defaultImageSize" , "Default size" )}
description = { tx ( "settings.help.defaultImageSize" , "Size hint sent to providers that support it." )}
>
< ProviderPicker
providers = { sizeOptions }
value = { form . defaultImageSize }
emptyLabel = { tx ( "settings.image.selectSize" , "Select size" )}
onChange = {( defaultImageSize ) =>
onChangeForm (( prev ) => ({ ... prev , defaultImageSize }))
}
/>
</ SettingsRow >
< SettingsRow
title = { tx ( "settings.rows.maxImagesPerTurn" , "Max images per turn" )}
description = { tx ( "settings.help.maxImagesPerTurn" , "Upper bound for one generate_image request." )}
>
< NumberInput
value = { form . maxImagesPerTurn }
min = { 1 }
max = { 8 }
onChange = {( maxImagesPerTurn ) =>
onChangeForm (( prev ) => ({ ... prev , maxImagesPerTurn }))
}
/>
</ SettingsRow >
< ReadOnlyRow title = { tx ( "settings.rows.imageSaveDir" , "Save directory" )} value = { settings . image_generation . save_dir } />
< RestartSettingsFooter
dirty = { dirty }
saving = { saving }
pendingRestart = { requiresRestartPending }
disabled = { missingCredential }
message = {
missingCredential
? tx ( "settings.image.missingCredential" , "Configure this provider before enabling image generation." )
: undefined
}
dirtyMessage = { tx ( "settings.status.restartAfterSaving" , "Save changes, then restart when ready." )}
pendingMessage = { tx ( "settings.status.savedRestartApply" , "Saved. Restart when ready." )}
onSave = { onSave }
onRestart = { onRestart }
isRestarting = { isRestarting }
/>
</ SettingsGroup >
</ section >
</ div >
);
}
function WebSettings ({
settings ,
form ,
keyVisible ,
keyEditing ,
saving ,
onChangeForm ,
onChangeProvider ,
onToggleKey ,
onToggleKeyEditing ,
onReset ,
onSave ,
onRestart ,
isRestarting ,
requiresRestartPending ,
} : {
settings : SettingsPayload ;
form : WebSearchSettingsUpdate ;
keyVisible : boolean ;
keyEditing : boolean ;
saving : boolean ;
onChangeForm : Dispatch < SetStateAction < WebSearchSettingsUpdate >>;
onChangeProvider : ( provider : string ) => void ;
onToggleKey : () => void ;
onToggleKeyEditing : () => void ;
onReset : () => void ;
onSave : () => void ;
onRestart ?: () => void ;
isRestarting? : boolean ;
requiresRestartPending : boolean ;
}) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
const selectedProvider =
settings . web_search . providers . find (( provider ) => provider . name === form . provider ) ??
settings . web_search . providers [ 0 ];
const hasExistingSecret =
selectedProvider ? . credential === "api_key" &&
form . provider === settings . web_search . provider &&
!! settings . web_search . api_key_hint ;
const showKeyInput = selectedProvider ? . credential === "api_key" && ( ! hasExistingSecret || keyEditing );
const apiKey = form . apiKey ? . trim () ?? "" ;
const baseUrl = form . baseUrl ? . trim () ?? "" ;
const effectiveJinaReader = form . useJinaReader ?? settings . web . fetch . use_jina_reader ;
const dirty =
form . provider !== settings . web_search . provider ||
apiKey . length > 0 ||
baseUrl !== ( settings . web_search . base_url ?? "" ) ||
form . maxResults !== settings . web_search . max_results ||
form . timeout !== settings . web_search . timeout ||
effectiveJinaReader !== settings . web . fetch . use_jina_reader ;
const jinaReaderDirty = effectiveJinaReader !== settings . web . fetch . use_jina_reader ;
const missingCredential =
selectedProvider ? . credential === "api_key"
? ! apiKey && ! hasExistingSecret
: selectedProvider?.credential === "base_url"
? ! baseUrl
: false ;
return (
< div className = "space-y-7" >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.webSearch" , "Web search" )}</ SettingsSectionTitle >
< SettingsGroup >
< SettingsRow
title = { t ( "settings.byok.webSearch.provider" )}
description = { t ( "settings.byok.webSearch.providerHelp" )}
>
< ProviderPicker
providers = { settings . web_search . providers }
value = { form . provider }
emptyLabel = { t ( "settings.byok.webSearch.selectProvider" )}
onChange = { onChangeProvider }
/>
</ SettingsRow >
{ selectedProvider ? . credential === "none" ? (
< SettingsRow
title = { t ( "settings.byok.webSearch.credentials" )}
description = { t ( "settings.byok.webSearch.noCredentialHelp" )}
>
< StatusPill tone = "success" >{ t ( "settings.byok.webSearch.noCredentialRequired" )}</ StatusPill >
</ SettingsRow >
) : null }
{ selectedProvider ? . credential === "api_key" ? (
< SettingsRow
title = { t ( "settings.byok.apiKey" )}
description = { t ( "settings.byok.webSearch.apiKeyHelp" )}
>
< div className = "relative w-[280px] max-w-full" >
{ showKeyInput ? (
<>
< Input
type = { keyVisible ? "text" : "password" }
value = { form . apiKey ?? "" }
onChange = {( event ) =>
onChangeForm (( prev ) => ({ ... prev , apiKey : event.target.value }))
}
placeholder = {
hasExistingSecret
? t ( "settings.byok.apiKeyConfiguredPlaceholder" )
: t ( "settings.byok.apiKeyPlaceholder" )
}
className = "h-9 rounded-full pr-11 text-[13px]"
/>
< Button
type = "button"
variant = "ghost"
size = "icon"
onClick = { onToggleKey }
aria-label = {
keyVisible ? t ( "settings.byok.hideApiKey" ) : t ( "settings.byok.showApiKey" )
}
className = "absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
{ keyVisible ? (
< EyeOff className = "h-3.5 w-3.5" aria - hidden />
) : (
< Eye className = "h-3.5 w-3.5" aria - hidden />
)}
</ Button >
</>
) : (
<>
< div className = "flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground" >
{ settings . web_search . api_key_hint ?? t ( "settings.byok.configuredKeyHint" )}
</ div >
< Button
type = "button"
variant = "ghost"
size = "icon"
onClick = { onToggleKeyEditing }
aria-label = { t ( "settings.actions.edit" )}
className = "absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
< Pencil className = "h-3.5 w-3.5" aria - hidden />
</ Button >
</>
)}
</ div >
</ SettingsRow >
) : null }
{ selectedProvider ? . credential === "base_url" ? (
< SettingsRow
title = { t ( "settings.byok.webSearch.baseUrl" )}
description = { t ( "settings.byok.webSearch.baseUrlHelp" )}
>
< Input
value = { form . baseUrl ?? "" }
onChange = {( event ) =>
onChangeForm (( prev ) => ({ ... prev , baseUrl : event.target.value }))
}
placeholder = { t ( "settings.byok.webSearch.baseUrlPlaceholder" )}
className = "h-9 w-[280px] rounded-full text-[13px]"
/>
</ SettingsRow >
) : null }
</ SettingsGroup >
</ section >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.webBehavior" , "Behavior" )}</ SettingsSectionTitle >
< SettingsGroup >
< SettingsRow
title = { tx ( "settings.rows.maxResults" , "Max results" )}
description = { tx ( "settings.help.maxResults" , "Results returned by each web_search call." )}
>
< NumberInput
value = { form . maxResults ?? settings . web_search . max_results }
min = { 1 }
max = { 10 }
onChange = {( maxResults ) => onChangeForm (( prev ) => ({ ... prev , maxResults }))}
/>
</ SettingsRow >
< SettingsRow
title = { tx ( "settings.rows.timeout" , "Timeout" )}
description = { tx ( "settings.help.timeout" , "Seconds before a search provider request times out." )}
>
< NumberInput
value = { form . timeout ?? settings . web_search . timeout }
min = { 1 }
max = { 120 }
onChange = {( timeout ) => onChangeForm (( prev ) => ({ ... prev , timeout }))}
suffix = "s"
/>
</ SettingsRow >
< SettingsRow
title = { tx ( "settings.rows.jinaReader" , "Jina reader" )}
description = { tx ( "settings.help.jinaReader" , "Use Jina Reader for web_fetch when available." )}
>
< ToggleButton
checked = { effectiveJinaReader }
onChange = {( useJinaReader ) => onChangeForm (( prev ) => ({ ... prev , useJinaReader }))}
label = { effectiveJinaReader ? tx ( "settings.values.on" , "On" ) : tx ( "settings.values.off" , "Off" )}
/>
</ SettingsRow >
< RestartSettingsFooter
dirty = { dirty }
saving = { saving }
pendingRestart = { requiresRestartPending }
disabled = { missingCredential }
message = {
missingCredential
? t ( "settings.byok.webSearch.missingCredential" )
: requiresRestartPending && ! dirty
? tx ( "settings.status.savedRestartApply" , "Saved. Restart when ready." )
: jinaReaderDirty
? tx ( "settings.status.restartAfterSaving" , "Save changes, then restart when ready." )
: dirty
? t ( "settings.byok.webSearch.saveHint" )
: undefined
}
onSave = { onSave }
onRestart = { onRestart }
onReset = { onReset }
isRestarting = { isRestarting }
/>
</ SettingsGroup >
</ section >
</ div >
);
}
function RuntimeSettings ({
form ,
setForm ,
settings ,
dirty ,
saving ,
onSave ,
onRestart ,
isRestarting ,
requiresRestartPending ,
} : {
form : AgentSettingsDraft ;
setForm : Dispatch < SetStateAction < AgentSettingsDraft >>;
settings : SettingsPayload ;
dirty : boolean ;
saving : boolean ;
onSave : () => void ;
onRestart ?: () => void ;
isRestarting? : boolean ;
requiresRestartPending : boolean ;
}) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
return (
< div className = "space-y-7" >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.identity" , "Identity" )}</ SettingsSectionTitle >
< SettingsGroup >
< SettingsRow title = { tx ( "settings.rows.botName" , "Bot name" )} description = { tx ( "settings.help.botName" , "Shown in runtime surfaces that use the configured bot identity." )}>
< Input
value = { form . botName }
onChange = {( event ) => setForm (( prev ) => ({ ... prev , botName : event.target.value }))}
className = "h-8 w-[220px] rounded-full text-[13px]"
/>
</ SettingsRow >
< SettingsRow title = { tx ( "settings.rows.botIcon" , "Bot icon" )} description = { tx ( "settings.help.botIcon" , "Short emoji or text shown beside the bot name." )}>
< Input
value = { form . botIcon }
onChange = {( event ) => setForm (( prev ) => ({ ... prev , botIcon : event.target.value }))}
className = "h-8 w-[120px] rounded-full text-center text-[13px]"
/>
</ SettingsRow >
< SettingsRow title = { tx ( "settings.rows.timezone" , "Timezone" )} description = { tx ( "settings.help.timezone" , "IANA timezone used by runtime context and schedules." )}>
< Input
value = { form . timezone }
onChange = {( event ) => setForm (( prev ) => ({ ... prev , timezone : event.target.value }))}
className = "h-8 w-[220px] rounded-full text-[13px]"
/>
</ SettingsRow >
< SettingsRow title = { tx ( "settings.rows.toolHintMaxLength" , "Tool hint length" )} description = { tx ( "settings.help.toolHintMaxLength" , "Maximum characters shown in tool progress hints." )}>
< NumberInput
value = { form . toolHintMaxLength }
min = { 20 }
max = { 500 }
onChange = {( toolHintMaxLength ) => setForm (( prev ) => ({ ... prev , toolHintMaxLength }))}
/>
</ SettingsRow >
< RestartSettingsFooter
dirty = { dirty }
saving = { saving }
pendingRestart = { requiresRestartPending }
dirtyMessage = { tx ( "settings.status.restartAfterSaving" , "Save changes, then restart when ready." )}
pendingMessage = { tx ( "settings.status.savedRestartApply" , "Saved. Restart when ready." )}
onSave = { onSave }
onRestart = { onRestart }
isRestarting = { isRestarting }
/>
</ SettingsGroup >
</ section >
< section >
< SettingsSectionTitle >{ t ( "settings.sections.system" )}</ SettingsSectionTitle >
< SettingsGroup >
{ onRestart && ! requiresRestartPending ? (
< SettingsRow
title = { t ( "settings.rows.restart" )}
description = { t ( "app.system.restartHint" )}
>
< Button
size = "sm"
variant = "outline"
onClick = { onRestart }
disabled = { isRestarting }
className = "rounded-full"
>
{ isRestarting ? (
< Loader2 className = "mr-1.5 h-3.5 w-3.5 animate-spin" aria - hidden />
) : (
< RotateCcw className = "mr-1.5 h-3.5 w-3.5" aria - hidden />
)}
{ isRestarting ? t ( "app.system.restarting" ) : t ( "app.system.restart" )}
</ Button >
</ SettingsRow >
) : null }
< ReadOnlyRow title = { t ( "settings.rows.configPath" )} value = { settings . runtime . config_path } />
< ReadOnlyRow title = { tx ( "settings.rows.workspacePath" , "Workspace path" )} value = { settings . runtime . workspace_path } />
< ReadOnlyRow title = { tx ( "settings.rows.heartbeat" , "Heartbeat" )} value = { settings . runtime . heartbeat . enabled ? ` ${ settings . runtime . heartbeat . interval_s } s` : tx ( "settings.values.disabled" , "Disabled" )} />
< ReadOnlyRow title = { tx ( "settings.rows.dream" , "Dream" )} value = { settings . runtime . dream . schedule } />
< ReadOnlyRow title = { tx ( "settings.rows.unifiedSession" , "Unified session" )} value = { settings . runtime . unified_session ? tx ( "settings.values.enabled" , "Enabled" ) : tx ( "settings.values.disabled" , "Disabled" )} />
</ SettingsGroup >
</ section >
</ div >
);
}
function AdvancedSettings ({ settings } : { settings : SettingsPayload }) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
return (
< div className = "space-y-7" >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.safety" , "Safety" )}</ SettingsSectionTitle >
< SettingsGroup >
< ReadOnlyRow title = { tx ( "settings.rows.restrictWorkspace" , "Restrict to workspace" )} value = { settings . advanced . restrict_to_workspace ? tx ( "settings.values.enabled" , "Enabled" ) : tx ( "settings.values.disabled" , "Disabled" )} />
< ReadOnlyRow title = { tx ( "settings.rows.execTool" , "Exec tool" )} value = { settings . advanced . exec_enabled ? tx ( "settings.values.enabled" , "Enabled" ) : tx ( "settings.values.disabled" , "Disabled" )} />
< ReadOnlyRow title = { tx ( "settings.rows.execSandbox" , "Exec sandbox" )} value = { settings . advanced . exec_sandbox ?? tx ( "settings.values.notAvailable" , "Not available" )} />
< ReadOnlyRow title = { tx ( "settings.rows.ssrfWhitelist" , "SSRF whitelist" )} value = { String ( settings . advanced . ssrf_whitelist_count )} />
</ SettingsGroup >
</ section >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.integrations" , "Integrations" )}</ SettingsSectionTitle >
< SettingsGroup >
< ReadOnlyRow title = { tx ( "settings.rows.mcpServers" , "MCP servers" )} value = { String ( settings . advanced . mcp_server_count )} />
< ReadOnlyRow title = { tx ( "settings.rows.pathAppend" , "PATH append" )} value = { settings . advanced . exec_path_append_set ? tx ( "settings.values.configured" , "Configured" ) : tx ( "settings.values.notConfigured" , "Not configured" )} />
< SettingsRow
title = { tx ( "settings.rows.configurationDocs" , "Configuration docs" )}
description = { tx ( "settings.help.advancedReadOnly" , "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed." )}
>
< a
className = "inline-flex h-8 items-center rounded-full border border-input bg-background px-3 text-[13px] font-medium text-foreground shadow-sm transition-colors hover:bg-muted"
href = "https://github.com/HKUDS/nanobot/blob/main/docs/configuration.md"
target = "_blank"
rel = "noreferrer"
>
< Info className = "mr-1.5 h-3.5 w-3.5" aria - hidden />
{ tx ( "settings.actions.openDocs" , "Open docs" )}
</ a >
</ SettingsRow >
</ SettingsGroup >
</ section >
</ div >
);
}
function ProviderPicker ({
providers ,
value ,
emptyLabel ,
onChange ,
} : {
providers : Array < { name : string ; label : string } > ;
value : string ;
emptyLabel : string ;
onChange : ( provider : string ) => void ;
}) {
const selectedProvider = providers . find (( provider ) => provider . name === value ) ?? null ;
const disabled = providers . length === 0 ;
return (
< DropdownMenu >
< DropdownMenuTrigger asChild disabled = { disabled }>
< Button
type = "button"
variant = "outline"
disabled = { disabled }
className = { cn (
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none" ,
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring" ,
disabled && "text-muted-foreground" ,
)}
>
< span className = "truncate" >{ selectedProvider ? . label ?? emptyLabel }</ span >
< ChevronDown className = "ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria - hidden />
</ Button >
</ DropdownMenuTrigger >
< DropdownMenuContent
align = "end"
className = "max-h-[18rem] w-[240px] overflow-y-auto rounded-[18px] border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]"
>
{ providers . map (( provider ) => {
const selected = provider . name === value ;
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-19 22:42:38 +08:00
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-3 py-2 text-[13px]" ,
"focus:bg-muted focus:text-foreground" ,
selected && "bg-primary/10 text-primary focus:bg-primary/12 focus:text-primary" ,
2026-05-09 06:48:26 +00:00
)}
2026-05-08 15:31:52 +00:00
>
2026-05-19 22:42:38 +08:00
< span className = "truncate" >{ provider . label }</ span >
{ 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-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-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 ),
);
}
function optionRowsWithCurrent (
options : Array < { name : string ; label : string } > ,
value : string ,
) : Array < { name : string ; label : string } > {
if ( ! value || options . some (( option ) => option . name === value )) return options ;
return [{ name : value , label : value }, ... options ];
}
function providerLabel (
providers : Array < { name : string ; label : string } > ,
value : string ,
) : string {
return providers . find (( provider ) => provider . name === value ) ? . label ?? value ;
}
2026-05-08 15:31:52 +00:00
const PROVIDER_ICONS : Record < string , LucideIcon > = {
custom : Hexagon ,
openrouter : Sparkles ,
aihubmix : Triangle ,
anthropic : Brain ,
openai : Bot ,
deepseek : Waves ,
zhipu : Grid3X3 ,
dashscope : Cloud ,
moonshot : Moon ,
minimax : Zap ,
minimax_anthropic : Brain ,
groq : Cpu ,
huggingface : Layers ,
gemini : Gem ,
mistral : Orbit ,
siliconflow : Layers ,
volcengine : Cloud ,
volcengine_coding_plan : Cloud ,
byteplus : Cloud ,
byteplus_coding_plan : Cloud ,
qianfan : Database ,
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-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
};
function ProviderIcon ({ provider } : { provider : string }) {
const Icon = PROVIDER_ICONS [ provider ] ?? Hexagon ;
return (
< span className = "grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70" >
< Icon className = "h-5 w-5" strokeWidth = { 2 } aria - hidden />
</ span >
);
}
2026-05-19 22:42:38 +08:00
function OverviewListRow ({
icon : Icon ,
title ,
value ,
caption ,
onClick ,
} : {
icon : LucideIcon ;
title : string ;
value : string ;
caption : string ;
onClick : () => void ;
}) {
return (
< button
type = "button"
onClick = { onClick }
className = "group flex min-h-[68px] w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
>
< span className = "grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/82 transition-colors group-hover:bg-muted/80 dark:bg-muted/70" >
< Icon className = "h-4 w-4" aria - hidden />
</ span >
< span className = "min-w-0 flex-1" >
< span className = "block text-[14px] font-medium leading-5 text-foreground" >{ title }</ span >
< span className = "mt-0.5 block truncate text-[12px] leading-5 text-muted-foreground" >{ caption }</ span >
</ span >
< span className = "ml-auto flex min-w-0 max-w-[48%] items-center gap-2" >
< span className = "truncate text-right text-[13px] leading-5 text-muted-foreground" >
{ value }
</ span >
< ChevronRight
className = "h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5"
aria - hidden
/>
</ span >
</ button >
);
}
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 >
);
}
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 ,
label ,
} : {
checked : boolean ;
onChange : ( checked : boolean ) => void ;
label : string ;
}) {
return (
< button
type = "button"
onClick = {() => onChange ( ! checked )}
className = { cn (
"inline-flex h-8 min-w-[64px] items-center justify-center rounded-full px-3 text-[12px] font-medium transition-colors" ,
checked
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground" ,
)}
>
{ label }
</ button >
);
}
function NumberInput ({
value ,
min ,
max ,
onChange ,
suffix ,
} : {
value : number ;
min : number ;
max : number ;
onChange : ( value : number ) => void ;
suffix? : string ;
}) {
return (
< div className = "flex items-center gap-2" >
< Input
type = "number"
min = { min }
max = { max }
value = { value }
onChange = {( event ) => {
const parsed = Number ( event . target . value );
if ( Number . isFinite ( parsed )) onChange ( parsed );
}}
className = "h-8 w-24 rounded-full text-[13px]"
/>
{ suffix ? < span className = "text-[12px] text-muted-foreground" >{ suffix }</ span > : null }
2026-04-25 18:05:06 +00:00
</ div >
);
}