feat(agent): v4.12.0 - refonte config 'Agent & IA', fournisseurs actifs/testes dans le panneau, contexte document + guide d'utilisation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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) ──
|
||||
|
||||
@@ -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
@@ -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
@@ -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 & 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
@@ -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 & 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> 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 & 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 & 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
@@ -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
|
||||
Reference in New Issue
Block a user