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:
@@ -1,17 +1,20 @@
|
||||
"""FlowDeck — AgentEngine: ReAct orchestrator (v4.10.0).
|
||||
"""FlowDeck — AgentEngine: ReAct orchestrator (v4.14.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.
|
||||
the UI can render reasoning + actions live. Since v4.14.0 a freshly created
|
||||
conversation is automatically renamed with a descriptive title derived from its
|
||||
content so the history stays easy to browse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
@@ -29,6 +32,7 @@ MAX_ITERATIONS = 12
|
||||
# when no document is attached to the conversation (generic help / onboarding).
|
||||
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? »)
|
||||
- **Pages** : le contenu est organisé en blocs (paragraphes, titres, listes, to-do, tableaux, images, formules, bases embarquées). La barre latérale liste les pages récentes, favoris, agents, partagées et publiées.
|
||||
- **Documents & espaces de travail** : un « document » est une page éditeur (type Notion) qui vit dans un espace de travail. Pour créer un document dans un espace : appelle `read_workspaces` (reprends le `workspace_name` ou l'id exact), puis `create_document`. Pour modifier un document existant : `read_document` puis `write_blocks` (blocs et/ou titre). Pour supprimer : `delete_document` (corbeille). `search_workspace` retrouve aussi les documents et les espaces par titre.
|
||||
- **Collections (bases de données)** : des ensembles de pages structurées avec des propriétés (texte, nombre, sélection, dates…). Chaque collection peut avoir plusieurs vues : tableau, board (kanban), calendrier, galerie, liste, timeline, graphique, formulaire, carte, flux, gantt. Ajouter une propriété ou une vue = outils add_property / create_view.
|
||||
- **Créer du contenu** : « crée une collection X », « crée une page », « ajoute une propriété Statut à la collection Y » sont des actions que l'agent peut exécuter directement avec ses outils.
|
||||
- **Espaces de travail** : FlowDeck gère des espaces locaux et des dépôts Gitea/GitHub (pages privées dans un dépôt, issues reliées via read_gitea_issues). On change d'espace depuis le menu en bas à gauche (« Switch workspace »).
|
||||
@@ -38,6 +42,38 @@ APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questio
|
||||
- **Agent IA** : ouvrable via le bouton 🤖 en bas à droite ou la section « Agents » du sidebar. On peut lui parler de la page ouverte, ou lui poser des questions générales sur l'utilisation de l'application.
|
||||
Quand la question est générale (« comment créer un kanban ? », « où sont mes favoris ? »), réponds de façon concise et guidée à partir de ces informations, sans inventer de fonctionnalités absentes."""
|
||||
|
||||
# Deterministic auto-title heuristics (used when no real LLM is configured, and
|
||||
# as a fallback when the generated title is unusable). Ordered by priority: the
|
||||
# first matching intent wins.
|
||||
_TITLE_INTENTS = (
|
||||
("Création", ("créer", "crée", "crées", "création", "nouveau", "nouvelle",
|
||||
"create", "creation")),
|
||||
("Ajout", ("ajouter", "ajoute", "ajout d", "ajoutons", "add")),
|
||||
("Renommage", ("renommer", "renomme", "renommage", "rename")),
|
||||
("Suppression", ("supprimer", "supprime", "suppression", "delete")),
|
||||
("Déplacement", ("déplacer", "déplace", "déplacement", "move")),
|
||||
("Mise à jour", ("modifier", "modifie", "modification", "mets à jour",
|
||||
"met à jour", "mettre à jour", "update", "éditer")),
|
||||
("Analyse", ("analyser", "analyse")),
|
||||
("Résumé", ("résumer", "résume", "résumé", "resume")),
|
||||
("Traduction", ("traduire", "traduis", "traduit", "traduction", "translate")),
|
||||
("Planification", ("planifier", "planifie", "préparer", "prépare", "organiser",
|
||||
"organise", "sprint", "agenda")),
|
||||
("Recherche", ("chercher", "cherche", "rechercher", "recherche", "trouver",
|
||||
"trouve", "liste", "lister", "search", "find")),
|
||||
)
|
||||
|
||||
_TITLE_TYPES = (
|
||||
("collection", "collection", ("collection", "base de données", "database", "db")),
|
||||
("propriété", "propriété", ("propriété", "property")),
|
||||
("vue", "vue", (" vue", "view")),
|
||||
("board", "board", ("board", "kanban")),
|
||||
("sprint", "sprint", ("sprint",)),
|
||||
("document", "document", ("document", "note de réunion", "compte-rendu", "compte rendu")),
|
||||
("tâche", "tâche", ("tâche", "task", "tache")),
|
||||
("issue", "issue", ("issue",)),
|
||||
)
|
||||
|
||||
|
||||
class AgentEngine:
|
||||
def __init__(self, user_id: int, workspace_id: int | None = None,
|
||||
@@ -168,6 +204,7 @@ class AgentEngine:
|
||||
|
||||
self._persist_message(conversation_id, "assistant", final_text,
|
||||
model=model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AgentEngine run failed")
|
||||
@@ -245,6 +282,74 @@ class AgentEngine:
|
||||
detail = getattr(exc, "detail", None)
|
||||
return detail if isinstance(detail, str) else str(exc)
|
||||
|
||||
# ── Auto-title (v4.14.0) ──
|
||||
|
||||
_DEFAULT_CONV_TITLES = {"", "new conversation", "nouvelle conversation", "new chat", "conversation"}
|
||||
|
||||
async def _autotitle(self, conversation_id: int, objective: str, final_text: str | None):
|
||||
"""Rename a freshly created conversation with a descriptive title derived
|
||||
from its content. Only runs while the title is still a default placeholder,
|
||||
so a user-renamed conversation is never overwritten."""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT title FROM agent_conversations WHERE id=?", (conversation_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return
|
||||
if (row["title"] or "").strip().lower() not in self._DEFAULT_CONV_TITLES:
|
||||
return
|
||||
suggested = self._suggest_title(objective, final_text)
|
||||
if suggested:
|
||||
self._update_conversation(conversation_id, title=suggested[:80])
|
||||
except Exception: # noqa: BLE001 — never break a run because of the title
|
||||
logger.exception("Auto-title failed for conversation #%s", conversation_id)
|
||||
|
||||
@classmethod
|
||||
def _suggest_title(cls, objective: str | None, final_text: str | None) -> str:
|
||||
"""Produce a short descriptive title from the user objective (offline-safe)."""
|
||||
text = (objective or "").split("\n# Contexte", 1)[0].strip() or (final_text or "").strip()
|
||||
if not text:
|
||||
return "Conversation"
|
||||
low = text.lower()
|
||||
|
||||
intent = None
|
||||
for label, words in _TITLE_INTENTS:
|
||||
if any(w in low for w in words):
|
||||
intent = label
|
||||
break
|
||||
|
||||
type_label = next(
|
||||
(t for t, _noun, words in _TITLE_TYPES if any(w in low for w in words)), None
|
||||
)
|
||||
has_workspace = any(w in low for w in ("workspace", "espace de travail"))
|
||||
quotes = [q.strip() for q in re.findall(r'[«"]([^«»"]{1,80})[»"]', text) if q.strip()]
|
||||
subject = quotes[0] if quotes else None
|
||||
ws = quotes[-1] if (has_workspace and len(quotes) > 1) else None
|
||||
|
||||
def _clean(s: str) -> str:
|
||||
return re.sub(r"\s+", " ", s).strip(" .;:-")
|
||||
|
||||
if not subject and type_label:
|
||||
noun = next((n for t, n, _w in _TITLE_TYPES if t == type_label), type_label)
|
||||
m = re.search(
|
||||
rf"\b{noun}\b\s*(?:nomm[ée]e?\s+|appel[ée]e?\s+|intitul[ée]e?\s+)?"
|
||||
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60}?)\s*[»"]?',
|
||||
text, re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
subject = m.group(1).strip()
|
||||
|
||||
if intent and subject:
|
||||
core = f"{intent} {type_label or 'élément'} « {subject} »" \
|
||||
if type_label else f"{intent} « {subject} »"
|
||||
if ws:
|
||||
core += f" (dans {ws})"
|
||||
return _clean(core)
|
||||
|
||||
generic = _clean(text)
|
||||
return generic[:70] if generic else "Conversation"
|
||||
|
||||
|
||||
def undo_action(action_id: int) -> bool:
|
||||
"""Reverse a single agent action using its stored undo snapshot.
|
||||
@@ -262,6 +367,8 @@ def undo_action(action_id: int) -> bool:
|
||||
|
||||
if op == "delete":
|
||||
conn.execute(f"DELETE FROM {table} WHERE id=?", (rid,))
|
||||
elif op == "softdelete":
|
||||
conn.execute(f"UPDATE {table} SET deleted_at=NULL WHERE id=?", (rid,))
|
||||
elif op == "insert":
|
||||
snapshot = undo.get("snapshot")
|
||||
if not snapshot:
|
||||
@@ -277,8 +384,16 @@ def undo_action(action_id: int) -> bool:
|
||||
(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))
|
||||
if isinstance(snapshot, dict):
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(snapshot.get("content", ""),
|
||||
snapshot.get("content_format", "markdown"),
|
||||
snapshot.get("title", ""), rid),
|
||||
)
|
||||
else:
|
||||
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:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""FlowDeck — Agent context builder (v4.10.0).
|
||||
"""FlowDeck — Agent context builder (v4.14.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.
|
||||
LLM can reason about real entities (workspaces, documents, collections, pages,
|
||||
Gitea issues) without touching the database directly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,6 +27,8 @@ class ContextBuilder:
|
||||
if include_collections:
|
||||
sections.append(self._collections_context())
|
||||
sections.append(self._pages_context())
|
||||
sections.append(self._documents_context())
|
||||
sections.append(self._workspaces_context())
|
||||
if mentions:
|
||||
sections.append(self._mentions_context(mentions))
|
||||
if files:
|
||||
@@ -68,11 +70,66 @@ class ContextBuilder:
|
||||
lines.append(f"- page #{r['id']} in collection #{r['collection_id']}: **{r['title']}**{(' — ' + summary) if summary else ''}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _documents_context(self, limit: int = 30) -> str:
|
||||
"""Recent editor documents (`pages`), usable with create/read/update tools."""
|
||||
with get_conn() as conn:
|
||||
ws_names = dict(
|
||||
conn.execute("SELECT id, name FROM workspaces").fetchall()
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace_id, content_format, parent_id "
|
||||
"FROM pages WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Documents\n(aucun document)"
|
||||
lines = ["## Documents (pages éditeur — outils: read_document, write_blocks, create_document)"]
|
||||
for r in rows:
|
||||
ws_name = ws_names.get(r["workspace_id"], str(r["workspace_id"]) if r["workspace_id"] else "racine")
|
||||
lines.append(f"- document #{r['id']} **{r['title'] or 'Sans titre'}** (espace: {ws_name})")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _workspaces_context(self) -> str:
|
||||
"""Workspaces accessible to the current user (with counts)."""
|
||||
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 "
|
||||
"FROM workspaces w {join} {where} ORDER BY w.name"
|
||||
)
|
||||
with get_conn() as conn:
|
||||
if self.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",
|
||||
),
|
||||
(self.user_id, self.user_id, self.user_id),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Espaces de travail\n(aucun espace)"
|
||||
lines = ["## Espaces de travail (outil: read_workspaces)"]
|
||||
for r in rows:
|
||||
lines.append(f"- espace #{r['id']} **{r['name']}** ({r['role']}, {r['document_count']} document(s))")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _mentions_context(self, mentions: list[str]) -> str:
|
||||
"""Resolve @collection:x / @page:y / @repo:o/r mentions to entity snapshots."""
|
||||
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
|
||||
|
||||
Mentions bring the *actual content* of the referenced object into the
|
||||
context so the LLM can summarise / rewrite / analyse it directly without
|
||||
needing a read tool round-trip (and so the offline mock stays useful).
|
||||
"""
|
||||
lines = ["## Mentioned context"]
|
||||
for m in mentions:
|
||||
if m.startswith("collection:"):
|
||||
if m.startswith("document:"):
|
||||
pid = m.split(":", 1)[1]
|
||||
lines.append(self._single_document(pid))
|
||||
elif m.startswith("collection:"):
|
||||
cid = m.split(":", 1)[1]
|
||||
lines.append(self._single_collection(cid))
|
||||
elif m.startswith("page:"):
|
||||
@@ -84,6 +141,74 @@ class ContextBuilder:
|
||||
lines.append("- @ws: full workspace context included above")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _blocks_to_text(content: str, limit: int = 9000) -> str:
|
||||
"""Flatten a ``blocks`` document JSON into plain readable text.
|
||||
|
||||
Collects the textual payload of each block (content, title, caption,
|
||||
children, meeting notes/summary) — enough for the LLM to reason about a
|
||||
mentioned editor page without the full block schema.
|
||||
"""
|
||||
try:
|
||||
blocks = json.loads(content or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return ""
|
||||
if not isinstance(blocks, list):
|
||||
return ""
|
||||
|
||||
_TEXT_KEYS = ("content", "title", "caption", "plain_text", "notes", "summary")
|
||||
out: list[str] = []
|
||||
total = 0
|
||||
|
||||
def walk(node):
|
||||
nonlocal total
|
||||
if total >= limit:
|
||||
return
|
||||
if isinstance(node, dict):
|
||||
for k in _TEXT_KEYS:
|
||||
v = node.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
line = v.replace("\r\n", "\n").strip()
|
||||
out.append(line)
|
||||
total += len(line) + 1
|
||||
if total >= limit:
|
||||
return
|
||||
for v in node.values():
|
||||
walk(v)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
walk(item)
|
||||
|
||||
walk(blocks)
|
||||
return "\n".join(out)[:limit]
|
||||
|
||||
def _single_document(self, pid: str) -> str:
|
||||
"""Full editor-document mention: title + workspace + real content."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT p.*, w.name AS ws_name FROM pages p "
|
||||
"LEFT JOIN workspaces w ON w.id=p.workspace_id "
|
||||
"WHERE p.id=? AND p.deleted_at IS NULL",
|
||||
(pid,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return f"- document #{pid}: not found"
|
||||
title = row["title"] or "Sans titre"
|
||||
ws = row["ws_name"] or ""
|
||||
loc = f" (espace: {ws})" if ws else ""
|
||||
fmt = row["content_format"] or "blocks"
|
||||
raw = row["content"] or ""
|
||||
if fmt == "markdown":
|
||||
body = raw.strip()
|
||||
elif fmt == "file":
|
||||
body = ""
|
||||
else:
|
||||
body = self._blocks_to_text(raw)
|
||||
head = f"- document #{row['id']} **{title}**{loc}"
|
||||
if body:
|
||||
return f"{head}:\n{body[:9000]}"
|
||||
return head
|
||||
|
||||
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()
|
||||
|
||||
@@ -227,6 +227,23 @@ class LLMClient:
|
||||
if draft:
|
||||
return LLMResponse(text=draft, model=model)
|
||||
|
||||
# Documents / espaces de travail (offline): unambiguous intents resolved
|
||||
# from the objective — create a document (optionally in a named
|
||||
# workspace) or list the accessible workspaces.
|
||||
doc_args = self._document_create_args(objective)
|
||||
if doc_args is not None:
|
||||
return LLMResponse(
|
||||
tool_calls=[{"name": "create_document", "arguments": doc_args}],
|
||||
text="Plan: création d'un document.",
|
||||
model=model,
|
||||
)
|
||||
if self._is_workspaces_request(objective):
|
||||
return LLMResponse(
|
||||
tool_calls=[{"name": "read_workspaces", "arguments": {}}],
|
||||
text="Plan: lister les espaces de travail.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Exact keyword → tool intent resolution (objective only).
|
||||
for regex, builder in _CREATE_PATTERNS:
|
||||
m = regex.search(objective)
|
||||
@@ -276,6 +293,46 @@ class LLMClient:
|
||||
args["collection_id"] = ids[0]
|
||||
return args
|
||||
|
||||
@staticmethod
|
||||
def _document_create_args(content: str) -> dict | None:
|
||||
"""Deterministic `create_document` intent for the offline mock.
|
||||
|
||||
Only fires when the user clearly asks to *create* a document (a content
|
||||
rewrite such as « résume / traduis ce document » is left to `_draft_reply`).
|
||||
Returns None when the message is not a create-document intent.
|
||||
"""
|
||||
low = content.lower()
|
||||
if "document" not in low:
|
||||
return None
|
||||
if not any(k in low for k in ("création", "créer", "crée", "crées", "create",
|
||||
"nouveau document", "nouvelle page", "faire un")):
|
||||
return None
|
||||
quotes = re.findall(r'[«"]([^«»"]{1,80})[»"]', content)
|
||||
title = quotes[0].strip() if quotes else None
|
||||
if not title:
|
||||
m = re.search(
|
||||
r"\bdocument\b\s*(?:nomm[ée]e?\s+|intitul[ée]e?\s+|appel[ée]e?\s+)?"
|
||||
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60})',
|
||||
content, re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
if not title:
|
||||
return None
|
||||
args = {"title": title}
|
||||
if len(quotes) > 1 and re.search(r"\b(workspace|espace de travail)\b", low):
|
||||
args["workspace_name"] = quotes[-1].strip()
|
||||
return args
|
||||
|
||||
@staticmethod
|
||||
def _is_workspaces_request(content: str) -> bool:
|
||||
"""True when the user asks to list / locate the workspaces."""
|
||||
low = content.lower()
|
||||
has_ws = any(w in low for w in ("workspace", "espace de travail", "espaces de travail"))
|
||||
has_verb = any(v in low for v in ("liste", "lister", "list", "quels", "montre",
|
||||
"affiche", "mes espaces", "ou sont", "où sont"))
|
||||
return has_ws and has_verb
|
||||
|
||||
@staticmethod
|
||||
def _system_content(messages) -> str:
|
||||
return "\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
|
||||
|
||||
@@ -22,13 +22,15 @@ 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",
|
||||
"write_blocks", "create_document", "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",
|
||||
"delete_page", "delete_collection", "delete_document",
|
||||
"delete_property", "delete_view",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+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