refactor: decompose Settings.tsx into sub-components
Extract three self-contained sections from Settings.tsx (1025 lines) into dedicated components under frontend/src/components/settings/: - SettingsBraveSearch: Brave API key lifecycle + use_brave_search toggle - SettingsAdvanced: article_history_days, batch_size, use_llm_for_source_links, search_agent_behavior - SettingsRateLimit: rate limit inputs + effective rate display + reset Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>master
parent
f44aa44c48
commit
ee6d833d70
@ -0,0 +1,143 @@
|
||||
import { type Component } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useI18n } from '~/i18n';
|
||||
import type { UserSettings } from '~/types';
|
||||
|
||||
interface SettingsAdvancedProps {
|
||||
settings: () => UserSettings;
|
||||
setSettings: (updater: (prev: UserSettings) => UserSettings) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced settings section on the Settings page.
|
||||
*
|
||||
* Groups fields that control extraction and pipeline behaviour:
|
||||
* - `article_history_days` — deduplication window
|
||||
* - `batch_size` — number of sources processed per LLM batch
|
||||
* - `use_llm_for_source_links` — whether to use LLM to extract links
|
||||
* - `search_agent_behavior` — free-text prompt injection for the search agent
|
||||
*/
|
||||
const SettingsAdvanced: Component<SettingsAdvancedProps> = (props) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* articleHistoryDays + batchSize grid */}
|
||||
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
for="articleHistoryDays"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
{t('settings.articleHistoryDays')}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input
|
||||
type="number"
|
||||
id="articleHistoryDays"
|
||||
min="0"
|
||||
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={props.settings().article_history_days}
|
||||
onInput={(e) =>
|
||||
props.setSettings((prev) => ({
|
||||
...prev,
|
||||
article_history_days:
|
||||
parseInt(e.currentTarget.value) || 90,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<A href="/article-history" class="text-sm text-indigo-600 hover:text-indigo-800 underline">
|
||||
{t('articleHistory.viewHistory')}
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="batchSize"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
{t('settings.batchSize')}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mb-1">{t('settings.batchSizeHelp')}</p>
|
||||
<div class="mt-1">
|
||||
<input
|
||||
type="number"
|
||||
id="batchSize"
|
||||
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={props.settings().batch_size}
|
||||
onInput={(e) =>
|
||||
props.setSettings((prev) => ({
|
||||
...prev,
|
||||
batch_size:
|
||||
parseInt(e.currentTarget.value) || 5,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced extraction */}
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||
{t('settings.advancedExtraction')}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useLlmSourceLinks"
|
||||
checked={props.settings().use_llm_for_source_links}
|
||||
onChange={(e) =>
|
||||
props.setSettings((prev) => ({
|
||||
...prev,
|
||||
use_llm_for_source_links: e.currentTarget.checked,
|
||||
}))
|
||||
}
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="useLlmSourceLinks" class="ml-2 block text-sm text-gray-700">
|
||||
{t('settings.useLlmForSourceLinks')}
|
||||
</label>
|
||||
</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={props.settings().search_agent_behavior}
|
||||
onInput={(e) =>
|
||||
props.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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsAdvanced;
|
||||
@ -0,0 +1,189 @@
|
||||
import { type Component, createSignal, createResource, Show } from 'solid-js';
|
||||
import { Trash2, RefreshCw } from 'lucide-solid';
|
||||
import { apiKeysApi } from '~/api/apiKeys';
|
||||
import { useI18n } from '~/i18n';
|
||||
import { isApiError } from '~/types';
|
||||
import type { UserSettings } from '~/types';
|
||||
import { useToast } from '~/components/ui/Toast';
|
||||
|
||||
interface SettingsBraveSearchProps {
|
||||
settings: () => UserSettings;
|
||||
setSettings: (updater: (prev: UserSettings) => UserSettings) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brave Search configuration section on the Settings page.
|
||||
*
|
||||
* Manages the Brave Search API key lifecycle (save, test, delete) and the
|
||||
* `use_brave_search` toggle. The key resource is owned here so that
|
||||
* refetching after mutations is self-contained.
|
||||
*/
|
||||
const SettingsBraveSearch: Component<SettingsBraveSearchProps> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [apiKeys, { refetch: refetchApiKeys }] = createResource(() => apiKeysApi.list());
|
||||
const braveKey = () => apiKeys()?.find((k) => k.provider_name === 'brave_search');
|
||||
const [braveKeyInput, setBraveKeyInput] = createSignal('');
|
||||
const [braveSaving, setBraveSaving] = createSignal(false);
|
||||
const [braveTesting, setBraveTesting] = createSignal(false);
|
||||
|
||||
const handleBraveKeySave = async () => {
|
||||
const key = braveKeyInput().trim();
|
||||
if (!key) return;
|
||||
setBraveSaving(true);
|
||||
try {
|
||||
await apiKeysApi.create({ provider_name: 'brave_search', api_key: key });
|
||||
addToast({ type: 'success', message: t('settings.apiKeys.saved'), duration: 4000 });
|
||||
setBraveKeyInput('');
|
||||
refetchApiKeys();
|
||||
} catch (err) {
|
||||
const msg = isApiError(err) ? err.message : t('settings.apiKeys.saveError');
|
||||
addToast({ type: 'error', message: msg, duration: 5000 });
|
||||
} finally {
|
||||
setBraveSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBraveKeyTest = async () => {
|
||||
setBraveTesting(true);
|
||||
try {
|
||||
const result = await apiKeysApi.test('brave_search');
|
||||
if (result.success) {
|
||||
addToast({ type: 'success', message: t('settings.apiKeys.testSuccess'), duration: 4000 });
|
||||
} else {
|
||||
addToast({ type: 'error', message: t('settings.apiKeys.testFailure', { message: result.message }), duration: 6000 });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: 'Erreur inconnue' });
|
||||
addToast({ type: 'error', message: msg, duration: 5000 });
|
||||
} finally {
|
||||
setBraveTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBraveKeyDelete = async () => {
|
||||
try {
|
||||
await apiKeysApi.remove('brave_search');
|
||||
addToast({ type: 'success', message: t('settings.apiKeys.deleted'), duration: 4000 });
|
||||
// Auto-disable use_brave_search when the key is removed
|
||||
props.setSettings((prev) => ({ ...prev, use_brave_search: false }));
|
||||
refetchApiKeys();
|
||||
} catch (err) {
|
||||
const msg = isApiError(err) ? err.message : t('settings.apiKeys.deleteError');
|
||||
addToast({ type: 'error', message: msg, duration: 5000 });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-1">
|
||||
{t('settings.braveSearch')}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
{t('settings.braveSearchKeyHelp')}
|
||||
</p>
|
||||
|
||||
{/* Key management */}
|
||||
<Show
|
||||
when={braveKey()}
|
||||
fallback={
|
||||
<div class="flex items-center gap-2">
|
||||
<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 font-mono"
|
||||
placeholder={t('settings.braveSearchKey')}
|
||||
value={braveKeyInput()}
|
||||
onInput={(e) => setBraveKeyInput(e.currentTarget.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBraveKeySave}
|
||||
disabled={braveSaving() || !braveKeyInput().trim()}
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent 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 whitespace-nowrap"
|
||||
>
|
||||
<Show when={braveSaving()}>
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||
</Show>
|
||||
{braveSaving() ? t('settings.apiKeys.saving') : t('settings.apiKeys.save')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(key) => (
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{t('settings.apiKeys.configured')}
|
||||
</span>
|
||||
<span class="text-sm font-mono text-gray-400">
|
||||
{t('settings.apiKeys.keyPrefix', { prefix: key().key_prefix })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBraveKeyTest}
|
||||
disabled={braveTesting()}
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-indigo-700 bg-indigo-50 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
<Show
|
||||
when={!braveTesting()}
|
||||
fallback={<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-indigo-700 mr-1.5" />}
|
||||
>
|
||||
<RefreshCw class="h-3 w-3 mr-1.5" />
|
||||
</Show>
|
||||
{braveTesting() ? t('settings.apiKeys.testing') : t('settings.apiKeys.test')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBraveKeyDelete}
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500"
|
||||
>
|
||||
<Trash2 class="h-3 w-3 mr-1.5" />
|
||||
{t('settings.apiKeys.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* use_brave_search toggle */}
|
||||
<div class="mt-4 space-y-1">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useBraveSearch"
|
||||
checked={props.settings().use_brave_search}
|
||||
disabled={!braveKey()}
|
||||
onChange={(e) =>
|
||||
props.setSettings((prev) => ({
|
||||
...prev,
|
||||
use_brave_search: e.currentTarget.checked,
|
||||
}))
|
||||
}
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded disabled:opacity-50"
|
||||
/>
|
||||
<label
|
||||
for="useBraveSearch"
|
||||
class={`ml-2 block text-sm ${!braveKey() ? 'text-gray-400' : 'text-gray-700'}`}
|
||||
>
|
||||
{t('settings.useBraveSearch')}
|
||||
</label>
|
||||
</div>
|
||||
<Show when={!braveKey()}>
|
||||
<p class="text-xs text-gray-400 ml-6">
|
||||
{t('settings.braveSearchNotConfigured')}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={braveKey()}>
|
||||
<p class="text-xs text-gray-500 ml-6">
|
||||
{t('settings.useBraveSearchHelp')}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsBraveSearch;
|
||||
@ -0,0 +1,111 @@
|
||||
import { type Component, Show } from 'solid-js';
|
||||
import { useI18n } from '~/i18n';
|
||||
import type { UserSettings } from '~/types';
|
||||
|
||||
interface SettingsRateLimitProps {
|
||||
settings: () => UserSettings;
|
||||
setSettings: (updater: (prev: UserSettings) => UserSettings) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit configuration section on the Settings page.
|
||||
*
|
||||
* Exposes `rate_limit_max_requests` and `rate_limit_time_window_seconds`.
|
||||
* Both fields accept `null` (meaning "use server defaults"); empty inputs
|
||||
* are stored as `null`, not zero. A reset button clears both fields back
|
||||
* to `null` at once.
|
||||
*/
|
||||
const SettingsRateLimit: Component<SettingsRateLimitProps> = (props) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<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={props.settings().rate_limit_max_requests ?? ''}
|
||||
onInput={(e) => {
|
||||
const val = e.currentTarget.value;
|
||||
props.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={props.settings().rate_limit_time_window_seconds ?? ''}
|
||||
onInput={(e) => {
|
||||
const val = e.currentTarget.value;
|
||||
props.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={props.settings().rate_limit_max_requests !== null && props.settings().rate_limit_time_window_seconds !== null}>
|
||||
<p class="mt-2 text-sm text-indigo-600 font-medium">
|
||||
{t('settings.rateLimitEffective')
|
||||
.replace('{max}', String(props.settings().rate_limit_max_requests))
|
||||
.replace('{window}', String(props.settings().rate_limit_time_window_seconds))}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={props.settings().rate_limit_max_requests !== null || props.settings().rate_limit_time_window_seconds !== null}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
props.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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsRateLimit;
|
||||
Loading…
Reference in New Issue