feat(agent): v4.11.0 - clés API par utilisateur, chargement dynamique modèles, commandes slash
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-05 11:47:58 -04:00
parent 1c11b9d351
commit 34368a4946
10 changed files with 772 additions and 24 deletions
+36 -1
View File
@@ -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 <nom>`, `/model <nom>` — 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
+7
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
4.10.1
4.11.0
+18
View File
@@ -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:
+1 -1
View File
@@ -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,
+111 -4
View File
@@ -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()
+154 -6
View File
@@ -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
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]
+111 -5
View File
@@ -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)}
</style>
@@ -55,7 +61,7 @@
<option :value="m" x-text="m"></option>
</template>
</select>
<a href="/settings" target="_blank" class="fd-agent-gear" title="Configurer (clé API, admin)" @click.stop>⚙️</a>
<a href="/settings" target="_blank" class="fd-agent-gear" title="Configurer (clés API, Settings → Agent & IA)" @click.stop>⚙️</a>
</div>
<div class="fd-agent-tabs">
@@ -94,7 +100,16 @@
</div>
<div class="fd-agent-input-area">
<textarea class="fd-agent-input" rows="2" placeholder="Demandez à l'agent (ex: crée une collection Projets)…" x-model="input" @keydown.enter.exact.prevent="send()"></textarea>
<div style="position:relative;flex:1;">
<div class="fd-agent-cmd-menu" :class="{show: commandOpen && commandSuggestions.length}">
<template x-for="c in commandSuggestions" :key="c.cmd">
<div class="fd-agent-cmd-item" @click="useSuggestion(c)">
<b x-text="c.cmd"></b><span x-text="c.desc"></span>
</div>
</template>
</div>
<textarea class="fd-agent-input" x-ref="cmdInput" rows="2" placeholder="Demandez à l'agent (ex: crée une collection Projets)… tapez / pour les commandes" x-model="input" @keydown.enter.exact.prevent="send()"></textarea>
</div>
<button class="fd-agent-send" @click="send()" :disabled="streaming || !input.trim()">▶</button>
</div>
</div>
@@ -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 <nom>', desc:'changer de fournisseur LLM'},
{cmd:'/model <nom>', 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 <nom> · /model <nom> · /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 <nom>. 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 <nom>. 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';
}
};
+159 -6
View File
@@ -88,13 +88,13 @@
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div>
<!-- Admin nav: only visible to admins -->
<template x-if="userIsAdmin">
<div>
<div class="settings-nav-header">Admin</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-llm' }" @click="activeSection='admin-llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div>
</div>
</template>
</div>
@@ -506,13 +506,68 @@
</div>
</div>
<!-- Admin: Agent & IA (LLM config) -->
<div x-show="activeSection==='admin-llm'">
<!-- Agent & IA: per-user provider keys + admin global default -->
<div x-show="activeSection==='llm'">
<h2>Agent &amp; IA</h2>
<p class="section-desc">Configurez le fournisseur LLM utilisé par l'agent FlowDeck. Les changements s'appliquent immédiatement, sans redémarrage.</p>
<p class="section-desc">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.</p>
<div class="setting-group">
<h3>Configuration LLM</h3>
<h3>Mes fournisseurs</h3>
<template x-for="p in llmProviders" :key="p.id">
<div style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:10px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<strong style="flex:1;font-size:13px;" x-text="p.name"></strong>
<span x-show="keyForm(p.id).has_key" style="font-size:11px;color:var(--toast-success-bg);">✓ clé enregistrée</span>
<span x-show="!keyForm(p.id).has_key" style="font-size:11px;color:var(--text-dim);">aucune clé enregistrée</span>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">
Clé API
<span style="font-size:11px;color:var(--text-dim);">(laisser vide pour conserver l'existante)</span>
</div>
<div class="pw-wrapper" style="max-width:340px;">
<input :type="keyForm(p.id).keyVisible ? 'text' : 'password'" class="settings-input"
x-model="keyForm(p.id).api_key" placeholder="sk-…" style="padding-right:36px;">
<button type="button" class="pw-toggle" @click="keyForm(p.id).keyVisible=!keyForm(p.id).keyVisible"
x-text="keyForm(p.id).keyVisible ? '{{ fd_icon('eye-off',14) }}' : '{{ fd_icon('eye',14) }}'" style="top:50%;"></button>
</div>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">URL API (optionnelle — Ollama, proxy OpenAI-compatible…)</div>
<input type="text" class="settings-input" x-model="keyForm(p.id).api_base" placeholder="https://…" style="max-width:340px;">
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">Modèle par défaut</div>
<select class="settings-input" x-model="keyForm(p.id).model" style="max-width:340px;">
<template x-for="m in keyModels(p.id)" :key="m">
<option :value="m" x-text="m"></option>
</template>
</select>
</div>
<div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;">
<button class="btn" @click="loadProviderModels(p.id)" :disabled="keyForm(p.id).modelLoading" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).modelLoading">{{ fd_icon("refresh",14) }} Charger les modèles</span>
<span x-show="keyForm(p.id).modelLoading">Chargement…</span>
</button>
<button class="btn btn-primary" @click="saveUserKey(p.id)" :disabled="keyForm(p.id).saving" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).saving">{{ fd_icon("save",14) }} Enregistrer</span>
<span x-show="keyForm(p.id).saving">Enregistrement…</span>
</button>
<button class="btn" @click="deleteUserKey(p.id)" :disabled="keyForm(p.id).deleting" x-show="keyForm(p.id).has_key" style="font-size:12px;padding:6px 12px;color:var(--danger);">Supprimer</button>
</div>
<div style="margin-top:8px;font-size:12px;">
<span x-show="keyForm(p.id).msg" x-text="keyForm(p.id).msg" :style="{color: keyForm(p.id).ok ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
</div>
</div>
</template>
<p style="font-size:12px;color:var(--text-dim);margin-top:6px;">
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 <code>/provider</code> et <code>/model</code>.
</p>
</div>
<div class="setting-group" x-show="userIsAdmin">
<h3>Configuration globale (défaut pour tous les utilisateurs)</h3>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">Provider</div>
<select class="settings-input" x-model="llmForm.provider" @change="onLlmProviderChange()" style="max-width:300px;">
@@ -552,7 +607,6 @@
<span x-show="llmTestMsg" x-text="llmTestMsg" :style="{color: llmTestOk ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
</div>
<p style="font-size:12px;color:var(--text-dim);margin-top:14px;">
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).
</p>
</div>
@@ -610,12 +664,15 @@ document.addEventListener('alpine:init', function() {
llmMsg: '', llmMsgOk: false,
llmTestMsg: '', llmTestOk: false,
llmKeyVisible: false,
// Per-user provider keys (multi-providers)
keyForms: {},
async init() {
await this.loadTags();
await this.loadGiteaStatus();
await this.loadGithubStatus();
await this.loadNotifPrefs();
await this.loadUserLlm();
if (this.userIsAdmin) await this.loadLlmConfig();
},
@@ -779,6 +836,102 @@ document.addEventListener('alpine:init', function() {
},
// ── LLM / Agent ──
keyForm(id) {
if (!this.keyForms[id]) {
this.keyForms[id] = {api_key:'', api_base:'', model:'', models:[], has_key:false,
keyVisible:false, modelLoading:false, saving:false, deleting:false, msg:'', ok:false};
}
return this.keyForms[id];
},
keyModels(id) {
var p = this.llmProviders.find(x => x.id === id);
var base = ((p && p.models) || []).slice();
var f = this.keyForms[id];
if (f && f.models) f.models.forEach(m => { if (base.indexOf(m) === -1) base.push(m); });
return base;
},
async loadUserLlm() {
try {
var r = await fetch('/api/agent/providers');
var d = await r.json();
this.llmProviders = d.providers || [];
var kr = await fetch('/api/agent/keys');
var kd = await kr.json();
var keys = kd.keys || [];
this.llmProviders.forEach(p => {
var k = keys.find(x => x.provider === p.id);
this.keyForm(p.id).api_base = (k && k.api_base) || '';
this.keyForm(p.id).model = (k && k.default_model) || (p.default_model) || '';
this.keyForm(p.id).models = (k && k.models) || [];
this.keyForm(p.id).has_key = !!(k && k.has_key);
});
} catch(e) {}
},
async loadProviderModels(id) {
var f = this.keyForm(id);
if (f.modelLoading) return;
f.modelLoading = true; f.msg = ''; f.ok = false;
try {
var r = await fetch('/api/agent/keys/' + id + '/models', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
});
var d = await r.json();
if (d.ok) {
f.models = d.models || [];
if (f.models.length) {
if (f.models.indexOf(f.model) === -1) f.model = f.models[0];
f.msg = f.models.length + ' modèles chargés';
f.ok = true;
} else {
f.msg = 'Aucun modèle renvoyé par le fournisseur';
}
} else {
f.msg = 'Erreur: ' + (d.error || 'échec de connexion');
}
} catch(e) { f.msg = 'Erreur réseau'; }
finally { f.modelLoading = false; }
},
async saveUserKey(id) {
var f = this.keyForm(id);
f.saving = true; f.msg = ''; f.ok = false;
try {
var body = {api_key: f.api_key, api_base: f.api_base, default_model: f.model};
if (f.models && f.models.length) body.models = f.models;
var r = await fetch('/api/agent/keys/' + id, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
var d = await r.json();
if (r.ok) {
f.has_key = !!d.has_key;
f.api_key = '';
f.msg = 'Enregistré pour ' + id + (f.has_key ? ' — clé active ✓' : ' — aucune clé stockée');
f.ok = true;
} else {
f.msg = d.detail || 'Erreur lors de l\'enregistrement';
}
} catch(e) { f.msg = 'Erreur réseau'; }
finally { f.saving = false; }
},
async deleteUserKey(id) {
var f = this.keyForm(id);
f.deleting = true; f.msg = ''; f.ok = false;
try {
var r = await fetch('/api/agent/keys/' + id, { method: 'DELETE' });
var d = await r.json();
if (r.ok) {
f.has_key = false; f.api_key = ''; f.models = []; f.model = '';
f.msg = 'Clé supprimée pour ' + id;
f.ok = true;
} else {
f.msg = d.detail || 'Erreur lors de la suppression';
}
} catch(e) { f.msg = 'Erreur réseau'; }
finally { f.deleting = false; }
},
async loadLlmConfig() {
try {
var r = await fetch('/api/agent/providers');
+174
View File
@@ -418,4 +418,178 @@ def _parse_sse(line: str) -> dict:
return json.loads(part[len("data:"):].strip())
except json.JSONDecodeError:
return {}
# ── Per-user provider keys (v4.10.2) ──
def _session_for_login(login: str) -> str:
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
return SessionManager.create_session(dict(user))
def test_router_user_keys_crud(client):
# no session → falls back to the single admin user
r = client.put("/api/agent/keys/openai",
json={"api_key": "sk-secret-1", "api_base": "", "default_model": "gpt-4o-mini"})
assert r.status_code == 200
assert r.json()["has_key"] is True
keys = client.get("/api/agent/keys").json()["keys"]
entry = [k for k in keys if k["provider"] == "openai"][0]
assert entry["has_key"] is True
assert entry["default_model"] == "gpt-4o-mini"
assert "sk-secret-1" not in json.dumps(entry), "raw api_key must never be exposed"
# empty key keeps the stored one
r = client.put("/api/agent/keys/openai",
json={"api_key": "", "models": ["gpt-4o", "gpt-4o-custom"]})
assert r.status_code == 200
keys = client.get("/api/agent/keys").json()["keys"]
entry = [k for k in keys if k["provider"] == "openai"][0]
assert entry["has_key"] is True
assert "gpt-4o-custom" in entry["models"]
# providers endpoint merges saved models + has_key flag
prov = client.get("/api/agent/providers").json()
p = [x for x in prov["providers"] if x["id"] == "openai"][0]
assert p["has_key"] is True
assert "gpt-4o-custom" in p["models"]
r = client.delete("/api/agent/keys/openai")
assert r.status_code == 200
keys = client.get("/api/agent/keys").json()["keys"]
assert all(k["provider"] != "openai" for k in keys)
def test_router_user_keys_scoped_per_user(client):
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, is_admin) VALUES ('alice', 'Alice', 0)")
conn.commit()
# admin saves a key
client.put("/api/agent/keys/deepseek", json={"api_key": "sk-admin-deepseek"})
assert client.get("/api/agent/keys").json()["keys"]
# alice saves her own key and must NOT see the admin's
client.cookies.set("flowdeck_session", _session_for_login("alice"))
client.put("/api/agent/keys/deepseek", json={"api_key": "sk-alice-deepseek"})
keys = client.get("/api/agent/keys").json()["keys"]
entry = [k for k in keys if k["provider"] == "deepseek"][0]
assert entry["has_key"] is True
from app.db import get_conn as _gc
with _gc() as conn:
rows = conn.execute(
"SELECT user_id, api_key FROM user_llm_keys WHERE provider='deepseek'"
).fetchall()
by_user = {r["user_id"]: r["api_key"] for r in rows}
assert len(by_user) == 2
assert "sk-admin-deepseek" in by_user.values()
assert "sk-alice-deepseek" in by_user.values()
# masked API never leaks a raw key
assert "sk-alice-deepseek" not in json.dumps(keys)
def test_router_user_keys_invalid_provider(client):
assert client.put("/api/agent/keys/bogus", json={"api_key": "x"}).status_code == 400
assert client.post("/api/agent/keys/bogus/models").status_code == 400
assert client.delete("/api/agent/keys/bogus").status_code == 400
def test_router_fetch_models_uses_stored_key(client, monkeypatch):
from app.routers import agent as agent_router
calls = []
async def fake_fetch(provider, *, api_key="", api_base="", timeout=20):
calls.append({"provider": provider, "api_key": api_key, "api_base": api_base})
return ["stub-a", "stub-b"]
monkeypatch.setattr(agent_router, "fetch_provider_models", fake_fetch)
# no stored key yet → empty key is sent
r = client.post("/api/agent/keys/openai/models", json={})
assert r.status_code == 200
assert r.json()["ok"] is True
assert r.json()["models"] == ["stub-a", "stub-b"]
assert calls[-1]["api_key"] == ""
# stored key is used as fallback when the body carries none
client.put("/api/agent/keys/openai", json={"api_key": "sk-stored"})
r = client.post("/api/agent/keys/openai/models", json={"api_base": "https://proxy.home/v1"})
assert r.status_code == 200
assert calls[-1]["api_key"] == "sk-stored"
assert calls[-1]["api_base"] == "https://proxy.home/v1"
# body-provided key wins over the stored one
r = client.post("/api/agent/keys/openai/models", json={"api_key": "sk-typed"})
assert calls[-1]["api_key"] == "sk-typed"
def test_router_fetch_models_error_surfaced(client, monkeypatch):
from app.routers import agent as agent_router
async def fake_fetch(provider, *, api_key="", api_base="", timeout=20):
raise ConnectionError("injoignable")
monkeypatch.setattr(agent_router, "fetch_provider_models", fake_fetch)
r = client.post("/api/agent/keys/openai/models", json={"api_key": "sk-x"})
assert r.status_code == 200
body = r.json()
assert body["ok"] is False
assert "injoignable" in body["error"]
def test_router_patch_conversation(client):
cid = _make_conversation(client)
r = client.patch(f"/api/agent/conversations/{cid}",
json={"provider": "openai", "model": "gpt-4o-mini", "title": "Sprint préparé"})
assert r.status_code == 200
detail = client.get(f"/api/agent/conversations/{cid}").json()
conv = detail["conversation"]
assert conv["provider"] == "openai"
assert conv["model"] == "gpt-4o-mini"
assert conv["title"] == "Sprint préparé"
def test_router_run_uses_user_key(client, monkeypatch):
from app.routers import agent as agent_router
created = {}
class FakeLLM(agent_router.LLMClient):
def __init__(self, *args, **kwargs):
created.update(kwargs)
super().__init__(*args, **kwargs)
async def complete(self, messages, *, model=None, tools=None, stream=False):
return await self._mock_complete(messages, model, tools)
monkeypatch.setattr(agent_router, "LLMClient", FakeLLM)
# user key saved → run must create the LLM client with that key
client.put("/api/agent/keys/openai", json={"api_key": "sk-user-secret", "api_base": "https://proxy.home/v1"})
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
assert created.get("api_key") == "sk-user-secret"
assert created.get("api_base") == "https://proxy.home/v1"
# without a saved key the user key is not injected (global config/environment wins)
assert client.delete("/api/agent/keys/openai").status_code == 200
created.clear()
cid2 = _make_conversation(client)
resp = client.post(f"/api/agent/conversations/{cid2}/run",
json={"message": "crée une collection Bilan",
"provider": "openai", "model": "gpt-4o-mini"})
assert resp.status_code == 200
assert created.get("api_key", "") == ""
return {}