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