From 3cb9edc1f3a842efb445d0048d43b79f83b19e0f Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 5 Sep 2026 10:58:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(agent):=20v4.10.1=20-=20config=20LLM=20dan?= =?UTF-8?q?s=20l'UI=20(s=C3=A9lecteur=20provider/mod=C3=A8le,=20config=20r?= =?UTF-8?q?untime,=20admin=20+=20test=20connexion)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Panneau agent : selects provider/modèles connus persistés par conversation (colonnes agent_conversations.provider/model, migration idempotente) - GET /api/agent/providers enrichi (liste providers + modèles + requires_key) - PATCH /api/agent/providers (admin) : config runtime DB-backed (table llm_config) - POST /api/agent/providers/test : test connexion via LLMClient.ping() sans fallback mock - Settings > Admin > Agent & IA : provider, modèle, clé API, URL API, save + test - VERSION -> 4.10.1, CHANGELOG + ROADMAP mis à jour, 232 tests verts --- CHANGELOG.md | 35 ++++++++ ROADMAP.md | 8 +- VERSION | 2 +- app/db.py | 21 +++++ app/routers/agent.py | 103 ++++++++++++++++++++--- app/services/llm_client.py | 42 +++++++++- app/services/llm_config.py | 82 +++++++++++++++++++ app/templates/agent_panel.html | 50 +++++++++++- app/templates/settings.html | 126 +++++++++++++++++++++++++++++ docs/Flowdeck_Agent_integration.md | 4 +- tests/test_agent.py | 92 +++++++++++++++++++++ 11 files changed, 546 insertions(+), 19 deletions(-) create mode 100644 app/services/llm_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba2944..a653f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog — FlowDeck +## v4.10.1 (2026-09-05) — Agent IA : configuration LLM dans l'UI + +> Comble le trou UX de v4.10.0 : l'utilisateur pouvait discuter avec l'agent mais **ne pouvait +> configurer ni provider ni modèle**. Ajout d'un sélecteur provider/modèle dans le panneau, d'une +> config runtime persistée en base (table `llm_config`) et d'un écran admin avec test de connexion. + +### Added +- **Sélecteur provider/modèle dans le panneau agent** (`agent_panel.html`) : deux ` + + + + ⚙️ + +
@@ -89,6 +108,7 @@ agents: [], conversations: [], currentConv: null, messages: [], input: '', streaming: false, liveReasoning: '', provider: 'offline', providerAvailable: false, errorMsg: '', + providers: [], providerModels: [], llmProvider: 'offline', llmModel: '', init(){ var self = this; @@ -107,8 +127,26 @@ fetch('/api/agent/providers').then(function(r){return r.json()}).then(function(d){ self.provider = d.provider || 'offline'; self.providerAvailable = !!d.available; + self.providers = d.providers || []; + if(!self.llmProvider) self.llmProvider = d.provider || 'offline'; + if(!self.llmModel) self.llmModel = d.model || ''; + self.applyModels(); }).catch(function(){}); }, + applyModels(){ + var self = this; + var p = self.providers.find(function(x){ return x.id === self.llmProvider; }); + self.providerModels = (p && p.models) || []; + if(self.providerModels.length && self.providerModels.indexOf(self.llmModel) === -1){ + self.llmModel = (p && p.default_model) || self.providerModels[0]; + } + }, + onProviderChange(){ + var self = this; + var p = self.providers.find(function(x){ return x.id === self.llmProvider; }); + self.providerModels = (p && p.models) || []; + self.llmModel = (p && p.default_model) || ((p && p.models && p.models[0]) || ''); + }, fetchAgents(){ var self = this; fetch('/api/agent').then(function(r){return r.json()}).then(function(d){ @@ -132,11 +170,16 @@ fetch('/api/agent/conversations/'+id).then(function(r){return r.json()}).then(function(d){ self.currentConv = d.conversation; self.messages = (d.messages||[]).map(function(m){ return {id:m.id, role:m.role, content:m.content, model:m.model, tool_calls:m.tool_calls_json}; }); + if(d.conversation && d.conversation.provider){ self.llmProvider = d.conversation.provider; self.applyModels(); } + if(d.conversation && d.conversation.model){ self.llmModel = d.conversation.model; } }).catch(function(){}); }, newConversation(){ var self = this; - fetch('/api/agent/conversations', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title:'New conversation'})}) + var body = {title:'New conversation'}; + if(self.llmProvider) body.provider = self.llmProvider; + if(self.llmModel) body.model = self.llmModel; + fetch('/api/agent/conversations', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}) .then(function(r){return r.json()}).then(function(d){ var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()}; self.conversations.unshift(conv); self.currentConv = conv; @@ -158,10 +201,13 @@ self.scrollBottom(); var respBuf = ''; + var payload = {message: text}; + if(self.llmProvider) payload.provider = self.llmProvider; + if(self.llmModel) payload.model = self.llmModel; return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', { method:'POST', headers:{'Content-Type':'application/json'}, - body: JSON.stringify({message: text}) + body: JSON.stringify(payload) }).then(function(resp){ if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); diff --git a/app/templates/settings.html b/app/templates/settings.html index 19eb7c5..1696726 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -94,6 +94,7 @@
Admin
{{ fd_icon("users",14) }} Users & Roles
{{ fd_icon("file-text",14) }} Audit Log
+
{{ fd_icon("bot",14) }} Agent & IA
@@ -505,6 +506,58 @@ + +
+

Agent & IA

+

Configurez le fournisseur LLM utilisé par l'agent FlowDeck. Les changements s'appliquent immédiatement, sans redémarrage.

+ +
+

Configuration LLM

+
+
Provider
+ +
+
+
Modèle
+ +
+
+
Clé API ✓ configurée
+
+ + +
+
+
+
URL API (optionnelle)
+ +
+
+ + +
+
+ + +
+

+ Tous les utilisateurs peuvent choisir un provider / modèle par conversation via le panneau Agent (🤖 en bas à droite). + Sans clé API, l'agent fonctionne en mode « offline mock » (plannificateur déterministe, aucun réseau). +

+
+
+ @@ -547,12 +600,23 @@ document.addEventListener('alpine:init', function() { newUser: {login:'',name:'',email:'',password:'',is_admin:false}, editingUser: null, editUserForm: {login:'',name:'',email:'',password:'',is_admin:false,is_active:true}, + // LLM / Agent config + llmProviders: [], + llmProviderModels: [], + llmForm: {provider:'offline', model:'', api_key:'', api_base:''}, + llmHasKey: false, + llmSaving: false, + llmTesting: false, + llmMsg: '', llmMsgOk: false, + llmTestMsg: '', llmTestOk: false, + llmKeyVisible: false, async init() { await this.loadTags(); await this.loadGiteaStatus(); await this.loadGithubStatus(); await this.loadNotifPrefs(); + if (this.userIsAdmin) await this.loadLlmConfig(); }, async loadNotifPrefs() { @@ -714,6 +778,68 @@ document.addEventListener('alpine:init', function() { } catch(e) {} }, + // ── LLM / Agent ── + async loadLlmConfig() { + try { + var r = await fetch('/api/agent/providers'); + var d = await r.json(); + this.llmProviders = d.providers || []; + this.llmHasKey = !!d.has_api_key; + this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '', api_base: d.api_base || ''}; + this.applyLlmModels(); + } catch(e) {} + }, + applyLlmModels() { + var p = this.llmProviders.find(x => x.id === this.llmForm.provider); + this.llmProviderModels = p ? (p.models || []) : []; + if (this.llmProviderModels.length && this.llmProviderModels.indexOf(this.llmForm.model) === -1) { + this.llmForm.model = (p && p.default_model) || this.llmProviderModels[0]; + } + }, + onLlmProviderChange() { this.applyLlmModels(); }, + async saveLlmConfig() { + this.llmSaving = true; + this.llmMsg = ''; this.llmMsgOk = false; + try { + var r = await this.adminFetch('/api/agent/providers', { + method: 'PATCH', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({provider: this.llmForm.provider, model: this.llmForm.model, + api_key: this.llmForm.api_key, api_base: this.llmForm.api_base}) + }); + var d = await r.json(); + if (r.ok) { + this.llmHasKey = !!d.has_api_key; + this.llmMsgOk = true; + this.llmMsg = 'Configuration enregistrée : ' + d.provider + ' / ' + d.model + (d.available ? ' — disponible ✓' : ' — clé manquante pour ce provider'); + this.llmForm.api_key = ''; + } else { + this.llmMsg = d.detail || 'Erreur lors de l\'enregistrement'; + } + } catch(e) { this.llmMsg = 'Erreur réseau'; } + finally { this.llmSaving = false; } + }, + async testLlm() { + if (!this.llmForm.provider) return; + this.llmTesting = true; + this.llmTestMsg = 'Test en cours…'; this.llmTestOk = false; + try { + var r = await this.adminFetch('/api/agent/providers/test', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({provider: this.llmForm.provider, model: this.llmForm.model, + api_key: this.llmForm.api_key, api_base: this.llmForm.api_base}) + }); + var d = await r.json(); + this.llmTestOk = !!d.ok; + this.llmTestMsg = d.ok + ? ('✓ ' + (d.model || '') + ' : ' + (d.reply || 'réponse reçue')) + : ('✗ ' + (d.error || 'échec de connexion')); + } catch(e) { this.llmTestOk = false; this.llmTestMsg = 'Erreur réseau'; } + finally { this.llmTesting = false; } + }, + getCompletedLlmTest() { return this.llmTestMsg; }, + async saveAccount() { this.accountSaving = true; this.accountMsg = ''; diff --git a/docs/Flowdeck_Agent_integration.md b/docs/Flowdeck_Agent_integration.md index 5c09276..0830239 100644 --- a/docs/Flowdeck_Agent_integration.md +++ b/docs/Flowdeck_Agent_integration.md @@ -655,7 +655,9 @@ Phase 3 — Interface Phase 4 — Personnalisation 1. Instructions custom par agent 2. Skills (table + /apply) - 3. Sélecteur de modèle (GPT/Claude/Gemini/Ollama) + 3. Sélecteur de modèle (GPT/Claude/Gemini/Ollama) ✅ (v4.10.1 : selects provider/modèle dans le + panneau + config admin runtime DB-backed `llm_config`, `/api/agent/providers` GET/PATCH, + `/api/agent/providers/test`) Phase 5 — Custom Agents 1. Scope + trigger_json diff --git a/tests/test_agent.py b/tests/test_agent.py index 32ceda0..03f58ac 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -317,6 +317,98 @@ def test_router_tools_and_providers(client): prov = client.get("/api/agent/providers").json() assert prov["provider"] == "offline" assert prov["available"] is False + assert "providers" in prov + + +def test_router_providers_enriched(client): + prov = client.get("/api/agent/providers").json() + assert "providers" in prov + ids = [p["id"] for p in prov["providers"]] + for expected in ("openai", "anthropic", "google", "ollama", "offline"): + assert expected in ids + openai = next(p for p in prov["providers"] if p["id"] == "openai") + assert openai["default_model"] == "gpt-4o" + assert openai["models"] + assert openai["requires_key"] is True + # offline requires no key and exposes no models + offline = next(p for p in prov["providers"] if p["id"] == "offline") + assert offline["requires_key"] is False + + +def test_router_patch_providers_updates_config(client): + from app.auth.session import SessionManager + from app.db import get_conn + + with get_conn() as conn: + admin = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone() + sess = SessionManager.create_session(dict(admin)) + + client.cookies.set("flowdeck_session", sess) + r = client.patch("/api/agent/providers", + json={"provider": "openai", "model": "gpt-4o-mini", + "api_key": "sk-test", "api_base": ""}) + assert r.status_code == 200 + body = r.json() + assert body["provider"] == "openai" + assert body["model"] == "gpt-4o-mini" + assert body["has_api_key"] is True + + # persisted in llm_config (single row id=1) + with get_conn() as conn: + row = conn.execute("SELECT * FROM llm_config WHERE id=1").fetchone() + assert row is not None + assert row["provider"] == "openai" + assert row["model"] == "gpt-4o-mini" + assert row["api_key"] == "sk-test" + + # GET /providers reflects the new config + prov = client.get("/api/agent/providers").json() + assert prov["provider"] == "openai" + assert prov["model"] == "gpt-4o-mini" + assert prov["has_api_key"] is True + # a real provider with a key is marked available (no network call made) + assert prov["available"] is True + + +def test_router_patch_providers_requires_admin(client): + from app.auth.session import SessionManager + from app.db import get_conn + + with get_conn() as conn: + conn.execute("INSERT INTO users (login, full_name, is_admin) VALUES ('user_basic', 'Basic', 0)") + user = conn.execute("SELECT * FROM users WHERE login='user_basic'").fetchone() + conn.commit() + sess = SessionManager.create_session(dict(user)) + client.cookies.set("flowdeck_session", sess) + + r = client.patch("/api/agent/providers", json={"provider": "openai"}) + assert r.status_code == 403 + + +def test_router_providers_test_offline(client): + r = client.post("/api/agent/providers/test", json={"provider": "offline"}) + assert r.status_code == 200 + assert r.json()["ok"] is True + + +def test_router_providers_test_missing_key(client): + # openai without a key must report failure (no mock fallback for the ping) + r = client.post("/api/agent/providers/test", + json={"provider": "openai", "api_key": ""}) + assert r.status_code == 200 + assert r.json()["ok"] is False + assert "clé" in r.json()["error"].lower() + + +def test_router_run_persists_provider_model(client): + cid = _make_conversation(client) + resp = client.post(f"/api/agent/conversations/{cid}/run", + json={"message": "crée une collection Bilan", + "provider": "openai", "model": "gpt-4o-mini"}) + assert resp.status_code == 200 + detail = client.get(f"/api/agent/conversations/{cid}").json() + assert detail["conversation"]["provider"] == "openai" + assert detail["conversation"]["model"] == "gpt-4o-mini" def _parse_sse(line: str) -> dict: