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,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()
|
||||
|
||||
Reference in New Issue
Block a user