diff --git a/CHANGELOG.md b/CHANGELOG.md
index 228b6ce..5ba2944 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/ROADMAP.md b/ROADMAP.md
index f9f8332..91472bc 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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 `
` 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)*
diff --git a/VERSION b/VERSION
index b617d99..2da4316 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-4.9.0
\ No newline at end of file
+4.10.0
diff --git a/app/config.py b/app/config.py
index 075eda0..bb4d2e4 100644
--- a/app/config.py
+++ b/app/config.py
@@ -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:":
diff --git a/app/db.py b/app/db.py
index fdf1eae..3fedd19 100644
--- a/app/db.py
+++ b/app/db.py
@@ -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():
diff --git a/app/main.py b/app/main.py
index d956d3b..730237e 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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")
diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py
index 0f73b53..78910ca 100644
--- a/app/middleware/csrf.py
+++ b/app/middleware/csrf.py
@@ -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
diff --git a/app/routers/agent.py b/app/routers/agent.py
new file mode 100644
index 0000000..084e00f
--- /dev/null
+++ b/app/routers/agent.py
@@ -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"}
\ No newline at end of file
diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py
new file mode 100644
index 0000000..c1d03b0
--- /dev/null
+++ b/app/services/agent_engine.py
@@ -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
\ No newline at end of file
diff --git a/app/services/context_builder.py b/app/services/context_builder.py
new file mode 100644
index 0000000..b8dd155
--- /dev/null
+++ b/app/services/context_builder.py
@@ -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
+ )
\ No newline at end of file
diff --git a/app/services/llm_client.py b/app/services/llm_client.py
new file mode 100644
index 0000000..179b2ba
--- /dev/null
+++ b/app/services/llm_client.py
@@ -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 " → 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 " 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)
\ No newline at end of file
diff --git a/app/services/permission_manager.py b/app/services/permission_manager.py
new file mode 100644
index 0000000..e74daf9
--- /dev/null
+++ b/app/services/permission_manager.py
@@ -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")
\ No newline at end of file
diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py
new file mode 100644
index 0000000..9e8989d
--- /dev/null
+++ b/app/services/tool_registry.py
@@ -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)
\ No newline at end of file
diff --git a/app/templates/agent_message.html b/app/templates/agent_message.html
new file mode 100644
index 0000000..f0c042f
--- /dev/null
+++ b/app/templates/agent_message.html
@@ -0,0 +1,17 @@
+{# FlowDeck — Agent message fragment (v4.10.0). Rendered standalone for history. #}
+
+
+ {% if role == 'assistant' %}🤖{% else %}👤{% endif %}
+
+
+
{{ role }} · {{ model or '' }}
+
{{ content }}
+ {% if tool_calls %}
+
+ {% for call in (tool_calls | fromjson_json) %}
+
🛠 {{ call.name }} — {{ call.arguments | tojson }}
+ {% endfor %}
+
+ {% endif %}
+
+
\ No newline at end of file
diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html
new file mode 100644
index 0000000..448be00
--- /dev/null
+++ b/app/templates/agent_panel.html
@@ -0,0 +1,224 @@
+{# ════════ FlowDeck Agent panel (v4.10.0) — sidebar-triggered chat + floating button ════════ #}
+
+
+
+
+
+
+
🤖 Agent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Aucune conversation. Démarrez-en une !
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
L'agent réfléchit et agit…
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/templates/base.html b/app/templates/base.html
index 8d70587..4fe94af 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -362,12 +362,20 @@
Agents