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
+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