v2: dual model selection, rate limit overrides, settings export/import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent 7eb24cfd9a
commit c698f6e4a3

@ -8,7 +8,10 @@ describe('Settings validation logic', () => {
expect(DEFAULT_SETTINGS.max_items_per_category).toBe(4);
expect(DEFAULT_SETTINGS.categories.length).toBeGreaterThan(0);
expect(DEFAULT_SETTINGS.ai_model).toBe('');
expect(DEFAULT_SETTINGS.ai_model_writing).toBe('');
expect(DEFAULT_SETTINGS.ai_provider).toBe('');
expect(DEFAULT_SETTINGS.rate_limit_max_requests).toBeNull();
expect(DEFAULT_SETTINGS.rate_limit_time_window_seconds).toBeNull();
});
it('should filter empty categories before save', () => {

@ -13,4 +13,7 @@ export const apiKeysApi = {
test: (provider: string): Promise<TestApiKeyResponse> =>
api.post<TestApiKeyResponse>(`/user/api-keys/${encodeURIComponent(provider)}/test`),
exportKeys: (): Promise<{ provider_name: string; api_key: string }[]> =>
api.post('/user/api-keys/export'),
};

@ -137,6 +137,22 @@ const fr = {
'settings.saved': 'Parametres enregistres avec succes.',
'settings.saveError': "Erreur lors de l'enregistrement des parametres.",
'settings.loadError': 'Erreur lors du chargement des parametres.',
'settings.modelResearch': "Modele d'IA (Recherche et Extraction)",
'settings.modelResearchHelp': "Choisissez le modele d'IA utilise pour rechercher et extraire les informations.",
'settings.modelWriting': "Modele d'IA (Redaction et Synthese)",
'settings.modelWritingHelp': "Choisissez le modele d'IA utilise pour le second agent, charge de rediger et structurer la synthese finale.",
'settings.rateLimitSection': 'Limitation de taux',
'settings.rateLimitMaxRequests': 'Requetes maximum',
'settings.rateLimitTimeWindow': 'Fenetre de temps (secondes)',
'settings.rateLimitHelp': "Configurez le nombre maximum de requetes autorisees pendant la fenetre de temps specifiee. Laissez vide pour utiliser les valeurs par defaut de l'administrateur.",
'settings.rateLimitEffective': '{max} requetes / {window} secondes',
'settings.rateLimitReset': 'Reinitialiser',
'settings.export': 'Exporter',
'settings.import': 'Importer',
'settings.exportIncludeKeys': 'Inclure les cles API',
'settings.exportKeysWarning': 'Les cles API seront incluses en clair dans le fichier. Ne partagez pas ce fichier.',
'settings.importSuccess': "Configuration importee avec succes. N'oubliez pas d'enregistrer.",
'settings.importError': "Erreur lors de l'importation du fichier JSON.",
// Sources
'sources.title': 'Sources Personnalisees',

@ -7,9 +7,10 @@ import {
For,
createEffect,
} from 'solid-js';
import { Settings as SettingsIcon, Save, Plus, Trash2, Info } from 'lucide-solid';
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';
@ -29,10 +30,13 @@ const Settings: Component = () => {
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();
@ -153,6 +157,80 @@ const Settings: Component = () => {
}));
};
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 over DEFAULT_SETTINGS for missing fields
const merged: UserSettings = {
...DEFAULT_SETTINGS,
theme: data.theme ?? DEFAULT_SETTINGS.theme,
max_age_days: data.max_age_days ?? DEFAULT_SETTINGS.max_age_days,
max_items_per_category: data.max_items_per_category ?? DEFAULT_SETTINGS.max_items_per_category,
search_agent_behavior: data.search_agent_behavior ?? DEFAULT_SETTINGS.search_agent_behavior,
ai_model: data.ai_model ?? DEFAULT_SETTINGS.ai_model,
ai_model_writing: data.ai_model_writing ?? DEFAULT_SETTINGS.ai_model_writing,
ai_provider: data.ai_provider ?? DEFAULT_SETTINGS.ai_provider,
rate_limit_max_requests: data.rate_limit_max_requests ?? DEFAULT_SETTINGS.rate_limit_max_requests,
rate_limit_time_window_seconds: data.rate_limit_time_window_seconds ?? DEFAULT_SETTINGS.rate_limit_time_window_seconds,
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">
@ -163,6 +241,56 @@ const Settings: Component = () => {
{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()}>
@ -342,13 +470,13 @@ const Settings: Component = () => {
</div>
</Show>
{/* Model dropdown */}
{/* Research model dropdown */}
<div>
<label
for="aiModel"
class="block text-sm font-medium text-gray-700"
>
{t('settings.model')}
{t('settings.modelResearch')}
</label>
<div class="mt-1">
<select
@ -378,7 +506,43 @@ const Settings: Component = () => {
</select>
</div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.modelHelp')}
{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>
@ -454,6 +618,92 @@ const Settings: Component = () => {
</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 */}

@ -45,7 +45,10 @@ export interface UserSettings {
max_items_per_category: number;
search_agent_behavior: string;
ai_model: string;
ai_model_writing: string;
ai_provider: string;
rate_limit_max_requests: number | null;
rate_limit_time_window_seconds: number | null;
categories: string[];
}
@ -56,7 +59,10 @@ export const DEFAULT_SETTINGS: UserSettings = {
search_agent_behavior:
"Tu peux egalement utiliser d'autres sources pertinentes trouvees via la recherche Google.",
ai_model: '',
ai_model_writing: '',
ai_provider: '',
rate_limit_max_requests: null,
rate_limit_time_window_seconds: null,
categories: [
'Annonces majeures / importantes',
'Entreprises des secteurs financiers (banques, assurances, etc.)',

Loading…
Cancel
Save