From fb765d6c8f6467c40c23a5622a549af92503cfe8 Mon Sep 17 00:00:00 2001
From: oabrivard
Date: Wed, 25 Mar 2026 08:40:50 +0100
Subject: [PATCH] =?UTF-8?q?feat:=20split=20model=20dropdowns=20=E2=80=94?=
=?UTF-8?q?=20scraping=20vs=20websearch=20in=20frontend?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the single `models` array in `ProviderConfig` and `AdminProvider`
with separate `models_scraping` / `models_websearch` lists. Rename
`ai_model_writing` → `ai_model_websearch` in `UserSettings` and all
references (Settings page, admin Providers page, E2E test, fixtures,
and unit tests). Update i18n label for the second dropdown to
"Modele d'IA (Recherche Web)".
Co-Authored-By: Claude Sonnet 4.6
---
e2e/tests/generation-live.spec.ts | 2 +-
frontend/src/__tests__/config-api.test.ts | 32 ++-
frontend/src/__tests__/fixtures.ts | 5 +-
.../src/__tests__/pages/settings.test.tsx | 10 +-
.../src/__tests__/settings-validation.test.ts | 2 +-
frontend/src/i18n/fr.ts | 6 +-
frontend/src/pages/GenerateSynthesis.tsx | 2 +-
frontend/src/pages/Settings.tsx | 28 +-
frontend/src/pages/admin/Providers.tsx | 254 +++++++++++++++---
frontend/src/types.ts | 16 +-
10 files changed, 283 insertions(+), 74 deletions(-)
diff --git a/e2e/tests/generation-live.spec.ts b/e2e/tests/generation-live.spec.ts
index 7ac9882..6b3094a 100644
--- a/e2e/tests/generation-live.spec.ts
+++ b/e2e/tests/generation-live.spec.ts
@@ -140,7 +140,7 @@ test.describe('Live generation with OpenAI', () => {
search_agent_behavior: '',
ai_provider: 'openai',
ai_model: 'gpt-4o-mini',
- ai_model_writing: 'gpt-4o-mini',
+ ai_model_websearch: 'gpt-4o-mini',
use_llm_for_source_links: false,
article_history_days: 90,
});
diff --git a/frontend/src/__tests__/config-api.test.ts b/frontend/src/__tests__/config-api.test.ts
index f9269a4..9a35af6 100644
--- a/frontend/src/__tests__/config-api.test.ts
+++ b/frontend/src/__tests__/config-api.test.ts
@@ -20,14 +20,20 @@ describe('Config API - Provider Config', () => {
{
provider_name: 'gemini',
display_name: 'Google Gemini',
- models: [
+ models_scraping: [
+ { model_id: 'gemini-3.1-pro', display_name: 'Gemini 3.1 Pro' },
+ ],
+ models_websearch: [
{ model_id: 'gemini-3.1-pro', display_name: 'Gemini 3.1 Pro' },
],
},
{
provider_name: 'openai',
display_name: 'OpenAI',
- models: [
+ models_scraping: [
+ { model_id: 'gpt-4o', display_name: 'GPT-4o' },
+ ],
+ models_websearch: [
{ model_id: 'gpt-4o', display_name: 'GPT-4o' },
],
},
@@ -56,7 +62,7 @@ describe('Config API - Provider Config', () => {
expect(result).toEqual(mockProviders);
expect(result).toHaveLength(2);
expect(result[0].provider_name).toBe('gemini');
- expect(result[0].models).toHaveLength(1);
+ expect(result[0].models_scraping).toHaveLength(1);
expect(result[1].provider_name).toBe('openai');
});
@@ -131,7 +137,14 @@ describe('Admin API - Providers', () => {
id: '1',
provider_name: 'gemini',
display_name: 'Google Gemini',
- models: [
+ models_scraping: [
+ {
+ model_id: 'gemini-3.1-pro',
+ display_name: 'Gemini 3.1 Pro',
+ is_default: true,
+ },
+ ],
+ models_websearch: [
{
model_id: 'gemini-3.1-pro',
display_name: 'Gemini 3.1 Pro',
@@ -160,14 +173,21 @@ describe('Admin API - Providers', () => {
expect(result).toEqual(mockProviders);
expect(result[0].is_enabled).toBe(true);
- expect(result[0].models[0].is_default).toBe(true);
+ expect(result[0].models_scraping[0].is_default).toBe(true);
});
it('should call POST /api/v1/admin/providers with body', async () => {
const newProvider = {
provider_name: 'anthropic',
display_name: 'Anthropic',
- models: [
+ models_scraping: [
+ {
+ model_id: 'claude-opus-4',
+ display_name: 'Claude Opus 4',
+ is_default: true,
+ },
+ ],
+ models_websearch: [
{
model_id: 'claude-opus-4',
display_name: 'Claude Opus 4',
diff --git a/frontend/src/__tests__/fixtures.ts b/frontend/src/__tests__/fixtures.ts
index 403ec9e..f2c59cb 100644
--- a/frontend/src/__tests__/fixtures.ts
+++ b/frontend/src/__tests__/fixtures.ts
@@ -53,13 +53,14 @@ export const MOCK_SOURCES: Source[] = [
// ---- Settings ----
export const MOCK_SETTINGS: UserSettings = {
- ...DEFAULT_SETTINGS, theme: 'Intelligence Artificielle', ai_provider: 'gemini', ai_model: 'gemini-2.5-pro', ai_model_writing: 'gemini-2.5-flash',
+ ...DEFAULT_SETTINGS, theme: 'Intelligence Artificielle', ai_provider: 'gemini', ai_model: 'gemini-2.5-pro', ai_model_websearch: 'gemini-2.5-flash',
};
// ---- Providers ----
export const MOCK_PROVIDER_CONFIG: ProviderConfig = {
provider_name: 'gemini', display_name: 'Google Gemini',
- models: [{ model_id: 'gemini-2.5-pro', display_name: 'Gemini 2.5 Pro' }, { model_id: 'gemini-2.5-flash', display_name: 'Gemini 2.5 Flash' }],
+ models_scraping: [{ model_id: 'gemini-2.5-pro', display_name: 'Gemini 2.5 Pro' }, { model_id: 'gemini-2.5-flash', display_name: 'Gemini 2.5 Flash' }],
+ models_websearch: [{ model_id: 'gemini-2.5-pro', display_name: 'Gemini 2.5 Pro' }, { model_id: 'gemini-2.5-flash', display_name: 'Gemini 2.5 Flash' }],
};
export const MOCK_PROVIDER_CONFIGS: ProviderConfig[] = [MOCK_PROVIDER_CONFIG];
diff --git a/frontend/src/__tests__/pages/settings.test.tsx b/frontend/src/__tests__/pages/settings.test.tsx
index 1518d2e..d8fee71 100644
--- a/frontend/src/__tests__/pages/settings.test.tsx
+++ b/frontend/src/__tests__/pages/settings.test.tsx
@@ -45,7 +45,11 @@ const mockProvidersWithOpenAI = [
{
provider_name: 'openai',
display_name: 'OpenAI',
- models: [
+ models_scraping: [
+ { model_id: 'gpt-4o', display_name: 'GPT-4o' },
+ { model_id: 'gpt-4o-mini', display_name: 'GPT-4o Mini' },
+ ],
+ models_websearch: [
{ model_id: 'gpt-4o', display_name: 'GPT-4o' },
{ model_id: 'gpt-4o-mini', display_name: 'GPT-4o Mini' },
],
@@ -146,7 +150,7 @@ describe('Settings Page', () => {
});
});
- it('should render two model dropdowns (research + writing)', async () => {
+ it('should render two model dropdowns (scraping + websearch)', async () => {
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(mockProvidersWithOpenAI);
mockedListKeys.mockResolvedValue([]);
@@ -159,7 +163,7 @@ describe('Settings Page', () => {
).toBeInTheDocument();
});
expect(
- screen.getByLabelText("Modele d'IA (Redaction et Synthese)"),
+ screen.getByLabelText("Modele d'IA (Recherche Web)"),
).toBeInTheDocument();
});
diff --git a/frontend/src/__tests__/settings-validation.test.ts b/frontend/src/__tests__/settings-validation.test.ts
index 555d4b7..415d405 100644
--- a/frontend/src/__tests__/settings-validation.test.ts
+++ b/frontend/src/__tests__/settings-validation.test.ts
@@ -8,7 +8,7 @@ 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_model_websearch).toBe('');
expect(DEFAULT_SETTINGS.ai_provider).toBe('');
expect(DEFAULT_SETTINGS.rate_limit_max_requests).toBeNull();
expect(DEFAULT_SETTINGS.rate_limit_time_window_seconds).toBeNull();
diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts
index 56fff29..15c7971 100644
--- a/frontend/src/i18n/fr.ts
+++ b/frontend/src/i18n/fr.ts
@@ -142,8 +142,8 @@ const fr = {
'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.modelWebsearch': "Modele d'IA (Recherche Web)",
+ 'settings.modelWebsearchHelp': "Choisissez le modele d'IA utilise pour la recherche web et la generation des syntheses.",
'settings.rateLimitSection': 'Limitation de taux',
'settings.rateLimitMaxRequests': 'Requetes maximum',
'settings.rateLimitTimeWindow': 'Fenetre de temps (secondes)',
@@ -266,6 +266,8 @@ const fr = {
'admin.providers.displayName': "Nom d'affichage",
'admin.providers.displayNamePlaceholder': 'ex: OpenAI, Google Gemini',
'admin.providers.models': 'Modeles disponibles',
+ 'admin.providers.modelsScraping': 'Modeles (Extraction / Scraping)',
+ 'admin.providers.modelsWebsearch': 'Modeles (Recherche Web)',
'admin.providers.modelId': 'Identifiant du modele',
'admin.providers.modelIdPlaceholder': 'ex: gpt-4o',
'admin.providers.modelDisplayName': "Nom d'affichage du modele",
diff --git a/frontend/src/pages/GenerateSynthesis.tsx b/frontend/src/pages/GenerateSynthesis.tsx
index 6ae1d33..58b020d 100644
--- a/frontend/src/pages/GenerateSynthesis.tsx
+++ b/frontend/src/pages/GenerateSynthesis.tsx
@@ -87,7 +87,7 @@ const GenerateSynthesis: Component = () => {
const modelDisplayName = (): string => {
const provider = selectedProvider();
if (!provider) return settings().ai_model;
- const model = provider.models.find((m) => m.model_id === settings().ai_model);
+ const model = provider.models_scraping.find((m) => m.model_id === settings().ai_model);
return model?.display_name ?? settings().ai_model;
};
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index 9063bc1..8d15a2e 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -31,9 +31,9 @@ import { getProviderInfoKey, providerSupportsWebSearch } from '~/utils/providers
* - **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.
+ * - **Dual model state**: Scraping model (`ai_model`) and websearch model
+ * (`ai_model_websearch`) are independently selectable from their
+ * respective provider model lists.
*/
const Settings: Component = () => {
const { t } = useI18n();
@@ -106,7 +106,7 @@ const Settings: Component = () => {
setSettings((prev) => ({
...prev,
ai_provider: providerName,
- ai_model: provider?.models[0]?.model_id ?? '',
+ ai_model: provider?.models_scraping[0]?.model_id ?? '',
}));
};
@@ -117,7 +117,7 @@ const Settings: Component = () => {
setSettings((prev) => ({
...prev,
ai_provider: single.provider_name,
- ai_model: prev.ai_model || single.models[0]?.model_id || '',
+ ai_model: prev.ai_model || single.models_scraping[0]?.model_id || '',
}));
}
});
@@ -590,7 +590,7 @@ const Settings: Component = () => {
{t('settings.modelPlaceholder')}
-
+
{(model) => (
- {/* Writing model dropdown */}
+ {/* Websearch model dropdown */}
- {t('settings.modelWritingHelp')}
+ {t('settings.modelWebsearchHelp')}
diff --git a/frontend/src/pages/admin/Providers.tsx b/frontend/src/pages/admin/Providers.tsx
index dd995ae..5f59a1e 100644
--- a/frontend/src/pages/admin/Providers.tsx
+++ b/frontend/src/pages/admin/Providers.tsx
@@ -17,7 +17,8 @@ import Button from '~/components/ui/Button';
/** Local editable state for an existing provider card. */
interface ProviderFormState {
display_name: string;
- models: AdminProviderModel[];
+ models_scraping: AdminProviderModel[];
+ models_websearch: AdminProviderModel[];
is_enabled: boolean;
}
@@ -62,7 +63,8 @@ const Providers: Component = () => {
const [newProvider, setNewProvider] = createSignal({
provider_name: '',
display_name: '',
- models: [emptyModel()] as AdminProviderModel[],
+ models_scraping: [emptyModel()] as AdminProviderModel[],
+ models_websearch: [emptyModel()] as AdminProviderModel[],
is_enabled: true,
});
@@ -71,7 +73,8 @@ const Providers: Component = () => {
if (existing) return existing;
return {
display_name: provider.display_name,
- models: [...provider.models],
+ models_scraping: [...provider.models_scraping],
+ models_websearch: [...provider.models_websearch],
is_enabled: provider.is_enabled,
};
};
@@ -89,7 +92,8 @@ const Providers: Component = () => {
...prev,
[provider.id]: {
display_name: provider.display_name,
- models: provider.models.map((m) => ({ ...m })),
+ models_scraping: provider.models_scraping.map((m) => ({ ...m })),
+ models_websearch: provider.models_websearch.map((m) => ({ ...m })),
is_enabled: provider.is_enabled,
},
}));
@@ -103,7 +107,8 @@ const Providers: Component = () => {
try {
await adminProvidersApi.update(provider.id, {
display_name: state.display_name,
- models: state.models.filter((m) => m.model_id.trim() !== ''),
+ models_scraping: state.models_scraping.filter((m) => m.model_id.trim() !== ''),
+ models_websearch: state.models_websearch.filter((m) => m.model_id.trim() !== ''),
is_enabled: state.is_enabled,
});
addToast({
@@ -152,7 +157,8 @@ const Providers: Component = () => {
await adminProvidersApi.create({
provider_name: data.provider_name.trim(),
display_name: data.display_name.trim(),
- models: data.models.filter((m) => m.model_id.trim() !== ''),
+ models_scraping: data.models_scraping.filter((m) => m.model_id.trim() !== ''),
+ models_websearch: data.models_websearch.filter((m) => m.model_id.trim() !== ''),
is_enabled: data.is_enabled,
});
addToast({
@@ -164,7 +170,8 @@ const Providers: Component = () => {
setNewProvider({
provider_name: '',
display_name: '',
- models: [emptyModel()],
+ models_scraping: [emptyModel()],
+ models_websearch: [emptyModel()],
is_enabled: true,
});
refetch();
@@ -183,28 +190,29 @@ const Providers: Component = () => {
}));
};
- const addModelToProvider = (id: string) => {
+ const addModelToProvider = (id: string, listKey: 'models_scraping' | 'models_websearch') => {
updateEdit(id, (prev) => ({
...prev,
- models: [...prev.models, emptyModel()],
+ [listKey]: [...prev[listKey], emptyModel()],
}));
};
- const removeModelFromProvider = (id: string, modelIndex: number) => {
+ const removeModelFromProvider = (id: string, listKey: 'models_scraping' | 'models_websearch', modelIndex: number) => {
updateEdit(id, (prev) => ({
...prev,
- models: prev.models.filter((_, i) => i !== modelIndex),
+ [listKey]: prev[listKey].filter((_, i) => i !== modelIndex),
}));
};
const updateModelField = (
id: string,
+ listKey: 'models_scraping' | 'models_websearch',
modelIndex: number,
field: keyof AdminProviderModel,
value: string | boolean,
) => {
updateEdit(id, (prev) => {
- const models = prev.models.map((m, i) => {
+ const models = prev[listKey].map((m, i) => {
if (i !== modelIndex) {
// If setting a new default, unset others
if (field === 'is_default' && value === true) {
@@ -214,14 +222,14 @@ const Providers: Component = () => {
}
return { ...m, [field]: value };
});
- return { ...prev, models };
+ return { ...prev, [listKey]: models };
});
};
- const setDefaultModel = (id: string, modelIndex: number) => {
+ const setDefaultModel = (id: string, listKey: 'models_scraping' | 'models_websearch', modelIndex: number) => {
updateEdit(id, (prev) => ({
...prev,
- models: prev.models.map((m, i) => ({
+ [listKey]: prev[listKey].map((m, i) => ({
...m,
is_default: i === modelIndex,
})),
@@ -229,28 +237,29 @@ const Providers: Component = () => {
};
// New provider form helpers
- const addModelToNew = () => {
+ const addModelToNew = (listKey: 'models_scraping' | 'models_websearch') => {
setNewProvider((prev) => ({
...prev,
- models: [...prev.models, emptyModel()],
+ [listKey]: [...prev[listKey], emptyModel()],
}));
};
- const removeModelFromNew = (index: number) => {
+ const removeModelFromNew = (listKey: 'models_scraping' | 'models_websearch', index: number) => {
setNewProvider((prev) => ({
...prev,
- models: prev.models.filter((_, i) => i !== index),
+ [listKey]: prev[listKey].filter((_, i) => i !== index),
}));
};
const updateNewModelField = (
+ listKey: 'models_scraping' | 'models_websearch',
index: number,
field: keyof AdminProviderModel,
value: string | boolean,
) => {
setNewProvider((prev) => ({
...prev,
- models: prev.models.map((m, i) => {
+ [listKey]: prev[listKey].map((m, i) => {
if (i !== index) {
if (field === 'is_default' && value === true) {
return { ...m, is_default: false };
@@ -262,10 +271,10 @@ const Providers: Component = () => {
}));
};
- const setNewDefaultModel = (index: number) => {
+ const setNewDefaultModel = (listKey: 'models_scraping' | 'models_websearch', index: number) => {
setNewProvider((prev) => ({
...prev,
- models: prev.models.map((m, i) => ({
+ [listKey]: prev[listKey].map((m, i) => ({
...m,
is_default: i === index,
})),
@@ -341,15 +350,15 @@ const Providers: Component = () => {
- {/* Models for new provider */}
+ {/* Models for new provider - Scraping */}
-
+
{(model, index) => (
{
value={model.model_id}
onInput={(e) =>
updateNewModelField(
+ 'models_scraping',
index(),
'model_id',
e.currentTarget.value,
@@ -379,6 +389,7 @@ const Providers: Component = () => {
value={model.display_name}
onInput={(e) =>
updateNewModelField(
+ 'models_scraping',
index(),
'display_name',
e.currentTarget.value,
@@ -391,16 +402,89 @@ const Providers: Component = () => {
+
+ )}
+
+
+
+
+ {/* Models for new provider - Websearch */}
+
+
+
+
+
+
+
+ {(model, index) => (
+
+
+ updateNewModelField(
+ 'models_websearch',
+ index(),
+ 'model_id',
+ e.currentTarget.value,
+ )
+ }
+ placeholder={t('admin.providers.modelIdPlaceholder')}
+ />
+
+ updateNewModelField(
+ 'models_websearch',
+ index(),
+ 'display_name',
+ e.currentTarget.value,
+ )
+ }
+ placeholder={t(
+ 'admin.providers.modelDisplayNamePlaceholder',
+ )}
+ />
+
+
- {/* Models */}
+ {/* Models - Scraping */}
+
+
+
+
+
+
+
0}
+ fallback={
+
+ {t('admin.providers.noModels')}
+
+ }
+ >
+
+
+ {(model, index) => (
+
+
+ updateModelField(
+ provider.id,
+ 'models_scraping',
+ index(),
+ 'model_id',
+ e.currentTarget.value,
+ )
+ }
+ placeholder={t(
+ 'admin.providers.modelIdPlaceholder',
+ )}
+ />
+
+ updateModelField(
+ provider.id,
+ 'models_scraping',
+ index(),
+ 'display_name',
+ e.currentTarget.value,
+ )
+ }
+ placeholder={t(
+ 'admin.providers.modelDisplayNamePlaceholder',
+ )}
+ />
+
+
+
+ )}
+
+
+
+
+
+ {/* Models - Websearch */}
0}
+ when={state().models_websearch.length > 0}
fallback={
{t('admin.providers.noModels')}
@@ -558,7 +734,7 @@ const Providers: Component = () => {
}
>
-
+
{(model, index) => (
{
onInput={(e) =>
updateModelField(
provider.id,
+ 'models_websearch',
index(),
'model_id',
e.currentTarget.value,
@@ -584,6 +761,7 @@ const Providers: Component = () => {
onInput={(e) =>
updateModelField(
provider.id,
+ 'models_websearch',
index(),
'display_name',
e.currentTarget.value,
@@ -596,10 +774,10 @@ const Providers: Component = () => {