import { type Component, createSignal, createResource, onMount, Show, For, createEffect, } from 'solid-js'; import { Settings as SettingsIcon, Save, Plus, Trash2, Info, Download, Upload } from 'lucide-solid'; import { settingsApi } from '~/api/settings'; import { configApi } from '~/api/config'; import { apiKeysApi } from '~/api/apiKeys'; import { useI18n } from '~/i18n'; import { DEFAULT_SETTINGS, isApiError } from '~/types'; import type { UserSettings, ProviderConfig } from '~/types'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; import ApiKeyManager from '~/components/ApiKeyManager'; import { getProviderInfoKey, providerSupportsWebSearch } from '~/utils/providers'; /** * Settings page for configuring the user's synthesis preferences. * * Key behaviors: * - **Export/Import**: Settings can be exported as JSON (optionally including * cleartext API keys). On import, missing fields are merged with * `DEFAULT_SETTINGS` so partial files are safe. * - **Provider auto-detection**: When only one provider is configured, the * provider dropdown is hidden and that provider is auto-selected. * - **Rate limit null handling**: The `rate_limit_max_requests` and * `rate_limit_time_window_seconds` fields accept `null` (meaning "use * server defaults"). Empty inputs are stored as `null`, not zero. * - **Dual model state**: Research model (`ai_model`) and writing model * (`ai_model_writing`) are independently selectable from the same * provider's model list. */ const Settings: Component = () => { const { t } = useI18n(); const [settings, setSettings] = createSignal({ ...DEFAULT_SETTINGS, }); const [loading, setLoading] = createSignal(true); const [saving, setSaving] = createSignal(false); const [message, setMessage] = createSignal<{ type: 'success' | 'error'; text: string; } | null>(null); const [includeApiKeys, setIncludeApiKeys] = createSignal(false); const [providers] = createResource(() => configApi.listProviders()); const [providerWarning, setProviderWarning] = createSignal(false); let fileInputRef: HTMLInputElement | undefined; onMount(async () => { try { const data = await settingsApi.get(); setSettings(data); } catch (err) { if (isApiError(err) && err.status !== 404) { setMessage({ type: 'error', text: t('settings.loadError') }); } // 404 means no settings yet -- use defaults } finally { setLoading(false); } }); // Check if saved provider is still available when providers load createEffect(() => { const providerList = providers(); const currentSettings = settings(); if ( providerList && currentSettings.ai_provider && !providerList.some( (p) => p.provider_name === currentSettings.ai_provider, ) ) { setProviderWarning(true); } }); const multipleProviders = () => (providers()?.length ?? 0) > 1; const singleProvider = () => providers()?.length === 1 ? providers()![0] : null; const selectedProvider = (): ProviderConfig | undefined => { const providerList = providers(); if (!providerList) return undefined; // If single provider, always use it if (providerList.length === 1) return providerList[0]; // Otherwise, find the selected one return providerList.find( (p) => p.provider_name === settings().ai_provider, ); }; const handleProviderChange = (providerName: string) => { setProviderWarning(false); const provider = providers()?.find( (p) => p.provider_name === providerName, ); setSettings((prev) => ({ ...prev, ai_provider: providerName, ai_model: provider?.models[0]?.model_id ?? '', })); }; // Auto-select the single provider if only one exists createEffect(() => { const single = singleProvider(); if (single && !settings().ai_provider) { setSettings((prev) => ({ ...prev, ai_provider: single.provider_name, ai_model: prev.ai_model || single.models[0]?.model_id || '', })); } }); const handleSave = async () => { setSaving(true); setMessage(null); try { const cleanedSettings = { ...settings(), categories: settings().categories.filter((c) => c.trim() !== ''), }; if (cleanedSettings.categories.length === 0) { cleanedSettings.categories = ['General']; } const saved = await settingsApi.update(cleanedSettings); setSettings(saved); setMessage({ type: 'success', text: t('settings.saved') }); } catch (err) { if (isApiError(err)) { setMessage({ type: 'error', text: err.message }); } else { setMessage({ type: 'error', text: t('settings.saveError') }); } } finally { setSaving(false); } }; const handleCategoryChange = (index: number, value: string) => { setSettings((prev) => { const newCategories = [...prev.categories]; newCategories[index] = value; return { ...prev, categories: newCategories }; }); }; const addCategory = () => { if (settings().categories.length >= 20) return; setSettings((prev) => ({ ...prev, categories: [...prev.categories, t('settings.newCategory')], })); }; const removeCategory = (index: number) => { if (settings().categories.length <= 1) return; setSettings((prev) => ({ ...prev, categories: prev.categories.filter((_, i) => i !== index), })); }; const handleExport = async () => { try { const exportData: Record = { ...settings() }; if (includeApiKeys()) { const keys = await apiKeysApi.exportKeys(); exportData.api_keys = keys; } const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json', }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'settings.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { if (isApiError(err)) { setMessage({ type: 'error', text: err.message }); } else { setMessage({ type: 'error', text: t('settings.saveError') }); } } }; const handleImport = async (file: File) => { try { const text = await file.text(); const data = JSON.parse(text); // Merge imported data over DEFAULT_SETTINGS so missing fields get defaults. // The spread provides defaults; explicit overrides handle the `categories` // field which must be validated as an array. const merged: UserSettings = { ...DEFAULT_SETTINGS, ...data, categories: Array.isArray(data.categories) ? data.categories : DEFAULT_SETTINGS.categories, }; setSettings(merged); // Import API keys if present if (Array.isArray(data.api_keys)) { for (const key of data.api_keys) { if (key.provider_name && key.api_key) { await apiKeysApi.create({ provider_name: key.provider_name, api_key: key.api_key, }); } } } setMessage({ type: 'success', text: t('settings.importSuccess') }); } catch { setMessage({ type: 'error', text: t('settings.importError') }); } // Reset file input if (fileInputRef) { fileInputRef.value = ''; } }; return ( }>

{t('settings.title')}

{/* Export button */} {/* Import button */} { const file = e.currentTarget.files?.[0]; if (file) handleImport(file); }} />
{/* Export: Include API keys checkbox */}

{t('settings.exportKeysWarning')}

{(msg) => (
{msg().text}
)}
{/* Provider unavailable warning */}
{t('settings.providerUnavailable')}
{/* Theme */}
setSettings((prev) => ({ ...prev, theme: e.currentTarget.value, })) } />

{t('settings.themeHelp')}

{/* Max age days + Max items per category */}
setSettings((prev) => ({ ...prev, max_age_days: parseInt(e.currentTarget.value) || 7, })) } />
setSettings((prev) => ({ ...prev, max_items_per_category: parseInt(e.currentTarget.value) || 4, })) } />
{/* Search agent behavior */}