- app/services/plugins.py + migration 35 : table plugins (slug, name,
description, enabled) pré-remplie avec 3 modules câblés — web-tools,
web-clipper, automations ; ligne absente = activé (défaut sûr)
- automations OFF → dépendance FastAPI posée à l'include_router dans main.py
(aucun router touché) → toutes les routes /workspace/automations* refusées +
garde de tick du scheduler en arrière-plan
- web-clipper OFF → GET /extensions et tout /api/v2/web-clipper/* refusés
- web-tools OFF → web_search et fetch_url retirés du schéma ET de execute()
via ToolRegistry._all() : le LLM ne les voit plus
- UI rendue côté serveur : global Jinja plugin_enabled(slug) — nav
« Extensions » / « Automations » en {% if %} (absentes du DOM), sections
conditionnées en x-show dans settings.html
- menu + : l'entrée « Add plugins » devient vivante (fini disabled:true) —
liste des 3 plugins avec bascule, GET/PATCH /api/agent/plugins[/slug]
(slug inconnu → 404, 401 sans session)
- tests : tests/test_v758_plugins.py (10 tests) — routes refusées (302 hors
/api, 404 JSON pour /api*), outils retirés, nav disparue, persistance,
câblage ; assertions disabled:true == 0 dans les tests des phases 1/3/4/5/7
- livraison : VERSION + app/main = 7.58.0, OpenAPI 525 chemins, CHANGELOG,
ROADMAP phase 8 cochée (menu + complet), avenant phase 8 (docs)
1210 lines
55 KiB
Python
1210 lines
55 KiB
Python
"""FlowDeck — Agent tool registry (v4.14.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, workspaces & documents).
|
|
Every mutating tool returns an *undo snapshot* so AgentEngine can journal and
|
|
roll back each action.
|
|
|
|
Since v4.14.0 the registry also covers *documents* (Notion-style pages that
|
|
live inside a workspace, table ``pages``) and lets the agent read the list of
|
|
workspaces — fixing the case where "crée un document dans le workspace X"
|
|
used to end with no action.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import sqlite3
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC
|
|
from typing import Any
|
|
|
|
from app.db import get_conn
|
|
from app.services.collection_lifecycle import delete_collections
|
|
|
|
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) dans tout FlowDeck : collections, pages de "
|
|
"collection, documents (pages éditeur) et espaces de travail.")
|
|
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()
|
|
docs = conn.execute(
|
|
"SELECT id, title, workspace_id, content_format FROM pages "
|
|
"WHERE deleted_at IS NULL AND title LIKE ? ORDER BY updated_at DESC LIMIT 20",
|
|
(q,),
|
|
).fetchall()
|
|
workspaces = conn.execute(
|
|
"SELECT id, name FROM workspaces WHERE name LIKE ? ORDER BY name", (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],
|
|
"documents": [dict(d) for d in docs],
|
|
"workspaces": [dict(w) for w in workspaces],
|
|
},
|
|
message=(f"{len(colls)} collection(s), {len(pages)} page(s), "
|
|
f"{len(docs)} document(s), {len(workspaces)} espace(s) trouvé(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]},
|
|
)
|
|
|
|
|
|
class ReadWorkspaces(Tool):
|
|
name = "read_workspaces"
|
|
description = ("Liste les espaces de travail accessibles (id, nom, rôle) avec le nombre de "
|
|
"documents et de collections qu'ils contiennent — utile pour savoir où créer "
|
|
"ou chercher un document.")
|
|
parameters = {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string", "description": "filtre optionnel sur le nom"}},
|
|
}
|
|
|
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
|
query = (args.get("query") or "").strip()
|
|
base_sql = (
|
|
"SELECT w.id, w.name, {role} AS role, "
|
|
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id=w.id AND p.deleted_at IS NULL) AS document_count, "
|
|
"(SELECT COUNT(*) FROM collections c WHERE c.workspace_id=w.id) AS collection_count "
|
|
"FROM workspaces w {join} {where} ORDER BY w.name"
|
|
)
|
|
with get_conn() as conn:
|
|
if user_id is None:
|
|
rows = conn.execute(
|
|
base_sql.format(role="'owner'", join="", where=""), []
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
base_sql.format(
|
|
role="COALESCE(wm.role, CASE WHEN w.owner_id=? THEN 'owner' ELSE 'viewer' END)",
|
|
join="LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=?",
|
|
where="WHERE w.owner_id=? OR wm.user_id IS NOT NULL",
|
|
),
|
|
(user_id, user_id, user_id),
|
|
).fetchall()
|
|
data = [dict(r) for r in rows]
|
|
if query:
|
|
q = query.lower()
|
|
data = [w for w in data if q in str(w.get("name", "")).lower()]
|
|
return ToolResult(
|
|
status="success", tool=self.name, target_type="workspaces",
|
|
data={"workspaces": data},
|
|
message=f"{len(data)} espace(s) de travail",
|
|
)
|
|
|
|
|
|
class ReadDocument(Tool):
|
|
name = "read_document"
|
|
description = "Lit un document (page éditeur) : titre, contenu et métadonnées."
|
|
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:
|
|
doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (pid,)).fetchone()
|
|
if not doc:
|
|
return ToolResult(status="error", tool=self.name, message=f"Document #{pid} introuvable")
|
|
return ToolResult(
|
|
status="success", tool=self.name, target_type="document", target_id=pid,
|
|
data={"document": dict(doc)},
|
|
)
|
|
|
|
|
|
# ══════════════════════════ 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 CreateDocument(Tool):
|
|
name = "create_document"
|
|
description = ("Crée un document (page éditeur type Notion) dans un espace de travail, ou à la "
|
|
"racine. Le contenu initial est optionnel (Markdown).")
|
|
parameters = {
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string", "description": "titre du document"},
|
|
"content": {"type": "string", "description": "contenu initial en Markdown (optionnel)"},
|
|
"workspace_id": {"type": "integer", "description": "id de l'espace de travail cible (optionnel)"},
|
|
"workspace_name": {"type": "string", "description": "nom exact de l'espace de travail cible (optionnel)"},
|
|
"parent_id": {"type": "integer", "description": "document parent (sous-page) optionnel"},
|
|
},
|
|
"required": ["title"],
|
|
}
|
|
|
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
|
title = (args.get("title") or "").strip()
|
|
if not title:
|
|
return ToolResult(status="error", tool=self.name, message="title est requis")
|
|
content = (args.get("content") or "").strip()
|
|
ws_id = args.get("workspace_id")
|
|
ws_name = (args.get("workspace_name") or "").strip()
|
|
parent_id = args.get("parent_id") or None
|
|
|
|
with get_conn() as conn:
|
|
workspace = None
|
|
if ws_id is not None:
|
|
workspace = conn.execute("SELECT * FROM workspaces WHERE id=?", (ws_id,)).fetchone()
|
|
if not workspace:
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=f"Workspace #{ws_id} introuvable")
|
|
elif ws_name:
|
|
workspace = conn.execute(
|
|
"SELECT * FROM workspaces WHERE lower(name)=lower(?) ORDER BY id LIMIT 1",
|
|
(ws_name,),
|
|
).fetchone()
|
|
if not workspace:
|
|
available = conn.execute("SELECT name FROM workspaces ORDER BY name").fetchall()
|
|
names = ", ".join(r["name"] for r in available) or "aucun"
|
|
return ToolResult(
|
|
status="error", tool=self.name,
|
|
message=f"Workspace « {ws_name} » introuvable. Disponibles : {names}",
|
|
)
|
|
if parent_id:
|
|
if not conn.execute("SELECT id FROM pages WHERE id=? AND deleted_at IS NULL",
|
|
(parent_id,)).fetchone():
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=f"Document parent #{parent_id} introuvable")
|
|
|
|
w = dict(workspace) if workspace else None
|
|
ws_id_val = w["id"] if w else (ws_id if ws_id is not None else None)
|
|
ws_key = (w["name"] if w else "") if ws_id_val is not None else ""
|
|
|
|
if content:
|
|
fmt, store = "markdown", content
|
|
else:
|
|
fmt, store = "blocks", "[]"
|
|
cur = conn.execute(
|
|
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format,
|
|
parent_section, parent_id, sort_order)
|
|
VALUES (?,?,?,?,?,?,?,0)""",
|
|
(ws_key, ws_id_val, title, store, fmt, "Private", parent_id),
|
|
)
|
|
pid = cur.lastrowid
|
|
conn.commit()
|
|
|
|
loc = f" dans « {ws_key} »" if ws_key else ""
|
|
return ToolResult(
|
|
status="success", tool=self.name, target_type="document", target_id=pid,
|
|
data={"document_id": pid, "title": title,
|
|
"workspace_id": ws_id_val, "workspace": ws_key},
|
|
message=f"Document « {title} » créé{loc}",
|
|
undo={"action": "delete", "table": "pages", "id": pid},
|
|
)
|
|
|
|
|
|
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 = ("Met à jour un document (page éditeur `pages`) : remplace son contenu en blocs "
|
|
"et/ou renomme son titre. Chaque bloc est un objet JSON "
|
|
"{\"type\": \"...\", \"content\": \"texte du bloc\"}. Types autorisés : "
|
|
"paragraph, heading_1, heading_2, heading_3, heading_4, bulleted_list, "
|
|
"numbered_list, to_do, quote, divider, toggle, callout, code. "
|
|
"Utilise `type: \"code\"` (avec `language`) pour un script ou du code "
|
|
"multi-lignes, `heading_1`/`heading_2`/… pour les titres. "
|
|
"Le champ contenant le texte s'appelle `content`, PAS `text`.")
|
|
parameters = {
|
|
"type": "object",
|
|
"properties": {
|
|
"page_id": {"type": "integer", "description": "id du document (page éditeur)"},
|
|
"blocks": {"type": "array",
|
|
"description": "nouveau contenu en blocs (remplace le contenu). "
|
|
"Exemple: [{\"type\": \"heading_1\", \"content\": \"Titre\"}, "
|
|
"{\"type\": \"code\", \"content\": \"Write-Host 'hi'\", \"language\": \"powershell\"}]"},
|
|
"title": {"type": "string", "description": "nouveau titre (optionnel)"},
|
|
},
|
|
"required": ["page_id"],
|
|
}
|
|
|
|
_VALID_BLOCK_TYPES = frozenset({
|
|
"paragraph", "heading_1", "heading_2", "heading_3", "heading_4",
|
|
"bulleted_list", "numbered_list", "to_do", "toggle", "quote",
|
|
"callout", "table_of_contents", "math", "columns", "divider",
|
|
"code", "table", "button", "image", "embed", "bookmark",
|
|
"video", "audio", "meeting",
|
|
})
|
|
|
|
@classmethod
|
|
def _normalize_blocks(cls, blocks: list) -> list:
|
|
"""Normalize blocks coming from the LLM so the editor can render them.
|
|
|
|
Common LLM mistakes handled:
|
|
- ``text`` instead of ``content``
|
|
- ``heading`` instead of ``heading_1`` / ``heading_2`` / …
|
|
- ``bullet`` / ``list`` instead of ``bulleted_list``
|
|
- ``numbered`` instead of ``numbered_list``
|
|
- ``check`` / ``checkbox`` instead of ``to_do``
|
|
- ``code_block`` instead of ``code``
|
|
- ``h1`` … ``h6`` shorthand
|
|
- Missing ``id`` fields (editor assigns them on load but we add them
|
|
to keep the JSON self-contained)
|
|
"""
|
|
import uuid
|
|
|
|
_TYPE_MAP = {
|
|
"heading": "heading_1", "h1": "heading_1", "h2": "heading_2",
|
|
"h3": "heading_3", "h4": "heading_4", "h5": "heading_4",
|
|
"h6": "heading_4",
|
|
"bullet": "bulleted_list", "bullets": "bulleted_list", "list": "bulleted_list",
|
|
"numbered": "numbered_list", "numbered_list": "numbered_list",
|
|
"check": "to_do", "checkbox": "to_do", "task": "to_do",
|
|
"code_block": "code",
|
|
"hr": "divider", "horizontal_rule": "divider", "horizontal rule": "divider",
|
|
}
|
|
|
|
out = []
|
|
for block in (blocks or []):
|
|
if not isinstance(block, dict):
|
|
continue
|
|
b = dict(block)
|
|
# --- normalize type ---
|
|
raw_type = (b.get("type") or "paragraph").strip().lower()
|
|
btype = _TYPE_MAP.get(raw_type, raw_type)
|
|
if btype not in cls._VALID_BLOCK_TYPES:
|
|
btype = "paragraph"
|
|
b["type"] = btype
|
|
# --- normalize content field (LLM often sends "text" instead of "content") ---
|
|
if "content" not in b and "text" in b:
|
|
b["content"] = b.pop("text")
|
|
elif "content" not in b:
|
|
# Try other common fields
|
|
for alt in ("value", "body", "source"):
|
|
if alt in b:
|
|
b["content"] = b.pop(alt)
|
|
break
|
|
else:
|
|
b.setdefault("content", "")
|
|
# --- generate id if missing ---
|
|
if not b.get("id"):
|
|
b["id"] = f"b_{uuid.uuid4().hex[:12]}"
|
|
out.append(b)
|
|
return out
|
|
|
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
|
pid = args.get("page_id")
|
|
has_blocks = "blocks" in args
|
|
new_title = (args.get("title") or "").strip()
|
|
if not has_blocks and not new_title:
|
|
return ToolResult(status="error", tool=self.name,
|
|
message="Fournir `blocks` et/ou `title`")
|
|
with get_conn() as conn:
|
|
page = conn.execute("SELECT * FROM pages WHERE id=?", (pid,)).fetchone()
|
|
if not page:
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=f"Document (page éditeur) #{pid} introuvable")
|
|
snapshot = {"content": page["content"], "content_format": page["content_format"],
|
|
"title": page["title"]}
|
|
final_title = new_title or page["title"]
|
|
if has_blocks:
|
|
normalized = self._normalize_blocks(args["blocks"])
|
|
conn.execute(
|
|
"UPDATE pages SET content=?, content_format='blocks', title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(json.dumps(normalized, ensure_ascii=False), final_title, pid),
|
|
)
|
|
else:
|
|
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(final_title, pid))
|
|
conn.commit()
|
|
return ToolResult(
|
|
status="success", tool=self.name, target_type="document", target_id=pid,
|
|
data={"document_id": pid, "title": final_title, "blocks": len(args.get("blocks", []))},
|
|
undo={"action": "update", "table": "pages", "id": pid, "snapshot": snapshot},
|
|
)
|
|
|
|
|
|
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 DeleteDocument(Tool):
|
|
name = "delete_document"
|
|
description = "Supprime un document (page éditeur) — envoi à la corbeille (destructif, mode confirm requis)."
|
|
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
|
|
|
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
|
from datetime import datetime
|
|
pid = args.get("page_id")
|
|
with get_conn() as conn:
|
|
doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
|
|
(pid,)).fetchone()
|
|
if not doc:
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=f"Document #{pid} introuvable")
|
|
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
|
|
(datetime.now(UTC).replace(tzinfo=None).isoformat(), pid))
|
|
conn.commit()
|
|
return ToolResult(
|
|
status="success", tool=self.name, target_type="document", target_id=pid,
|
|
message=f"Document #{pid} déplacé vers la corbeille",
|
|
undo={"action": "softdelete", "table": "pages", "id": pid},
|
|
)
|
|
|
|
|
|
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")
|
|
delete_collections(conn, [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.auth.session import SessionManager
|
|
from app.services.gitea_client import GiteaClient
|
|
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.auth.session import SessionManager
|
|
from app.services.collection_adapter import GiteaBoardCompat
|
|
from app.services.gitea_client import GiteaClient
|
|
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.auth.session import SessionManager
|
|
from app.services.gitea_client import GiteaClient
|
|
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",
|
|
)
|
|
|
|
|
|
# ══════════════════════════ Web tools ══════════════════════════
|
|
# Outils réseau : recherche web, lecture d'une page, recherche GitHub.
|
|
# Ils réutilisent les garde-fous SSRF déjà éprouvés par l'importeur URL
|
|
# (``importers.url_fetch`` / ``og_fetcher``) — aucune adresse privée,
|
|
# re-vérifiée à chaque saut de redirection.
|
|
|
|
|
|
class WebSearch(Tool):
|
|
name = "web_search"
|
|
description = (
|
|
"Recherche sur le web (actualités, docs, libs, comparatifs) et "
|
|
"renvoie titre + URL + extrait pour chaque résultat. Utilise-la AVANT de "
|
|
"répondre sur un fait récent ou technique, puis CITE les URL obtenues."
|
|
)
|
|
parameters = {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "description": "requête de recherche"},
|
|
"num_results": {"type": "integer",
|
|
"description": "nombre de résultats (1-10, défaut 5)"},
|
|
},
|
|
"required": ["query"],
|
|
}
|
|
|
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
|
from app.services.web_search import available_providers, search_web
|
|
|
|
query = str(args.get("query") or "").strip()
|
|
if not query:
|
|
return ToolResult(status="error", tool=self.name, message="query vide")
|
|
try:
|
|
num = max(1, min(int(args.get("num_results") or 5), 10))
|
|
except (TypeError, ValueError):
|
|
num = 5
|
|
try:
|
|
results, provider = await search_web(query, num)
|
|
except Exception as exc: # noqa: BLE001
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=f"Recherche web indisponible: {exc}")
|
|
if not results:
|
|
hint = ""
|
|
if not available_providers().get(provider, True):
|
|
hint = " — configurez EXA_API_KEY dans les réglages FlowDeck"
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=f"Aucun résultat pour « {query} » (provider: {provider}){hint}")
|
|
return ToolResult(
|
|
status="success", tool=self.name, target_type="web_search",
|
|
data={"query": query, "provider": provider, "results": results,
|
|
"count": len(results)},
|
|
message=f"{len(results)} résultat(s) via {provider}",
|
|
)
|
|
|
|
|
|
class FetchUrl(Tool):
|
|
name = "fetch_url"
|
|
description = (
|
|
"Télécharge une page web et la convertit en Markdown (titres, listes, "
|
|
"code, tableaux) pour pouvoir la lire et la citer. Utilise-la après "
|
|
"web_search pour approfondir un résultat. Les URLs internes / privées "
|
|
"sont refusées."
|
|
)
|
|
parameters = {
|
|
"type": "object",
|
|
"properties": {
|
|
"url": {"type": "string", "description": "URL http(s) de la page"},
|
|
"max_chars": {"type": "integer",
|
|
"description": "longueur max du markdown renvoyé (défaut 20000)"},
|
|
},
|
|
"required": ["url"],
|
|
}
|
|
|
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
|
from app.config import settings
|
|
from app.services.http_client import shared_client
|
|
from app.services.importers.html_notes import _html_to_markdown
|
|
from app.services.importers.url_fetch import _validate_url
|
|
from app.services.og_fetcher import parse_og
|
|
|
|
raw = str(args.get("url") or "").strip()
|
|
if not raw:
|
|
return ToolResult(status="error", tool=self.name, message="url vide")
|
|
# « example.com/page » → https://example.com/page, mais un schéma
|
|
# explicite (file:, ftp:, …) est conservé pour être REJETÉ par le
|
|
# garde-fou, au lieu d'être transformé en requête réseau hasardeuse.
|
|
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", raw):
|
|
raw = "https://" + raw
|
|
try:
|
|
url = _validate_url(raw)
|
|
except ValueError as exc:
|
|
return ToolResult(status="error", tool=self.name, message=str(exc))
|
|
|
|
try:
|
|
max_chars = max(500, min(int(args.get("max_chars")
|
|
or settings.web_fetch_max_chars), 60000))
|
|
except (TypeError, ValueError):
|
|
max_chars = int(settings.web_fetch_max_chars or 20000)
|
|
|
|
current = url
|
|
resp = None
|
|
try:
|
|
async with shared_client(timeout=15.0, follow_redirects=False,
|
|
headers={"User-Agent": "FlowDeck-Agent/1.0 (fetch_url)"}) as client:
|
|
for _ in range(6):
|
|
resp = await client.get(current)
|
|
if resp.status_code in (301, 302, 303, 307, 308):
|
|
loc = resp.headers.get("location")
|
|
if not loc:
|
|
raise ValueError("redirection sans cible")
|
|
from urllib.parse import urljoin
|
|
|
|
current = urljoin(current, loc)
|
|
# re-validation : une URL publique peut rediriger vers 169.254.x
|
|
current = _validate_url(current)
|
|
continue
|
|
resp.raise_for_status()
|
|
break
|
|
else:
|
|
raise ValueError("trop de redirections")
|
|
except ValueError as exc:
|
|
return ToolResult(status="error", tool=self.name, message=str(exc))
|
|
except Exception as exc: # noqa: BLE001
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=f"Page injoignable: {exc}")
|
|
if resp is None:
|
|
return ToolResult(status="error", tool=self.name, message="Aucune réponse")
|
|
|
|
ctype = (resp.headers.get("content-type") or "").lower()
|
|
body = resp.text
|
|
if "html" not in ctype and "xhtml" not in ctype:
|
|
# JSON / texte brut : on le renvoie tel quel, c'est souvent une API.
|
|
markdown = body
|
|
meta: dict = {}
|
|
else:
|
|
meta = parse_og(body, current)
|
|
markdown = _html_to_markdown(body)
|
|
|
|
truncated = len(markdown) > max_chars
|
|
if truncated:
|
|
markdown = markdown[:max_chars] + "\n\n[contenu tronqué]"
|
|
|
|
title = (meta.get("title") or "").strip() or current
|
|
return ToolResult(
|
|
status="success", tool=self.name, target_type="web_page", target_id=current,
|
|
data={"url": current, "title": title[:200], "markdown": markdown,
|
|
"truncated": truncated, "chars": len(markdown)},
|
|
message=f"Page lue : {title[:80]} ({len(markdown)} car.)",
|
|
)
|
|
|
|
|
|
class SearchCode(Tool):
|
|
name = "search_code"
|
|
description = (
|
|
"Recherche GitHub : dépôts, code source ou issues (uniquement GitHub "
|
|
"public, sans clé possible mais limitée à 10 requêtes/min). Sert à "
|
|
"trouver une implémentation de référence, une lib ou l'historique d'un bug."
|
|
)
|
|
parameters = {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "description": "termes GitHub (lang:python, repo:, etc.)"},
|
|
"kind": {"type": "string",
|
|
"description": "repositories | code | issues (défaut repositories)"},
|
|
"limit": {"type": "integer", "description": "nombre de résultats (1-10, défaut 5)"},
|
|
},
|
|
"required": ["query"],
|
|
}
|
|
|
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
|
from app.services.web_search import search_github
|
|
|
|
query = str(args.get("query") or "").strip()
|
|
if not query:
|
|
return ToolResult(status="error", tool=self.name, message="query vide")
|
|
kind = str(args.get("kind") or "repositories")
|
|
try:
|
|
limit = max(1, min(int(args.get("limit") or 5), 10))
|
|
except (TypeError, ValueError):
|
|
limit = 5
|
|
try:
|
|
results = await search_github(query, kind, limit)
|
|
except Exception as exc: # noqa: BLE001
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=f"Recherche GitHub impossible: {exc}")
|
|
return ToolResult(
|
|
status="success", tool=self.name, target_type="github",
|
|
target_id=f"{kind}:{query}",
|
|
data={"query": query, "kind": kind, "results": results, "count": len(results)},
|
|
message=f"{len(results)} résultat(s) GitHub ({kind})",
|
|
)
|
|
|
|
|
|
# ══════════════════════════ Registry ══════════════════════════
|
|
|
|
class ConnectorFetch(Tool):
|
|
name = "connector_fetch"
|
|
description = (
|
|
"Lit un connecteur configuré dans FlowDeck : API Gitea / GitHub (natives), "
|
|
"recherche web (natif « web »), ou un connecteur personnalisé (URL + clé). "
|
|
"`path` est relatif à l'URL du connecteur (ex. « /api/v1/repos ») ; "
|
|
"`query` sert au connecteur web. Les hôtes privés sont refusés."
|
|
)
|
|
parameters = {
|
|
"type": "object",
|
|
"properties": {
|
|
"connector": {"type": "string",
|
|
"description": "id, nom ou kind (gitea / github / web)"},
|
|
"path": {"type": "string", "description": "chemin relatif (API natives)"},
|
|
"query": {"type": "string", "description": "terme de recherche (web)"},
|
|
},
|
|
"required": ["connector"],
|
|
}
|
|
|
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
|
from app.services import connectors
|
|
|
|
try:
|
|
res = await connectors.connector_fetch(
|
|
str(args.get("connector") or ""),
|
|
str(args.get("path") or ""),
|
|
str(args.get("query") or ""),
|
|
user_id=user_id,
|
|
)
|
|
except ValueError as exc:
|
|
return ToolResult(status="error", tool=self.name, message=str(exc))
|
|
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
|
|
logger.warning("connector_fetch failed: %s", exc)
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=f"Connecteur indisponible: {exc}")
|
|
if res.get("status") != "ok":
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=(res.get("text") or "erreur")[:500])
|
|
text = res.get("text") or ""
|
|
return ToolResult(status="success", tool=self.name, target_type="connector",
|
|
message=text[:800], data={"text": text})
|
|
|
|
|
|
class McpTool(Tool):
|
|
"""Outil MCP dynamique — instance construite à la volée depuis le cache
|
|
en base (`mcp_client.cached_tools()`), pas enregistrée dans TOOL_CLASSES."""
|
|
|
|
def __init__(self, entry: dict):
|
|
self.name = entry["name"]
|
|
self.description = entry.get("description") or "Outil MCP"
|
|
self.parameters = entry.get("parameters") or {"type": "object", "properties": {}}
|
|
self.entry = entry
|
|
|
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
|
from app.services import mcp_client
|
|
|
|
try:
|
|
res = await mcp_client.call_tool(self.entry["connector_id"],
|
|
self.entry.get("original") or "", args)
|
|
except ValueError as exc:
|
|
return ToolResult(status="error", tool=self.name, message=str(exc))
|
|
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
|
|
logger.warning("MCP tool failed (%s): %s", self.name, exc)
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=f"MCP indisponible: {exc}")
|
|
if res.get("status") != "ok":
|
|
return ToolResult(status="error", tool=self.name,
|
|
message=(res.get("text") or "erreur MCP")[:500])
|
|
text = res.get("text") or ""
|
|
return ToolResult(status="success", tool=self.name, target_type="mcp",
|
|
message=text[:800], data={"text": text})
|
|
|
|
|
|
TOOL_CLASSES = [
|
|
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
|
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
|
UpdatePage, WriteBlocks,
|
|
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
|
|
DeletePage, DeleteCollection,
|
|
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
|
DeleteDocument,
|
|
WebSearch, FetchUrl, SearchCode, ConnectorFetch,
|
|
]
|
|
|
|
|
|
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 _all(self) -> dict[str, Tool]:
|
|
"""Outils statiques + **outils MCP dynamiques** (cache en base).
|
|
|
|
Les MCP ne sont jamais mis en cache en mémoire : un run re-lit la base,
|
|
donc « Tester le connecteur » suffit à rendre un outil disponible.
|
|
"""
|
|
tools = dict(self.tools)
|
|
try:
|
|
from app.services import mcp_client
|
|
from app.services import plugins as plugins_service
|
|
for entry in mcp_client.cached_tools():
|
|
tools[entry["name"]] = McpTool(entry)
|
|
# plugin « web-tools » OFF → outils web retirés du schéma ET de execute()
|
|
for slug, names in plugins_service.PLUGIN_TOOLS.items():
|
|
if not plugins_service.is_enabled(slug):
|
|
for name in names:
|
|
tools.pop(name, None)
|
|
except Exception: # noqa: BLE001 — la base ne doit jamais casser un run
|
|
logger.exception("dynamic tools load failed")
|
|
return tools
|
|
|
|
def list(self, scope: dict | None = None) -> list[str]:
|
|
"""Tool names allowed by an agent scope (default: all)."""
|
|
tools = self._all()
|
|
allowed = (scope or {}).get("tools")
|
|
if allowed is None:
|
|
return list(tools.keys())
|
|
return [n for n in tools if n in allowed]
|
|
|
|
def schema(self, scope: dict | None = None) -> list[dict]:
|
|
"""Function-calling schema for the LLM, filtered by scope."""
|
|
tools = self._all()
|
|
return [
|
|
{"name": tools[n].name,
|
|
"description": tools[n].description,
|
|
"parameters": 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._all().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)
|