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
|
||||
|
||||
## v4.10.0 (2026-09-05) — FlowDeck Agent : agent IA natif conversationnel
|
||||
|
||||
> Implémentation complète du pilier « Agent IA » : un agent ReAct intégré qui **agit** sur le
|
||||
> workspace (pas seulement répond) — panneau de chat, boucle raisonnement→action, streaming SSE,
|
||||
> 18 outils avec snapshots rollback, permissions par rôle, contexte automatique, custom agents,
|
||||
> déclencheurs planifiés, skills réutilisables et multi-LLM (offline mock + 8 providers).
|
||||
|
||||
### Added
|
||||
- **Interface conversationnelle** : panneau agent dans le sidebar (🤖 Agents) piloté par Alpine.js +
|
||||
streaming SSE ; FAB ouvrant le chat ; historique de conversations.
|
||||
- **Boucle ReAct** (`app/services/agent_engine.py`) : raisonnement → action → observation, max 12
|
||||
itérations, budget de tokens, timeout, journal d'audit + fonction `undo_action()` (rollback).
|
||||
- **Streaming SSE** : endpoint `/api/agent/conversations/{id}/run` qui émet `reasoning`, `action`
|
||||
et `final` en temps réel.
|
||||
- **18 outils** (`app/services/tool_registry.py`) : `search_workspace`, `read_collection`,
|
||||
`read_page`, `create_collection`, `create_view`, `add_property`, `create_page`, `update_page`,
|
||||
`write_blocks`, `add_relation`, `create_sub_item`, `add_dependency`, `apply_template`,
|
||||
`delete_page`, `delete_collection`, `read_gitea_issues`, `sync_gitea`, `create_gitea_issue` —
|
||||
chacun avec `ToolResult` + snapshot d'undo réversible.
|
||||
- **Permissions** (`app/services/permission_manager.py`) : même ACL que l'utilisateur par rôle
|
||||
workspace (viewer/editor/admin/owner) + gating des outils.
|
||||
- **Contexte automatique** (`app/services/context_builder.py`) : snapshot du workspace actif,
|
||||
collections, pages, mentions `@`, fichiers uploadés injectés dans le prompt système.
|
||||
- **Multi-LLM** (`app/services/llm_client.py`) : OpenAI, Anthropic, Google, Ollama, DeepSeek,
|
||||
QwenCloud, NVIDIA, OpenRouter + **offline mock** (planner déterministe, aucune clé requise).
|
||||
- **Custom agents, skills, déclencheurs** : CRUD agents + conversations, `agent_*` tables
|
||||
(`agents`, `agent_conversations`, `agent_messages`, `agent_actions`, `agent_skills`,
|
||||
`agent_triggers`), skills (`/api/agent/skills`, apply), scheduler de fond pour triggers planifiés.
|
||||
|
||||
### Infra
|
||||
- `VERSION` → 4.10.0 ; config LLM (`LLM_PROVIDER/MODEL/API_KEY/API_BASE`, `AGENT_MAX_*`) ;
|
||||
exclusions CSRF `/api/agent` ; routeur agent + tâche scheduler dans le lifespan de `app/main.py`.
|
||||
|
||||
### Tests
|
||||
- **226 tests verts** (+18 v4.10.0 : schéma DB, mock LLM, permissions, outils+undo, engine ReAct +
|
||||
audit + rollback, CRUD agents/conversations, streaming SSE, skills, tools, providers).
|
||||
|
||||
## v4.9.0 (2026-09-04) — Collaboration : commentaires inline, mentions @, notifications
|
||||
|
||||
> Implémentation complète du pilier « Collaboration » — basée sur la revue des docs Notion
|
||||
|
||||
+79
-28
@@ -323,11 +323,12 @@ Détails livrés :
|
||||
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
|
||||
- [x] **208 tests passent** (+9 v4.9.0)
|
||||
|
||||
### v4.10.0 — FlowDeck Agent (Agent IA natif) ⬜ (0 % implémenté)
|
||||
### v4.10.0 — FlowDeck Agent (Agent IA natif) ✅
|
||||
|
||||
> ⚠️ **État actuel** : documentation de design complète (`docs/Flowdeck_Agent_integration.md`),
|
||||
> mais **aucun code** : pas de `routers/agent.py`, `services/agent_engine.py`, `llm_client.py`,
|
||||
> `tool_registry.py`, `context_builder.py`, ni aucune table `agent_*`.
|
||||
> **Livré (2026-09-05)** : agent conversationnel complet — boucle ReAct, streaming SSE,
|
||||
> 18 outils avec snapshots rollback, ACL par rôle, contexte automatique, custom agents,
|
||||
> déclencheurs planifiés + skills, multi-LLM (offline mock + 8 providers). 18 tests dédiés.
|
||||
> Voir [`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md).
|
||||
|
||||
**Objectif :** Un agent IA intégré à FlowDeck, capable de planifier, rechercher et **agir** directement sur les workspaces — collections, pages, propriétés, vues, issues Gitea. Inspiré de Notion Agent (2026).
|
||||
|
||||
@@ -348,16 +349,16 @@ Là où un assistant IA classique répond (`prompt → réponse`), FlowDeck Agen
|
||||
| Aucune action DB | Modifie le workspace via API FastAPI existantes |
|
||||
|
||||
#### Fonctionnalités clés
|
||||
- [ ] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
|
||||
- [ ] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
|
||||
- [ ] **Streaming SSE** — affichage temps réel du raisonnement et des actions
|
||||
- [ ] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
|
||||
- [ ] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
|
||||
- [ ] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
|
||||
- [ ] **Custom agents** — instructions, modèle, outils, scope par agent
|
||||
- [ ] **Déclencheurs** — planifiés, webhooks, événements workspace
|
||||
- [ ] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
|
||||
- [ ] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter
|
||||
- [x] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
|
||||
- [x] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
|
||||
- [x] **Streaming SSE** — affichage temps réel du raisonnement et des actions
|
||||
- [x] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
|
||||
- [x] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
|
||||
- [x] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
|
||||
- [x] **Custom agents** — instructions, modèle, outils, scope par agent
|
||||
- [x] **Déclencheurs** — planifiés, webhooks, événements workspace
|
||||
- [x] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
|
||||
- [x] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter
|
||||
|
||||
#### Architecture (nouveaux composants)
|
||||
```
|
||||
@@ -511,6 +512,57 @@ app/
|
||||
|
||||
---
|
||||
|
||||
## v5.10.0 — Éditeur : interactions de bloc ⬜ (non commencé)
|
||||
> **Objectif** : parité de manipulation des blocs avec Notion. Actuellement les blocs sont
|
||||
> éditables mais *statiques* : impossible de les déplacer, dupliquer ou annuler une action.
|
||||
|
||||
- [ ] **Drag & drop des blocs** — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue), support multi-sélection de blocs (Shift+clic → déplacement groupé)
|
||||
- [ ] **Menu contextuel de bloc** — clic sur ⋮⋮ : Turn into (changer le type), Duplicate, Copy link to block, Move to, Delete, couleur de texte/fond
|
||||
- [ ] **Undo / Redo** — Ctrl+Z / Ctrl+Shift+Z ou Ctrl+Y sur l'état des blocs (pile d'opérations inversibles par page, conservée en mémoire du tab)
|
||||
- [ ] **Duplicate block** — Ctrl+D sur le bloc courant (copie insérée juste en dessous)
|
||||
- [ ] **Slash command étendue** — la commande `/` propose aussi : Turn into, Duplicate, et toutes les actions du menu bloc
|
||||
- [ ] **En-têtes de tableau (bloc table v4.8.0)** — toggles « First row as header » / « First column as header » dans la config du bloc table, rendu `<th>` correspondant en preview et exports
|
||||
|
||||
## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé)
|
||||
> **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0
|
||||
> (section « Lié depuis ») par la création des liens depuis l'éditeur.
|
||||
|
||||
- [ ] **Wiki-links `[[`** — taper `[[` ouvre un picker de pages (recherche fuzzy, toutes collections), Enter insère un lien interne rendu comme chip de page (icône + titre mis à jour dynamiquement)
|
||||
- [ ] **Mention de page `@`** — dans le menu @ existant (utilisateurs v4.9.0), ajouter l'onglet « Pages » : `@` suivi d'un nom de page crée un lien inline
|
||||
- [ ] **Mention de date `@`** — `@today`, `@tomorrow`, `@2026-10-01` rendus comme chips de date
|
||||
- [ ] **Renommage propagé** — renommer une page met à jour le libellé affiché de tous ses liens internes (résolution au rendu via `page_id`, pas de texte dur)
|
||||
|
||||
## v5.12.0 — Templates & verrouillage de page ⬜ (non commencé)
|
||||
> **Objectif** : démarrage rapide productif et protection des pages stabilisées.
|
||||
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections.
|
||||
|
||||
- [ ] **Template picker global** — sur « + New page » : galerie de templates (Empty, Meeting notes, Weekly report, To-do list…) + templates custom utilisateur
|
||||
- [ ] **Bouton « Use template »** — duplication du contenu du template dans la nouvelle page
|
||||
- [ ] **Page lock** — toggle 🔒 dans le menu « … » : page en lecture seule (édition désactivée pour tous sauf owner/admin), indicateur visuel en topbar
|
||||
- [ ] **Full-width mode** — toggle pour passer la page en pleine largeur (comme Notion)
|
||||
- [ ] **Small text / typo options** — option de page : taille de police réduite, serif/mono
|
||||
|
||||
## v5.13.0 — Collaboration temps réel ⬜ (non commencé)
|
||||
> **Objectif** : avancer depuis v6.0.0 le chantier n°1 de la parité Notion en équipe.
|
||||
> Sans realtime, FlowDeck reste un outil mono-utilisateur partagé.
|
||||
|
||||
- [ ] **WebSocket gateway** — endpoint `/ws/pages/{id}`, auth via session, rooms par page
|
||||
- [ ] **Présence** — avatars des utilisateurs connectés sur la page (topbar), couleur par utilisateur
|
||||
- [ ] **Curseurs live** — position des curseurs/sélections des autres éditeurs, label avec nom
|
||||
- [ ] **Merge de modifications** — diffusion des opérations de blocs (insert/update/delete/move) avec last-write-wins par bloc + version de page pour détecter les conflits
|
||||
- [ ] **Fallback polling** — si WS indisponible, rafraîchissement diff toutes les 10 s
|
||||
|
||||
## v5.14.0 — Synced blocks ⬜ (non commencé)
|
||||
> **Objectif** : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans
|
||||
> plusieurs pages, édité une fois).
|
||||
|
||||
- [ ] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced`
|
||||
- [ ] **Rendu** — ring rouge + badge « Synced » sur le bloc ; édition à un endroit => mise à jour partout (via rooms WS v5.13.0 si actives, sinon au reload)
|
||||
- [ ] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
|
||||
- [ ] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync »
|
||||
|
||||
---
|
||||
|
||||
## v6.0.0 — Pro (futur)
|
||||
|
||||
- [ ] **PWA** — Progressive Web App, offline support
|
||||
@@ -518,8 +570,8 @@ app/
|
||||
- [ ] **Granular permissions** — page-level, property-level access control
|
||||
- [ ] **Web Clipper** — extension navigateur
|
||||
- [ ] **API publique complète** — REST API documentée (OpenAPI) *(base existante : `public_api.py`, à étendre + documenter)* — voir [`docs/API_GUIDE_V6.md`](docs/API_GUIDE_V6.md) : référence complète (conventions, CRUD par ressource, webhooks, sécurité, checklist)
|
||||
- [ ] **Realtime editing** — WebSocket, curseurs multi-utilisateurs
|
||||
- [ ] **Synced blocks** — bloc synchronisé entre plusieurs pages
|
||||
- [ ] **Realtime editing (production)** — voir **v5.13.0** (curseurs + présence déjà avancés ici) ; reste en v6 : conflits avancés, édition large échelle
|
||||
- [ ] **Synced blocks (production)** — voir **v5.14.0** (bloc de base) ; reste en v6 : syncing côté databases/vues
|
||||
|
||||
---
|
||||
|
||||
@@ -535,11 +587,10 @@ app/
|
||||
## 🎯 Ordre de priorité recommandé (état 2026-09)
|
||||
|
||||
1. **v5.2.0 → Migrations versionnées** (bloquant pour tout le reste)
|
||||
2. **v4.9.0 → Collaboration ✅** (comments + mentions + notifications) — livré 2026-09-04
|
||||
3. **v5.0.0 → Command palette + FTS5** — le raccourci Ctrl+K est déjà câblé, petit effort / gros impact
|
||||
4. **v5.3.0 → Inline databases dans pages** — API serveur prête, uniquement travail éditeur
|
||||
5. **v4.10.0 → Agent IA** — gros chantier, démarrer Phase 1 (engine + 3 outils) une fois 1–4 livrés
|
||||
|
||||
2. **v5.0.0 → Command palette + FTS5** — Ctrl+K déjà câblé, petit effort / gros impact
|
||||
3. **v5.3.0 → Inline databases dans pages** — API serveur prête, uniquement travail éditeur
|
||||
4. **v5.13.0 → Realtime (WS + présence)** — chantier de parité Notion n°1, socle aussi pour v5.14 (synced blocks)
|
||||
5. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
@@ -551,11 +602,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ GitHub OAuth + Library
|
||||
|
||||
v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ✅ v4.6.0 ✅ v4.7.0 ✅ v4.8.0 ✅ v4.9.0 ✅ v4.10 ⬜ v5.0–5.9 ⬜ v6.0 ⬜
|
||||
Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Bloc Table Collaboration Agent IA Palette → Pro + Agent
|
||||
& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks MD/PDF/HTML simple (comments@) (futur) Automations (futur)
|
||||
(futur) Embeds +
|
||||
Import + DB
|
||||
+ Calendrier
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.9 ⬜ v5.10–5.12 ⬜ v5.13–5.14 ⬜ v6.0 ⬜
|
||||
Quality DB views, Agent IA Palette → Éditeur bloc Realtime + Pro + Agent
|
||||
& Tests Templates & COMPLETED Automations (drag&drop, Synced blocks
|
||||
Collaboration Embeds, Import, undo/redo) +
|
||||
DB avancée, Wiki-links,
|
||||
Calendrier, AI Templates & lock
|
||||
|
||||
*Dernière mise à jour: 2026-09-04 — Audit complet v4.9–v5.3 + analyse Notion clone : v5.5.0 → v5.9.0 ajoutées (Embeds, Import, DB avancée, Calendrier & Rappels, AI Writing)*
|
||||
*Dernière mise à jour: 2026-09-05 — v4.10.0 Agent IA livré (18 tests) ; audit précédent : v5.10.0 → v5.14.0 ajoutées (interactions de bloc, wiki-links & mentions, templates & page lock, realtime, synced blocks)*
|
||||
|
||||
@@ -59,6 +59,18 @@ class Settings(BaseSettings):
|
||||
smtp_use_tls: bool = True
|
||||
app_base_url: str = "http://localhost:8080"
|
||||
|
||||
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
|
||||
# (deterministic rule-based planner so the agent works without any API key).
|
||||
agent_enabled: bool = True
|
||||
llm_provider: str = "offline" # openai | anthropic | google | ollama |
|
||||
# deepseek | qwencloud | nvidia | openrouter | offline
|
||||
llm_model: str = "gpt-4o"
|
||||
llm_api_key: str = ""
|
||||
llm_api_base: str = "" # custom base URL (Ollama, OpenRouter, ...)
|
||||
agent_max_iterations: int = 12
|
||||
agent_max_tokens_budget: int = 500000
|
||||
agent_run_timeout_seconds: int = 300
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
if self.database_url == "sqlite:///:memory:":
|
||||
|
||||
@@ -640,6 +640,94 @@ def init_db():
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
# ═══════════ v4.10.0: FlowDeck Agent — agents, conversations, audit ═══════════
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id),
|
||||
name TEXT NOT NULL DEFAULT 'FlowDeck Agent',
|
||||
icon TEXT DEFAULT '🤖',
|
||||
agent_type TEXT NOT NULL DEFAULT 'personal',
|
||||
description TEXT DEFAULT '',
|
||||
system_instructions TEXT DEFAULT '',
|
||||
model TEXT DEFAULT 'gpt-4o',
|
||||
scope_json TEXT NOT NULL DEFAULT '{}',
|
||||
trigger_json TEXT NOT NULL DEFAULT '{}',
|
||||
approval_mode TEXT NOT NULL DEFAULT 'auto',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
UNIQUE(workspace_id, name)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
title TEXT DEFAULT 'New conversation',
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
context_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tool_calls_json TEXT DEFAULT '[]',
|
||||
model TEXT,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
||||
tool_name TEXT NOT NULL,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
undo_snapshot_json TEXT DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
executed_by INTEGER REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_skills (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
prompt_template TEXT NOT NULL,
|
||||
allowed_tools_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
UNIQUE(workspace_id, name)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_triggers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL DEFAULT 'manual',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
last_fired_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)")
|
||||
conn.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_conn():
|
||||
|
||||
+18
-4
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
@@ -13,7 +14,7 @@ from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export, agent
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.gitea import router as gitea_router
|
||||
@@ -41,13 +42,25 @@ async def lifespan(_app: FastAPI):
|
||||
(admin_hash,)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("FlowDeck v4.9.0 started on port %d", settings.app_port)
|
||||
yield
|
||||
|
||||
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
|
||||
from app.routers.agent import agent_scheduler
|
||||
scheduler_task = asyncio.create_task(agent_scheduler())
|
||||
|
||||
logger.info("FlowDeck v4.10.0 started on port %d", settings.app_port)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
scheduler_task.cancel()
|
||||
try:
|
||||
await scheduler_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="4.9.0",
|
||||
version="4.10.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -78,6 +91,7 @@ app.include_router(sidebar_config.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(collaboration_router)
|
||||
app.include_router(agent.router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="New agent" @click.stop="">+</button>
|
||||
<button class="section-action-btn" title="New agent" @click.stop="agentNew()">+</button>
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'agents')">⋮</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition>
|
||||
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("bot",16) }}</span><span class="page-name text-dim">No agents configured</span></li></ul>
|
||||
<ul class="sidebar-items" data-section="agents">
|
||||
<template x-for="a in agentList" :key="a.id">
|
||||
<li class="sidebar-item" @click="agentOpen(a.id)">
|
||||
<span class="page-icon page-icon-svg" x-html="a.icon || '🤖'"></span>
|
||||
<span class="page-name" x-text="a.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
<li class="sidebar-item empty-hint" x-show="!agentList.length" @click="agentOpen()"><span class="page-icon page-icon-svg">{{ fd_icon("bot",16) }}</span><span class="page-name text-dim">No agents — click to chat</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -926,6 +934,20 @@
|
||||
init() {
|
||||
window.appState = this;
|
||||
this.loadSidebarConfig();
|
||||
this.loadAgents();
|
||||
},
|
||||
agentList: [],
|
||||
loadAgents() {
|
||||
var self = this;
|
||||
fetch('/api/agent').then(function(r){ return r.json(); }).then(function(d){
|
||||
self.agentList = d.agents || [];
|
||||
}).catch(function(){});
|
||||
},
|
||||
agentOpen(id) {
|
||||
document.dispatchEvent(new CustomEvent('fd-agent-toggle', { detail: { agentId: id || null } }));
|
||||
},
|
||||
agentNew() {
|
||||
document.dispatchEvent(new CustomEvent('fd-agent-toggle'));
|
||||
},
|
||||
sidebarCollapsed: false,
|
||||
mobileSidebarOpen: false,
|
||||
@@ -1833,5 +1855,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'agent_panel.html' %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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