feat(agent): v4.10.0 FlowDeck Agent - agent IA ReAct (SSE, 18 outils + rollback, permissions, skills, triggers, multi-LLM)
This commit is contained in:
@@ -1,5 +1,42 @@
|
|||||||
# Changelog — FlowDeck
|
# 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
|
## 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
|
> Implémentation complète du pilier « Collaboration » — basée sur la revue des docs Notion
|
||||||
|
|||||||
+79
-28
@@ -323,11 +323,12 @@ Détails livrés :
|
|||||||
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
|
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
|
||||||
- [x] **208 tests passent** (+9 v4.9.0)
|
- [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`),
|
> **Livré (2026-09-05)** : agent conversationnel complet — boucle ReAct, streaming SSE,
|
||||||
> mais **aucun code** : pas de `routers/agent.py`, `services/agent_engine.py`, `llm_client.py`,
|
> 18 outils avec snapshots rollback, ACL par rôle, contexte automatique, custom agents,
|
||||||
> `tool_registry.py`, `context_builder.py`, ni aucune table `agent_*`.
|
> 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).
|
**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 |
|
| Aucune action DB | Modifie le workspace via API FastAPI existantes |
|
||||||
|
|
||||||
#### Fonctionnalités clés
|
#### Fonctionnalités clés
|
||||||
- [ ] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
|
- [x] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
|
||||||
- [ ] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
|
- [x] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
|
||||||
- [ ] **Streaming SSE** — affichage temps réel du raisonnement et des actions
|
- [x] **Streaming SSE** — affichage temps réel du raisonnement et des actions
|
||||||
- [ ] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
|
- [x] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
|
||||||
- [ ] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
|
- [x] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
|
||||||
- [ ] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
|
- [x] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
|
||||||
- [ ] **Custom agents** — instructions, modèle, outils, scope par agent
|
- [x] **Custom agents** — instructions, modèle, outils, scope par agent
|
||||||
- [ ] **Déclencheurs** — planifiés, webhooks, événements workspace
|
- [x] **Déclencheurs** — planifiés, webhooks, événements workspace
|
||||||
- [ ] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
|
- [x] **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] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter
|
||||||
|
|
||||||
#### Architecture (nouveaux composants)
|
#### 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)
|
## v6.0.0 — Pro (futur)
|
||||||
|
|
||||||
- [ ] **PWA** — Progressive Web App, offline support
|
- [ ] **PWA** — Progressive Web App, offline support
|
||||||
@@ -518,8 +570,8 @@ app/
|
|||||||
- [ ] **Granular permissions** — page-level, property-level access control
|
- [ ] **Granular permissions** — page-level, property-level access control
|
||||||
- [ ] **Web Clipper** — extension navigateur
|
- [ ] **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)
|
- [ ] **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
|
- [ ] **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** — bloc synchronisé entre plusieurs pages
|
- [ ] **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)
|
## 🎯 Ordre de priorité recommandé (état 2026-09)
|
||||||
|
|
||||||
1. **v5.2.0 → Migrations versionnées** (bloquant pour tout le reste)
|
1. **v5.2.0 → Migrations versionnées** (bloquant pour tout le reste)
|
||||||
2. **v4.9.0 → Collaboration ✅** (comments + mentions + notifications) — livré 2026-09-04
|
2. **v5.0.0 → Command palette + FTS5** — Ctrl+K déjà câblé, petit effort / gros impact
|
||||||
3. **v5.0.0 → Command palette + FTS5** — le raccourci Ctrl+K est 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.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. **v4.10.0 → Agent IA** — gros chantier, démarrer Phase 1 (engine + 3 outils) une fois 1–4 livrés
|
5. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Résumé des phases
|
## Résumé des phases
|
||||||
@@ -551,11 +602,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
|
|||||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||||
+ GitHub OAuth + Library
|
+ 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 ⬜
|
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.9 ⬜ v5.10–5.12 ⬜ v5.13–5.14 ⬜ v6.0 ⬜
|
||||||
Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Bloc Table Collaboration Agent IA Palette → Pro + Agent
|
Quality DB views, Agent IA Palette → Éditeur bloc Realtime + Pro + Agent
|
||||||
& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks MD/PDF/HTML simple (comments@) (futur) Automations (futur)
|
& Tests Templates & COMPLETED Automations (drag&drop, Synced blocks
|
||||||
(futur) Embeds +
|
Collaboration Embeds, Import, undo/redo) +
|
||||||
Import + DB
|
DB avancée, Wiki-links,
|
||||||
+ Calendrier
|
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)*
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ class Settings(BaseSettings):
|
|||||||
smtp_use_tls: bool = True
|
smtp_use_tls: bool = True
|
||||||
app_base_url: str = "http://localhost:8080"
|
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
|
@property
|
||||||
def db_path(self) -> Path:
|
def db_path(self) -> Path:
|
||||||
if self.database_url == "sqlite:///:memory:":
|
if self.database_url == "sqlite:///:memory:":
|
||||||
|
|||||||
@@ -640,6 +640,94 @@ def init_db():
|
|||||||
pass
|
pass
|
||||||
conn.commit()
|
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
|
@contextmanager
|
||||||
def get_conn():
|
def get_conn():
|
||||||
|
|||||||
+17
-3
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
@@ -13,7 +14,7 @@ from app.config import settings
|
|||||||
from app.db import init_db
|
from app.db import init_db
|
||||||
from app.middleware.csrf import CSRFMiddleware
|
from app.middleware.csrf import CSRFMiddleware
|
||||||
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
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.notifications import router as notifications_router
|
||||||
from app.routers.collaboration import router as collaboration_router
|
from app.routers.collaboration import router as collaboration_router
|
||||||
from app.routers.gitea import router as gitea_router
|
from app.routers.gitea import router as gitea_router
|
||||||
@@ -41,13 +42,25 @@ async def lifespan(_app: FastAPI):
|
|||||||
(admin_hash,)
|
(admin_hash,)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("FlowDeck v4.9.0 started on port %d", settings.app_port)
|
|
||||||
|
# ── 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
|
yield
|
||||||
|
finally:
|
||||||
|
scheduler_task.cancel()
|
||||||
|
try:
|
||||||
|
await scheduler_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="4.9.0",
|
version="4.10.0",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -78,6 +91,7 @@ app.include_router(sidebar_config.router)
|
|||||||
app.include_router(export.router)
|
app.include_router(export.router)
|
||||||
app.include_router(notifications_router)
|
app.include_router(notifications_router)
|
||||||
app.include_router(collaboration_router)
|
app.include_router(collaboration_router)
|
||||||
|
app.include_router(agent.router)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
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):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
@@ -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()">×</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
@@ -362,12 +362,20 @@
|
|||||||
<span class="section-label">Agents</span>
|
<span class="section-label">Agents</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-section-actions">
|
<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>
|
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'agents')">⋮</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -926,6 +934,20 @@
|
|||||||
init() {
|
init() {
|
||||||
window.appState = this;
|
window.appState = this;
|
||||||
this.loadSidebarConfig();
|
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,
|
sidebarCollapsed: false,
|
||||||
mobileSidebarOpen: false,
|
mobileSidebarOpen: false,
|
||||||
@@ -1833,5 +1855,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'agent_panel.html' %}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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 {}
|
||||||
Reference in New Issue
Block a user