You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

750 lines
27 KiB
TypeScript

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<UserSettings>({
...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<string, unknown> = { ...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 (
<Show when={!loading()} fallback={<LoadingSpinner />}>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center">
<SettingsIcon class="h-8 w-8 text-indigo-600 mr-3" />
<h1 class="text-3xl font-extrabold text-gray-900">
{t('settings.title')}
</h1>
</div>
<div class="flex items-center gap-2">
{/* Export button */}
<button
type="button"
onClick={handleExport}
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
title={t('settings.export')}
>
<Download class="h-4 w-4 mr-1" />
{t('settings.export')}
</button>
{/* Import button */}
<button
type="button"
onClick={() => fileInputRef?.click()}
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
title={t('settings.import')}
>
<Upload class="h-4 w-4 mr-1" />
{t('settings.import')}
</button>
<input
ref={fileInputRef}
type="file"
accept=".json"
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) handleImport(file);
}}
/>
</div>
</div>
{/* Export: Include API keys checkbox */}
<div class="mb-6">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeApiKeys()}
onChange={(e) => setIncludeApiKeys(e.currentTarget.checked)}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span class="text-sm text-gray-700">{t('settings.exportIncludeKeys')}</span>
</label>
<Show when={includeApiKeys()}>
<p class="mt-1 text-sm text-amber-600">
{t('settings.exportKeysWarning')}
</p>
</Show>
</div>
<Show when={message()}>
{(msg) => (
<div
class={`mb-6 p-4 rounded-md ${
msg().type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{msg().text}
</div>
)}
</Show>
{/* Provider unavailable warning */}
<Show when={providerWarning()}>
<div class="mb-6 p-4 rounded-md bg-yellow-50 text-yellow-800 border border-yellow-200">
{t('settings.providerUnavailable')}
</div>
</Show>
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200">
<div class="px-4 py-5 sm:p-6 space-y-6">
{/* Theme */}
<div>
<label for="theme" class="block text-sm font-medium text-gray-700">
{t('settings.theme')}
</label>
<div class="mt-1">
<input
type="text"
id="theme"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={settings().theme}
onInput={(e) =>
setSettings((prev) => ({
...prev,
theme: e.currentTarget.value,
}))
}
/>
</div>
<p class="mt-2 text-sm text-gray-500">{t('settings.themeHelp')}</p>
</div>
{/* Max age days + Max items per category */}
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
<div>
<label
for="maxAgeDays"
class="block text-sm font-medium text-gray-700"
>
{t('settings.maxAgeDays')}
</label>
<div class="mt-1">
<input
type="number"
id="maxAgeDays"
min="1"
max="365"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={settings().max_age_days}
onInput={(e) =>
setSettings((prev) => ({
...prev,
max_age_days: parseInt(e.currentTarget.value) || 7,
}))
}
/>
</div>
</div>
<div>
<label
for="maxItemsPerCategory"
class="block text-sm font-medium text-gray-700"
>
{t('settings.maxItems')}
</label>
<div class="mt-1">
<input
type="number"
id="maxItemsPerCategory"
min="1"
max="20"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={settings().max_items_per_category}
onInput={(e) =>
setSettings((prev) => ({
...prev,
max_items_per_category:
parseInt(e.currentTarget.value) || 4,
}))
}
/>
</div>
</div>
</div>
{/* Search agent behavior */}
<div>
<label
for="searchAgentBehavior"
class="block text-sm font-medium text-gray-700"
>
{t('settings.searchBehavior')}
</label>
<div class="mt-1">
<textarea
id="searchAgentBehavior"
rows={3}
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={settings().search_agent_behavior}
onInput={(e) =>
setSettings((prev) => ({
...prev,
search_agent_behavior: e.currentTarget.value,
}))
}
placeholder={t('settings.searchBehaviorPlaceholder')}
/>
</div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.searchBehaviorHelp')}
</p>
</div>
{/* AI Provider & Model - Dynamic selection */}
<Show
when={providers() && providers()!.length > 0}
fallback={
<div>
<label class="block text-sm font-medium text-gray-700">
{t('settings.model')}
</label>
<p class="mt-1 text-sm text-gray-400 italic">
{t('common.loading')}
</p>
</div>
}
>
{/* Provider dropdown - only if multiple providers */}
<Show when={multipleProviders()}>
<div>
<label
for="aiProvider"
class="block text-sm font-medium text-gray-700"
>
{t('settings.provider')}
</label>
<div class="mt-1">
<select
id="aiProvider"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md border"
value={settings().ai_provider}
onChange={(e) =>
handleProviderChange(e.currentTarget.value)
}
>
<option value="">
{t('settings.providerPlaceholder')}
</option>
<For each={providers()}>
{(provider) => (
<option value={provider.provider_name}>
{provider.display_name}
</option>
)}
</For>
</select>
</div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.providerHelp')}
</p>
</div>
</Show>
{/* Research model dropdown */}
<div>
<label
for="aiModel"
class="block text-sm font-medium text-gray-700"
>
{t('settings.modelResearch')}
</label>
<div class="mt-1">
<select
id="aiModel"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md border"
value={settings().ai_model}
onChange={(e) =>
setSettings((prev) => ({
...prev,
ai_model: e.currentTarget.value,
}))
}
disabled={!selectedProvider()}
>
<Show when={!selectedProvider()}>
<option value="">
{t('settings.modelPlaceholder')}
</option>
</Show>
<For each={selectedProvider()?.models ?? []}>
{(model) => (
<option value={model.model_id}>
{model.display_name}
</option>
)}
</For>
</select>
</div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.modelResearchHelp')}
</p>
</div>
{/* Writing model dropdown */}
<div>
<label
for="aiModelWriting"
class="block text-sm font-medium text-gray-700"
>
{t('settings.modelWriting')}
</label>
<div class="mt-1">
<select
id="aiModelWriting"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md border"
value={settings().ai_model_writing}
onChange={(e) =>
setSettings((prev) => ({
...prev,
ai_model_writing: e.currentTarget.value,
}))
}
disabled={!selectedProvider()}
>
<option value="">{t('settings.modelPlaceholder')}</option>
<For each={selectedProvider()?.models ?? []}>
{(model) => (
<option value={model.model_id}>
{model.display_name}
</option>
)}
</For>
</select>
</div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.modelWritingHelp')}
</p>
</div>
{/* Provider info text + web search badge */}
<Show when={selectedProvider()}>
{(provider) => (
<div class="mt-3 flex items-start gap-2">
<Info class="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" />
<div class="space-y-1">
<p class="text-sm text-gray-500">
{t(getProviderInfoKey(provider().provider_name))}
</p>
<Show
when={providerSupportsWebSearch(provider().provider_name)}
fallback={
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
{t('settings.provider.noWebSearchBadge')}
</span>
}
>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{t('settings.provider.webSearchBadge')}
</span>
</Show>
</div>
</div>
)}
</Show>
</Show>
{/* Categories */}
<div>
<div class="flex justify-between items-center mb-4">
<label class="block text-sm font-medium text-gray-700">
{t('settings.categories')}
</label>
<button
type="button"
onClick={addCategory}
disabled={settings().categories.length >= 20}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
<Plus class="h-4 w-4 mr-1" />
{t('settings.addCategory')}
</button>
</div>
<div class="space-y-3">
<For each={settings().categories}>
{(category, index) => (
<div class="flex items-center gap-2">
<span class="text-gray-500 font-medium w-6">
{index() + 1}.
</span>
<input
type="text"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={category}
onInput={(e) =>
handleCategoryChange(index(), e.currentTarget.value)
}
/>
<button
type="button"
onClick={() => removeCategory(index())}
disabled={settings().categories.length <= 1}
class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
title={t('settings.removeCategory')}
>
<Trash2 class="h-5 w-5" />
</button>
</div>
)}
</For>
</div>
</div>
{/* Rate Limit Section */}
<hr class="border-gray-200" />
<div>
<h3 class="text-sm font-medium text-gray-700 mb-4">
{t('settings.rateLimitSection')}
</h3>
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
<div>
<label
for="rateLimitMaxRequests"
class="block text-sm font-medium text-gray-700"
>
{t('settings.rateLimitMaxRequests')}
</label>
<div class="mt-1">
<input
type="number"
id="rateLimitMaxRequests"
min="1"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={settings().rate_limit_max_requests ?? ''}
onInput={(e) => {
const val = e.currentTarget.value;
setSettings((prev) => ({
...prev,
rate_limit_max_requests: val === '' ? null : (parseInt(val) || null),
}));
}}
placeholder=""
/>
</div>
</div>
<div>
<label
for="rateLimitTimeWindow"
class="block text-sm font-medium text-gray-700"
>
{t('settings.rateLimitTimeWindow')}
</label>
<div class="mt-1">
<input
type="number"
id="rateLimitTimeWindow"
min="1"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={settings().rate_limit_time_window_seconds ?? ''}
onInput={(e) => {
const val = e.currentTarget.value;
setSettings((prev) => ({
...prev,
rate_limit_time_window_seconds: val === '' ? null : (parseInt(val) || null),
}));
}}
placeholder=""
/>
</div>
</div>
</div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.rateLimitHelp')}
</p>
<Show when={settings().rate_limit_max_requests !== null && settings().rate_limit_time_window_seconds !== null}>
<p class="mt-2 text-sm text-indigo-600 font-medium">
{t('settings.rateLimitEffective')
.replace('{max}', String(settings().rate_limit_max_requests))
.replace('{window}', String(settings().rate_limit_time_window_seconds))}
</p>
</Show>
<Show when={settings().rate_limit_max_requests !== null || settings().rate_limit_time_window_seconds !== null}>
<button
type="button"
onClick={() =>
setSettings((prev) => ({
...prev,
rate_limit_max_requests: null,
rate_limit_time_window_seconds: null,
}))
}
class="mt-2 text-sm text-indigo-600 hover:text-indigo-800 underline"
>
{t('settings.rateLimitReset')}
</button>
</Show>
</div>
</div>
{/* Save button */}
<div class="px-4 py-3 bg-gray-50 text-right sm:px-6">
<button
onClick={handleSave}
disabled={saving()}
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
<Show
when={!saving()}
fallback={
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
}
>
<Save class="h-4 w-4 mr-2" />
</Show>
{t('settings.save')}
</button>
</div>
</div>
{/* API Key Management */}
<Show when={providers() && providers()!.length > 0}>
<ApiKeyManager providers={providers()!} />
</Show>
</div>
</Show>
);
};
export default Settings;