feat(agent): v4.12.0 - refonte config 'Agent & IA', fournisseurs actifs/testes dans le panneau, contexte document + guide d'utilisation
FlowDeck CI / test (push) Failing after 9s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-05 22:47:16 -04:00
parent bd109c273a
commit c322f30801
10 changed files with 798 additions and 150 deletions
+41
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
4.11.1
4.12.0
+30
View File
@@ -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:
+1 -1
View File
@@ -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,
+126 -13
View File
@@ -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) ──
+19 -1
View File
@@ -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},
+96 -13
View File
@@ -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
+105 -12
View File
@@ -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)}
</style>
<button id="fd-agent-fab" title="FlowDeck Agent" onclick="document.dispatchEvent(new CustomEvent('fd-agent-toggle'))">🤖</button>
@@ -53,7 +57,7 @@
<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>
<option :value="p.id" x-text="p.id==='offline' ? 'Hors-ligne (mock)' : p.name"></option>
</template>
</select>
<select class="fd-agent-select" x-model="llmModel" x-show="llmProvider!=='offline'" title="Modèle">
@@ -64,6 +68,17 @@
<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-noconfig" x-show="!hasActiveProvider">
<span>⚠️ Aucun fournisseur activé et fonctionnel.</span>
<a href="/settings" target="_blank">Configurer → Agent &amp; IA</a>
<template x-if="unverifiedCount">
<span style="width:100%;color:var(--text-dim,#bbb)">🔑 <span x-text="unverifiedCount"></span> clé(s) enregistrée(s) à tester : ouvrez Paramètres puis « Tester ».</span>
</template>
</div>
<div class="fd-agent-ctx" x-show="chatContextActive" x-cloak>
<span>📄 Document ouvert : l'agent peut interagir avec cette page.</span>
</div>
<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=='history'}" @click="tab='history'; loadConversations()">Historique</button>
@@ -123,7 +138,8 @@
agents: [], conversations: [], currentConv: null,
messages: [], input: '', streaming: false, liveReasoning: '',
provider: 'offline', providerAvailable: false, errorMsg: '',
providers: [], providerModels: [], llmProvider: 'offline', llmModel: '', llmKeys: [],
allProviders: [], providers: [], providerModels: [], llmProvider: 'offline',
llmModel: '', llmKeys: [], defaultProvider: 'offline', ctxDetected: false,
init(){
var self = this;
@@ -131,6 +147,7 @@
window.addEventListener('fd-agent-toggle', function(e){
self.open = !self.open;
if(e && e.detail && e.detail.agentId){ self.agentId = e.detail.agentId; }
self.detectCtx();
self.loadConversations();
});
this.fetchProviders();
@@ -148,14 +165,25 @@
};
},
// Only providers *configured and functional* (verified) are selectable,
// plus the built-in "offline" mock so the chat always stays usable.
fetchProviders(){
var self = this;
fetch('/api/agent/providers').then(function(r){return r.json()}).then(function(d){
self.provider = d.provider || 'offline';
self.providerAvailable = !!d.available;
self.providers = d.providers || [];
self.allProviders = d.providers || [];
self.llmKeys = d.keys || [];
if(!self.llmProvider) self.llmProvider = d.provider || 'offline';
self.defaultProvider = d.default_provider || d.provider || 'offline';
self.providers = self.allProviders.filter(function(p){ return p.functional; });
if(!self.providers.some(function(x){ return x.id === 'offline'; })){
self.providers.push({id:'offline', name:'Hors-ligne', functional:true, verified:true,
models: [], default_model:'', requires_key:false});
}
var want = self.defaultProvider;
if(!self.providers.some(function(x){ return x.id === want; })) want = 'offline';
if(!self.providers.some(function(x){ return x.id === self.llmProvider; })) self.llmProvider = want;
if(!self.llmProvider) self.llmProvider = want;
if(!self.llmModel) self.llmModel = d.model || '';
self.applyModels();
}).catch(function(){});
@@ -174,6 +202,32 @@
self.providerModels = (p && p.models) || [];
self.llmModel = (p && p.default_model) || ((p && p.models && p.models[0]) || '');
},
get hasActiveProvider(){
return (this.providers || []).some(function(p){ return p.id !== 'offline'; });
},
get unverifiedCount(){
return (this.allProviders || []).filter(function(p){
return p.configured && !p.verified && p.id !== 'offline';
}).length;
},
get chatContextActive(){ return this.ctxDetected; },
detectCtx(){
var has = false;
try{ has = !!(window.fdGetPageContext && String(window.fdGetPageContext()).trim()); }catch(e){ has = false; }
this.ctxDetected = has;
},
docContext(){
try{
if(window.fdGetPageContext && typeof window.fdGetPageContext === 'function'){
var c = window.fdGetPageContext();
if(c && c.trim()) return String(c).slice(0, 9000);
}
}catch(e){}
try{
if(window.fdPageContext && String(window.fdPageContext).trim()) return String(window.fdPageContext).slice(0, 9000);
}catch(e){}
return '';
},
fetchAgents(){
var self = this;
fetch('/api/agent').then(function(r){return r.json()}).then(function(d){
@@ -197,8 +251,13 @@
fetch('/api/agent/conversations/'+id).then(function(r){return r.json()}).then(function(d){
self.currentConv = d.conversation;
self.messages = (d.messages||[]).map(function(m){ return {id:m.id, role:m.role, content:m.content, model:m.model, tool_calls:m.tool_calls_json}; });
if(d.conversation && d.conversation.provider){ self.llmProvider = d.conversation.provider; self.applyModels(); }
if(d.conversation && d.conversation.model){ self.llmModel = d.conversation.model; }
var prov = d.conversation && d.conversation.provider;
// Reuse the conversation's provider only while it stays functional.
if(prov && (!self.providers.length || self.providers.some(function(x){ return x.id === prov; }))){
self.llmProvider = prov;
}
self.applyModels();
if(d.conversation && d.conversation.model && self.providerModels.indexOf(d.conversation.model) !== -1){ self.llmModel = d.conversation.model; }
}).catch(function(){});
},
newConversation(){
@@ -237,7 +296,7 @@
});
return self._ensuring;
},
openAgent(){ this.open=true; this.loadConversations(); },
openAgent(){ this.open=true; this.detectCtx(); this.loadConversations(); },
close(){ this.open=false; },
get commandOpen(){ return /^\s*\//.test(this.input) && !this.streaming; },
get commandSuggestions(){
@@ -286,12 +345,13 @@
return true;
}
if(cmd === 'providers'){
var list = this.providers.map(function(x){ return x.id + (x.has_key ? ' ✓' : ''); }).join(', ');
this.echo('Fournisseurs : ' + (list || 'aucun'));
var list = this.providers.filter(function(x){ return x.id !== 'offline'; })
.map(function(x){ return x.id + (x.verified ? ' ✓' : ''); }).join(', ');
this.echo('Fournisseurs actifs : ' + (list || 'aucun — configurez-en un dans Paramètres → Agent & IA'));
return true;
}
if(cmd === 'keys'){
var saved = this.llmKeys.map(function(k){ return k.provider + ' ✓'; }).join(', ');
var saved = this.llmKeys.map(function(k){ return k.provider + (k.verified ? ' ✓' : ' (à tester)'); }).join(', ');
this.echo('Clés enregistrées : ' + (saved || 'aucune — configurez-les dans Paramètres → Agent & IA'));
return true;
}
@@ -346,6 +406,10 @@
var payload = {message: text};
if(self.llmProvider) payload.provider = self.llmProvider;
if(self.llmModel) payload.model = self.llmModel;
// Attach the currently open document/page so the agent can interact
// with it; empty on general screens (then the agent guides on the app).
var docCtx = self.docContext();
if(docCtx) payload.context = docCtx;
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST',
headers:{'Content-Type':'application/json'},
@@ -402,14 +466,43 @@
this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; });
},
get providerLabel(){
if(this.providerAvailable) return (this.llmProvider || this.provider);
return 'offline mock';
var p = this.llmProvider || this.provider || 'offline';
return p === 'offline' ? 'offline · mock' : p;
}
};
}
document.addEventListener('alpine:init', function(){
Alpine.data('agentPanel', agentPanel);
});
// ── Context reader ──
// Reads the document currently open in the page editor so the Agent can
// interact with it. Returns '' outside of a document (workspace, dashboard…):
// the agent then answers general "how do I use FlowDeck ?" questions.
window.fdReadPageContext = function(){
try{
var tEl = document.getElementById('_titleEl');
if(!tEl) return '';
var title = (tEl.textContent || '').trim();
var ct = document.getElementById('_blocksCt');
var text = '';
if(ct){
var kids = ct.children || [];
for(var i = 0; i < kids.length; i++){
var el = kids[i];
if(!el || !el.textContent) continue;
var t = el.textContent.replace(/\s+/g, ' ').trim();
if(t) text += t + '\n';
}
text = text.trim();
}
if(!title && !text) return '';
var out = title ? ('Titre du document : ' + title) : 'Document ouvert';
if(text) out += '\n\n' + text;
return out.slice(0, 9000);
}catch(e){ return ''; }
};
if(!window.fdGetPageContext) window.fdGetPageContext = window.fdReadPageContext;
window.agentPanel = agentPanel;
})();
</script>
+309 -108
View File
@@ -72,6 +72,33 @@
/* Close button */
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
/* Agent & IA — status summary + provider cards */
.llm-summary{display:flex;gap:14px;align-items:flex-start;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:22px;flex-wrap:wrap;}
.llm-summary-icon{font-size:26px;line-height:1;flex-shrink:0;}
.llm-summary-body{flex:1;min-width:220px;}
.llm-summary-title{font-size:14px;font-weight:600;color:var(--text);}
.llm-summary-desc{font-size:12.5px;color:var(--text-dim);margin-top:2px;}
.llm-summary-side{display:flex;align-items:center;gap:8px;flex-shrink:0;}
.llm-badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:3px 10px;border-radius:99px;white-space:nowrap;}
.llm-badge.ok{background:rgba(46,160,67,.15);color:#2ea043;}
.llm-badge.warn{background:rgba(217,115,13,.16);color:#d9730d;}
.llm-badge.err{background:rgba(224,62,62,.14);color:#e03e3e;}
.llm-badge.off{background:rgba(120,119,116,.15);color:var(--text-dim);}
.prov-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px;}
.prov-card{border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:var(--bg-secondary);display:flex;flex-direction:column;gap:4px;}
.prov-card-head{display:flex;align-items:center;gap:8px;margin-bottom:2px;flex-wrap:wrap;}
.prov-card-name{font-size:13.5px;font-weight:600;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;}
.prov-card-sub{font-size:11.5px;color:var(--text-dim);margin-bottom:8px;line-height:1.4;}
.prov-field-row{display:flex;flex-direction:column;gap:4px;margin-top:2px;}
.prov-field-label{font-size:12px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
.prov-field-label .muted{font-size:10.5px;color:var(--text-dim);font-weight:400;}
.prov-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;}
.prov-card input.settings-input,.prov-card select.settings-input{width:100%;box-sizing:border-box;}
.prov-card .pw-wrapper{width:100%;}
.prov-card .pw-wrapper .settings-input{padding-right:36px;}
.prov-msg{font-size:12px;margin-top:8px;min-height:0;word-break:break-word;}
.llm-hint{font-size:12px;color:var(--text-secondary);background:rgba(35,131,226,.07);border:1px solid rgba(35,131,226,.18);border-radius:8px;padding:8px 12px;margin-top:14px;line-height:1.5;}
</style>
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
@@ -509,106 +536,132 @@
<!-- Agent & IA: per-user provider keys + admin global default -->
<div x-show="activeSection==='llm'">
<h2>Agent &amp; IA</h2>
<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>
<p class="section-desc">Connectez des fournisseurs d'IA pour piloter l'agent FlowDeck. Un fournisseur doit être enregistré <b>et</b> avoir réussi son test de connexion pour devenir disponible dans le panneau Agent (🤖 en bas à droite). Sans fournisseur actif, l'agent fonctionne en mode hors-ligne (mock).</p>
<div class="setting-group">
<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>
<!-- Status summary -->
<div class="llm-summary">
<div class="llm-summary-icon">🤖</div>
<div class="llm-summary-body">
<div class="llm-summary-title" x-text="llmSummaryTitle"></div>
<div class="llm-summary-desc" x-text="llmSummaryDesc"></div>
</div>
<div class="llm-summary-side" x-show="llmActiveCount">
<span class="llm-badge ok">✓ <span x-text="llmActiveCount"></span>&nbsp;actif(s)</span>
</div>
</div>
<!-- Admin: workspace default provider -->
<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;">
<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>
<h3>Fournisseur par défaut de l'espace (tous les utilisateurs)</h3>
<div class="prov-card">
<div class="prov-card-head">
<span class="prov-card-name">Configuration globale</span>
<span class="llm-badge" :class="llmGlobalBadgeClass()" x-text="llmGlobalBadge()"></span>
</div>
<div class="prov-field-row" style="margin-top:2px;">
<div class="prov-field-label">Fournisseur</div>
<select class="settings-input" x-model="llmForm.provider" @change="onLlmProviderChange()" style="max-width:320px;">
<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="prov-field-row" x-show="llmForm.provider !== 'offline'">
<div class="prov-field-label">Modèle par défaut</div>
<select class="settings-input" x-model="llmForm.model" style="max-width:320px;">
<template x-for="m in llmProviderModels" :key="m">
<option :value="m" x-text="m"></option>
</template>
</select>
</div>
<div class="prov-field-row" x-show="globalNeedsKey()">
<div class="prov-field-label">Clé API <span class="muted" x-text="llmHasKey ? '(déjà configurée — laisser vide pour conserver)' : '(requise pour ce fournisseur)'"></span></div>
<div class="pw-wrapper" style="max-width:320px;">
<input :type="llmKeyVisible ? 'text' : 'password'" class="settings-input" x-model="llmForm.api_key" placeholder="sk-…" 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="prov-field-row">
<div class="prov-field-label">URL API <span class="muted">(optionnelle — Ollama, proxy OpenAI-compatible…)</span></div>
<input type="text" class="settings-input" x-model="llmForm.api_base" placeholder="https://…" style="max-width:320px;">
</div>
<div style="display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;">
<button class="btn btn-primary" @click="saveLlmConfig()" :disabled="llmSaving" style="font-size:13px;">
<span x-show="!llmSaving">{{ fd_icon("save",14) }} Enregistrer</span>
<span x-show="llmSaving">Enregistrement…</span>
</button>
<button class="btn" @click="testLlm()" :disabled="llmTesting" style="font-size:13px;">
<span x-show="!llmTesting">🔌 Tester la connexion</span>
<span x-show="llmTesting">Test…</span>
</button>
</div>
<div style="margin-top:10px;font-size:12.5px;">
<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:11.5px;color:var(--text-dim);margin:10px 0 0;">💡 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.</p>
</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>
<!-- My providers (per-user API keys) -->
<div class="setting-group">
<h3>Mes fournisseurs</h3>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 12px;">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).</p>
<div class="prov-grid">
<template x-for="p in userProviders()" :key="p.id">
<div class="prov-card">
<div class="prov-card-head">
<span class="prov-card-name" x-text="p.name"></span>
<span class="llm-badge" :class="llmBadgeClass(p)" x-text="llmBadge(p)"></span>
</div>
<div class="prov-card-sub" x-show="keyForm(p.id).last_error" style="color:var(--danger);" x-text="'Dernier test : ' + keyForm(p.id).last_error"></div>
<div class="prov-field-row">
<div class="prov-field-label">Clé API <span class="muted" x-text="keyForm(p.id).has_key ? '(enregistrée — laisser vide pour conserver)' : ''"></span></div>
<div class="pw-wrapper" style="max-width:100%;">
<input :type="keyForm(p.id).keyVisible ? 'text' : 'password'" class="settings-input"
x-model="keyForm(p.id).api_key" placeholder="sk-…" style="padding-right:36px;box-sizing:border-box;">
<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="prov-field-row">
<div class="prov-field-label">URL API <span class="muted">(optionnelle)</span></div>
<input type="text" class="settings-input" x-model="keyForm(p.id).api_base" placeholder="https://…" style="box-sizing:border-box;">
</div>
<div class="prov-field-row">
<div class="prov-field-label">Modèle par défaut</div>
<select class="settings-input" x-model="keyForm(p.id).model" style="box-sizing:border-box;">
<template x-for="m in keyModels(p.id)" :key="m">
<option :value="m" x-text="m"></option>
</template>
</select>
</div>
<div class="prov-actions">
<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 &amp; tester</span>
<span x-show="keyForm(p.id).saving">Enregistrement…</span>
</button>
<button class="btn" @click="testUserKey(p.id)" :disabled="keyForm(p.id).testing" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).testing">🔌 Tester</span>
<span x-show="keyForm(p.id).testing">Test…</span>
</button>
<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) }} Modèles</span>
<span x-show="keyForm(p.id).modelLoading">Chargement…</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 class="prov-msg">
<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>
</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 class="llm-hint">
💡 <b>Comment activer un fournisseur ?</b> Enregistrez votre clé puis cliquez « Enregistrer &amp; 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. <code>http://localhost:11434/v1</code>) puis testez.
En mode hors-ligne (aucun fournisseur actif), l'agent reste utilisable mais génère du contenu local, sans appel réseau.
</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;">
Sans clé API, l'agent fonctionne en mode « offline mock » (plannificateur déterministe, aucun réseau).
</p>
</div>
</div>
@@ -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; }
},
+70 -1
View File
@@ -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 {}
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