feat(agent): v4.15.0 - panneau Agent style Notion AI (mentions @ inline + skills / + actions sous chaque réponse)
- Composeur contenteditable : mentions inline (pastilles icône+nom), barre de contexte groupée documents/skills, sélecteur @ (icône+titre+chemin), skills intégrés + agent_skills + commandes admin via '/' - Actions sous chaque réponse agent : copier, insérer dans la page, feedback 👍/👎 - Backend : table agent_feedback, GET /api/agent/mentions, POST /api/agent/feedback, contexte document:<id>/page:<id>/collection:<id> résolu en contenu réel - Inclut le travail v4.14.0 (documents/espace de travail + outils + auto-titre de conversation)
This commit is contained in:
+216
-21
@@ -1,9 +1,15 @@
|
||||
"""FlowDeck — Agent tool registry (v4.10.0).
|
||||
"""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). Every mutating tool
|
||||
returns an *undo snapshot* so AgentEngine can journal + roll back each action.
|
||||
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
|
||||
|
||||
@@ -43,7 +49,8 @@ class Tool:
|
||||
|
||||
class SearchWorkspace(Tool):
|
||||
name = "search_workspace"
|
||||
description = "Recherche full-text (par titre) des collections et pages FlowDeck."
|
||||
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é"}},
|
||||
@@ -55,13 +62,24 @@ class SearchWorkspace(Tool):
|
||||
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) et {len(pages)} page(s) trouvée(s)",
|
||||
message=(f"{len(colls)} collection(s), {len(pages)} page(s), "
|
||||
f"{len(docs)} document(s), {len(workspaces)} espace(s) trouvé(s)"),
|
||||
)
|
||||
|
||||
|
||||
@@ -116,6 +134,66 @@ class ReadPage(Tool):
|
||||
)
|
||||
|
||||
|
||||
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) ══════════════════════════
|
||||
|
||||
|
||||
@@ -282,6 +360,83 @@ class CreatePage(Tool):
|
||||
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."
|
||||
@@ -323,32 +478,46 @@ class UpdatePage(Tool):
|
||||
|
||||
class WriteBlocks(Tool):
|
||||
name = "write_blocks"
|
||||
description = "Écrit du contenu (blocs éditeur) sur une page éditeur (`pages`)."
|
||||
description = ("Met à jour un document (page éditeur `pages`) : remplace son contenu en blocs "
|
||||
"et/ou renomme son titre.")
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_id": {"type": "integer"},
|
||||
"blocks": {"type": "array"},
|
||||
"page_id": {"type": "integer", "description": "id du document (page éditeur)"},
|
||||
"blocks": {"type": "array", "description": "nouveau contenu en blocs (remplace le contenu)"},
|
||||
"title": {"type": "string", "description": "nouveau titre (optionnel)"},
|
||||
},
|
||||
"required": ["page_id", "blocks"],
|
||||
"required": ["page_id"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
pid = args.get("page_id")
|
||||
blocks = args.get("blocks", [])
|
||||
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 content FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
page = conn.execute("SELECT * 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),
|
||||
)
|
||||
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:
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(args["blocks"], 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="page", target_id=pid,
|
||||
data={"page_id": pid, "blocks": len(blocks)},
|
||||
undo={"action": "update", "table": "pages", "id": pid, "snapshot": page["content"]},
|
||||
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},
|
||||
)
|
||||
|
||||
|
||||
@@ -486,6 +655,30 @@ class ApplyTemplate(Tool):
|
||||
# ══════════════════════════ 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.utcnow().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)."
|
||||
@@ -612,11 +805,13 @@ class CreateGiteaIssue(Tool):
|
||||
# ══════════════════════════ Registry ══════════════════════════
|
||||
|
||||
TOOL_CLASSES = [
|
||||
SearchWorkspace, ReadCollection, ReadPage,
|
||||
CreateCollection, CreateView, AddProperty, CreatePage, UpdatePage, WriteBlocks,
|
||||
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
||||
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
||||
UpdatePage, WriteBlocks,
|
||||
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
|
||||
DeletePage, DeleteCollection,
|
||||
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
||||
DeleteDocument,
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user