feat(agent): v4.10.0 FlowDeck Agent - agent IA ReAct (SSE, 18 outils + rollback, permissions, skills, triggers, multi-LLM)
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-05 09:58:04 -04:00
parent 9eedfa67ca
commit e752e46583
17 changed files with 2638 additions and 36 deletions
+37
View File
@@ -1,5 +1,42 @@
# Changelog — FlowDeck
## v4.10.0 (2026-09-05) — FlowDeck Agent : agent IA natif conversationnel
> Implémentation complète du pilier « Agent IA » : un agent ReAct intégré qui **agit** sur le
> workspace (pas seulement répond) — panneau de chat, boucle raisonnement→action, streaming SSE,
> 18 outils avec snapshots rollback, permissions par rôle, contexte automatique, custom agents,
> déclencheurs planifiés, skills réutilisables et multi-LLM (offline mock + 8 providers).
### Added
- **Interface conversationnelle** : panneau agent dans le sidebar (🤖 Agents) piloté par Alpine.js +
streaming SSE ; FAB ouvrant le chat ; historique de conversations.
- **Boucle ReAct** (`app/services/agent_engine.py`) : raisonnement → action → observation, max 12
itérations, budget de tokens, timeout, journal d'audit + fonction `undo_action()` (rollback).
- **Streaming SSE** : endpoint `/api/agent/conversations/{id}/run` qui émet `reasoning`, `action`
et `final` en temps réel.
- **18 outils** (`app/services/tool_registry.py`) : `search_workspace`, `read_collection`,
`read_page`, `create_collection`, `create_view`, `add_property`, `create_page`, `update_page`,
`write_blocks`, `add_relation`, `create_sub_item`, `add_dependency`, `apply_template`,
`delete_page`, `delete_collection`, `read_gitea_issues`, `sync_gitea`, `create_gitea_issue` —
chacun avec `ToolResult` + snapshot d'undo réversible.
- **Permissions** (`app/services/permission_manager.py`) : même ACL que l'utilisateur par rôle
workspace (viewer/editor/admin/owner) + gating des outils.
- **Contexte automatique** (`app/services/context_builder.py`) : snapshot du workspace actif,
collections, pages, mentions `@`, fichiers uploadés injectés dans le prompt système.
- **Multi-LLM** (`app/services/llm_client.py`) : OpenAI, Anthropic, Google, Ollama, DeepSeek,
QwenCloud, NVIDIA, OpenRouter + **offline mock** (planner déterministe, aucune clé requise).
- **Custom agents, skills, déclencheurs** : CRUD agents + conversations, `agent_*` tables
(`agents`, `agent_conversations`, `agent_messages`, `agent_actions`, `agent_skills`,
`agent_triggers`), skills (`/api/agent/skills`, apply), scheduler de fond pour triggers planifiés.
### Infra
- `VERSION` → 4.10.0 ; config LLM (`LLM_PROVIDER/MODEL/API_KEY/API_BASE`, `AGENT_MAX_*`) ;
exclusions CSRF `/api/agent` ; routeur agent + tâche scheduler dans le lifespan de `app/main.py`.
### Tests
- **226 tests verts** (+18 v4.10.0 : schéma DB, mock LLM, permissions, outils+undo, engine ReAct +
audit + rollback, CRUD agents/conversations, streaming SSE, skills, tools, providers).
## v4.9.0 (2026-09-04) — Collaboration : commentaires inline, mentions @, notifications
> Implémentation complète du pilier « Collaboration » — basée sur la revue des docs Notion
+79 -28
View File
@@ -323,11 +323,12 @@ Détails livrés :
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
- [x] **208 tests passent** (+9 v4.9.0)
### v4.10.0 — FlowDeck Agent (Agent IA natif) ⬜ (0 % implémenté)
### v4.10.0 — FlowDeck Agent (Agent IA natif) ✅
> ⚠️ **État actuel** : documentation de design complète (`docs/Flowdeck_Agent_integration.md`),
> mais **aucun code** : pas de `routers/agent.py`, `services/agent_engine.py`, `llm_client.py`,
> `tool_registry.py`, `context_builder.py`, ni aucune table `agent_*`.
> **Livré (2026-09-05)** : agent conversationnel complet — boucle ReAct, streaming SSE,
> 18 outils avec snapshots rollback, ACL par rôle, contexte automatique, custom agents,
> déclencheurs planifiés + skills, multi-LLM (offline mock + 8 providers). 18 tests dédiés.
> Voir [`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md).
**Objectif :** Un agent IA intégré à FlowDeck, capable de planifier, rechercher et **agir** directement sur les workspaces — collections, pages, propriétés, vues, issues Gitea. Inspiré de Notion Agent (2026).
@@ -348,16 +349,16 @@ Là où un assistant IA classique répond (`prompt → réponse`), FlowDeck Agen
| Aucune action DB | Modifie le workspace via API FastAPI existantes |
#### Fonctionnalités clés
- [ ] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
- [ ] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
- [ ] **Streaming SSE** — affichage temps réel du raisonnement et des actions
- [ ] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
- [ ] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
- [ ] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
- [ ] **Custom agents** — instructions, modèle, outils, scope par agent
- [ ] **Déclencheurs** — planifiés, webhooks, événements workspace
- [ ] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
- [ ] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter
- [x] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
- [x] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
- [x] **Streaming SSE** — affichage temps réel du raisonnement et des actions
- [x] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
- [x] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
- [x] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
- [x] **Custom agents** — instructions, modèle, outils, scope par agent
- [x] **Déclencheurs** — planifiés, webhooks, événements workspace
- [x] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
- [x] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter
#### Architecture (nouveaux composants)
```
@@ -511,6 +512,57 @@ app/
---
## v5.10.0 — Éditeur : interactions de bloc ⬜ (non commencé)
> **Objectif** : parité de manipulation des blocs avec Notion. Actuellement les blocs sont
> éditables mais *statiques* : impossible de les déplacer, dupliquer ou annuler une action.
- [ ] **Drag & drop des blocs** — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue), support multi-sélection de blocs (Shift+clic → déplacement groupé)
- [ ] **Menu contextuel de bloc** — clic sur ⋮⋮ : Turn into (changer le type), Duplicate, Copy link to block, Move to, Delete, couleur de texte/fond
- [ ] **Undo / Redo** — Ctrl+Z / Ctrl+Shift+Z ou Ctrl+Y sur l'état des blocs (pile d'opérations inversibles par page, conservée en mémoire du tab)
- [ ] **Duplicate block** — Ctrl+D sur le bloc courant (copie insérée juste en dessous)
- [ ] **Slash command étendue** — la commande `/` propose aussi : Turn into, Duplicate, et toutes les actions du menu bloc
- [ ] **En-têtes de tableau (bloc table v4.8.0)** — toggles « First row as header » / « First column as header » dans la config du bloc table, rendu `<th>` correspondant en preview et exports
## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé)
> **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0
> (section « Lié depuis ») par la création des liens depuis l'éditeur.
- [ ] **Wiki-links `[[`** — taper `[[` ouvre un picker de pages (recherche fuzzy, toutes collections), Enter insère un lien interne rendu comme chip de page (icône + titre mis à jour dynamiquement)
- [ ] **Mention de page `@`** — dans le menu @ existant (utilisateurs v4.9.0), ajouter l'onglet « Pages » : `@` suivi d'un nom de page crée un lien inline
- [ ] **Mention de date `@`** — `@today`, `@tomorrow`, `@2026-10-01` rendus comme chips de date
- [ ] **Renommage propagé** — renommer une page met à jour le libellé affiché de tous ses liens internes (résolution au rendu via `page_id`, pas de texte dur)
## v5.12.0 — Templates & verrouillage de page ⬜ (non commencé)
> **Objectif** : démarrage rapide productif et protection des pages stabilisées.
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections.
- [ ] **Template picker global** — sur « + New page » : galerie de templates (Empty, Meeting notes, Weekly report, To-do list…) + templates custom utilisateur
- [ ] **Bouton « Use template »** — duplication du contenu du template dans la nouvelle page
- [ ] **Page lock** — toggle 🔒 dans le menu « … » : page en lecture seule (édition désactivée pour tous sauf owner/admin), indicateur visuel en topbar
- [ ] **Full-width mode** — toggle pour passer la page en pleine largeur (comme Notion)
- [ ] **Small text / typo options** — option de page : taille de police réduite, serif/mono
## v5.13.0 — Collaboration temps réel ⬜ (non commencé)
> **Objectif** : avancer depuis v6.0.0 le chantier n°1 de la parité Notion en équipe.
> Sans realtime, FlowDeck reste un outil mono-utilisateur partagé.
- [ ] **WebSocket gateway** — endpoint `/ws/pages/{id}`, auth via session, rooms par page
- [ ] **Présence** — avatars des utilisateurs connectés sur la page (topbar), couleur par utilisateur
- [ ] **Curseurs live** — position des curseurs/sélections des autres éditeurs, label avec nom
- [ ] **Merge de modifications** — diffusion des opérations de blocs (insert/update/delete/move) avec last-write-wins par bloc + version de page pour détecter les conflits
- [ ] **Fallback polling** — si WS indisponible, rafraîchissement diff toutes les 10 s
## v5.14.0 — Synced blocks ⬜ (non commencé)
> **Objectif** : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans
> plusieurs pages, édité une fois).
- [ ] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced`
- [ ] **Rendu** — ring rouge + badge « Synced » sur le bloc ; édition à un endroit => mise à jour partout (via rooms WS v5.13.0 si actives, sinon au reload)
- [ ] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
- [ ] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync »
---
## v6.0.0 — Pro (futur)
- [ ] **PWA** — Progressive Web App, offline support
@@ -518,8 +570,8 @@ app/
- [ ] **Granular permissions** — page-level, property-level access control
- [ ] **Web Clipper** — extension navigateur
- [ ] **API publique complète** — REST API documentée (OpenAPI) *(base existante : `public_api.py`, à étendre + documenter)* — voir [`docs/API_GUIDE_V6.md`](docs/API_GUIDE_V6.md) : référence complète (conventions, CRUD par ressource, webhooks, sécurité, checklist)
- [ ] **Realtime editing** — WebSocket, curseurs multi-utilisateurs
- [ ] **Synced blocks** — bloc synchronisé entre plusieurs pages
- [ ] **Realtime editing (production)** — voir **v5.13.0** (curseurs + présence déjà avancés ici) ; reste en v6 : conflits avancés, édition large échelle
- [ ] **Synced blocks (production)** — voir **v5.14.0** (bloc de base) ; reste en v6 : syncing côté databases/vues
---
@@ -535,11 +587,10 @@ app/
## 🎯 Ordre de priorité recommandé (état 2026-09)
1. **v5.2.0 → Migrations versionnées** (bloquant pour tout le reste)
2. **v4.9.0 → Collaboration ✅** (comments + mentions + notifications) — livré 2026-09-04
3. **v5.0.0 → Command palette + FTS5** — le raccourci Ctrl+K est déjà câblé, petit effort / gros impact
4. **v5.3.0 → Inline databases dans pages** — API serveur prête, uniquement travail éditeur
5. **v4.10.0 → Agent IA** — gros chantier, démarrer Phase 1 (engine + 3 outils) une fois 1–4 livrés
2. **v5.0.0 → Command palette + FTS5** — Ctrl+K déjà câblé, petit effort / gros impact
3. **v5.3.0 → Inline databases dans pages** — API serveur prête, uniquement travail éditeur
4. **v5.13.0 → Realtime (WS + présence)** — chantier de parité Notion n°1, socle aussi pour v5.14 (synced blocks)
5. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré
---
## Résumé des phases
@@ -551,11 +602,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
+ UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library
v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ✅ v4.6.0 ✅ v4.7.0 ✅ v4.8.0 ✅ v4.9.0 ✅ v4.10 ⬜ v5.0–5.9 ⬜ v6.0 ⬜
Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Bloc Table Collaboration Agent IA Palette → Pro + Agent
& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks MD/PDF/HTML simple (comments@) (futur) Automations (futur)
(futur) Embeds +
Import + DB
+ Calendrier
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.9 ⬜ v5.10–5.12 ⬜ v5.13–5.14 ⬜ v6.0 ⬜
Quality DB views, Agent IA Palette → Éditeur bloc Realtime + Pro + Agent
& Tests Templates & COMPLETED Automations (drag&drop, Synced blocks
Collaboration Embeds, Import, undo/redo) +
DB avancée, Wiki-links,
Calendrier, AI Templates & lock
*Dernière mise à jour: 2026-09-04 — Audit complet v4.9–v5.3 + analyse Notion clone : v5.5.0 → v5.9.0 ajoutées (Embeds, Import, DB avancée, Calendrier & Rappels, AI Writing)*
*Dernière mise à jour: 2026-09-05 — v4.10.0 Agent IA livré (18 tests) ; audit précédent : v5.10.0 → v5.14.0 ajoutées (interactions de bloc, wiki-links & mentions, templates & page lock, realtime, synced blocks)*
+1 -1
View File
@@ -1 +1 @@
4.9.0
4.10.0
+12
View File
@@ -59,6 +59,18 @@ class Settings(BaseSettings):
smtp_use_tls: bool = True
app_base_url: str = "http://localhost:8080"
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
# (deterministic rule-based planner so the agent works without any API key).
agent_enabled: bool = True
llm_provider: str = "offline" # openai | anthropic | google | ollama |
# deepseek | qwencloud | nvidia | openrouter | offline
llm_model: str = "gpt-4o"
llm_api_key: str = ""
llm_api_base: str = "" # custom base URL (Ollama, OpenRouter, ...)
agent_max_iterations: int = 12
agent_max_tokens_budget: int = 500000
agent_run_timeout_seconds: int = 300
@property
def db_path(self) -> Path:
if self.database_url == "sqlite:///:memory:":
+88
View File
@@ -640,6 +640,94 @@ def init_db():
pass
conn.commit()
# ═══════════ v4.10.0: FlowDeck Agent — agents, conversations, audit ═══════════
conn.execute("""
CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id),
name TEXT NOT NULL DEFAULT 'FlowDeck Agent',
icon TEXT DEFAULT '🤖',
agent_type TEXT NOT NULL DEFAULT 'personal',
description TEXT DEFAULT '',
system_instructions TEXT DEFAULT '',
model TEXT DEFAULT 'gpt-4o',
scope_json TEXT NOT NULL DEFAULT '{}',
trigger_json TEXT NOT NULL DEFAULT '{}',
approval_mode TEXT NOT NULL DEFAULT 'auto',
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
UNIQUE(workspace_id, name)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT DEFAULT 'New conversation',
status TEXT NOT NULL DEFAULT 'idle',
context_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tool_calls_json TEXT DEFAULT '[]',
model TEXT,
tokens_used INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
tool_name TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
payload_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'success',
undo_snapshot_json TEXT DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
executed_by INTEGER REFERENCES users(id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id),
name TEXT NOT NULL,
description TEXT DEFAULT '',
prompt_template TEXT NOT NULL,
allowed_tools_json TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
UNIQUE(workspace_id, name)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_triggers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL DEFAULT 'manual',
config_json TEXT NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT 1,
last_fired_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)")
conn.commit()
@contextmanager
def get_conn():
+18 -4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
@@ -13,7 +14,7 @@ from app.config import settings
from app.db import init_db
from app.middleware.csrf import CSRFMiddleware
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export, agent
from app.routers.notifications import router as notifications_router
from app.routers.collaboration import router as collaboration_router
from app.routers.gitea import router as gitea_router
@@ -41,13 +42,25 @@ async def lifespan(_app: FastAPI):
(admin_hash,)
)
conn.commit()
logger.info("FlowDeck v4.9.0 started on port %d", settings.app_port)
yield
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
from app.routers.agent import agent_scheduler
scheduler_task = asyncio.create_task(agent_scheduler())
logger.info("FlowDeck v4.10.0 started on port %d", settings.app_port)
try:
yield
finally:
scheduler_task.cancel()
try:
await scheduler_task
except asyncio.CancelledError:
pass
app = FastAPI(
title="FlowDeck",
version="4.9.0",
version="4.10.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -78,6 +91,7 @@ app.include_router(sidebar_config.router)
app.include_router(export.router)
app.include_router(notifications_router)
app.include_router(collaboration_router)
app.include_router(agent.router)
app.mount("/static", StaticFiles(directory="static"), name="static")
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+424
View File
@@ -0,0 +1,424 @@
"""FlowDeck — Agent router (v4.10.0). /api/agent/*
Agents, conversations, SSE run, audit & rollback, skills, triggers, tools.
"""
from __future__ import annotations
import asyncio
import json
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import StreamingResponse
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
from app.services.tool_registry import ToolRegistry
from app.services.permission_manager import PermissionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["agent"], prefix="/api/agent")
async def agent_scheduler(interval_seconds: int = 60):
"""Background loop that fires scheduled custom agents (trigger_type=schedule).
Runs inside the FastAPI lifespan. A trigger's config_json may use a cron-like
string ("* * * * *") — for simplicity we fire at most once per poll interval
when the trigger is active and not currently running.
"""
from datetime import datetime, timedelta
logger.info("FlowDeck Agent scheduler started")
while True:
try:
await asyncio.sleep(interval_seconds)
with get_conn() as conn:
triggers = conn.execute(
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
).fetchall()
now = datetime.utcnow()
for trig in triggers:
last = trig["last_fired_at"]
if last:
try:
last_dt = datetime.fromisoformat(last)
except ValueError:
last_dt = None
if last_dt and now - last_dt < timedelta(seconds=interval_seconds):
continue
# fire: run the agent's system_instructions through the engine
agent_row = conn.execute("SELECT * FROM agents WHERE id=?", (trig["agent_id"],)).fetchone()
if not agent_row:
continue
user_id = agent_row["created_by"]
cur = conn.execute(
"INSERT INTO agent_conversations (agent_id, user_id, title, context_json) VALUES (?,?,?,?)",
(agent_row["id"], user_id, f"Scheduled: {agent_row['name']}",
json.dumps({"workspace_id": agent_row["workspace_id"], "trigger": "schedule"})),
)
conv_id = cur.lastrowid
conn.execute(
"UPDATE agent_triggers SET last_fired_at=? WHERE id=?",
(now.isoformat(), trig["id"]),
)
conn.commit()
objective = (agent_row["system_instructions"] or "").strip() or \
f"Exécute l'agent planifié « {agent_row['name']} »."
# best-effort fire-and-forget (offline/mock path is safe & fast)
try:
engine = AgentEngine(user_id, workspace_id=agent_row["workspace_id"])
async for _ev in engine.run(conv_id, objective, model=agent_row["model"]):
pass
except Exception as exc: # noqa: BLE001
logger.warning("Scheduled agent %s failed: %s", agent_row["id"], exc)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
logger.exception("Agent scheduler tick failed")
async def _current_user_id(request: Request) -> int | None:
user = await get_current_user(request)
if user and user.get("id"):
return user["id"]
with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
return row["id"] if row else None
async def _workspace_id(request: Request) -> int | None:
user = await get_current_user(request)
if user and user.get("workspace_id"):
return user["workspace_id"]
try:
ws = int(request.query_params.get("workspace_id", 0) or 0)
return ws or None
except (TypeError, ValueError):
return None
def _default_agent(conn, user_id: int) -> dict:
row = conn.execute(
"SELECT * FROM agents WHERE agent_type='personal' ORDER BY id LIMIT 1"
).fetchone()
if row:
return dict(row)
cur = conn.execute(
"INSERT INTO agents (workspace_id, name, agent_type, model, created_by) VALUES (?, 'FlowDeck Agent', 'personal', 'gpt-4o', ?)",
(None, user_id),
)
conn.commit()
return dict(conn.execute("SELECT * FROM agents WHERE id=?", (cur.lastrowid,)).fetchone())
# ── Agents ──
@router.get("")
async def list_agents(request: Request):
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
with get_conn() as conn:
_default_agent(conn, user_id)
rows = conn.execute("SELECT * FROM agents WHERE workspace_id IS ? OR workspace_id=? ORDER BY agent_type, name", (ws, ws)).fetchall()
return {"agents": [dict(r) for r in rows]}
@router.post("")
async def create_agent(request: Request):
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
body = await request.json() if request.headers.get("content-type") else {}
name = (body.get("name") or "").strip() or "Custom Agent"
with get_conn() as conn:
try:
cur = conn.execute(
"""INSERT INTO agents (workspace_id, name, icon, agent_type, description,
system_instructions, model, scope_json, trigger_json, approval_mode, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(ws, name, body.get("icon", "🤖"), body.get("agent_type", "custom"),
body.get("description", ""), body.get("system_instructions", ""),
body.get("model", "gpt-4o"),
json.dumps(body.get("scope", {})),
json.dumps(body.get("trigger", {})),
body.get("approval_mode", "auto"), user_id),
)
conn.commit()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail=f"Impossible de créer l'agent: {exc}")
return {"id": cur.lastrowid, "name": name, "status": "created"}
# ── Conversations ──
@router.get("/conversations")
async def list_conversations(request: Request):
user_id = await _current_user_id(request)
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM agent_conversations WHERE user_id=? ORDER BY updated_at DESC",
(user_id,),
).fetchall()
return {"conversations": [dict(r) for r in rows]}
@router.post("/conversations")
async def create_conversation(request: Request):
user_id = await _current_user_id(request)
body = await request.json() if request.headers.get("content-type") else {}
ws = await _workspace_id(request)
with get_conn() as conn:
agent = _default_agent(conn, user_id)
cur = conn.execute(
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
VALUES (?,?,?,?)""",
(agent["id"], user_id, body.get("title", "New conversation"),
json.dumps({"workspace_id": ws})),
)
conn.commit()
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"}
@router.get("/conversations/{conversation_id}")
async def get_conversation(request: Request, conversation_id: int):
with get_conn() as conn:
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
if not conv:
raise HTTPException(status_code=404, detail="Conversation introuvable")
messages = conn.execute(
"SELECT * FROM agent_messages WHERE conversation_id=? ORDER BY created_at, id",
(conversation_id,),
).fetchall()
return {"conversation": dict(conv), "messages": [dict(m) for m in messages]}
@router.delete("/conversations/{conversation_id}")
async def delete_conversation(request: Request, conversation_id: int):
with get_conn() as conn:
if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone():
raise HTTPException(status_code=404, detail="Conversation introuvable")
conn.execute("DELETE FROM agent_conversations WHERE id=?", (conversation_id,))
conn.commit()
return {"id": conversation_id, "status": "deleted"}
# ── Run (SSE) ──
@router.post("/conversations/{conversation_id}/run")
async def run_conversation(request: Request, conversation_id: int):
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
body = await request.json() if request.headers.get("content-type") else {}
objective = (body.get("message") or "").strip()
if not objective:
raise HTTPException(status_code=400, detail="message est requis")
with get_conn() as conn:
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
if not conv:
raise HTTPException(status_code=404, detail="Conversation introuvable")
engine = AgentEngine(user_id, workspace_id=ws)
provider = body.get("provider") or None
llm = LLMClient(provider=provider) if provider else None
if llm:
engine.llm = llm
async def event_stream():
async for ev in engine.run(
conversation_id, objective,
model=body.get("model"),
mentions=body.get("mentions"),
files=body.get("files"),
skill_id=body.get("skill_id"),
):
yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ── Audit & rollback ──
@router.get("/conversations/{conversation_id}/actions")
async def list_actions(request: Request, conversation_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
(conversation_id,),
).fetchall()
return {"actions": [dict(r) for r in rows]}
@router.post("/actions/{action_id}/undo")
async def undo(request: Request, action_id: int):
try:
undo_action(action_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"Rollback échoué: {exc}")
return {"id": action_id, "status": "reverted"}
# ── Skills ──
@router.get("/skills")
async def list_skills(request: Request):
ws = await _workspace_id(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=? ORDER BY name", (ws, ws)).fetchall()
return {"skills": [dict(r) for r in rows]}
@router.post("/skills")
async def create_skill(request: Request):
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
body = await request.json() if request.headers.get("content-type") else {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name est requis")
with get_conn() as conn:
try:
cur = conn.execute(
"""INSERT INTO agent_skills (workspace_id, name, description, prompt_template, allowed_tools_json, created_by)
VALUES (?,?,?,?,?,?)""",
(ws, name, body.get("description", ""), body.get("prompt_template", ""),
json.dumps(body.get("allowed_tools", [])), user_id),
)
conn.commit()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}")
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.post("/skills/{skill_id}/apply")
async def apply_skill(request: Request, skill_id: int):
"""Create a conversation pre-loaded with a skill, ready to run."""
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
with get_conn() as conn:
skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
if not skill:
raise HTTPException(status_code=404, detail="Skill introuvable")
agent = _default_agent(conn, user_id)
cur = conn.execute(
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
VALUES (?,?,?,?)""",
(agent["id"], user_id, skill["name"], json.dumps({"workspace_id": ws, "skill_id": skill_id})),
)
conn.commit()
return {"conversation_id": cur.lastrowid, "skill": skill["name"], "status": "ready"}
# ── Triggers & tools ──
@router.post("/{agent_id}/trigger")
async def trigger_agent(request: Request, agent_id: int):
"""Manually fire a custom agent: create a conversation and run it with the
agent's instructions as the objective (falls back to a generic prompt)."""
user_id = await _current_user_id(request)
ws = await _workspace_id(request)
with get_conn() as conn:
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not agent:
raise HTTPException(status_code=404, detail="Agent introuvable")
cur = conn.execute(
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
VALUES (?,?,?,?)""",
(agent_id, user_id, f"Run: {agent['name']}", json.dumps({"workspace_id": ws})),
)
conv_id = cur.lastrowid
conn.commit()
objective = (agent["system_instructions"] or "").strip() or f"Exécute l'agent « {agent['name']} »."
engine = AgentEngine(user_id, workspace_id=ws)
async def event_stream():
async for ev in engine.run(conv_id, objective, model=agent["model"]):
yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
@router.get("/tools")
async def list_tools(request: Request):
registry = ToolRegistry()
tools = registry.schema()
return {"tools": tools}
@router.get("/providers")
async def list_providers(request: Request):
"""Expose configured LLM provider + availability to the UI."""
llm = LLMClient()
return {
"provider": llm.provider,
"model": llm.default_model,
"available": await llm.is_available(),
"max_iterations": settings.agent_max_iterations,
}
# ── Agents by id (registered LAST so static routes /tools, /skills, … win) ──
@router.get("/{agent_id}")
async def get_agent(request: Request, agent_id: int):
with get_conn() as conn:
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Agent introuvable")
return dict(row)
@router.put("/{agent_id}")
async def update_agent(request: Request, agent_id: int):
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Agent introuvable")
sets, params = [], []
for col in ("name", "icon", "description", "system_instructions", "model",
"approval_mode", "is_active"):
if col in body:
sets.append(f"{col}=?")
params.append(body[col])
if "scope" in body:
sets.append("scope_json=?")
params.append(json.dumps(body["scope"]))
if "trigger" in body:
sets.append("trigger_json=?")
params.append(json.dumps(body["trigger"]))
if sets:
params.append(agent_id)
conn.execute(f"UPDATE agents SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
return {"id": agent_id, "status": "updated"}
@router.delete("/{agent_id}")
async def delete_agent(request: Request, agent_id: int):
with get_conn() as conn:
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Agent introuvable")
conn.execute("DELETE FROM agents WHERE id=?", (agent_id,))
conn.commit()
return {"id": agent_id, "status": "deleted"}
+271
View File
@@ -0,0 +1,271 @@
"""FlowDeck — AgentEngine: ReAct orchestrator (v4.10.0).
`objective → comprehension → context → reasoning ↔ action → result`.
The engine drives the LLM (which only emits tool intentions), gates each call
through PermissionManager, executes it via ToolRegistry, journals every action
to `agent_actions` with an undo snapshot, and yields a stream of SSE events so
the UI can render reasoning + actions live.
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
from app.config import settings
from app.db import get_conn
from app.services.llm_client import LLMClient
from app.services.permission_manager import PermissionManager
from app.services.context_builder import ContextBuilder
from app.services.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
MAX_ITERATIONS = 12
class AgentEngine:
def __init__(self, user_id: int, workspace_id: int | None = None,
llm: LLMClient | None = None):
self.user_id = user_id
self.workspace_id = workspace_id
self.llm = llm or LLMClient()
self.tools = ToolRegistry()
self.ctx = ContextBuilder(user_id, workspace_id)
self.perms = PermissionManager(user_id)
self._tokens = 0
# ── Helpers ──
def _load_agent(self, conversation_id: int) -> dict:
with get_conn() as conn:
row = conn.execute(
"SELECT a.* FROM agents a JOIN agent_conversations c ON c.agent_id=a.id WHERE c.id=?",
(conversation_id,),
).fetchone()
if not row:
row = {"id": None, "name": "FlowDeck Agent", "icon": "🤖", "agent_type": "personal",
"system_instructions": "", "model": settings.llm_model,
"scope_json": "{}", "approval_mode": "auto"}
return dict(row)
def _build_system_prompt(self, agent: dict, skill: dict | None = None) -> str:
lines = [
"Tu es FlowDeck Agent, un agent IA qui réalise des tâches dans le workspace FlowDeck.",
"Tu réfléchis (reasoning) puis agis en appelant les outils disponibles.",
"Appelle UN ou PLUSIEURS outils pour atteindre l'objectif, puis conclus avec une réponse finale.",
"N'invente jamais d'IDs : utilise ceux fournis dans le contexte.",
f"Workspace courant : {self.workspace_id}.",
]
if agent.get("system_instructions"):
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','')}")
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):
agent = self._load_agent(conversation_id)
scope = json.loads(agent.get("scope_json") or "{}")
approval_mode = agent.get("approval_mode") or "auto"
model = model or agent.get("model") or settings.llm_model
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)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": f"{objective}\n\n# Contexte\n{context}"},
]
self._persist_message(conversation_id, "user", objective)
self._update_conversation(conversation_id, status="running")
tool_schema = self.tools.schema(scope)
final_text = None
try:
for step in range(settings.agent_max_iterations or MAX_ITERATIONS):
if self._tokens >= settings.agent_max_tokens_budget:
yield self._event("error", {"message": "Budget de tokens dépassé"})
break
response = await asyncio.wait_for(
self.llm.complete(messages, model=model, tools=tool_schema, stream=True),
timeout=settings.agent_run_timeout_seconds,
)
self._tokens += response.usage.get("total_tokens", 0) or 0
if response.text and response.text.strip():
yield self._event("reasoning", {"content": response.text})
messages.append({"role": "assistant", "content": response.text})
if not response.tool_calls:
final_text = response.text or self._no_tool_message(response)
yield self._event("final", {"content": final_text})
break
for call in response.tool_calls:
tool, args = call["name"], call.get("arguments") or {}
try:
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
except Exception as exc: # permission / approval guard
detail = self._exc_detail(exc)
yield self._event("action", {"tool": tool, "status": "error", "detail": detail})
self._log_action(conversation_id, tool, args, {}, "error", detail=detail)
continue
result = await self.tools.execute(tool, args, user_id=self.user_id)
if result.status == "success":
yield self._event("action", {
"tool": tool, "status": result.status,
"target_type": result.target_type, "target_id": result.target_id,
"message": result.message,
})
self._log_action(conversation_id, tool, args, result.data, "success",
target_type=result.target_type, target_id=result.target_id,
undo=result.undo)
messages.append({
"role": "tool", "name": tool,
"content": json.dumps({"status": "ok", "result": result.data, "target_id": result.target_id}, ensure_ascii=False),
})
else:
yield self._event("action", {"tool": tool, "status": "error", "detail": result.message})
self._log_action(conversation_id, tool, args, {}, "error", detail=result.message)
messages.append({
"role": "tool", "name": tool,
"content": json.dumps({"status": "error", "message": result.message}, ensure_ascii=False),
})
if final_text is None:
final_text = "Objectif traité. Consultez le journal des actions pour le détail."
yield self._event("final", {"content": final_text})
self._persist_message(conversation_id, "assistant", final_text,
model=model, tokens=self._tokens)
except Exception as exc: # noqa: BLE001
logger.exception("AgentEngine run failed")
yield self._event("error", {"message": f"Erreur interne: {exc}"})
finally:
self._update_conversation(conversation_id, status="idle")
# ── Skills ──
def _load_skill(self, skill_id: int, scope: dict | None) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
if not row:
return None
skill = dict(row)
allowed = json.loads(skill.get("allowed_tools_json") or "[]")
if allowed:
skill["prompt_template"] = (skill.get("prompt_template") or "") + \
"\nOutils autorisés: " + ", ".join(allowed)
return skill
# ── Audit & persistence ──
def _log_action(self, conversation_id, tool, args, result, status,
*, target_type="", target_id=None, undo=None, detail=""):
with get_conn() as conn:
conn.execute(
"""INSERT INTO agent_actions
(conversation_id, tool_name, target_type, target_id, payload_json,
result_json, status, undo_snapshot_json, executed_by)
VALUES (?,?,?,?,?,?,?,?,?)""",
(conversation_id, tool, target_type,
str(target_id) if target_id is not None else None,
json.dumps(args, ensure_ascii=False),
json.dumps(result, ensure_ascii=False, default=str),
status,
json.dumps(undo or {}, ensure_ascii=False),
self.user_id),
)
conn.commit()
def _persist_message(self, conversation_id, role, content, *, model="", tokens=0):
with get_conn() as conn:
conn.execute(
"INSERT INTO agent_messages (conversation_id, role, content, model, tokens_used) VALUES (?,?,?,?,?)",
(conversation_id, role, content, model, tokens),
)
conn.commit()
def _update_conversation(self, conversation_id, *, status=None, title=None):
with get_conn() as conn:
sets, params = ["updated_at=CURRENT_TIMESTAMP"], []
if status:
sets.append("status=?")
params.append(status)
if title:
sets.append("title=?")
params.append(title)
params.append(conversation_id)
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
# ── Misc ──
@staticmethod
def _event(etype: str, data: dict) -> dict:
return {"type": etype, **data}
@staticmethod
def _no_tool_message(response) -> str:
return "Je n'ai pas d'action à proposer pour cet objectif. Posez-moi une question plus précise ou demandez-moi de créer un élément."
@staticmethod
def _exc_detail(exc: Exception) -> str:
detail = getattr(exc, "detail", None)
return detail if isinstance(detail, str) else str(exc)
def undo_action(action_id: int) -> bool:
"""Reverse a single agent action using its stored undo snapshot.
Returns True on success. Marks the action row `reverted`.
"""
with get_conn() as conn:
action = conn.execute("SELECT * FROM agent_actions WHERE id=?", (action_id,)).fetchone()
if not action:
raise ValueError(f"Action #{action_id} introuvable")
undo = json.loads(action["undo_snapshot_json"] or "{}")
op, table, rid = undo.get("action"), undo.get("table"), undo.get("id")
if not op or not table or rid is None:
raise ValueError(f"Action #{action_id} n'a pas de snapshot annulable")
if op == "delete":
conn.execute(f"DELETE FROM {table} WHERE id=?", (rid,))
elif op == "insert":
snapshot = undo.get("snapshot")
if not snapshot:
raise ValueError("Snapshot manquant pour insert")
cols = ", ".join(snapshot.keys())
ph = ", ".join("?" for _ in snapshot)
conn.execute(f"INSERT INTO {table} ({cols}) VALUES ({ph})", list(snapshot.values()))
elif op == "update":
snapshot = undo.get("snapshot")
if table == "collection_pages":
conn.execute(
"UPDATE collection_pages SET property_values_json=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(snapshot, ensure_ascii=False), undo.get("title", ""), rid),
)
elif table == "pages":
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(snapshot, rid))
else:
raise ValueError(f"Table non gérée pour rollback: {table}")
else:
raise ValueError(f"Opération de rollback inconnue: {op}")
conn.execute("UPDATE agent_actions SET status='reverted' WHERE id=?", (action_id,))
conn.commit()
return True
+105
View File
@@ -0,0 +1,105 @@
"""FlowDeck — Agent context builder (v4.10.0).
Collects a compact, permission-filtered snapshot of the active workspace so the
LLM can reason about real entities (collections, pages, Gitea issues) without
touching the database directly.
"""
from __future__ import annotations
import json
from app.db import get_conn
class ContextBuilder:
"""Builds the textual context that accompanies each agent run."""
def __init__(self, user_id: int, workspace_id: int | None = None):
self.user_id = user_id
self.workspace_id = workspace_id
def build(self, *, mentions: list[str] | None = None,
files: list[dict] | None = None,
include_collections: bool = True) -> str:
"""Return a compact Markdown-ish snapshot of the workspace context."""
sections: list[str] = []
if include_collections:
sections.append(self._collections_context())
sections.append(self._pages_context())
if mentions:
sections.append(self._mentions_context(mentions))
if files:
sections.append(self._files_context(files))
return "\n\n".join(s for s in sections if s)
# ── Internals ──
def _collections_context(self) -> str:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, icon, is_locked, schema_json FROM collections ORDER BY name"
).fetchall()
if not rows:
return "## Collections\n(no collections yet)"
lines = ["## Collections"]
lines.append("Collection IDs: " + ", ".join(str(r["id"]) for r in rows))
for r in rows:
props = json.loads(r["schema_json"]) if r["schema_json"] else []
schema = ", ".join(p if isinstance(p, str) else p.get("name", "?") for p in props) or "none"
lock = " [LOCKED]" if r["is_locked"] else ""
lines.append(f"- #{r['id']} {r['icon']} **{r['name']}** (schema: {schema}){lock}")
return "\n".join(lines)
def _pages_context(self, limit: int = 40) -> str:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, collection_id, title, property_values_json "
"FROM collection_pages ORDER BY updated_at DESC LIMIT ?",
(limit,),
).fetchall()
if not rows:
return "## Pages\n(no pages yet)"
lines = ["## Recent pages"]
for r in rows:
props = json.loads(r["property_values_json"]) if r["property_values_json"] else {}
summary = ", ".join(str(v) for v in props.values() if v) if props else ""
lines.append(f"- page #{r['id']} in collection #{r['collection_id']}: **{r['title']}**{(' — ' + summary) if summary else ''}")
return "\n".join(lines)
def _mentions_context(self, mentions: list[str]) -> str:
"""Resolve @collection:x / @page:y / @repo:o/r mentions to entity snapshots."""
lines = ["## Mentioned context"]
for m in mentions:
if m.startswith("collection:"):
cid = m.split(":", 1)[1]
lines.append(self._single_collection(cid))
elif m.startswith("page:"):
pid = m.split(":", 1)[1]
lines.append(self._single_page(pid))
elif m.startswith("repo:"):
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
elif m == "ws":
lines.append("- @ws: full workspace context included above")
return "\n".join(lines)
def _single_collection(self, cid: str) -> str:
with get_conn() as conn:
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
if not row:
return f"- collection #{cid}: not found"
return f"- collection #{row['id']} {row['icon']} **{row['name']}**"
def _single_page(self, pid: str) -> str:
with get_conn() as conn:
row = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not row:
return f"- page #{pid}: not found"
props = json.loads(row["property_values_json"]) if row["property_values_json"] else {}
return f"- page #{row['id']} **{row['title']}** props={json.dumps(props, ensure_ascii=False)}"
def _files_context(self, files: list[dict]) -> str:
return "## Attached files\n" + "\n".join(
f"- {f.get('name', 'file')} ({f.get('size', '?')} bytes)" for f in files
)
+257
View File
@@ -0,0 +1,257 @@
"""FlowDeck — LLM client abstraction (v4.10.0).
Abstraction over multiple LLM providers so the agent never talks to the DB
directly — it emits *tool intentions* (function calls) that AgentEngine turns
into guarded internal actions.
Supported providers (OpenAI-compatible chat-completions JSON response):
openai, anthropic*, google*, deepseek, qwencloud, nvidia, openrouter, ollama.
(* routed through an OpenAI-compatible gateway / any configured api_base)
When no API key is configured (or provider == "offline") the client falls back
to a deterministic, dependency-free *mock planner*. This keeps the whole agent
functional — and fully testable — with zero external calls, which is what the
local deployment and the test-suite rely on.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass, field
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# Provider → default model + base URL when llm_model/api_base are empty.
PROVIDERS = {
"openai": ("https://api.openai.com/v1", "gpt-4o"),
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
"google": ("https://generativelanguage.googleapis.com/v1beta", "gemini-2.0-pro"),
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
"qwencloud": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen-max"),
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-70b-instruct"),
"openrouter": ("https://openrouter.ai/api/v1", "meta-llama/llama-3.3-70b-instruct"),
"ollama": ("http://localhost:11434/v1", "llama3.1"),
"offline": (None, None),
}
# Llama-style / ChatML tool markers used by the mock planner.
_CREATE_PATTERNS = [
(re.compile(r"cr[eéé]er\s+(?:une\s+)?collection[:\s]+[\"']?([A-Za-zÀ-ÿ0-9 _\-]+)"),
lambda m: ("create_collection", {"name": m.group(1).strip()})),
(re.compile(r"create\s+collection\s+[\"']?([A-Za-z0-9 _\-]+)"),
lambda m: ("create_collection", {"name": m.group(1).strip()})),
(re.compile(r"create\s+a\s+page\s+[\"']?([A-Za-z0-9 _\-]+)"),
lambda m: ("create_page", {"title": m.group(1).strip()})),
]
_SEARCH_PATTERNS = [
(re.compile(r"(?:recherche|search|trouve|find)\s+[\"']?([A-Za-z0-9 _\-]+)"),
lambda m: ("search_workspace", {"query": m.group(1).strip()})),
]
# Loose fallback: "collection <Name>" → create_collection (covers "crée une collection X",
# "créer la collection X", "create collection X", etc.)
_COLLECTION_LINE = re.compile(
r"\bcollection\b[:\s]+(?:nomm[ée]e\s+)?([A-Za-zÀ-ÿ0-9_][^,.\n()]*[A-Za-zÀ-ÿ0-9_])",
re.IGNORECASE,
)
@dataclass
class LLMResponse:
"""Normalized completion: either a final text or one or more tool calls."""
text: str = ""
tool_calls: list[dict] = field(default_factory=list)
model: str = ""
usage: dict = field(default_factory=dict)
class LLMClient:
"""Multi-provider chat client with tool-calling support and offline mock."""
def __init__(self, provider: str | None = None, api_key: str | None = None,
api_base: str | None = None):
self.provider = (provider or settings.llm_provider or "offline").lower()
self.api_key = api_key if api_key is not None else settings.llm_api_key
self.api_base = api_base if api_base is not None else settings.llm_api_base
base, model = PROVIDERS.get(self.provider, (None, None))
self.api_base = self.api_base or base
self.default_model = settings.llm_model or model or "gpt-4o"
# ── Public API ──
async def complete(self, messages: list[dict], *, model: str | None = None,
tools: list[dict] | None = None,
stream: bool = False) -> LLMResponse:
"""Send a chat completion. Returns text and/or tool_calls."""
model = model or self.default_model
if self.provider == "offline" or not self._has_credentials():
return await self._mock_complete(messages, model, tools)
try:
return await asyncio.wait_for(
self._http_complete(messages, model, tools),
timeout=settings.agent_run_timeout_seconds,
)
except Exception as exc: # noqa: BLE001 — degrade gracefully to mock
logger.warning("LLM provider '%s' failed (%s); falling back to offline", self.provider, exc)
return await self._mock_complete(messages, model, tools)
async def is_available(self) -> bool:
"""True when a real provider is configured."""
return self.provider != "offline" and self._has_credentials()
# ── Helpers ──
def _has_credentials(self) -> bool:
if self.provider == "ollama":
return True # local, no key required
return bool(self.api_key)
def _endpoint(self) -> str:
return f"{self.api_base.rstrip('/')}/chat/completions"
async def _http_complete(self, messages, model, tools) -> LLMResponse:
payload: dict = {
"model": model,
"messages": messages,
"temperature": 0.2,
}
if tools:
payload["tools"] = [{"type": "function", "function": t} for t in tools]
payload["tool_choice"] = "auto"
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client:
resp = await client.post(self._endpoint(), json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
choice = data["choices"][0]["message"]
text = choice.get("content") or ""
tool_calls = []
for tc in choice.get("tool_calls") or []:
try:
args = json.loads(tc["function"].get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
tool_calls.append({"name": tc["function"]["name"], "arguments": args})
return LLMResponse(
text=text,
tool_calls=tool_calls,
model=model,
usage=data.get("usage", {}),
)
# ── Offline mock planner (deterministic, no network) ──
async def _mock_complete(self, messages, model, tools) -> LLMResponse:
user_content = self._last_user_content(messages)
sys_content = self._system_content(messages)
# Once tool results are already in the conversation, we have acted:
# stop issuing new tool calls and conclude.
if any(m.get("role") == "tool" for m in messages):
return LLMResponse(
text="Objectif traité — actions enregistrées dans le journal d'audit.",
model=model,
)
# Skill-driven: if the objective names a known skill, mirror its template.
skill_hint = self._extract_skill_hint(user_content)
if skill_hint == "sprint":
return LLMResponse(
tool_calls=[
{"name": "read_gitea_issues", "arguments": {"owner": "bruno", "repo": "flowdeck", "state": "open"}},
{"name": "create_collection", "arguments": {"name": "Sprint"}},
{"name": "add_property", "arguments": {"collection_id": 0, "name": "Status", "prop_type": "select", "options": ["Todo", "In Progress", "Done"]}},
{"name": "create_view", "arguments": {"collection_id": 0, "view_type": "board"}},
],
text="Plan: analyze open issues, then build a sprint board.",
model=model,
)
# Exact keyword → tool intent resolution.
for regex, builder in _CREATE_PATTERNS:
m = regex.search(user_content)
if m:
return LLMResponse(
tool_calls=[dict(name=name, arguments=self._bind_placeholders(args, sys_content)) for name, args in [builder(m)]],
text=f"Plan: running {builder(m)[0]}.",
model=model,
)
for regex, builder in _SEARCH_PATTERNS:
m = regex.search(user_content)
if m:
return LLMResponse(
tool_calls=[dict(name=name, arguments=args) for name, args in [builder(m)]],
text=f"Plan: searching '{m.group(1)}'.",
model=model,
)
# Loose "collection <X>" detection → treat as a create intent.
low = user_content.lower()
if "collection" in low:
m = _COLLECTION_LINE.search(user_content)
if m:
name = m.group(1).strip()
return LLMResponse(
tool_calls=[{"name": "create_collection",
"arguments": self._bind_placeholders({"name": name}, sys_content)}],
text=f"Plan: create collection '{name}'.",
model=model,
)
# Plain conversational objective → final answer (no tool).
return LLMResponse(
text=self._summarize(user_content),
model=model,
)
def _bind_placeholders(self, args: dict, sys_content: str) -> dict:
"""Inject a collection id from the context when the planner left it as 0."""
args = dict(args)
if args.get("collection_id") == 0:
match = re.search(r"Collection IDs?:\s*([0-9,\s]+)", sys_content)
if match:
ids = [int(x) for x in re.split(r"[,\s]+", match.group(1).strip()) if x.isdigit()]
if ids:
args["collection_id"] = ids[0]
return args
@staticmethod
def _system_content(messages) -> str:
return "\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
@staticmethod
def _last_user_content(messages) -> str:
for m in reversed(messages):
if m.get("role") == "user":
c = m.get("content", "")
if isinstance(c, list):
return " ".join(p.get("text", "") for p in c if isinstance(p, dict))
return str(c)
return ""
@staticmethod
def _extract_skill_hint(content: str) -> str | None:
low = content.lower()
if "sprint" in low or "préparation de sprint" in low:
return "sprint"
return None
@staticmethod
def _summarize(content: str) -> str:
"""Produce a terse final summary from a conversational objective."""
content = content.split("\n# Contexte")[0]
return (content[:600] + "…" if len(content) > 600 else content)
+100
View File
@@ -0,0 +1,100 @@
"""FlowDeck — Agent permission guard (v4.10.0).
The agent always acts with *at most* the permissions of the invoking user
(Notion Agent principle). This manager resolves the user's role in the active
workspace and gates tool execution before any write reaches the database.
"""
from __future__ import annotations
import logging
from fastapi import HTTPException
from app.db import get_conn
logger = logging.getLogger(__name__)
# Workspace roles, from least to most privileged.
READ_ROLES = {"viewer", "commenter", "editor", "admin", "owner"}
WRITE_ROLES = {"editor", "admin", "owner"}
DESTRUCTIVE_ROLES = {"admin", "owner"}
# Tools that mutate state and therefore require at least an editor role.
WRITE_TOOLS = {
"create_collection", "create_view", "create_page", "update_page",
"write_blocks", "add_property", "add_relation", "create_sub_item",
"add_dependency", "sync_gitea", "create_gitea_issue", "apply_template",
}
# Tools that delete / are destructive → admin/owner (or confirm mode).
DESTRUCTIVE_TOOLS = {
"delete_page", "delete_collection", "delete_property", "delete_view",
}
class PermissionManager:
"""Resolves workspace role and gates agent tool calls."""
def __init__(self, user_id: int):
self.user_id = user_id
# ── Role resolution ──
def role_in_workspace(self, workspace_id: int | None) -> str:
"""Return the user's role for a workspace (owner > member role)."""
if workspace_id is None:
# No workspace → fall back to the most permissive own-content model.
return "owner"
with get_conn() as conn:
member = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(workspace_id, self.user_id),
).fetchone()
if member:
return member["role"] or "editor"
owner = conn.execute(
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(workspace_id, self.user_id),
).fetchone()
return "owner" if owner else "viewer"
def can_read(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in READ_ROLES
def can_write(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in WRITE_ROLES
def can_destructive(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in DESTRUCTIVE_ROLES
# ── Gate for the engine ──
def assert_can(self, tool: str, args: dict, workspace_id: int | None,
approval_mode: str = "auto") -> None:
"""Raise HTTPException if the tool call exceeds the user's permissions.
- read tools: any authenticated user in the workspace (viewer+).
- write tools: editor+.
- destructive tools: admin/owner, or requires confirm approval mode.
"""
role = self.role_in_workspace(workspace_id)
if tool in WRITE_TOOLS and role not in WRITE_ROLES:
raise HTTPException(
status_code=403,
detail=f"Agent tool '{tool}' requires editor+ role (user is '{role}')",
)
if tool in DESTRUCTIVE_TOOLS:
if role not in DESTRUCTIVE_ROLES:
raise HTTPException(
status_code=403,
detail=f"Agent tool '{tool}' is destructive and requires admin/owner "
f"(user is '{role}')",
)
if approval_mode != "confirm":
raise HTTPException(
status_code=428, # Precondition Required
detail=f"Destructive tool '{tool}' requires approval (confirm mode)",
)
# A viewer can always read; editor can read+write.
if role not in READ_ROLES:
raise HTTPException(status_code=403, detail="User has no access to this workspace")
+649
View File
@@ -0,0 +1,649 @@
"""FlowDeck — Agent tool registry (v4.10.0).
The agent never re-invents FlowDeck: each tool is a thin wrapper over the same
operations the human-facing routers perform (create collection/page/property,
manage views, dependencies, Gitea issues, templates). Every mutating tool
returns an *undo snapshot* so AgentEngine can journal + roll back each action.
"""
from __future__ import annotations
import json
import logging
import sqlite3
from dataclasses import dataclass, field
from typing import Any
from app.db import get_conn
logger = logging.getLogger(__name__)
@dataclass
class ToolResult:
status: str # success | error | reverted
tool: str
target_type: str = ""
target_id: Any = None
message: str = ""
data: dict = field(default_factory=dict)
undo: dict = field(default_factory=dict) # snapshot to restore on rollback
class Tool:
name: str = ""
description: str = ""
parameters: dict = field(default_factory=dict)
async def execute(self, args: dict, *, user_id: int | None = None) -> ToolResult: # pragma: no cover
raise NotImplementedError
# ══════════════════════════ Read tools ══════════════════════════
class SearchWorkspace(Tool):
name = "search_workspace"
description = "Recherche full-text (par titre) des collections et pages FlowDeck."
parameters = {
"type": "object",
"properties": {"query": {"type": "string", "description": "terme recherché"}},
"required": ["query"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
q = f"%{args.get('query', '').strip()}%"
with get_conn() as conn:
colls = conn.execute("SELECT id, name, icon FROM collections WHERE name LIKE ? ORDER BY name", (q,)).fetchall()
pages = conn.execute("SELECT id, collection_id, title FROM collection_pages WHERE title LIKE ? ORDER BY updated_at DESC LIMIT 20", (q,)).fetchall()
return ToolResult(
status="success", tool=self.name, target_type="search",
data={
"collections": [dict(c) for c in colls],
"pages": [dict(p) for p in pages],
},
message=f"{len(colls)} collection(s) et {len(pages)} page(s) trouvée(s)",
)
class ReadCollection(Tool):
name = "read_collection"
description = "Lit le schéma (propriétés + vues) d'une collection."
parameters = {
"type": "object",
"properties": {"collection_id": {"type": "integer"}},
"required": ["collection_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
props = conn.execute("SELECT id, name, prop_type, options_json FROM collection_properties WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
views = conn.execute("SELECT id, name, view_type, config_json FROM collection_views WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
pages = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
data={
"collection": dict(coll),
"properties": [dict(p) for p in props],
"views": [dict(v) for v in views],
"pages": [dict(p) for p in pages],
},
)
class ReadPage(Tool):
name = "read_page"
description = "Lit une page d'une collection (propriétés + valeurs)."
parameters = {
"type": "object",
"properties": {"page_id": {"type": "integer"}},
"required": ["page_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
with get_conn() as conn:
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
deps = conn.execute("SELECT * FROM page_dependencies WHERE page_id=?", (pid,)).fetchall()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=pid,
data={"page": dict(page), "dependencies": [dict(d) for d in deps]},
)
# ══════════════════════════ Write tools (with undo) ══════════════════════════
class CreateCollection(Tool):
name = "create_collection"
description = "Crée une collection (base de données) avec sa vue par défaut."
parameters = {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"icon": {"type": "string"},
},
"required": ["name"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
name = (args.get("name") or "").strip()
if not name:
return ToolResult(status="error", tool=self.name, message="name est requis")
description = args.get("description", "")
icon = args.get("icon", "📋")
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,'[]')",
(name, description, icon),
)
cid = cur.lastrowid
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
(cid, "Default View", "table", json.dumps({"visible_properties": ["Title"], "sorts": [], "filters": []})),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
data={"collection_id": cid, "name": name},
undo={"action": "delete", "table": "collections", "id": cid},
)
class CreateView(Tool):
name = "create_view"
description = "Crée une vue (table/board/calendar/gallery/list/timeline/gantt/chart/form/map/feed) sur une collection."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"view_type": {"type": "string"},
"name": {"type": "string"},
},
"required": ["collection_id", "view_type"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid, vtype = args.get("collection_id"), args.get("view_type", "table")
name = args.get("name", vtype.title())
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?", (cid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?,?,?,?,?)",
(cid, name, vtype, json.dumps({}), max_pos),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="view", target_id=cur.lastrowid,
data={"view_id": cur.lastrowid, "collection_id": cid, "view_type": vtype},
undo={"action": "delete", "table": "collection_views", "id": cur.lastrowid},
)
class AddProperty(Tool):
name = "add_property"
description = "Ajoute une propriété typée à une collection."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"name": {"type": "string"},
"prop_type": {"type": "string"},
"options": {"type": "array", "items": {"type": "string"}},
},
"required": ["collection_id", "name"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
name = (args.get("name") or "").strip()
prop_type = args.get("prop_type", "text")
options = args.get("options", [])
if not name:
return ToolResult(status="error", tool=self.name, message="name est requis")
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (cid,)).fetchone()[0]
try:
cur = conn.execute(
"INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?,?,?,?,?)",
(cid, name, prop_type, json.dumps(options), max_pos),
)
conn.commit()
except sqlite3.IntegrityError:
return ToolResult(status="error", tool=self.name, message=f"Propriété '{name}' existe déjà")
return ToolResult(
status="success", tool=self.name, target_type="property", target_id=cur.lastrowid,
data={"property_id": cur.lastrowid, "name": name, "prop_type": prop_type},
undo={"action": "delete", "table": "collection_properties", "id": cur.lastrowid},
)
class CreatePage(Tool):
name = "create_page"
description = "Crée une page dans une collection avec les valeurs de ses propriétés."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"title": {"type": "string"},
"properties": {"type": "object", "description": "map name→valeur"},
},
"required": ["collection_id", "title"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
title = (args.get("title") or "").strip()
if not title:
return ToolResult(status="error", tool=self.name, message="title est requis")
with get_conn() as conn:
coll = conn.execute("SELECT id, is_locked FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
if coll["is_locked"]:
return ToolResult(status="error", tool=self.name, message="Collection verrouillée (is_locked)")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
props = self._resolve_properties(conn, cid, args.get("properties", {}))
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(cid, title, max_pos, json.dumps(props, ensure_ascii=False)),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
data={"page_id": cur.lastrowid, "title": title},
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
)
@staticmethod
def _resolve_properties(conn, cid, values: dict) -> dict:
"""Map human property names → property ids for the JSON blob."""
props = conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (cid,)).fetchall()
id_by_name = {r["name"]: r["id"] for r in props}
out = {}
for k, v in (values or {}).items():
key = id_by_name.get(k, k)
if isinstance(v, list):
out[str(key)] = v
else:
out[str(key)] = v
return out
class UpdatePage(Tool):
name = "update_page"
description = "Modifie le titre et/ou les valeurs de propriétés d'une page."
parameters = {
"type": "object",
"properties": {
"page_id": {"type": "integer"},
"title": {"type": "string"},
"properties": {"type": "object"},
},
"required": ["page_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
with get_conn() as conn:
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
coll = conn.execute("SELECT is_locked FROM collections WHERE id=?", (page["collection_id"],)).fetchone()
if coll and coll["is_locked"]:
return ToolResult(status="error", tool=self.name, message="Collection verrouillée (is_locked)")
new_title = args.get("title", page["title"])
props = json.loads(page["property_values_json"])
if args.get("properties"):
props.update(args["properties"])
conn.execute(
"UPDATE collection_pages SET title=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_title, json.dumps(props, ensure_ascii=False), pid),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=pid,
data={"page_id": pid, "title": new_title},
undo={"action": "update", "table": "collection_pages", "id": pid,
"snapshot": json.loads(page["property_values_json"]), "title": page["title"]},
)
class WriteBlocks(Tool):
name = "write_blocks"
description = "Écrit du contenu (blocs éditeur) sur une page éditeur (`pages`)."
parameters = {
"type": "object",
"properties": {
"page_id": {"type": "integer"},
"blocks": {"type": "array"},
},
"required": ["page_id", "blocks"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
blocks = args.get("blocks", [])
with get_conn() as conn:
page = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name, message=f"Page éditeur #{pid} introuvable")
conn.execute(
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(blocks, ensure_ascii=False), pid),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=pid,
data={"page_id": pid, "blocks": len(blocks)},
undo={"action": "update", "table": "pages", "id": pid, "snapshot": page["content"]},
)
class AddRelation(Tool):
name = "add_relation"
description = "Lie deux collections via une propriété relation (avec réciproque)."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"related_collection_id": {"type": "integer"},
"name": {"type": "string"},
"reverse_name": {"type": "string"},
},
"required": ["collection_id", "related_collection_id", "name"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
rel = args.get("related_collection_id")
name = (args.get("name") or "").strip()
reverse = args.get("reverse_name", "")
if not name or not rel:
return ToolResult(status="error", tool=self.name, message="name et related_collection_id requis")
with get_conn() as conn:
for c in (cid, rel):
if not conn.execute("SELECT id FROM collections WHERE id=?", (c,)).fetchone():
return ToolResult(status="error", tool=self.name, message=f"Collection #{c} introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (cid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?,?,?,?,?,?)",
(cid, name, "relation", rel, reverse, max_pos),
)
prop_id = cur.lastrowid
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="property", target_id=prop_id,
data={"property_id": prop_id, "relation": name},
undo={"action": "delete", "table": "collection_properties", "id": prop_id},
)
class CreateSubItem(Tool):
name = "create_sub_item"
description = "Crée un sous-élément (sub-item) sous une page."
parameters = {
"type": "object",
"properties": {"collection_id": {"type": "integer"}, "page_id": {"type": "integer"}, "title": {"type": "string"}},
"required": ["collection_id", "page_id", "title"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid, pid = args.get("collection_id"), args.get("page_id")
title = (args.get("title") or "").strip()
with get_conn() as conn:
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (pid, cid)).fetchone()
if not parent:
return ToolResult(status="error", tool=self.name, message="Page parent introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?", (pid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?,?,?,?,?)",
(cid, title, pid, max_pos, json.dumps({})),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
data={"page_id": cur.lastrowid, "parent_id": pid},
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
)
class AddDependency(Tool):
name = "add_dependency"
description = "Ajoute une dépendance (bloque) entre deux pages."
parameters = {
"type": "object",
"properties": {
"page_id": {"type": "integer"},
"dependency_id": {"type": "integer"},
"dependency_type": {"type": "string"},
},
"required": ["page_id", "dependency_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid, dep = args.get("page_id"), args.get("dependency_id")
dtype = args.get("dependency_type", "blocks")
with get_conn() as conn:
for p in (pid, dep):
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (p,)).fetchone():
return ToolResult(status="error", tool=self.name, message=f"Page #{p} introuvable")
try:
cur = conn.execute(
"INSERT INTO page_dependencies (page_id, dependency_id, dependency_type) VALUES (?,?,?)",
(pid, dep, dtype),
)
conn.commit()
except sqlite3.IntegrityError:
return ToolResult(status="error", tool=self.name, message="Dépendance déjà existante")
return ToolResult(
status="success", tool=self.name, target_type="dependency", target_id=cur.lastrowid,
data={"dependency_id": cur.lastrowid, "page_id": pid},
undo={"action": "delete", "table": "page_dependencies", "id": cur.lastrowid},
)
class ApplyTemplate(Tool):
name = "apply_template"
description = "Applique un template de page dans une collection (crée une page)."
parameters = {
"type": "object",
"properties": {"collection_id": {"type": "integer"}, "template_id": {"type": "integer"}, "title": {"type": "string"}},
"required": ["collection_id", "template_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid, tid = args.get("collection_id"), args.get("template_id")
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, cid)).fetchone()
if not tmpl:
return ToolResult(status="error", tool=self.name, message="Template introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(cid, args.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
data={"page_id": cur.lastrowid},
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
)
# ══════════════════════════ Destructive tools ══════════════════════════
class DeletePage(Tool):
name = "delete_page"
description = "Supprime une page d'une collection (destructif, mode confirm requis)."
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
with get_conn() as conn:
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
conn.execute("DELETE FROM collection_pages WHERE id=?", (pid,))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=pid,
message="Page supprimée",
undo={"action": "insert", "table": "collection_pages", "snapshot": dict(page)},
)
class DeleteCollection(Tool):
name = "delete_collection"
description = "Supprime une collection (destructif, mode confirm requis)."
parameters = {"type": "object", "properties": {"collection_id": {"type": "integer"}}, "required": ["collection_id"]}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
message="Collection supprimée",
undo={"action": "insert", "table": "collections", "snapshot": dict(coll)},
)
# ══════════════════════════ Gitea tools ══════════════════════════
class ReadGiteaIssues(Tool):
name = "read_gitea_issues"
description = "Lit les issues Gitea d'un repo (état, labels, milestones)."
parameters = {
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}, "state": {"type": "string"}},
"required": ["owner", "repo"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.gitea_client import GiteaClient, get_user_gitea_client
from app.auth.session import SessionManager
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
owner, repo = args.get("owner"), args.get("repo")
try:
issues = await client.get_issues(owner, repo, state=args.get("state", "open"))
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
return ToolResult(
status="success", tool=self.name, target_type="issues", target_id=f"{owner}/{repo}",
data={"issues": issues, "count": len(issues)},
message=f"{len(issues)} issues",
)
class SyncGitea(Tool):
name = "sync_gitea"
description = "Synchronise les issues d'un repo Gitea vers une collection."
parameters = {
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
"required": ["owner", "repo"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.collection_adapter import GiteaBoardCompat
from app.services.gitea_client import GiteaClient
from app.auth.session import SessionManager
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
owner, repo = args.get("owner"), args.get("repo")
try:
issues = await client.get_issues(owner, repo, state="all")
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues)
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=coll_id,
data={"collection_id": coll_id, "issues": len(issues)},
message=f"{len(issues)} issues synchronisées",
undo={"action": "delete", "table": "collections", "id": coll_id} if coll_id else {},
)
class CreateGiteaIssue(Tool):
name = "create_gitea_issue"
description = "Crée une issue Gitea dans un repo."
parameters = {
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}},
"required": ["owner", "repo", "title"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.gitea_client import GiteaClient
from app.auth.session import SessionManager
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
try:
issue = await client.create_issue(args["owner"], args["repo"], args["title"], args.get("body", ""))
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
return ToolResult(
status="success", tool=self.name, target_type="issue", target_id=issue.get("number"),
data={"issue": issue},
message=f"Issue #{issue.get('number')} créée",
)
# ══════════════════════════ Registry ══════════════════════════
TOOL_CLASSES = [
SearchWorkspace, ReadCollection, ReadPage,
CreateCollection, CreateView, AddProperty, CreatePage, UpdatePage, WriteBlocks,
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
DeletePage, DeleteCollection,
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
]
class ToolRegistry:
"""Holds tool instances and exposes their LLM schemas + execution."""
def __init__(self):
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
def list(self, scope: dict | None = None) -> list[str]:
"""Tool names allowed by an agent scope (default: all)."""
allowed = (scope or {}).get("tools")
if allowed is None:
return list(self.tools.keys())
return [n for n in self.tools if n in allowed]
def schema(self, scope: dict | None = None) -> list[dict]:
"""Function-calling schema for the LLM, filtered by scope."""
return [
{"name": self.tools[n].name,
"description": self.tools[n].description,
"parameters": self.tools[n].parameters}
for n in self.list(scope)
]
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
impl = self.tools.get(tool)
if not impl:
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
return await impl.execute(args, user_id=user_id)
+17
View File
@@ -0,0 +1,17 @@
{# FlowDeck — Agent message fragment (v4.10.0). Rendered standalone for history. #}
<div class="fd-agent-msg fd-agent-msg-{{ role }}">
<div class="fd-agent-msg-avatar">
{% if role == 'assistant' %}🤖{% else %}👤{% endif %}
</div>
<div class="fd-agent-msg-body">
<div class="fd-agent-msg-meta">{{ role }} · {{ model or '' }}</div>
<div class="fd-agent-msg-content">{{ content }}</div>
{% if tool_calls %}
<ul class="fd-agent-msg-tools">
{% for call in (tool_calls | fromjson_json) %}
<li>🛠 {{ call.name }} — <code>{{ call.arguments | tojson }}</code></li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
+224
View File
@@ -0,0 +1,224 @@
{# ════════ FlowDeck Agent panel (v4.10.0) — sidebar-triggered chat + floating button ════════ #}
<style>
#fd-agent-fab{position:fixed;right:24px;bottom:24px;width:52px;height:52px;border-radius:50%;background:var(--accent,#2383E2);color:#fff;border:none;font-size:22px;cursor:pointer;box-shadow:0 8px 24px rgba(35,131,226,.5);display:flex;align-items:center;justify-content:center;z-index:1700;transition:transform .15s,box-shadow .15s}
#fd-agent-fab:hover{transform:scale(1.06);box-shadow:0 12px 32px rgba(35,131,226,.6)}
#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:400px;max-width:100vw;background:var(--bg-secondary,#1f1f1f);border-left:1px solid var(--border,rgba(255,255,255,.09));z-index:1800;display:flex;flex-direction:column;box-shadow:-12px 0 40px rgba(0,0,0,.5);transform:translateX(100%);transition:transform .25s ease}
#fd-agent-panel.open{transform:translateX(0)}
.fd-agent-head{padding:14px 16px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));display:flex;align-items:center;gap:10px}
.fd-agent-title{font-weight:600;font-size:15px;color:var(--text,#e0e0e0);flex:1;display:flex;align-items:center;gap:8px}
.fd-agent-provider{font-size:11px;color:var(--text-dim,#999);background:rgba(255,255,255,.06);padding:2px 8px;border-radius:99px}
.fd-agent-close{background:none;border:none;color:var(--text-dim,#999);font-size:20px;cursor:pointer;padding:2px 6px;border-radius:6px}
.fd-agent-close:hover{background:var(--bg-hover,#333);color:var(--text,#fff)}
.fd-agent-tabs{display:flex;gap:4px;padding:6px 10px;border-bottom:1px solid var(--border,rgba(255,255,255,.06))}
.fd-agent-tab{flex:1;text-align:center;padding:7px;background:none;border:none;border-radius:7px;color:var(--text-dim,#999);font-size:12px;cursor:pointer}
.fd-agent-tab.active{background:var(--bg-tertiary,#2a2a2a);color:var(--text,#fff);font-weight:600}
.fd-agent-body{flex:1;overflow-y:auto;padding:14px}
.fd-agent-hist-item{padding:8px 10px;border-radius:8px;cursor:pointer;color:var(--text,#e0e0e0);font-size:13px;border:1px solid transparent}
.fd-agent-hist-item:hover,.fd-agent-hist-item.active{background:var(--bg-hover,#2c2c2c);border-color:var(--border,rgba(255,255,255,.08))}
.fd-agent-msg{display:flex;gap:8px;margin-bottom:12px}
.fd-agent-msg-avatar{width:26px;height:26px;border-radius:50%;background:var(--bg-tertiary,#2c2c2c);display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0}
.fd-agent-msg-body{flex:1;min-width:0}
.fd-agent-msg-meta{font-size:10px;color:var(--text-dim,#888);margin-bottom:2px;text-transform:uppercase;letter-spacing:.5px}
.fd-agent-msg-content{font-size:13px;color:var(--text,#e6e6e6);white-space:pre-wrap;word-break:break-word}
.fd-agent-msg-tools{list-style:none;padding:6px 8px;margin:6px 0 0;background:rgba(35,131,226,.08);border-left:3px solid var(--accent,#2383E2);border-radius:4px;font-size:12px;color:var(--text-dim,#bbb)}
.fd-agent-thinking{font-size:12px;color:var(--text-dim,#999);padding:10px;border-radius:8px;background:rgba(255,255,255,.04);margin-bottom:10px;font-style:italic}
.fd-agent-input-area{padding:12px;border-top:1px solid var(--border,rgba(255,255,255,.08));display:flex;gap:8px;align-items:flex-end}
.fd-agent-input{flex:1;background:var(--bg-tertiary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:10px;padding:10px 12px;color:var(--text,#fff);font-size:13px;resize:none;outline:none;font-family:inherit}
.fd-agent-input:focus{border-color:var(--accent,#2383E2)}
.fd-agent-send{background:var(--accent,#2383E2);border:none;color:#fff;border-radius:8px;padding:10px 14px;cursor:pointer;font-size:14px}
.fd-agent-send:disabled{opacity:.5;cursor:not-allowed}
.fd-agent-new{background:none;border:1px solid var(--border,rgba(255,255,255,.1));color:var(--text,#ddd);border-radius:8px;padding:4px 8px;font-size:12px;cursor:pointer}
</style>
<button id="fd-agent-fab" title="FlowDeck Agent" onclick="document.dispatchEvent(new CustomEvent('fd-agent-toggle'))">🤖</button>
<div id="fd-agent-panel" x-data="agentPanel()" :class="{open: open}" @keydown.escape.window="close()">
<div class="fd-agent-head">
<div class="fd-agent-title">🤖 Agent <span class="fd-agent-provider" x-text="providerLabel"></span></div>
<button class="fd-agent-new" @click="newConversation()">+ New</button>
<button class="fd-agent-close" @click="close()">&times;</button>
</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>
</div>
<div class="fd-agent-body">
<template x-if="tab=='history'">
<div>
<template x-for="c in conversations" :key="c.id">
<div class="fd-agent-hist-item" :class="{active: currentConv && currentConv.id===c.id}" @click="loadConversation(c.id)">
<div x-text="c.title || 'New conversation'"></div>
<div style="font-size:11px;color:var(--text-dim)" x-text="c.updated_at"></div>
</div>
</template>
<div x-show="!conversations.length" style="font-size:13px;color:var(--text-dim);padding:8px">Aucune conversation. Démarrez-en une !</div>
</div>
</template>
<template x-if="tab=='chat'">
<div>
<template x-for="m in messages" :key="m.id">
<div class="fd-agent-msg" :class="'fd-agent-msg-'+m.role">
<div class="fd-agent-msg-avatar" x-text="m.role==='assistant'?'🤖':'👤'"></div>
<div class="fd-agent-msg-body">
<div class="fd-agent-msg-meta" x-text="(m.role||'user')+(m.model?' · '+m.model:'')"></div>
<div class="fd-agent-msg-content" x-text="m.content"></div>
</div>
</div>
</template>
<div class="fd-agent-thinking" x-show="streaming">L'agent réfléchit et agit… <span x-text="liveReasoning"></span></div>
<div x-show="errorMsg" style="color:#e06b6b;font-size:12px;padding:6px" x-text="errorMsg"></div>
</div>
</template>
</div>
<div class="fd-agent-input-area">
<textarea class="fd-agent-input" rows="2" placeholder="Demandez à l'agent (ex: crée une collection Projets)…" x-model="input" @keydown.enter.exact.prevent="send()"></textarea>
<button class="fd-agent-send" @click="send()" :disabled="streaming || !input.trim()">▶</button>
</div>
</div>
<script>
(function(){
function fmt(ev){ return ev; }
function agentPanel(){
return {
open: false, tab: 'chat',
agents: [], conversations: [], currentConv: null,
messages: [], input: '', streaming: false, liveReasoning: '',
provider: 'offline', providerAvailable: false, errorMsg: '',
init(){
var self = this;
// The panel lives outside the root appState; toggle via the FAB + Agents sidebar.
window.addEventListener('fd-agent-toggle', function(e){
self.open = !self.open;
if(e && e.detail && e.detail.agentId){ self.agentId = e.detail.agentId; }
self.loadConversations();
});
this.fetchProviders();
this.fetchAgents();
},
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;
}).catch(function(){});
},
fetchAgents(){
var self = this;
fetch('/api/agent').then(function(r){return r.json()}).then(function(d){
self.agents = d.agents || [];
}).catch(function(){});
},
loadConversations(){
var self = this;
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
self.conversations = d.conversations || [];
if(self.conversations.length && !self.currentConv){
self.currentConv = self.conversations[0];
self.loadConversation(self.currentConv.id);
}
}).catch(function(){});
},
loadConversation(id){
var self = this;
this.tab='chat';
this.errorMsg = '';
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}; });
}).catch(function(){});
},
newConversation(){
var self = this;
fetch('/api/agent/conversations', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title:'New conversation'})})
.then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.tab='chat';
}).catch(function(){});
},
openAgent(){ this.open=true; this.loadConversations(); },
close(){ this.open=false; },
send(){
var self = this;
if(!self.currentConv || self.streaming || !self.input.trim()) return;
var text = self.input.trim();
self.input = '';
self.errorMsg = '';
self.streaming = true;
self.liveReasoning = '…';
self.messages.push({id:'u-'+Date.now(), role:'user', content: text});
self.scrollBottom();
var respBuf = '';
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({message: text})
}).then(function(resp){
if(!resp.ok){
return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); });
}
var reader = resp.body.getReader();
var dec = new TextDecoder();
function read(){
return reader.read().then(function(res){
if(res.done){ return; }
respBuf += dec.decode(res.value);
var parts = respBuf.split('\n\n');
respBuf = parts.pop();
parts.forEach(function(p){
var line = p.replace(/^data:\s*/, '').trim();
if(!line) return;
try{
var ev = JSON.parse(line);
if(ev.type==='reasoning'){ self.liveReasoning = ev.content; }
else if(ev.type==='action'){
var msg = ev.status==='success'
? (ev.tool+' ✓ '+(ev.message||''))
: (ev.tool+' ✗ '+(ev.detail||'erreur'));
self.messages.push({id:'a-'+Date.now()+Math.random(), role:'assistant', content: msg});
}
else if(ev.type==='final'){
self.messages.push({id:'f-'+Date.now(), role:'assistant', content: ev.content});
}
else if(ev.type==='error'){ self.errorMsg = ev.message; }
}catch(e){}
});
self.scrollBottom();
return read();
});
}
return read();
}).catch(function(err){ self.errorMsg = (err && err.message) ? err.message : 'Erreur agent'; })
.finally(function(){
self.streaming = false; self.liveReasoning='';
self.fetchConversationsRefresh();
});
},
fetchConversationsRefresh(){
var self = this;
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){ self.conversations = d.conversations || []; }).catch(function(){});
},
scrollBottom(){
this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; });
},
get providerLabel(){
if(this.providerAvailable) return this.provider;
return 'offline mock';
}
};
}
document.addEventListener('alpine:init', function(){
Alpine.data('agentPanel', agentPanel);
});
window.agentPanel = agentPanel;
})();
</script>
+26 -2
View File
@@ -362,12 +362,20 @@
<span class="section-label">Agents</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="New agent" @click.stop="">+</button>
<button class="section-action-btn" title="New agent" @click.stop="agentNew()">+</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'agents')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("bot",16) }}</span><span class="page-name text-dim">No agents configured</span></li></ul>
<ul class="sidebar-items" data-section="agents">
<template x-for="a in agentList" :key="a.id">
<li class="sidebar-item" @click="agentOpen(a.id)">
<span class="page-icon page-icon-svg" x-html="a.icon || '🤖'"></span>
<span class="page-name" x-text="a.name"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!agentList.length" @click="agentOpen()"><span class="page-icon page-icon-svg">{{ fd_icon("bot",16) }}</span><span class="page-name text-dim">No agents — click to chat</span></li>
</ul>
</div>
</div>
@@ -926,6 +934,20 @@
init() {
window.appState = this;
this.loadSidebarConfig();
this.loadAgents();
},
agentList: [],
loadAgents() {
var self = this;
fetch('/api/agent').then(function(r){ return r.json(); }).then(function(d){
self.agentList = d.agents || [];
}).catch(function(){});
},
agentOpen(id) {
document.dispatchEvent(new CustomEvent('fd-agent-toggle', { detail: { agentId: id || null } }));
},
agentNew() {
document.dispatchEvent(new CustomEvent('fd-agent-toggle'));
},
sidebarCollapsed: false,
mobileSidebarOpen: false,
@@ -1833,5 +1855,7 @@
</div>
</div>
{% include 'agent_panel.html' %}
</body>
</html>
+329
View File
@@ -0,0 +1,329 @@
"""FlowDeck — Agent tests (v4.10.0).
Covers the DB schema, LLM client (offline mock), permission manager, tool
registry (writes + undo snapshots + rollback), the ReAct engine (SSE events)
and the /api/agent/* router (agents, conversations, run, audit, skills).
"""
import asyncio
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client():
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
db_path = db_file.name
db_file.close()
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false"
os.environ["LLM_PROVIDER"] = "offline" # deterministic, no network
from app.main import app
from app.db import init_db, get_conn
from app.password_utils import hash_password
from app.config import settings
# Ensure settings singleton uses OUR temp DB (not one set by another test file).
settings.database_url = f"sqlite:///{db_path}"
settings.llm_provider = "offline"
settings.agent_max_iterations = 12
settings.agent_max_tokens_budget = 500_000
settings.agent_run_timeout_seconds = 30
init_db()
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) "
"VALUES ('admin', 'Admin', '', ?, 1)",
(hash_password("test"),),
)
conn.commit()
yield TestClient(app)
try:
os.unlink(db_path)
except FileNotFoundError:
pass
def _admin_id() -> int:
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE login='admin'").fetchone()
return row["id"] if row else 1
def _collect(events):
return [e for e in events]
async def _run_engine(engine, *args, **kwargs):
return [e async for e in engine.run(*args, **kwargs)]
# ── LLM client (offline mock) ──
def test_llm_client_offline_mock(client):
from app.services.llm_client import LLMClient
llm = LLMClient(provider="offline")
resp = asyncio.run(llm.complete(
[{"role": "user", "content": "create collection Projets"}], tools=[],
))
assert resp.tool_calls, "offline mock should detect a create-collection intent"
assert resp.tool_calls[0]["name"] == "create_collection"
assert resp.tool_calls[0]["arguments"]["name"] == "Projets"
def test_llm_client_providers_listed(client):
from app.services.llm_client import PROVIDERS
for name in ("openai", "anthropic", "google", "deepseek", "qwencloud",
"nvidia", "openrouter", "ollama", "offline"):
assert name in PROVIDERS
def test_llm_client_offline_no_credentials(client):
from app.services.llm_client import LLMClient
llm = LLMClient(provider="openai", api_key="")
resp = asyncio.run(llm.complete(
[{"role": "user", "content": "hello"}], tools=[],
))
# No key → falls back to mock (a final text answer, no crash)
assert resp.text != "" or resp.tool_calls
# ── DB schema ──
def test_agent_tables_exist(client):
tables = {"agents", "agent_conversations", "agent_messages",
"agent_actions", "agent_skills", "agent_triggers"}
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
names = {r["name"] for r in rows}
assert tables.issubset(names)
# ── Tools ──
def test_tool_create_collection_and_search(client):
from app.services.tool_registry import ToolRegistry
reg = ToolRegistry()
res = asyncio.run(reg.execute("create_collection", {"name": "Projets"}))
assert res.status == "success"
cid = res.data["collection_id"]
assert res.undo == {"action": "delete", "table": "collections", "id": cid}
search = asyncio.run(reg.execute("search_workspace", {"query": "Projets"}))
assert search.status == "success"
assert any(c["id"] == cid for c in search.data["collections"])
def test_tool_create_page_with_properties(client):
from app.services.tool_registry import ToolRegistry
reg = ToolRegistry()
coll = asyncio.run(reg.execute("create_collection", {"name": "Tasks"}))
cid = coll.data["collection_id"]
asyncio.run(reg.execute("add_property", {"collection_id": cid, "name": "Status", "prop_type": "select", "options": ["Todo", "Done"]}))
page = asyncio.run(reg.execute("create_page", {"collection_id": cid, "title": "Task 1", "properties": {"Status": "Todo"}}))
assert page.status == "success"
assert page.target_type == "page"
assert page.data["title"] == "Task 1"
def test_tool_unknown_returns_error(client):
from app.services.tool_registry import ToolRegistry
res = asyncio.run(ToolRegistry().execute("nope", {}))
assert res.status == "error"
# ── Permissions ──
def test_permission_manager_roles(client):
from app.services.permission_manager import PermissionManager
from app.db import get_conn
with get_conn() as conn:
owner = conn.execute("SELECT id FROM users WHERE login='admin'").fetchone()
conn.execute("INSERT INTO users (login, is_admin) VALUES ('viewer_user', 0)")
viewer = conn.execute("SELECT id FROM users WHERE login='viewer_user'").fetchone()
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('ws', ?)", (owner["id"],))
ws = conn.execute("SELECT id FROM workspaces WHERE name='ws'").fetchone()
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", (ws["id"], viewer["id"], "viewer"))
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", (ws["id"], owner["id"], "owner"))
conn.commit()
pm_owner = PermissionManager(owner["id"])
pm_viewer = PermissionManager(viewer["id"])
assert pm_owner.can_write(ws["id"]) is True
assert pm_viewer.can_write(ws["id"]) is False
from fastapi import HTTPException
# viewer cannot write
with pytest.raises(HTTPException):
pm_viewer.assert_can("create_page", {}, ws["id"])
# owner can write
pm_owner.assert_can("create_page", {}, ws["id"])
# destructive requires confirm mode
with pytest.raises(HTTPException):
pm_owner.assert_can("delete_page", {}, ws["id"], approval_mode="auto")
pm_owner.assert_can("delete_page", {}, ws["id"], approval_mode="confirm")
# ── Engine (ReAct + audit + rollback) ──
def test_engine_run_creates_collection(client):
from app.services.agent_engine import AgentEngine
from app.db import get_conn
engine = AgentEngine(_admin_id(), workspace_id=None)
events = asyncio.run(_run_engine(engine, _make_conversation(client), "crée une collection Stats"))
types = [e["type"] for e in events]
assert "reasoning" in types
assert "action" in types
assert "final" in types
actions = [e for e in events if e["type"] == "action"]
assert any(a["tool"] == "create_collection" and a["status"] == "success" for a in actions)
# Collection actually persisted
with get_conn() as conn:
row = conn.execute("SELECT id FROM collections WHERE name='Stats'").fetchone()
assert row is not None
def test_engine_audit_and_undo(client):
from app.services.agent_engine import AgentEngine, undo_action
from app.db import get_conn
conv = _make_conversation(client)
engine = AgentEngine(_admin_id())
asyncio.run(_run_engine(engine, conv, "créer une collection CRM"))
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE name='CRM'").fetchone()
assert coll is not None
action = conn.execute(
"SELECT id FROM agent_actions WHERE tool_name='create_collection' AND target_id=?",
(str(coll["id"]),),
).fetchone()
assert action is not None
# undo removes the collection and marks the action reverted
undo_action(action["id"])
from app.db import get_conn as gc
with gc() as conn:
assert conn.execute("SELECT id FROM collections WHERE name='CRM'").fetchone() is None
a = conn.execute("SELECT status FROM agent_actions WHERE id=?", (action["id"],)).fetchone()
assert a["status"] == "reverted"
def _make_conversation(client) -> int:
resp = client.post("/api/agent/conversations", json={"title": "test"})
return resp.json()["id"]
# ── Router: agents, conversations, run, skills ──
def test_router_list_agents(client):
resp = client.get("/api/agent")
assert resp.status_code == 200
assert resp.json()["agents"]
assert any(a["agent_type"] == "personal" for a in resp.json()["agents"])
def test_router_create_custom_agent(client):
resp = client.post("/api/agent", json={"name": "Reporting", "agent_type": "custom",
"system_instructions": "Produce weekly report"})
assert resp.status_code == 200
assert resp.json()["id"]
# list now includes it
agents = client.get("/api/agent").json()["agents"]
assert any(a["name"] == "Reporting" for a in agents)
def test_router_conversation_crud(client):
c = client.post("/api/agent/conversations", json={"title": "Hello"}).json()
cid = c["id"]
detail = client.get(f"/api/agent/conversations/{cid}")
assert detail.status_code == 200
assert detail.json()["conversation"]["id"] == cid
assert client.delete(f"/api/agent/conversations/{cid}").status_code == 200
assert client.get(f"/api/agent/conversations/{cid}").status_code == 404
def test_router_run_sse_stream(client):
cid = _make_conversation(client)
resp = client.post(f"/api/agent/conversations/{cid}/run",
json={"message": "create collection Roadmap"})
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/event-stream")
body = resp.text
assert "data:" in body
events = [_parse_sse(line) for line in body.strip().split("\n\n") if line.strip()]
types = [e.get("type") for e in events if e]
assert "final" in types
def test_router_run_malformed(client):
cid = _make_conversation(client)
resp = client.post(f"/api/agent/conversations/{cid}/run", json={})
assert resp.status_code == 400
def test_router_actions_and_undo(client):
cid = _make_conversation(client)
client.post(f"/api/agent/conversations/{cid}/run", json={"message": "créer une collection Docs"})
actions = client.get(f"/api/agent/conversations/{cid}/actions").json()["actions"]
assert actions, "run should have logged actions"
target = actions[0]
assert target["tool_name"] == "create_collection"
# undo via endpoint
r = client.post(f"/api/agent/actions/{target['id']}/undo")
assert r.status_code == 200
assert r.json()["status"] == "reverted"
def test_router_skills(client):
s = client.post("/api/agent/skills", json={"name": "Préparation de sprint",
"prompt_template": "Analyse les issues et crée un sprint",
"allowed_tools": ["read_gitea_issues", "create_collection"]})
assert s.status_code == 200
sid = s.json()["id"]
skills = client.get("/api/agent/skills").json()["skills"]
assert any(k["name"] == "Préparation de sprint" for k in skills)
# apply → creates a conversation ready to run
apply = client.post(f"/api/agent/skills/{sid}/apply")
assert apply.status_code == 200
assert apply.json()["conversation_id"]
def test_router_tools_and_providers(client):
tools = client.get("/api/agent/tools").json()["tools"]
assert len(tools) >= 10
names = {t["name"] for t in tools}
for expected in ("search_workspace", "create_collection", "create_page",
"update_page", "add_property", "create_view", "read_page"):
assert expected in names
prov = client.get("/api/agent/providers").json()
assert prov["provider"] == "offline"
assert prov["available"] is False
def _parse_sse(line: str) -> dict:
for part in line.split("\n"):
if part.startswith("data:"):
try:
return json.loads(part[len("data:"):].strip())
except json.JSONDecodeError:
return {}
return {}