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
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;
|
|
|