From c322f3080133677f841969ebb1563d5b698d35c9 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 5 Sep 2026 22:47:16 -0400 Subject: [PATCH] feat(agent): v4.12.0 - refonte config 'Agent & IA', fournisseurs actifs/testes dans le panneau, contexte document + guide d'utilisation --- CHANGELOG.md | 41 ++++ VERSION | 2 +- app/db.py | 30 +++ app/main.py | 2 +- app/routers/agent.py | 139 ++++++++++- app/services/agent_engine.py | 20 +- app/services/llm_config.py | 109 ++++++++- app/templates/agent_panel.html | 117 ++++++++- app/templates/settings.html | 417 ++++++++++++++++++++++++--------- tests/test_agent.py | 71 +++++- 10 files changed, 798 insertions(+), 150 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 286dbe3..6c2751a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog — FlowDeck +## v4.12.0 (2026-09-05) — Agent & IA : refonte des réglages, fournisseurs activés/fonctionnels dans le panneau, contexte document & guide + +> Refonte de la section **Settings → Agent & IA** pour la rendre plus claire, et aligne le panneau +> Agent sur la réalité de la configuration : **seuls les fournisseurs configurés ET dont le test de +> connexion a réussi** sont proposés (avant, tous les fournisseurs connus apparaissaient, même non +> configurés). Le panneau sait aussi sur quel **document** l'utilisateur travaille et peut guider +> sur l'utilisation de l'application quand aucun document n'est ouvert. + +### Added +- **Vérification des fournisseurs** : colonnes `verified` / `verified_at` / `last_error` sur + `user_llm_keys` et `llm_config` (migration automatique au démarrage). Un fournisseur est + « fonctionnel » après un test de connexion réussi ou un chargement de modèles réussi. +- **Endpoint non-admin** `POST /api/agent/keys/{provider}/test` : teste la clé enregistrée de + l'utilisateur (sans mock) et met à jour l'état `verified` ; l'endpoint admin `/providers/test` + marque le fournisseur par défaut de l'espace. +- **`/api/agent/providers` enrichi** : chaque provider expose `configured`, `verified`, + `functional`, `last_error` et `source` (user/global/open) ; `default_provider` + `default_verified` + en tête de réponse. +- **Panneau Agent** : les listes fournisseur/modèle ne contiennent plus que les providers + **activés et fonctionnels** (+ mode hors-ligne). Bandeau « Aucun fournisseur activé » avec lien + vers les réglages et rappel des clés « à tester » le cas échéant. +- **Contexte document** : le panneau joint automatiquement la page ouverte (titre + contenu des + blocs) à chaque requête — l'agent peut interagir avec le document courant. +- **Guide d'utilisation** : le prompt système intègre un guide FlowDeck concis pour répondre aux + questions « comment faire… ? » (pages, collections/vues, Gitea, corbeille, réglages…) lorsqu'aucun + document n'est fourni. + +### Changed +- **Settings → Agent & IA refondu** : carte de statut (prêt / N fournisseurs actifs / mode + hors-ligne), cartes de fournisseurs avec badges d'état, erreur du dernier test, boutons + « Enregistrer & tester », « Tester », « Modèles », « Supprimer » et encart d'aide. +- Le bouton **Save configuration** admin gagne un rappel « Tester la connexion » pour activer le + fournisseur pour tous ; changer de clé réinitialise la vérification. + +### Fix +- Saisie clé : une clé vide conserve désormais l'existante aussi pour la config globale (au lieu de + l'effacer), et un changement de clé invalide l'ancien statut `verified`. + +### Infra +- `VERSION` → 4.12.0 ; `app/main.py` version → 4.12.0. + ## v4.11.1 (2026-09-05) — Correction & intégration des points d'entrée IA > Aligne les entrées AI de l'interface sur le FlowDeck Agent (alignement Guide Notion AI) et corrige diff --git a/VERSION b/VERSION index d782fca..815588e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.11.1 +4.12.0 diff --git a/app/db.py b/app/db.py index 424ed05..aa74549 100644 --- a/app/db.py +++ b/app/db.py @@ -737,6 +737,10 @@ def init_db(): model TEXT DEFAULT '', api_key TEXT DEFAULT '', api_base TEXT DEFAULT '', + verified INTEGER NOT NULL DEFAULT 0, + verified_model TEXT DEFAULT '', + verified_at TIMESTAMP, + last_error TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) @@ -752,12 +756,38 @@ def init_db(): api_base TEXT NOT NULL DEFAULT '', default_model TEXT DEFAULT '', models_json TEXT NOT NULL DEFAULT '[]', + verified INTEGER NOT NULL DEFAULT 0, + verified_model TEXT DEFAULT '', + verified_at TIMESTAMP, + last_error TEXT 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)") + # v4.12: provider activation/verification — a provider is only offered in + # the Agent UI once it is configured AND its connection test succeeded. + for col, ddl in ( + ("verified", "INTEGER NOT NULL DEFAULT 0"), + ("verified_model", "TEXT DEFAULT ''"), + ("verified_at", "TIMESTAMP"), + ("last_error", "TEXT DEFAULT ''"), + ): + try: + conn.execute(f"ALTER TABLE user_llm_keys ADD COLUMN {col} {ddl}") + except sqlite3.OperationalError: + pass # column already exists + for col, ddl in ( + ("verified", "INTEGER NOT NULL DEFAULT 0"), + ("verified_model", "TEXT DEFAULT ''"), + ("verified_at", "TIMESTAMP"), + ("last_error", "TEXT DEFAULT ''"), + ): + try: + conn.execute(f"ALTER TABLE llm_config ADD COLUMN {col} {ddl}") + except sqlite3.OperationalError: + pass # column already exists # Migration: per-conversation provider/model override columns for col in ("provider", "model"): try: diff --git a/app/main.py b/app/main.py index bf9e5f4..a0991c9 100644 --- a/app/main.py +++ b/app/main.py @@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="4.11.1", + version="4.12.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 7197104..29e693f 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -15,11 +15,12 @@ from app.db import get_conn 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_client import LLMClient, PROVIDERS, PROVIDER_MODELS 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, + mark_llm_config_verified, mark_user_llm_key_verified, ) from app.services.tool_registry import ToolRegistry from app.services.permission_manager import PermissionManager @@ -298,6 +299,7 @@ async def run_conversation(request: Request, conversation_id: int): mentions=body.get("mentions"), files=body.get("files"), skill_id=body.get("skill_id"), + extra_context=body.get("context"), ): yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n" @@ -427,12 +429,26 @@ 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.""" + """Expose the LLM providers list + current config to the UI. + + Every provider known by FlowDeck is returned (the Settings UI lets the user + configure any of them). Each provider also carries activation flags used by + the Agent panel to offer only the providers that are *configured and + functional* for the current user: + + - ``configured`` : a usable credential exists (own key OR the workspace + default provider carries a key, OR no key is required). + - ``verified`` : the last connection test / model fetch succeeded. + - ``functional`` : the provider is ready to chat (verified, or `offline`). + """ 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} + default_provider = (cfg.get("provider") or "offline").lower() + global_has_key = bool(cfg.get("api_key")) and default_provider != "offline" + global_verified = bool(cfg.get("verified")) and global_has_key provs = provider_info() for p in provs: k = keys_map.get(p["id"]) @@ -440,7 +456,28 @@ async def list_providers(request: Request): for m in k["models"]: if m not in p["models"]: p["models"].append(m) - p["has_key"] = bool(k and k["has_key"]) + user_has_key = bool(k and k["has_key"]) + is_default = p["id"] == default_provider + configured = bool(k and k["has_key"]) or (is_default and global_has_key) \ + or not p["requires_key"] + user_verified = bool(k and k.get("verified")) + verified = (user_verified and bool(k and k["has_key"])) or \ + (is_default and global_has_key and global_verified) + if not p["requires_key"] and not (k and k["has_key"]) and not (is_default and global_has_key): + # ollama / offline: no key needed, but only offline is usable as-is; + # local providers must still pass a connection test. + verified = bool(user_verified or (is_default and global_verified)) \ + if p["id"] != "offline" else True + p["has_key"] = user_has_key + p["configured"] = bool(configured) + p["source"] = ("user" if user_has_key else + "global" if is_default and global_has_key else "open") + p["verified"] = bool(verified) + p["verified_model"] = (k.get("verified_model") if user_has_key and k else + cfg.get("verified_model") if is_default and global_has_key else "") or "" + p["last_error"] = (k.get("last_error") if user_has_key and k else + cfg.get("last_error") if is_default and global_has_key else "") or "" + p["functional"] = p["id"] == "offline" or bool(verified) return { "provider": llm.provider, "model": llm.default_model, @@ -448,6 +485,8 @@ async def list_providers(request: Request): "max_iterations": settings.agent_max_iterations, "api_base": cfg["api_base"], "has_api_key": bool(cfg["api_key"]), + "default_provider": default_provider, + "default_verified": bool(cfg.get("verified")), "providers": provs, "keys": keys, } @@ -494,10 +533,68 @@ async def delete_llm_key(request: Request, llm_provider: str): return {"status": "deleted", "provider": provider} +@router.post("/keys/{llm_provider}/test") +async def test_user_llm_key(request: Request, llm_provider: str): + """Non-admin: verify one of the user's own provider keys (no mock fallback). + + On success the provider is flagged ``verified`` so it can be offered in the + Agent panel; on failure the stored error is kept for display in Settings. + """ + 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 {} + stored = get_user_llm_key(user_id, provider) + api_key = (body.get("api_key") or "").strip() + api_base = (body.get("api_base") or "").strip() + if not api_key and stored and stored.get("api_key"): + api_key = stored["api_key"] + if not api_base: + api_base = (stored.get("api_base") or "").strip() if stored else "" + _, provider_default = PROVIDERS.get(provider, (None, "gpt-4o")) + candidates = PROVIDER_MODELS.get(provider) or [provider_default] + model = (body.get("model") or "").strip() or \ + ((stored or {}).get("default_model") or "") or \ + next((m for m in candidates if m), provider_default) + # Only record the result against the *stored* credential when it is the one + # being tested (avoids flagging a stored key from a test run on a typed key). + can_record = bool(stored) and ( + not (stored or {}).get("api_key") or + api_key == (stored or {}).get("api_key") + ) + llm = LLMClient(provider=provider, api_key=api_key, api_base=api_base or None) + try: + resp = await llm.ping(model=model or None) + except Exception as exc: # noqa: BLE001 — surface connectivity errors + if can_record: + mark_user_llm_key_verified(user_id, provider, False, error=str(exc)) + return {"ok": False, "provider": provider, "error": str(exc), "verified": False} + if not stored and provider in ("ollama", "offline"): + stored = upsert_user_llm_key(user_id, provider, api_key="", api_base=api_base) + can_record = True + if can_record: + mark_user_llm_key_verified( + user_id, provider, True, + model=resp.model or model or "", + ) + return { + "ok": True, + "provider": provider, + "model": resp.model or model or "", + "reply": (resp.text or "").strip()[:200], + "verified": bool(can_record), + } + + @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.""" + stored key when no key is supplied in the body. + + A successful fetch proves connectivity, so when it used the *stored* key the + provider is flagged ``verified`` (functional) for the Agent panel. + """ user_id = await _current_user_id(request) provider = llm_provider.lower() if provider not in PROVIDERS: @@ -505,13 +602,18 @@ async def fetch_llm_models(request: Request, llm_provider: str): 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() + used_stored = False 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() + used_stored = True try: models = await fetch_provider_models(provider, api_key=api_key, api_base=api_base) + if used_stored and models: + mark_user_llm_key_verified(user_id, provider, True, + model=(body.get("default_model") or "").strip()) return {"ok": True, "provider": provider, "models": models} except Exception as exc: # noqa: BLE001 — surface connectivity errors return {"ok": False, "provider": provider, "error": str(exc)} @@ -543,26 +645,37 @@ async def update_provider_config(request: Request): @router.post("/providers/test") async def test_provider_config(request: Request): - """Admin: verify a provider is reachable (no mock fallback).""" + """Admin: verify a provider is reachable (no mock fallback). + + A successful test flags the workspace default provider as ``verified`` so it + becomes available (functional) for every user in the Agent panel. + """ await _current_admin(request) body = await request.json() if request.headers.get("content-type") else {} - provider = (body.get("provider") or "").strip().lower() + provider = (body.get("provider") or "").strip().lower() or None if provider and provider not in PROVIDERS: raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}") llm = LLMClient( - provider=provider or None, + provider=provider, 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)} + cfg = get_llm_config() + if (provider or cfg.get("provider")) == cfg.get("provider"): + mark_llm_config_verified(False, error=str(exc)) + return {"ok": False, "error": str(exc), "verified": False} + cfg = get_llm_config() + if (provider or cfg.get("provider")) == cfg.get("provider"): + mark_llm_config_verified(True, model=resp.model or llm.default_model or "") + return { + "ok": True, + "model": resp.model or (body.get("model") or "").strip() or llm.default_model, + "reply": (resp.text or "").strip()[:200], + "verified": True, + } # ── Agents by id (registered LAST so static routes /tools, /skills, … win) ── diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index c1d03b0..6bcf44c 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -25,6 +25,19 @@ logger = logging.getLogger(__name__) MAX_ITERATIONS = 12 +# Compact in-app guide so the LLM can answer « comment faire… ? » questions even +# when no document is attached to the conversation (generic help / onboarding). +APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? ») +- **Pages** : le contenu est organisé en blocs (paragraphes, titres, listes, to-do, tableaux, images, formules, bases embarquées). La barre latérale liste les pages récentes, favoris, agents, partagées et publiées. +- **Collections (bases de données)** : des ensembles de pages structurées avec des propriétés (texte, nombre, sélection, dates…). Chaque collection peut avoir plusieurs vues : tableau, board (kanban), calendrier, galerie, liste, timeline, graphique, formulaire, carte, flux, gantt. Ajouter une propriété ou une vue = outils add_property / create_view. +- **Créer du contenu** : « crée une collection X », « crée une page », « ajoute une propriété Statut à la collection Y » sont des actions que l'agent peut exécuter directement avec ses outils. +- **Espaces de travail** : FlowDeck gère des espaces locaux et des dépôts Gitea/GitHub (pages privées dans un dépôt, issues reliées via read_gitea_issues). On change d'espace depuis le menu en bas à gauche (« Switch workspace »). +- **Recherche** : la commande Ctrl+K / la barre de recherche du haut permet de retrouver pages et collections. +- **Corbeille & Bibliothèque** : les pages supprimées vont dans la Corbeille ; Favoris / Récents / Partagés / Publiés se consultent dans la Bibliothèque. +- **Réglages** : Paramètres (en bas à gauche → Settings) pour le compte, les notifications, les tags, les intégrations et la section « Agent & IA » (clés API, fournisseurs, modèle global). +- **Agent IA** : ouvrable via le bouton 🤖 en bas à droite ou la section « Agents » du sidebar. On peut lui parler de la page ouverte, ou lui poser des questions générales sur l'utilisation de l'application. +Quand la question est générale (« comment créer un kanban ? », « où sont mes favoris ? »), réponds de façon concise et guidée à partir de ces informations, sans inventer de fonctionnalités absentes.""" + class AgentEngine: def __init__(self, user_id: int, workspace_id: int | None = None, @@ -63,13 +76,16 @@ class AgentEngine: lines.append(f"\nInstructions de l'agent {agent.get('name','')}:\n{agent['system_instructions']}") if skill: lines.append(f"\nSkill appliquée « {skill.get('name','')} »:\n{skill.get('prompt_template','')}") + # L'utilisateur peut poser des questions d'aide sans contexte de document ; + # le guide intégré permet d'y répondre (aucun outil requis). + lines.append("\n" + APP_GUIDE) return "\n".join(lines) # ── Main run (async generator of SSE events) ── async def run(self, conversation_id: int, objective: str, *, model: str | None = None, mentions: list[str] | None = None, files: list[dict] | None = None, - skill_id: int | None = None): + skill_id: int | None = None, extra_context: str | None = None): agent = self._load_agent(conversation_id) scope = json.loads(agent.get("scope_json") or "{}") approval_mode = agent.get("approval_mode") or "auto" @@ -78,6 +94,8 @@ class AgentEngine: skill = self._load_skill(skill_id, scope) if skill_id else None system = self._build_system_prompt(agent, skill) context = self.ctx.build(mentions=mentions, files=files) + if extra_context and extra_context.strip(): + context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip() messages = [ {"role": "system", "content": system}, diff --git a/app/services/llm_config.py b/app/services/llm_config.py index 5e6a893..8c3c0f1 100644 --- a/app/services/llm_config.py +++ b/app/services/llm_config.py @@ -20,6 +20,7 @@ __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", + "mark_llm_config_verified", "mark_user_llm_key_verified", ] @@ -30,13 +31,18 @@ def get_llm_config() -> dict: "model": settings.llm_model or "gpt-4o", "api_key": settings.llm_api_key or "", "api_base": settings.llm_api_base or "", + "verified": 0, + "verified_model": "", + "verified_at": "", + "last_error": "", } 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" + "SELECT provider, model, api_key, api_base, verified, verified_model, " + "verified_at, last_error FROM llm_config WHERE id=1" ).fetchone() except Exception: # noqa: BLE001 — DB not ready yet → env defaults return cfg @@ -44,12 +50,22 @@ def get_llm_config() -> dict: for key in ("provider", "model", "api_key", "api_base"): if row[key]: cfg[key] = row[key] + if row["provider"]: + cfg["verified"] = row["verified"] or 0 + cfg["verified_model"] = row["verified_model"] or "" + cfg["verified_at"] = row["verified_at"] or "" + cfg["last_error"] = row["last_error"] or "" 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.""" + """Upsert the runtime LLM config row (id=1) and return the new effective config. + + An empty `api_key`/`api_base` keeps the stored value (so an admin can tweak + the model/base without re-typing the key). Changing the API key resets the + `verified` flag — the provider has to pass a connection test again. + """ from app.db import get_conn cfg = get_llm_config() @@ -57,25 +73,58 @@ def set_llm_config(*, provider: str | None = None, model: str | None = 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 + if api_key is not None and api_key.strip(): + key_changed = cfg.get("api_key") != api_key.strip() + cfg["api_key"] = api_key.strip() + if key_changed: + cfg["verified"] = 0 + cfg["verified_model"] = "" + cfg["last_error"] = "" + if api_base is not None and api_base.strip(): + cfg["api_base"] = api_base.strip() with get_conn() as conn: conn.execute( - """INSERT INTO llm_config (id, provider, model, api_key, api_base, updated_at) - VALUES (1, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """INSERT INTO llm_config (id, provider, model, api_key, api_base, + verified, verified_model, last_error, 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, + verified=excluded.verified, verified_model=excluded.verified_model, + last_error=excluded.last_error, updated_at=CURRENT_TIMESTAMP""", - (cfg["provider"], cfg["model"], cfg["api_key"], cfg["api_base"]), + (cfg["provider"], cfg["model"], cfg["api_key"], cfg["api_base"], + cfg.get("verified", 0), cfg.get("verified_model", ""), + cfg.get("last_error", "")), ) conn.commit() return cfg +def mark_llm_config_verified(ok: bool, *, model: str = "", error: str = "") -> None: + """Record the outcome of the admin 'Test connection' for the global default.""" + from app.db import get_conn + + with get_conn() as conn: + row = conn.execute("SELECT provider FROM llm_config WHERE id=1").fetchone() + provider = row["provider"] if row else (settings.llm_provider or "offline") + conn.execute( + """INSERT INTO llm_config (id, provider, model, verified, verified_model, + verified_at, last_error, updated_at) + VALUES (1, ?, '', ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP END, ?, CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET + verified=excluded.verified, + verified_model=excluded.verified_model, + verified_at=excluded.verified_at, + last_error=excluded.last_error, + updated_at=CURRENT_TIMESTAMP""", + (provider, 1 if ok else 0, model if ok else "", + 1 if ok else 0, "" if ok else error), + ) + conn.commit() + + def provider_info() -> list[dict]: """Providers list for the UI: known models + whether an API key is required.""" out: list[dict] = [] @@ -98,12 +147,21 @@ def provider_info() -> list[dict]: def _mask_key(row) -> dict: """Public view of a stored key row: never exposes the raw api_key.""" + def _get(name, default=""): + try: + v = row[name] + return default if v is None else v + except (KeyError, IndexError): + return default 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"]), + "verified": bool(_get("verified", 0)), + "verified_model": _get("verified_model") or "", + "last_error": _get("last_error") or "", } @@ -136,7 +194,8 @@ 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).""" + (allows saving model/base without re-typing the key). Saving a *different* + key resets the `verified` flag so the provider must pass a test again.""" from app.db import get_conn provider = provider.lower() @@ -147,24 +206,48 @@ def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "", new_models = models if models is not None else ( json.loads(existing["models_json"]) if existing and existing.get("models_json") else [] ) + key_changed = bool(api_key) and (not existing or existing.get("api_key", "") != api_key) + # A changed key invalidates the previous verification; keep it otherwise. + verified = 0 if key_changed else (existing.get("verified") or 0) if existing else 0 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) + """INSERT INTO user_llm_keys (user_id, provider, api_key, api_base, default_model, models_json, verified, 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, + verified=excluded.verified, updated_at=CURRENT_TIMESTAMP""", (user_id, provider, new_key, new_base, new_model, - json.dumps(new_models, ensure_ascii=False)), + json.dumps(new_models, ensure_ascii=False), verified), ) 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), + "verified": verified, } +def mark_user_llm_key_verified(user_id: int, provider: str, ok: bool, *, + model: str = "", error: str = "") -> None: + """Record the outcome of a connection test on one of the user's providers.""" + from app.db import get_conn + + provider = provider.lower() + with get_conn() as conn: + conn.execute( + """UPDATE user_llm_keys + SET verified=?, verified_model=?, last_error=?, + verified_at=CASE WHEN ? THEN CURRENT_TIMESTAMP END, + updated_at=CURRENT_TIMESTAMP + WHERE user_id=? AND provider=?""", + (1 if ok else 0, model if ok else "", "" if ok else error, + 1 if ok else 0, user_id, provider), + ) + conn.commit() + + def delete_user_llm_key(user_id: int, provider: str) -> None: from app.db import get_conn diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index 03898a9..b142789 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -39,6 +39,10 @@ .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)} +.fd-agent-noconfig{padding:8px 10px;font-size:12px;color:var(--text-dim,#ccc);background:rgba(227,98,98,.10);border-bottom:1px solid var(--border,rgba(255,255,255,.06));display:flex;gap:6px;align-items:center;flex-wrap:wrap} +.fd-agent-noconfig a{color:var(--accent,#2383E2);text-decoration:none;font-weight:600} +.fd-agent-ctx{font-size:11px;color:var(--text-dim,#999);padding:5px 12px;border-bottom:1px solid var(--border,rgba(255,255,255,.06));display:flex;gap:6px;align-items:center;background:rgba(35,131,226,.08)} + @@ -53,7 +57,7 @@
- - -
-
-
Modèle
- -
-
-
Clé API ✓ configurée
-
- - +

Fournisseur par défaut de l'espace (tous les utilisateurs)

+
+
+ Configuration globale +
+
+
Fournisseur
+ +
+
+
Modèle par défaut
+ +
+
+
Clé API
+
+ + +
+
+
+
URL API (optionnelle — Ollama, proxy OpenAI-compatible…)
+ +
+
+ + +
+
+ + +
+

💡 Après un changement de fournisseur ou de clé, lancez « Tester la connexion » : si le test réussit, ce fournisseur devient disponible pour tous dans le panneau Agent.

-
-
URL API (optionnelle)
- +
+ + +
+

Mes fournisseurs

+

Enregistrez vos propres clés : elles sont utilisées quand vous choisissez ce fournisseur dans le panneau Agent, et vous rendent le fournisseur disponible (après test réussi).

+
+
-
- - +
+ 💡 Comment activer un fournisseur ? Enregistrez votre clé puis cliquez « Enregistrer & tester » (ou « Tester ») : dès que la connexion réussit, le fournisseur et ses modèles apparaissent dans le sélecteur du panneau Agent. + Pour Ollama, aucun fournisseur distant : renseignez l'URL locale (ex. http://localhost:11434/v1) puis testez. + En mode hors-ligne (aucun fournisseur actif), l'agent reste utilisable mais génère du contenu local, sans appel réseau.
-
- - -
-

- Sans clé API, l'agent fonctionne en mode « offline mock » (plannificateur déterministe, aucun réseau). -

@@ -664,6 +717,11 @@ document.addEventListener('alpine:init', function() { llmMsg: '', llmMsgOk: false, llmTestMsg: '', llmTestOk: false, llmKeyVisible: false, + llmDefaultProvider: 'offline', + llmActiveCount: 0, + llmDefaultFunctional: false, + llmSummaryTitle: '', + llmSummaryDesc: '', // Per-user provider keys (multi-providers) keyForms: {}, @@ -839,32 +897,112 @@ document.addEventListener('alpine:init', function() { 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}; + keyVisible:false, modelLoading:false, saving:false, testing:false, + deleting:false, last_error:'', msg:'', ok:false}; } return this.keyForms[id]; }, keyModels(id) { - var p = this.llmProviders.find(x => x.id === id); + var p = this.providerById(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; }, + providerById(id) { + return (this.llmProviders || []).find(x => x.id === id) || null; + }, + userProviders() { + return (this.llmProviders || []).filter(x => x.id !== 'offline'); + }, + // Set the verified/last_error state on the shared provider entry + its keyForm + _setProviderState(id, ok, error) { + var p = this.providerById(id); + var f = this.keyForm(id); + if (p) { + p.verified = !!ok; + p.last_error = ok ? '' : (error || ''); + p.functional = p.id === 'offline' ? true : !!ok; + if (!ok) { p.configured = p.has_key || !p.requires_key; } + } + f.last_error = ok ? '' : (error || ''); + this.syncLlmStatus(); + }, + syncLlmStatus() { + var list = this.llmProviders || []; + var active = list.filter(p => p.verified && p.id !== 'offline'); + this.llmActiveCount = active.length; + var def = list.find(p => p.id === this.llmDefaultProvider) || null; + this.llmDefaultFunctional = !!(def && def.functional && def.id !== 'offline'); + if (active.length) { + if (this.llmDefaultFunctional && def) { + this.llmSummaryTitle = 'L\u2019agent est prêt à l\u2019emploi'; + this.llmSummaryDesc = 'Le fournisseur par défaut « ' + def.name + ' » est activé et fonctionnel : vos conversations l\u2019utiliseront automatiquement.'; + } else { + this.llmSummaryTitle = this.llmActiveCount + ' fournisseur(s) fonctionnel(s) pour votre compte'; + this.llmSummaryDesc = 'Choisissez-en un dans le sélecteur du panneau Agent (🤖 en bas à droite) pour chaque conversation' + + (this.userIsAdmin ? ' — ou activez le fournisseur par défaut de l\u2019espace ci-dessous.' : '.'); + } + } else { + this.llmSummaryTitle = 'Agent en mode hors-ligne (mock)'; + this.llmSummaryDesc = 'Aucun fournisseur activé et fonctionnel. Enregistrez une clé ci-dessous puis lancez « Tester » : dès le test réussi, le fournisseur et ses modèles apparaissent dans le panneau Agent.'; + } + }, + llmBadge(p) { + if (!p) return ''; + if (p.id === 'offline') return 'Toujours dispo (mock)'; + if (p.verified) return p.source === 'global' ? 'Actif (défaut espace)' : 'Activé et fonctionnel'; + if (p.source === 'global') return 'Défaut espace — à tester (admin)'; + if (!p.requires_key) return 'Local — à tester'; + if (p.configured) return 'Configuré — à tester'; + return 'Non configuré'; + }, + llmBadgeClass(p) { + if (!p) return 'off'; + if (p.id === 'offline') return 'off'; + if (p.verified) return 'ok'; + if (p.configured) return 'warn'; + return 'off'; + }, + globalNeedsKey() { + var cur = this.providerById(this.llmForm.provider); + return !!(cur && cur.requires_key); + }, + llmGlobalBadge() { + var cur = this.providerById(this.llmForm.provider); + if (!cur) return '—'; + if (cur.id === 'offline') return 'Mode hors-ligne'; + if (cur.id !== this.llmDefaultProvider) return 'Changement non enregistré'; + if (cur.verified) return 'Actif pour tous'; + if (this.llmHasKey) return 'Configuré — à tester'; + return 'Non configuré'; + }, + llmGlobalBadgeClass() { + var cur = this.providerById(this.llmForm.provider); + if (!cur || cur.id === 'offline') return 'off'; + if (cur.id !== this.llmDefaultProvider) return 'warn'; + if (cur.verified) return 'ok'; + return 'off'; + }, async loadUserLlm() { try { var r = await fetch('/api/agent/providers'); var d = await r.json(); this.llmProviders = d.providers || []; + this.llmDefaultProvider = d.default_provider || d.provider || 'offline'; 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); + var f = this.keyForm(p.id); + f.api_base = (k && k.api_base) || ''; + f.model = (k && k.default_model) || (p.default_model) || ''; + f.models = (k && k.models) || []; + f.has_key = !!(k && k.has_key); + f.last_error = (k && k.last_error) || p.last_error || ''; }); + this.syncLlmStatus(); } catch(e) {} }, async loadProviderModels(id) { @@ -882,10 +1020,12 @@ document.addEventListener('alpine:init', function() { 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.msg = f.models.length + ' modèles chargés depuis le fournisseur'; f.ok = true; + // Une récupération réussie avec la clé stockée prouve la connexion. + if (!f.api_key) this._setProviderState(id, true, ''); } else { - f.msg = 'Aucun modèle renvoyé par le fournisseur'; + f.msg = 'Connexion OK, mais aucun modèle renvoyé par le fournisseur'; } } else { f.msg = 'Erreur: ' + (d.error || 'échec de connexion'); @@ -908,14 +1048,54 @@ document.addEventListener('alpine:init', function() { 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; + if (f.has_key || !this.globalNeedsKeyFor(id)) { + f.msg = 'Clé enregistrée — test de connexion en cours…'; + f.ok = true; + await this.testUserKey(id); + } else { + f.msg = 'Enregistré pour ' + id + ' — aucune clé stockée'; + f.ok = true; + } + var p = this.providerById(id); + if (p) { p.has_key = f.has_key; p.configured = f.has_key || !p.requires_key; } } else { f.msg = d.detail || 'Erreur lors de l\'enregistrement'; } } catch(e) { f.msg = 'Erreur réseau'; } finally { f.saving = false; } }, + globalNeedsKeyFor(id) { + var p = this.providerById(id); + return !!(p && p.requires_key); + }, + async testUserKey(id) { + var f = this.keyForm(id); + if (f.testing) return; + f.testing = true; f.msg = ''; f.ok = false; + try { + var body = {api_base: f.api_base, model: f.model}; + if (f.api_key) body.api_key = f.api_key; + var r = await fetch('/api/agent/keys/' + id + '/test', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify(body) + }); + var d = await r.json(); + if (d.ok) { + this._setProviderState(id, true, ''); + f.msg = '✓ ' + id + ' fonctionnel' + (d.model ? ' (' + d.model + ')' : '') + (d.reply ? ' — ' + d.reply : ''); + f.ok = true; + if (f.models.indexOf(d.model) === -1 && d.model) f.models.push(d.model); + } else { + this._setProviderState(id, false, d.error || 'échec de connexion'); + f.msg = '✗ Test échoué : ' + (d.error || 'échec de connexion'); + } + } catch(e) { + this._setProviderState(id, false, 'Erreur réseau'); + f.msg = 'Erreur réseau'; + } + finally { f.testing = false; } + }, async deleteUserKey(id) { var f = this.keyForm(id); f.deleting = true; f.msg = ''; f.ok = false; @@ -923,8 +1103,11 @@ document.addEventListener('alpine:init', function() { 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.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = ''; + var p = this.providerById(id); + if (p) { p.has_key = false; p.configured = !p.requires_key; } + this._setProviderState(id, false, ''); + f.msg = 'Clé supprimée pour ' + id + ' — le fournisseur n\u2019est plus disponible dans le panneau Agent.'; f.ok = true; } else { f.msg = d.detail || 'Erreur lors de la suppression'; @@ -937,19 +1120,25 @@ document.addEventListener('alpine:init', function() { var r = await fetch('/api/agent/providers'); var d = await r.json(); this.llmProviders = d.providers || []; + this.llmDefaultProvider = d.default_provider || d.provider || 'offline'; this.llmHasKey = !!d.has_api_key; this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '', api_base: d.api_base || ''}; this.applyLlmModels(); + this.syncLlmStatus(); } catch(e) {} }, applyLlmModels() { - var p = this.llmProviders.find(x => x.id === this.llmForm.provider); + var p = this.providerById(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(); }, + onLlmProviderChange() { + this.applyLlmModels(); + var cur = this.providerById(this.llmForm.provider); + if (cur) this.llmForm.model = (cur.default_model || this.llmProviderModels[0] || ''); + }, async saveLlmConfig() { this.llmSaving = true; this.llmMsg = ''; this.llmMsgOk = false; @@ -963,9 +1152,15 @@ document.addEventListener('alpine:init', function() { var d = await r.json(); if (r.ok) { this.llmHasKey = !!d.has_api_key; + this.llmDefaultProvider = d.provider || this.llmDefaultProvider; this.llmMsgOk = true; - this.llmMsg = 'Configuration enregistrée : ' + d.provider + ' / ' + d.model + (d.available ? ' — disponible ✓' : ' — clé manquante pour ce provider'); + this.llmMsg = 'Configuration enregistrée : ' + d.provider + ' / ' + d.model + + (d.available ? ' — lancez « Tester la connexion » pour l\u2019activer pour tous.' : ' — clé manquante pour ce provider'); + // Re-évalue le badge du fournisseur par défaut localement. + var cur = this.providerById(d.provider); + if (cur) { cur.configured = !!d.has_api_key || !cur.requires_key; } this.llmForm.api_key = ''; + this.syncLlmStatus(); } else { this.llmMsg = d.detail || 'Erreur lors de l\'enregistrement'; } @@ -986,8 +1181,14 @@ document.addEventListener('alpine:init', function() { var d = await r.json(); this.llmTestOk = !!d.ok; this.llmTestMsg = d.ok - ? ('✓ ' + (d.model || '') + ' : ' + (d.reply || 'réponse reçue')) + ? ('✓ ' + (d.model || '') + ' fonctionnel' + (d.reply ? ' — ' + d.reply : '')) : ('✗ ' + (d.error || 'échec de connexion')); + // Reflète l'état sur le fournisseur courant du formulaire global. + var cur = this.providerById(this.llmForm.provider); + if (cur && this.llmForm.provider === this.llmDefaultProvider) { + this._setProviderState(this.llmForm.provider, d.ok, d.error || ''); + if (d.ok) cur.verified_model = d.model || ''; + } } catch(e) { this.llmTestOk = false; this.llmTestMsg = 'Erreur réseau'; } finally { this.llmTesting = false; } }, diff --git a/tests/test_agent.py b/tests/test_agent.py index 8e31492..629fab3 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -592,4 +592,73 @@ def test_router_run_uses_user_key(client, monkeypatch): "provider": "openai", "model": "gpt-4o-mini"}) assert resp.status_code == 200 assert created.get("api_key", "") == "" - return {} \ No newline at end of file + return {} + + +def test_router_user_key_test_verifies(client, monkeypatch): + """A successful user-key test flags the provider as functional (verified).""" + from app.routers import agent as agent_router + from app.services.llm_client import LLMResponse + + seen = {} + + class FakeLLM(agent_router.LLMClient): + def __init__(self, *args, **kwargs): + seen.update(kwargs) + super().__init__(*args, **kwargs) + + async def ping(self, *, model=None): + return LLMResponse(text="PONG", model=model or "gpt-4o") + + monkeypatch.setattr(agent_router, "LLMClient", FakeLLM) + + client.put("/api/agent/keys/openai", json={"api_key": "sk-user"}) + r = client.post("/api/agent/keys/openai/test", json={"model": "gpt-4o-mini"}) + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert body["verified"] is True + assert seen.get("api_key") == "sk-user" + + # the stored key row now carries verified=True + entry = [k for k in client.get("/api/agent/keys").json()["keys"] + if k["provider"] == "openai"][0] + assert entry["verified"] is True + + # /providers exposes openai as configured + verified + functional + 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 p["configured"] is True + assert p["verified"] is True + assert p["functional"] is True + + +def test_router_user_key_test_failure(client, monkeypatch): + """A failing test keeps the key but marks the provider non-functional.""" + from app.routers import agent as agent_router + + class FakeLLM(agent_router.LLMClient): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + async def ping(self, *, model=None): + raise PermissionError("Clé API manquante pour le provider « openai »") + + monkeypatch.setattr(agent_router, "LLMClient", FakeLLM) + + client.put("/api/agent/keys/openai", json={"api_key": "sk-user"}) + r = client.post("/api/agent/keys/openai/test", json={}) + assert r.status_code == 200 + body = r.json() + assert body["ok"] is False + assert body["verified"] is False + + keys = client.get("/api/agent/keys").json()["keys"] + entry = [k for k in keys if k["provider"] == "openai"][0] + assert entry["verified"] is False + assert entry["last_error"] + + prov = client.get("/api/agent/providers").json() + p = [x for x in prov["providers"] if x["id"] == "openai"][0] + assert p["functional"] is False \ No newline at end of file