feat(agent): v4.15.0 - panneau Agent style Notion AI (mentions @ inline + skills / + actions sous chaque réponse)
FlowDeck CI / test (push) Failing after 15s
FlowDeck CI / docker (push) Skipped

- 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:
2026-09-06 12:17:40 -04:00
parent 3025a7a44e
commit 27c9eb98db
12 changed files with 1633 additions and 86 deletions
+130 -5
View File
@@ -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()