feat(agent): v4.10.1 - config LLM dans l'UI (sélecteur provider/modèle, config runtime, admin + test connexion)
- 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
This commit is contained in:
@@ -1,5 +1,40 @@
|
|||||||
# Changelog — FlowDeck
|
# 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 `<select>`
|
||||||
|
(provider + modèles connus par provider) dans l'en-tête du chat, branchés sur `POST /run` et
|
||||||
|
**persistés par conversation** (colonnes `provider`/`model` sur `agent_conversations`,
|
||||||
|
migrées automatiquement). Bouton ⚙️ pointant vers la config admin.
|
||||||
|
- **Endpoints API** :
|
||||||
|
- `GET /api/agent/providers` enrichi : liste complète des providers avec modèles connus,
|
||||||
|
`requires_key`, `api_base` et `has_api_key`.
|
||||||
|
- `PATCH /api/agent/providers` (admin) : persiste provider/modèle/clé/base en runtime.
|
||||||
|
- `POST /api/agent/providers/test` (admin) : test de connexion **sans fallback mock**
|
||||||
|
(méthode `LLMClient.ping()`) ; le provider `offline` répond sans réseau.
|
||||||
|
- **Table `llm_config`** (1 ligne, id=1) + service `app/services/llm_config.py` :
|
||||||
|
précédence **base (runtime) > `.env`**, créée seulement au premier enregistrement.
|
||||||
|
- **Écran admin** : nouvelle section *Agent & IA* dans Settings (onglet Admin) : provider,
|
||||||
|
modèle, clé API (masquée / réaffichable), URL API optionnelle, boutons
|
||||||
|
**Save configuration** et **Test connection** avec retour d'état inline.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `LLMClient.__init__` : le modèle par défaut suit maintenant la config runtime
|
||||||
|
(DB > `.env`) au lieu d'être toujours `gpt-4o` ; `PROVIDER_MODELS` documente les modèles
|
||||||
|
connus par provider.
|
||||||
|
|
||||||
|
### Infra
|
||||||
|
- `VERSION` → 4.10.1 ; migration idempotente des colonnes `agent_conversations.provider/model`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- **232 tests verts** (+6 : providers enrichi, PATCH config (admin + 403 non-admin),
|
||||||
|
test connexion offline, test connexion clé manquante, persistance provider/modèle par conversation).
|
||||||
|
|
||||||
## v4.10.0 (2026-09-05) — FlowDeck Agent : agent IA natif conversationnel
|
## v4.10.0 (2026-09-05) — FlowDeck Agent : agent IA natif conversationnel
|
||||||
|
|
||||||
> Implémentation complète du pilier « Agent IA » : un agent ReAct intégré qui **agit** sur le
|
> Implémentation complète du pilier « Agent IA » : un agent ReAct intégré qui **agit** sur le
|
||||||
|
|||||||
+7
-1
@@ -323,6 +323,12 @@ Détails livrés :
|
|||||||
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
|
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
|
||||||
- [x] **208 tests passent** (+9 v4.9.0)
|
- [x] **208 tests passent** (+9 v4.9.0)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
> conversation), config runtime DB-backed (`llm_config`), écran admin *Agent & IA* avec
|
||||||
|
> test de connexion (`LLMClient.ping()`, sans fallback mock). 232 tests verts.
|
||||||
|
|
||||||
### v4.10.0 — FlowDeck Agent (Agent IA natif) ✅
|
### v4.10.0 — FlowDeck Agent (Agent IA natif) ✅
|
||||||
|
|
||||||
> **Livré (2026-09-05)** : agent conversationnel complet — boucle ReAct, streaming SSE,
|
> **Livré (2026-09-05)** : agent conversationnel complet — boucle ReAct, streaming SSE,
|
||||||
@@ -392,7 +398,7 @@ app/
|
|||||||
|
|
||||||
#### Plan de migration (5 phases)
|
#### Plan de migration (5 phases)
|
||||||
1. **Phase 1 — Core Agent** : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
|
1. **Phase 1 — Core Agent** : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
|
||||||
2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle
|
2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle ✅ (v4.10.1)
|
||||||
3. **Phase 3 — Outils avancés** : 7 outils supplémentaires (views, properties, Gitea, uploads)
|
3. **Phase 3 — Outils avancés** : 7 outils supplémentaires (views, properties, Gitea, uploads)
|
||||||
4. **Phase 4 — Autonomie** : custom agents, skills, déclencheurs planifiés
|
4. **Phase 4 — Autonomie** : custom agents, skills, déclencheurs planifiés
|
||||||
5. **Phase 5 — Plateforme** : API publique agent → intégrations tierces, marketplace skills
|
5. **Phase 5 — Plateforme** : API publique agent → intégrations tierces, marketplace skills
|
||||||
|
|||||||
@@ -726,6 +726,27 @@ def init_db():
|
|||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)")
|
||||||
|
|
||||||
|
# ─── v4.10.1: LLM runtime config (single row id=1) ─────────────────────
|
||||||
|
# Created lazily (no seed) so .env stays the default until an admin saves
|
||||||
|
# the LLM settings from the UI. Precedence: DB row > settings.llm_*.
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
provider TEXT NOT NULL DEFAULT 'offline',
|
||||||
|
model TEXT DEFAULT '',
|
||||||
|
api_key TEXT DEFAULT '',
|
||||||
|
api_base TEXT DEFAULT '',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Migration: per-conversation provider/model override columns
|
||||||
|
for col in ("provider", "model"):
|
||||||
|
try:
|
||||||
|
conn.execute(f"ALTER TABLE agent_conversations ADD COLUMN {col} TEXT DEFAULT ''")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # column already exists
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_llm ON agent_conversations(provider, model)")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+93
-10
@@ -15,7 +15,8 @@ from app.db import get_conn
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.auth.session import get_current_user
|
from app.auth.session import get_current_user
|
||||||
from app.services.agent_engine import AgentEngine, undo_action
|
from app.services.agent_engine import AgentEngine, undo_action
|
||||||
from app.services.llm_client import LLMClient
|
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.tool_registry import ToolRegistry
|
from app.services.tool_registry import ToolRegistry
|
||||||
from app.services.permission_manager import PermissionManager
|
from app.services.permission_manager import PermissionManager
|
||||||
|
|
||||||
@@ -100,6 +101,25 @@ async def _workspace_id(request: Request) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _current_admin(request: Request) -> dict:
|
||||||
|
"""Require an admin session. Falls back to the single admin row, matching
|
||||||
|
the agent router's unauthenticated convention (single-user deployments)."""
|
||||||
|
user = await get_current_user(request)
|
||||||
|
if user:
|
||||||
|
if not user.get("is_admin"):
|
||||||
|
from app.db import get_conn as _gc
|
||||||
|
with _gc() as conn:
|
||||||
|
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||||
|
if not row or not row["is_admin"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||||
|
return user
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
||||||
|
if not row or not row["is_admin"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
def _default_agent(conn, user_id: int) -> dict:
|
def _default_agent(conn, user_id: int) -> dict:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM agents WHERE agent_type='personal' ORDER BY id LIMIT 1"
|
"SELECT * FROM agents WHERE agent_type='personal' ORDER BY id LIMIT 1"
|
||||||
@@ -174,10 +194,11 @@ async def create_conversation(request: Request):
|
|||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
agent = _default_agent(conn, user_id)
|
agent = _default_agent(conn, user_id)
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
|
||||||
VALUES (?,?,?,?)""",
|
VALUES (?,?,?,?,?,?)""",
|
||||||
(agent["id"], user_id, body.get("title", "New conversation"),
|
(agent["id"], user_id, body.get("title", "New conversation"),
|
||||||
json.dumps({"workspace_id": ws})),
|
json.dumps({"workspace_id": ws}),
|
||||||
|
body.get("provider", ""), body.get("model", "")),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"}
|
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"}
|
||||||
@@ -222,17 +243,26 @@ async def run_conversation(request: Request, conversation_id: int):
|
|||||||
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
|
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
|
||||||
if not conv:
|
if not conv:
|
||||||
raise HTTPException(status_code=404, detail="Conversation introuvable")
|
raise HTTPException(status_code=404, detail="Conversation introuvable")
|
||||||
|
eff_provider = body.get("provider") or conv["provider"] or None
|
||||||
|
eff_model = body.get("model") or conv["model"] or None
|
||||||
|
# Persist the selection so the same provider/model is reused next time.
|
||||||
|
if body.get("provider") is not None or body.get("model") is not None:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE agent_conversations SET provider=?, model=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
|
(body.get("provider", conv["provider"] or ""),
|
||||||
|
body.get("model", conv["model"] or ""),
|
||||||
|
conversation_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
engine = AgentEngine(user_id, workspace_id=ws)
|
engine = AgentEngine(user_id, workspace_id=ws)
|
||||||
provider = body.get("provider") or None
|
if eff_provider:
|
||||||
llm = LLMClient(provider=provider) if provider else None
|
engine.llm = LLMClient(provider=eff_provider)
|
||||||
if llm:
|
|
||||||
engine.llm = llm
|
|
||||||
|
|
||||||
async def event_stream():
|
async def event_stream():
|
||||||
async for ev in engine.run(
|
async for ev in engine.run(
|
||||||
conversation_id, objective,
|
conversation_id, objective,
|
||||||
model=body.get("model"),
|
model=eff_model,
|
||||||
mentions=body.get("mentions"),
|
mentions=body.get("mentions"),
|
||||||
files=body.get("files"),
|
files=body.get("files"),
|
||||||
skill_id=body.get("skill_id"),
|
skill_id=body.get("skill_id"),
|
||||||
@@ -365,16 +395,69 @@ async def list_tools(request: Request):
|
|||||||
|
|
||||||
@router.get("/providers")
|
@router.get("/providers")
|
||||||
async def list_providers(request: Request):
|
async def list_providers(request: Request):
|
||||||
"""Expose configured LLM provider + availability to the UI."""
|
"""Expose the LLM providers list + current config to the UI."""
|
||||||
llm = LLMClient()
|
llm = LLMClient()
|
||||||
|
cfg = get_llm_config()
|
||||||
return {
|
return {
|
||||||
"provider": llm.provider,
|
"provider": llm.provider,
|
||||||
"model": llm.default_model,
|
"model": llm.default_model,
|
||||||
"available": await llm.is_available(),
|
"available": await llm.is_available(),
|
||||||
"max_iterations": settings.agent_max_iterations,
|
"max_iterations": settings.agent_max_iterations,
|
||||||
|
"api_base": cfg["api_base"],
|
||||||
|
"has_api_key": bool(cfg["api_key"]),
|
||||||
|
"providers": provider_info(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
if provider and provider not in PROVIDERS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||||
|
_ = set_llm_config(
|
||||||
|
provider=provider or None,
|
||||||
|
model=(body.get("model") or "").strip() or None,
|
||||||
|
api_key=body.get("api_key"),
|
||||||
|
api_base=(body.get("api_base") or "").strip() or None,
|
||||||
|
)
|
||||||
|
llm = LLMClient()
|
||||||
|
return {
|
||||||
|
"status": "saved",
|
||||||
|
"provider": llm.provider,
|
||||||
|
"model": llm.default_model,
|
||||||
|
"available": await llm.is_available(),
|
||||||
|
"api_base": (body.get("api_base") or "").strip() or "",
|
||||||
|
"has_api_key": bool(llm.api_key),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/providers/test")
|
||||||
|
async def test_provider_config(request: Request):
|
||||||
|
"""Admin: verify a provider is reachable (no mock fallback)."""
|
||||||
|
await _current_admin(request)
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
provider = (body.get("provider") or "").strip().lower()
|
||||||
|
if provider and provider not in PROVIDERS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||||
|
llm = LLMClient(
|
||||||
|
provider=provider or None,
|
||||||
|
api_key=body.get("api_key"),
|
||||||
|
api_base=(body.get("api_base") or "").strip() or None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"model": resp.model or (body.get("model") or "").strip() or llm.default_model,
|
||||||
|
"reply": (resp.text or "").strip()[:200],
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface real connectivity errors
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
# ── Agents by id (registered LAST so static routes /tools, /skills, … win) ──
|
# ── Agents by id (registered LAST so static routes /tools, /skills, … win) ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ PROVIDERS = {
|
|||||||
"offline": (None, None),
|
"offline": (None, None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Curated model presets surfaced by /api/agent/providers for the UI selectors.
|
||||||
|
PROVIDER_MODELS: dict[str, list[str]] = {
|
||||||
|
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
|
||||||
|
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
|
||||||
|
"google": ["gemini-2.0-pro", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||||
|
"deepseek": ["deepseek-chat", "deepseek-reasoner"],
|
||||||
|
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
|
||||||
|
"nvidia": ["nvidia/llama-3.1-70b-instruct", "nvidia/nemotron-4-340b-instruct"],
|
||||||
|
"openrouter": ["meta-llama/llama-3.3-70b-instruct", "anthropic/claude-3.5-sonnet",
|
||||||
|
"openai/gpt-4o", "mistralai/mistral-large"],
|
||||||
|
"ollama": ["llama3.1", "llama3", "mistral", "qwen2.5", "gemma2", "mixtral"],
|
||||||
|
"offline": [],
|
||||||
|
}
|
||||||
|
|
||||||
# Llama-style / ChatML tool markers used by the mock planner.
|
# Llama-style / ChatML tool markers used by the mock planner.
|
||||||
_CREATE_PATTERNS = [
|
_CREATE_PATTERNS = [
|
||||||
(re.compile(r"cr[eéé]er\s+(?:une\s+)?collection[:\s]+[\"']?([A-Za-zÀ-ÿ0-9 _\-]+)"),
|
(re.compile(r"cr[eéé]er\s+(?:une\s+)?collection[:\s]+[\"']?([A-Za-zÀ-ÿ0-9 _\-]+)"),
|
||||||
@@ -76,12 +90,15 @@ class LLMClient:
|
|||||||
|
|
||||||
def __init__(self, provider: str | None = None, api_key: str | None = None,
|
def __init__(self, provider: str | None = None, api_key: str | None = None,
|
||||||
api_base: str | None = None):
|
api_base: str | None = None):
|
||||||
self.provider = (provider or settings.llm_provider or "offline").lower()
|
from .llm_config import get_llm_config # local import avoids a cycle
|
||||||
self.api_key = api_key if api_key is not None else settings.llm_api_key
|
|
||||||
self.api_base = api_base if api_base is not None else settings.llm_api_base
|
cfg = get_llm_config()
|
||||||
|
self.provider = (provider or cfg["provider"] or "offline").lower()
|
||||||
|
self.api_key = api_key if api_key is not None else cfg["api_key"]
|
||||||
|
self.api_base = api_base if api_base is not None else cfg["api_base"]
|
||||||
base, model = PROVIDERS.get(self.provider, (None, None))
|
base, model = PROVIDERS.get(self.provider, (None, None))
|
||||||
self.api_base = self.api_base or base
|
self.api_base = self.api_base or base
|
||||||
self.default_model = settings.llm_model or model or "gpt-4o"
|
self.default_model = cfg["model"] or model or "gpt-4o"
|
||||||
|
|
||||||
# ── Public API ──
|
# ── Public API ──
|
||||||
|
|
||||||
@@ -107,6 +124,23 @@ class LLMClient:
|
|||||||
"""True when a real provider is configured."""
|
"""True when a real provider is configured."""
|
||||||
return self.provider != "offline" and self._has_credentials()
|
return self.provider != "offline" and self._has_credentials()
|
||||||
|
|
||||||
|
async def ping(self, *, model: str | None = None) -> LLMResponse:
|
||||||
|
"""Reach the provider without mock fallback (used by the "Test connection"
|
||||||
|
UI). Raises on any real error so the caller can surface it."""
|
||||||
|
model = model or self.default_model
|
||||||
|
if self.provider == "offline":
|
||||||
|
return LLMResponse(text="Mode hors-ligne (mock) — aucun appel réseau nécessaire.", model=model)
|
||||||
|
if not self._has_credentials():
|
||||||
|
raise PermissionError(f"Clé API manquante pour le provider « {self.provider} »")
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
self._http_complete(
|
||||||
|
[{"role": "user", "content": "Réponds uniquement par le mot : PONG"}],
|
||||||
|
model,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
timeout=settings.agent_run_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Helpers ──
|
# ── Helpers ──
|
||||||
|
|
||||||
def _has_credentials(self) -> bool:
|
def _has_credentials(self) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""FlowDeck — Runtime LLM configuration store (v4.10.1).
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.llm_client import PROVIDERS, PROVIDER_MODELS
|
||||||
|
|
||||||
|
__all__ = ["get_llm_config", "set_llm_config", "provider_info"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_config() -> dict:
|
||||||
|
"""Return the effective LLM config — DB overrides .env when present."""
|
||||||
|
cfg = {
|
||||||
|
"provider": settings.llm_provider or "offline",
|
||||||
|
"model": settings.llm_model or "gpt-4o",
|
||||||
|
"api_key": settings.llm_api_key or "",
|
||||||
|
"api_base": settings.llm_api_base or "",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT provider, model, api_key, api_base FROM llm_config WHERE id=1"
|
||||||
|
).fetchone()
|
||||||
|
except Exception: # noqa: BLE001 — DB not ready yet → env defaults
|
||||||
|
return cfg
|
||||||
|
if row:
|
||||||
|
for key in ("provider", "model", "api_key", "api_base"):
|
||||||
|
if row[key]:
|
||||||
|
cfg[key] = row[key]
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def set_llm_config(*, provider: str | None = None, model: str | None = None,
|
||||||
|
api_key: str | None = None, api_base: str | None = None) -> dict:
|
||||||
|
"""Upsert the runtime LLM config row (id=1) and return the new effective config."""
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
cfg = get_llm_config()
|
||||||
|
if provider is not None:
|
||||||
|
cfg["provider"] = provider
|
||||||
|
if model is not None:
|
||||||
|
cfg["model"] = model
|
||||||
|
if api_key is not None:
|
||||||
|
cfg["api_key"] = api_key
|
||||||
|
if api_base is not None:
|
||||||
|
cfg["api_base"] = api_base
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO llm_config (id, provider, model, api_key, api_base, updated_at)
|
||||||
|
VALUES (1, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
provider=excluded.provider, model=excluded.model,
|
||||||
|
api_key=excluded.api_key, api_base=excluded.api_base,
|
||||||
|
updated_at=CURRENT_TIMESTAMP""",
|
||||||
|
(cfg["provider"], cfg["model"], cfg["api_key"], cfg["api_base"]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def provider_info() -> list[dict]:
|
||||||
|
"""Providers list for the UI: known models + whether an API key is required."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for name, (_base, default_model) in PROVIDERS.items():
|
||||||
|
models = list(PROVIDER_MODELS.get(name) or ())
|
||||||
|
if default_model and default_model not in models:
|
||||||
|
models.insert(0, default_model)
|
||||||
|
out.append({
|
||||||
|
"id": name,
|
||||||
|
"name": name.capitalize(),
|
||||||
|
"default_model": default_model,
|
||||||
|
"models": models,
|
||||||
|
"requires_key": name not in ("offline", "ollama"),
|
||||||
|
})
|
||||||
|
return out
|
||||||
@@ -28,6 +28,11 @@
|
|||||||
.fd-agent-send{background:var(--accent,#2383E2);border:none;color:#fff;border-radius:8px;padding:10px 14px;cursor:pointer;font-size:14px}
|
.fd-agent-send{background:var(--accent,#2383E2);border:none;color:#fff;border-radius:8px;padding:10px 14px;cursor:pointer;font-size:14px}
|
||||||
.fd-agent-send:disabled{opacity:.5;cursor:not-allowed}
|
.fd-agent-send:disabled{opacity:.5;cursor:not-allowed}
|
||||||
.fd-agent-new{background:none;border:1px solid var(--border,rgba(255,255,255,.1));color:var(--text,#ddd);border-radius:8px;padding:4px 8px;font-size:12px;cursor:pointer}
|
.fd-agent-new{background:none;border:1px solid var(--border,rgba(255,255,255,.1));color:var(--text,#ddd);border-radius:8px;padding:4px 8px;font-size:12px;cursor:pointer}
|
||||||
|
.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-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>
|
</style>
|
||||||
|
|
||||||
<button id="fd-agent-fab" title="FlowDeck Agent" onclick="document.dispatchEvent(new CustomEvent('fd-agent-toggle'))">🤖</button>
|
<button id="fd-agent-fab" title="FlowDeck Agent" onclick="document.dispatchEvent(new CustomEvent('fd-agent-toggle'))">🤖</button>
|
||||||
@@ -39,6 +44,20 @@
|
|||||||
<button class="fd-agent-close" @click="close()">×</button>
|
<button class="fd-agent-close" @click="close()">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="fd-agent-config">
|
||||||
|
<select class="fd-agent-select" x-model="llmProvider" @change="onProviderChange()" title="Provider">
|
||||||
|
<template x-for="p in providers" :key="p.id">
|
||||||
|
<option :value="p.id" x-text="p.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<select class="fd-agent-select" x-model="llmModel" x-show="llmProvider!=='offline'" title="Modèle">
|
||||||
|
<template x-for="m in providerModels" :key="m">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="fd-agent-tabs">
|
<div class="fd-agent-tabs">
|
||||||
<button class="fd-agent-tab" :class="{active: tab=='chat'}" @click="tab='chat'">Conversation</button>
|
<button class="fd-agent-tab" :class="{active: tab=='chat'}" @click="tab='chat'">Conversation</button>
|
||||||
<button class="fd-agent-tab" :class="{active: tab=='history'}" @click="tab='history'; loadConversations()">Historique</button>
|
<button class="fd-agent-tab" :class="{active: tab=='history'}" @click="tab='history'; loadConversations()">Historique</button>
|
||||||
@@ -89,6 +108,7 @@
|
|||||||
agents: [], conversations: [], currentConv: null,
|
agents: [], conversations: [], currentConv: null,
|
||||||
messages: [], input: '', streaming: false, liveReasoning: '',
|
messages: [], input: '', streaming: false, liveReasoning: '',
|
||||||
provider: 'offline', providerAvailable: false, errorMsg: '',
|
provider: 'offline', providerAvailable: false, errorMsg: '',
|
||||||
|
providers: [], providerModels: [], llmProvider: 'offline', llmModel: '',
|
||||||
|
|
||||||
init(){
|
init(){
|
||||||
var self = this;
|
var self = this;
|
||||||
@@ -107,8 +127,26 @@
|
|||||||
fetch('/api/agent/providers').then(function(r){return r.json()}).then(function(d){
|
fetch('/api/agent/providers').then(function(r){return r.json()}).then(function(d){
|
||||||
self.provider = d.provider || 'offline';
|
self.provider = d.provider || 'offline';
|
||||||
self.providerAvailable = !!d.available;
|
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(){});
|
}).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(){
|
fetchAgents(){
|
||||||
var self = this;
|
var self = this;
|
||||||
fetch('/api/agent').then(function(r){return r.json()}).then(function(d){
|
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){
|
fetch('/api/agent/conversations/'+id).then(function(r){return r.json()}).then(function(d){
|
||||||
self.currentConv = d.conversation;
|
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}; });
|
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(){});
|
}).catch(function(){});
|
||||||
},
|
},
|
||||||
newConversation(){
|
newConversation(){
|
||||||
var self = this;
|
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){
|
.then(function(r){return r.json()}).then(function(d){
|
||||||
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
|
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
|
||||||
self.conversations.unshift(conv); self.currentConv = conv;
|
self.conversations.unshift(conv); self.currentConv = conv;
|
||||||
@@ -158,10 +201,13 @@
|
|||||||
self.scrollBottom();
|
self.scrollBottom();
|
||||||
|
|
||||||
var respBuf = '';
|
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', {
|
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
|
||||||
method:'POST',
|
method:'POST',
|
||||||
headers:{'Content-Type':'application/json'},
|
headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({message: text})
|
body: JSON.stringify(payload)
|
||||||
}).then(function(resp){
|
}).then(function(resp){
|
||||||
if(!resp.ok){
|
if(!resp.ok){
|
||||||
return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); });
|
return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); });
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
<div class="settings-nav-header">Admin</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 & Roles</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & 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-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 & IA</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -505,6 +506,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin: Agent & IA (LLM config) -->
|
||||||
|
<div x-show="activeSection==='admin-llm'">
|
||||||
|
<h2>Agent & 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>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Configuration LLM</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;">
|
||||||
|
<template x-for="p in llmProviders" :key="p.id">
|
||||||
|
<option :value="p.id" x-text="p.name + (p.requires_key ? ' (clé API requise)' : '')"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||||
|
<div class="setting-label">Modèle</div>
|
||||||
|
<select class="settings-input" x-model="llmForm.model" style="max-width:300px;">
|
||||||
|
<template x-for="m in llmProviderModels" :key="m">
|
||||||
|
<option :value="m" x-text="m"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||||
|
<div class="setting-label">Clé API <span x-show="llmHasKey" style="font-size:11px;color:var(--toast-success-bg);margin-left:6px;">✓ configurée</span></div>
|
||||||
|
<div class="pw-wrapper" style="max-width:300px;">
|
||||||
|
<input :type="llmKeyVisible ? 'text' : 'password'" class="settings-input" x-model="llmForm.api_key" placeholder="sk-… (laisser vide pour conserver l'existante)" style="padding-right:36px;">
|
||||||
|
<button type="button" class="pw-toggle" @click="llmKeyVisible=!llmKeyVisible" x-text="llmKeyVisible ? '{{ 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)</div>
|
||||||
|
<input type="text" class="settings-input" x-model="llmForm.api_base" placeholder="https://… (Ollama, proxy OpenAI-compatible…)" style="max-width:300px;">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:10px;margin-top:14px;">
|
||||||
|
<button class="btn btn-primary" @click="saveLlmConfig()" :disabled="llmSaving" style="font-size:13px;">
|
||||||
|
<span x-show="!llmSaving">{{ fd_icon("save",14) }} Save configuration</span>
|
||||||
|
<span x-show="llmSaving">Saving…</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn" @click="testLlm()" :disabled="llmTesting" style="font-size:13px;">🔌 Test connection</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;font-size:13px;">
|
||||||
|
<span x-show="llmMsg" x-text="llmMsg" :style="{color: llmMsgOk ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -547,12 +600,23 @@ document.addEventListener('alpine:init', function() {
|
|||||||
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
|
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
|
||||||
editingUser: null,
|
editingUser: null,
|
||||||
editUserForm: {login:'',name:'',email:'',password:'',is_admin:false,is_active:true},
|
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() {
|
async init() {
|
||||||
await this.loadTags();
|
await this.loadTags();
|
||||||
await this.loadGiteaStatus();
|
await this.loadGiteaStatus();
|
||||||
await this.loadGithubStatus();
|
await this.loadGithubStatus();
|
||||||
await this.loadNotifPrefs();
|
await this.loadNotifPrefs();
|
||||||
|
if (this.userIsAdmin) await this.loadLlmConfig();
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadNotifPrefs() {
|
async loadNotifPrefs() {
|
||||||
@@ -714,6 +778,68 @@ document.addEventListener('alpine:init', function() {
|
|||||||
} catch(e) {}
|
} 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() {
|
async saveAccount() {
|
||||||
this.accountSaving = true;
|
this.accountSaving = true;
|
||||||
this.accountMsg = '';
|
this.accountMsg = '';
|
||||||
|
|||||||
@@ -655,7 +655,9 @@ Phase 3 — Interface
|
|||||||
Phase 4 — Personnalisation
|
Phase 4 — Personnalisation
|
||||||
1. Instructions custom par agent
|
1. Instructions custom par agent
|
||||||
2. Skills (table + /apply)
|
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
|
Phase 5 — Custom Agents
|
||||||
1. Scope + trigger_json
|
1. Scope + trigger_json
|
||||||
|
|||||||
@@ -317,6 +317,98 @@ def test_router_tools_and_providers(client):
|
|||||||
prov = client.get("/api/agent/providers").json()
|
prov = client.get("/api/agent/providers").json()
|
||||||
assert prov["provider"] == "offline"
|
assert prov["provider"] == "offline"
|
||||||
assert prov["available"] is False
|
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:
|
def _parse_sse(line: str) -> dict:
|
||||||
|
|||||||
Reference in New Issue
Block a user