From 34368a4946028116f451c9bceb4b4a7d847b642b Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 5 Sep 2026 11:47:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(agent):=20v4.11.0=20-=20cl=C3=A9s=20API=20?= =?UTF-8?q?par=20utilisateur,=20chargement=20dynamique=20mod=C3=A8les,=20c?= =?UTF-8?q?ommandes=20slash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 37 ++++++- ROADMAP.md | 7 ++ VERSION | 2 +- app/db.py | 18 ++++ app/main.py | 2 +- app/routers/agent.py | 115 +++++++++++++++++++++- app/services/llm_config.py | 160 ++++++++++++++++++++++++++++-- app/templates/agent_panel.html | 116 +++++++++++++++++++++- app/templates/settings.html | 165 +++++++++++++++++++++++++++++-- tests/test_agent.py | 174 +++++++++++++++++++++++++++++++++ 10 files changed, 772 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a653f32..d3d7b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,41 @@ # Changelog — FlowDeck -## v4.10.1 (2026-09-05) — Agent IA : configuration LLM dans l'UI +## v4.11.0 (2026-09-05) — Agent IA : clés API par utilisateur & chargement dynamique des modèles + +> Suite de v4.10.1 : migre la config LLM d'un **global admin** vers des **credentials par +> utilisateur**. Chacun peut enregistrer plusieurs provider + clé API dans son profil, les modèles +> disponibles sont **chargés automatiquement depuis le fournisseur**, et le provider/modèle actif +> d'une conversation se pilote **directement dans le chat** via des commandes slash. + +### Added +- **Clés par utilisateur** : table `user_llm_keys` (UNIQUE(user_id, provider)) + service + `app/services/llm_config.py` : `get_user_llm_key`, `list_user_llm_keys` (masqué), + `upsert_user_llm_key`, `delete_user_llm_key`. Une clé vide conserve l'existante. +- **Chargement dynamique des modèles** : `fetch_provider_models()` (httpx) — GET `{base}/models` + pour OpenAI-compatible (Bearer), headers `x-api-key`/`anthropic-version` (Anthropic), + `x-goog-api-key` (Gemini) ; stockés dans `models_json` et fusionnés dans `GET /providers`. +- **Endpoints API** (tout utilisateur authentifié) : + - `GET /api/agent/keys`, `PUT/DELETE /api/agent/keys/{provider}`, + - `POST /api/agent/keys/{provider}/models` (fallback sur la clé stockée, pas de fuite de clé), + - `PATCH /api/agent/conversations/{id}` (title / provider / model — commande slash). +- **`POST /run`** : utilise la clé API de l'utilisateur pour le provider de la conversation + (sinon config globale / `.env` / offline mock). +- **UI Settings → Agent & IA** (visible pour tous, plus seulement admin) : carte par provider + (clé API masquée, URL API, « Charger les modèles » en live, modèle par défaut, + Enregistrer / Supprimer). La config globale reste admin sous « Configuration globale ». +- **Commandes slash dans le panel agent** : `/help`, `/providers`, `/keys`, + `/provider `, `/model ` — menu de suggestions live, appliquées via `PATCH` + et sans appel LLM. + +### Infra +- `VERSION` → 4.11.0 ; migration idempotente (création table `user_llm_keys` + index). + +### Tests +- **239 tests verts** (+7 : CRUD clés + masquage des secrets, isolation par utilisateur, + provider invalide, fetch modèles (clé stockée / body gagnant / erreur), PATCH conversation, + `/run` utilise la clé de l'utilisateur). + + > 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 diff --git a/ROADMAP.md b/ROADMAP.md index 634765f..a07a1b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -323,6 +323,13 @@ Détails livrés : - [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions) - [x] **208 tests passent** (+9 v4.9.0) +### v4.11.0 — Agent IA : clés API par utilisateur & commandes slash ✅ + +> **Livré (2026-09-05)** : credentials par utilisateur (table `user_llm_keys`, plusieurs providers), +> chargement dynamique des modèles depuis le fournisseur (`POST /keys/{p}/models`), +> commandes slash dans le chat (`/provider`, `/model`, `/keys`, … via `PATCH` conversation), +> `/run` utilise la clé de l'utilisateur. 239 tests verts. + ### v4.10.1 — Agent IA : config LLM dans l'UI ✅ > **Livré (2026-09-05)** : sélecteur provider/modèle dans le panneau agent (persisté par diff --git a/VERSION b/VERSION index 58426c1..91f3b43 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.10.1 \ No newline at end of file +4.11.0 \ No newline at end of file diff --git a/app/db.py b/app/db.py index 9474a23..424ed05 100644 --- a/app/db.py +++ b/app/db.py @@ -740,6 +740,24 @@ def init_db(): updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) + # ─── v4.10.2: per-user provider API keys ────────────────────────────── + # Each user can save several providers with their own key/base + the + # live model list fetched from the provider (models_json cache). + conn.execute(""" + CREATE TABLE IF NOT EXISTS user_llm_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + api_key TEXT NOT NULL DEFAULT '', + api_base TEXT NOT NULL DEFAULT '', + default_model TEXT DEFAULT '', + models_json TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, provider) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_user_llm_keys_user ON user_llm_keys(user_id)") # Migration: per-conversation provider/model override columns for col in ("provider", "model"): try: diff --git a/app/main.py b/app/main.py index 76cc20f..ef83793 100644 --- a/app/main.py +++ b/app/main.py @@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="4.10.1", + version="4.11.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/agent.py b/app/routers/agent.py index fa2ed85..7197104 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -16,7 +16,11 @@ from app.config import settings from app.auth.session import get_current_user from app.services.agent_engine import AgentEngine, undo_action from app.services.llm_client import LLMClient, PROVIDERS -from app.services.llm_config import get_llm_config, set_llm_config, provider_info +from app.services.llm_config import ( + get_llm_config, set_llm_config, provider_info, + get_user_llm_key, list_user_llm_keys, upsert_user_llm_key, + delete_user_llm_key, fetch_provider_models, +) from app.services.tool_registry import ToolRegistry from app.services.permission_manager import PermissionManager @@ -227,6 +231,29 @@ async def delete_conversation(request: Request, conversation_id: int): return {"id": conversation_id, "status": "deleted"} +@router.patch("/conversations/{conversation_id}") +async def patch_conversation(request: Request, conversation_id: int): + """Update a conversation's title / provider / model (slash-command support).""" + user_id = await _current_user_id(request) + body = await request.json() if request.headers.get("content-type") else {} + with get_conn() as conn: + conv = conn.execute( + "SELECT id FROM agent_conversations WHERE id=? AND user_id=?", + (conversation_id, user_id), + ).fetchone() + if not conv: + raise HTTPException(status_code=404, detail="Conversation introuvable") + sets, params = ["updated_at=CURRENT_TIMESTAMP"], [] + for col in ("title", "provider", "model"): + if body.get(col) is not None: + sets.append(f"{col}=?") + params.append(str(body[col])) + params.append(conversation_id) + conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params) + conn.commit() + return {"id": conversation_id, "status": "updated"} + + # ── Run (SSE) ── @@ -257,7 +284,12 @@ async def run_conversation(request: Request, conversation_id: int): engine = AgentEngine(user_id, workspace_id=ws) if eff_provider: - engine.llm = LLMClient(provider=eff_provider) + user_key = get_user_llm_key(user_id, eff_provider) + if user_key and user_key.get("api_key"): + engine.llm = LLMClient(provider=eff_provider, api_key=user_key["api_key"], + api_base=user_key.get("api_base") or None) + else: + engine.llm = LLMClient(provider=eff_provider) async def event_stream(): async for ev in engine.run( @@ -396,8 +428,19 @@ async def list_tools(request: Request): @router.get("/providers") async def list_providers(request: Request): """Expose the LLM providers list + current config to the UI.""" + user_id = await _current_user_id(request) llm = LLMClient() cfg = get_llm_config() + keys = list_user_llm_keys(user_id) + keys_map = {k["provider"]: k for k in keys} + provs = provider_info() + for p in provs: + k = keys_map.get(p["id"]) + if k and k["models"]: + for m in k["models"]: + if m not in p["models"]: + p["models"].append(m) + p["has_key"] = bool(k and k["has_key"]) return { "provider": llm.provider, "model": llm.default_model, @@ -405,13 +448,77 @@ async def list_providers(request: Request): "max_iterations": settings.agent_max_iterations, "api_base": cfg["api_base"], "has_api_key": bool(cfg["api_key"]), - "providers": provider_info(), + "providers": provs, + "keys": keys, } +# ── Per-user provider API keys (non-admin: any authenticated user) ── + + +@router.get("/keys") +async def list_llm_keys(request: Request): + """The user's saved provider keys + API keys (masked).""" + user_id = await _current_user_id(request) + return {"keys": list_user_llm_keys(user_id)} + + +@router.put("/keys/{llm_provider}") +async def save_llm_key(request: Request, llm_provider: str): + """Upsert a provider key for the current user (masked in responses).""" + user_id = await _current_user_id(request) + provider = llm_provider.lower() + if provider not in PROVIDERS: + raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}") + body = await request.json() if request.headers.get("content-type") else {} + raw = upsert_user_llm_key( + user_id, + provider, + api_key=(body.get("api_key") or "").strip(), + api_base=(body.get("api_base") or "").strip(), + default_model=(body.get("default_model") or "").strip(), + models=body.get("models"), + ) + stored = bool(raw.get("api_key")) + return {"status": "saved", "provider": provider, "has_key": stored} + + +@router.delete("/keys/{llm_provider}") +async def delete_llm_key(request: Request, llm_provider: str): + """Remove a saved provider key for the current user.""" + user_id = await _current_user_id(request) + provider = llm_provider.lower() + if provider not in PROVIDERS: + raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}") + delete_user_llm_key(user_id, provider) + return {"status": "deleted", "provider": provider} + + +@router.post("/keys/{llm_provider}/models") +async def fetch_llm_models(request: Request, llm_provider: str): + """Fetch the live model list from a provider. Falls back to the user's + stored key when no key is supplied in the body.""" + user_id = await _current_user_id(request) + provider = llm_provider.lower() + if provider not in PROVIDERS: + raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}") + body = await request.json() if request.headers.get("content-type") else {} + api_key = (body.get("api_key") or "").strip() + api_base = (body.get("api_base") or "").strip() + if not api_key: + stored = get_user_llm_key(user_id, provider) + if stored and stored.get("api_key"): + api_key = stored["api_key"] + api_base = api_base or (stored.get("api_base") or "").strip() + try: + models = await fetch_provider_models(provider, api_key=api_key, api_base=api_base) + return {"ok": True, "provider": provider, "models": models} + except Exception as exc: # noqa: BLE001 — surface connectivity errors + return {"ok": False, "provider": provider, "error": str(exc)} + + @router.patch("/providers") async def update_provider_config(request: Request): - """Admin: persist the global LLM provider/model/key/base (runtime, DB-backed).""" await _current_admin(request) body = await request.json() if request.headers.get("content-type") else {} provider = (body.get("provider") or "").strip().lower() diff --git a/app/services/llm_config.py b/app/services/llm_config.py index 1feee84..5e6a893 100644 --- a/app/services/llm_config.py +++ b/app/services/llm_config.py @@ -1,15 +1,26 @@ -"""FlowDeck — Runtime LLM configuration store (v4.10.1). +"""FlowDeck — Runtime LLM configuration store (v4.10.2). -Precedence: a saved `llm_config` DB row (id=1) overrides the .env settings. -The row is created only once an admin saves the LLM settings from the UI; -until then the app falls back to `settings.llm_*` (default: offline mock). +Precedence (per conversation): + 1. explicit `provider`/`model` passed to the run endpoint, + 2. the user's saved key for that provider (`user_llm_keys`), + 3. the global `llm_config` row (id=1) — admin UI, + 4. `settings.llm_*` (.env), default « offline mock ». + +Rows are created lazily, so .env stays the default until saved from the UI. """ from __future__ import annotations +import json +from typing import Optional + from app.config import settings from app.services.llm_client import PROVIDERS, PROVIDER_MODELS -__all__ = ["get_llm_config", "set_llm_config", "provider_info"] +__all__ = [ + "get_llm_config", "set_llm_config", "provider_info", + "get_user_llm_key", "list_user_llm_keys", "upsert_user_llm_key", + "delete_user_llm_key", "fetch_provider_models", +] def get_llm_config() -> dict: @@ -79,4 +90,141 @@ def provider_info() -> list[dict]: "models": models, "requires_key": name not in ("offline", "ollama"), }) - return out \ No newline at end of file + return out + + +# ── Per-user provider keys ── + + +def _mask_key(row) -> dict: + """Public view of a stored key row: never exposes the raw api_key.""" + return { + "provider": row["provider"], + "api_base": row["api_base"] or "", + "default_model": row["default_model"] or "", + "models": json.loads(row["models_json"] or "[]"), + "has_key": bool(row["api_key"]), + } + + +def get_user_llm_key(user_id: int, provider: str) -> Optional[dict]: + """Return a stored key row (includes the raw api_key — server-side only).""" + from app.db import get_conn + + provider = provider.lower() + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM user_llm_keys WHERE user_id=? AND provider=?", + (user_id, provider), + ).fetchone() + return dict(row) if row else None + + +def list_user_llm_keys(user_id: int) -> list[dict]: + """Public (masked) list of the user's saved provider keys.""" + from app.db import get_conn + + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM user_llm_keys WHERE user_id=? ORDER BY provider", + (user_id,), + ).fetchall() + return [_mask_key(r) for r in rows] + + +def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "", + api_base: str = "", default_model: str = "", + models: Optional[list[str]] = None) -> dict: + """Upsert a user's provider key. Empty api_key keeps the existing one + (allows saving model/base without re-typing the key).""" + from app.db import get_conn + + provider = provider.lower() + existing = get_user_llm_key(user_id, provider) + new_key = api_key if api_key else (existing.get("api_key", "") if existing else "") + new_base = api_base if api_base else (existing.get("api_base", "") if existing else "") + new_model = default_model if default_model else (existing.get("default_model", "") if existing else "") + new_models = models if models is not None else ( + json.loads(existing["models_json"]) if existing and existing.get("models_json") else [] + ) + with get_conn() as conn: + conn.execute( + """INSERT INTO user_llm_keys (user_id, provider, api_key, api_base, default_model, models_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id, provider) DO UPDATE SET + api_key=excluded.api_key, api_base=excluded.api_base, + default_model=excluded.default_model, models_json=excluded.models_json, + updated_at=CURRENT_TIMESTAMP""", + (user_id, provider, new_key, new_base, new_model, + json.dumps(new_models, ensure_ascii=False)), + ) + conn.commit() + return get_user_llm_key(user_id, provider) or { + "provider": provider, "api_key": new_key, "api_base": new_base, + "default_model": new_model, "models_json": json.dumps(new_models, ensure_ascii=False), + } + + +def delete_user_llm_key(user_id: int, provider: str) -> None: + from app.db import get_conn + + provider = provider.lower() + with get_conn() as conn: + conn.execute( + "DELETE FROM user_llm_keys WHERE user_id=? AND provider=?", + (user_id, provider), + ) + conn.commit() + + +async def fetch_provider_models(provider: str, *, api_key: str = "", + api_base: str = "", timeout: int = 20) -> list[str]: + """Fetch the live model list from a provider (best-effort, no mock). + + OpenAI-compatible providers use `GET {base}/models` with a Bearer token; + Anthropic uses `x-api-key` + `anthropic-version`; Gemini an `x-goog-api-key`. + Returns a de-duplicated list capped at 300 models. + """ + import httpx + + provider = provider.lower() + base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0] + if not base: + return [] # offline — nothing to fetch + + base_url = base.rstrip("/") + headers: dict = {} + if provider == "anthropic": + headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"} + elif provider == "google": + headers = {"x-goog-api-key": api_key} + elif api_key: + headers = {"Authorization": f"Bearer {api_key}"} + + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.get(f"{base_url}/models", headers=headers) + resp.raise_for_status() + data = resp.json() + + ids: list[str] = [] + for item in data.get("data") or []: + if not isinstance(item, dict): + continue + i = (item.get("id") or "").strip() + if i: + ids.append(i) + for item in data.get("models") or []: + if not isinstance(item, dict): + continue + n = (item.get("name") or item.get("id") or "").strip() + if provider == "google" and n.startswith("models/"): + n = n[len("models/"):] + if n: + ids.append(n) + seen: set[str] = set() + out: list[str] = [] + for i in ids: + if i not in seen: + seen.add(i) + out.append(i) + return out[:300] \ No newline at end of file diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index 42cb469..178698e 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -31,6 +31,12 @@ .fd-agent-config{display:flex;align-items:center;gap:6px;padding:6px 10px;border-bottom:1px solid var(--border,rgba(255,255,255,.06))} .fd-agent-select{flex:1;min-width:0;background:var(--bg-tertiary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:7px;color:var(--text,#fff);font-size:12px;padding:5px 8px;outline:none;font-family:inherit} .fd-agent-select:focus{border-color:var(--accent,#2383E2)} +.fd-agent-cmd-menu{position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;background:var(--bg-tertiary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:10px;max-height:180px;overflow-y:auto;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:20;display:none} +.fd-agent-cmd-menu.show{display:block} +.fd-agent-cmd-item{padding:7px 12px;font-size:12px;color:var(--text,#eee);cursor:pointer;border-radius:6px;margin:2px;display:flex;gap:8px;align-items:baseline} +.fd-agent-cmd-item:hover{background:var(--bg-hover,#333)} +.fd-agent-cmd-item b{color:var(--accent,#2383E2);font-family:monospace;white-space:nowrap} +.fd-agent-cmd-item span{color:var(--text-dim,#999);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .fd-agent-gear{background:none;border:none;color:var(--text-dim,#999);font-size:14px;cursor:pointer;padding:2px 4px;border-radius:6px;text-decoration:none;line-height:1} .fd-agent-gear:hover{background:var(--bg-hover,#333);color:var(--text,#fff)} @@ -55,7 +61,7 @@ - ⚙️ + ⚙️
@@ -94,7 +100,16 @@
- +
+
+ +
+ +
@@ -108,7 +123,7 @@ agents: [], conversations: [], currentConv: null, messages: [], input: '', streaming: false, liveReasoning: '', provider: 'offline', providerAvailable: false, errorMsg: '', - providers: [], providerModels: [], llmProvider: 'offline', llmModel: '', + providers: [], providerModels: [], llmProvider: 'offline', llmModel: '', llmKeys: [], init(){ var self = this; @@ -128,6 +143,7 @@ self.provider = d.provider || 'offline'; self.providerAvailable = !!d.available; self.providers = d.providers || []; + self.llmKeys = d.keys || []; if(!self.llmProvider) self.llmProvider = d.provider || 'offline'; if(!self.llmModel) self.llmModel = d.model || ''; self.applyModels(); @@ -188,10 +204,100 @@ }, openAgent(){ this.open=true; this.loadConversations(); }, close(){ this.open=false; }, + get commandOpen(){ return /^\s*\//.test(this.input) && !this.streaming; }, + get commandSuggestions(){ + var raw = (this.input||'').trim(); + if(!/^\//.test(raw)) return []; + var q = raw.replace(/^\/+/, '').toLowerCase(); + var first = (q.split(/\s+/).filter(Boolean)[0] || ''); + var all = [ + {cmd:'/help', desc:'liste des commandes'}, + {cmd:'/providers', desc:'liste les fournisseurs et leurs clés'}, + {cmd:'/provider ', desc:'changer de fournisseur LLM'}, + {cmd:'/model ', desc:'changer de modèle LLM'}, + {cmd:'/keys', desc:'fournisseurs avec clé API enregistrée'} + ]; + if(!first) return all; + return all.filter(function(c){ return c.cmd.toLowerCase().replace(/^\/+/, '').indexOf(first) === 0; }); + }, + useSuggestion(c){ + var v = c.cmd.replace(/\s*<[^>]*>\s*$/, ' '); + this.input = v; + if(this.$refs.cmdInput) this.$refs.cmdInput.focus(); + }, + echo(msg){ + this.messages.push({id:'cmd-'+Date.now()+Math.random(), role:'assistant', content: msg, model:''}); + this.scrollBottom(); + }, + patchConversation(){ + var self = this; + if(!self.currentConv) return; + var body = {}; + if(self.llmProvider) body.provider = self.llmProvider; + if(self.llmModel) body.model = self.llmModel; + fetch('/api/agent/conversations/'+self.currentConv.id, { + method:'PATCH', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify(body) + }).catch(function(){}); + }, + handleCommand(text){ + var self = this; + var parts = text.replace(/^\/+/, '').trim().split(/\s+/); + var cmd = (parts.shift() || '').toLowerCase(); + var arg = parts.join(' ').trim(); + if(cmd === 'help' || cmd === 'aide' || cmd === '?'){ + this.echo('Commandes : /providers · /keys · /provider · /model · /help'); + return true; + } + if(cmd === 'providers'){ + var list = this.providers.map(function(x){ return x.id + (x.has_key ? ' ✓' : ''); }).join(', '); + this.echo('Fournisseurs : ' + (list || 'aucun')); + return true; + } + if(cmd === 'keys'){ + var saved = this.llmKeys.map(function(k){ return k.provider + ' ✓'; }).join(', '); + this.echo('Clés enregistrées : ' + (saved || 'aucune — configurez-les dans Paramètres → Agent & IA')); + return true; + } + if(cmd === 'provider'){ + var avail = this.providers.map(function(x){ return x.id; }); + if(!arg){ + this.echo('Utilisez : /provider . Fournisseurs : ' + avail.join(', ')); + return true; + } + var p = this.providers.find(function(x){ return x.id === arg.toLowerCase(); }); + if(!p){ + this.echo('Fournisseur inconnu : ' + arg + ' (disponibles : ' + avail.join(', ') + ')'); + return true; + } + this.llmProvider = p.id; + this.onProviderChange(); + if(p.models && p.models.length && this.providerModels.indexOf(this.llmModel) === -1){ + this.llmModel = (p.default_model) || p.models[0]; + } + this.patchConversation(); + this.echo('Provider activé : ' + p.id + ' · modèle : ' + (this.llmModel || 'défaut')); + return true; + } + if(cmd === 'model'){ + if(!arg){ + this.echo('Utilisez : /model . Modèles pour ' + this.llmProvider + ' : ' + this.providerModels.join(', ')); + return true; + } + this.llmModel = arg; + this.patchConversation(); + this.echo('Modèle activé : ' + arg + ' (' + this.llmProvider + ')'); + return true; + } + return false; + }, send(){ var self = this; - if(!self.currentConv || self.streaming || !self.input.trim()) return; + if(!self.currentConv || self.streaming) return; var text = self.input.trim(); + if(!text) return; + if(self.commandOpen && self.handleCommand(text)){ self.input=''; return; } self.input = ''; self.errorMsg = ''; self.streaming = true; @@ -257,7 +363,7 @@ this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; }); }, get providerLabel(){ - if(this.providerAvailable) return this.provider; + if(this.providerAvailable) return (this.llmProvider || this.provider); return 'offline mock'; } }; diff --git a/app/templates/settings.html b/app/templates/settings.html index 1696726..883f167 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -88,13 +88,13 @@
{{ fd_icon("tag",14) }} Tags
Features
{{ fd_icon("link",14) }} Integrations
+
{{ fd_icon("bot",14) }} Agent & IA
@@ -506,13 +506,68 @@ - -
+ +

Agent & IA

-

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

+

Sauvegardez vos propres clés API par fournisseur : l'agent FlowDeck utilisera la clé du provider sélectionné dans chaque conversation. Les modèles disponibles sont chargés automatiquement depuis le fournisseur.

-

Configuration LLM

+

Mes fournisseurs

+ +

+ Sans clé enregistrée pour le provider choisi, l'agent utilise la configuration globale par défaut (ou le mode « offline mock »). + Activez un provider / modèle dans chaque conversation via les sélecteurs du panneau Agent (🤖 en bas à droite) ou la commande /provider et /model. +

+
+ +
+

Configuration globale (défaut pour tous les utilisateurs)

Provider