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
+210 -2
View File
@@ -98,11 +98,28 @@ def test_llm_client_offline_no_credentials(client):
assert resp.text != "" or resp.tool_calls
def test_llm_client_offline_mock_creates_document(client):
"""A document creation request must resolve to the create_document tool."""
from app.services.llm_client import LLMClient
llm = LLMClient(provider="offline")
resp = asyncio.run(llm.complete(
[{"role": "user", "content": 'Faire la création d\'un document "Projet" '
'dans le workspace "TEST WS 1"'}],
tools=[],
))
assert resp.tool_calls
assert resp.tool_calls[0]["name"] == "create_document"
args = resp.tool_calls[0]["arguments"]
assert args["title"] == "Projet"
assert args["workspace_name"] == "TEST WS 1"
# ── DB schema ──
def test_agent_tables_exist(client):
tables = {"agents", "agent_conversations", "agent_messages",
"agent_actions", "agent_skills", "agent_triggers"}
"agent_actions", "agent_skills", "agent_triggers",
"agent_feedback"}
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
@@ -145,6 +162,86 @@ def test_tool_unknown_returns_error(client):
assert res.status == "error"
def test_tool_workspace_document_crud_and_search(client):
from app.db import get_conn
from app.services.tool_registry import ToolRegistry
with get_conn() as conn:
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('TEST WS 1', ?)",
(_admin_id(),))
conn.commit()
reg = ToolRegistry()
ws = asyncio.run(reg.execute("read_workspaces", {}))
assert ws.status == "success"
assert any(w["name"] == "TEST WS 1" for w in ws.data["workspaces"])
res = asyncio.run(reg.execute("create_document",
{"title": "Projet", "workspace_name": "TEST WS 1"}))
assert res.status == "success", res.message
pid = res.data["document_id"]
assert res.target_type == "document"
assert res.undo == {"action": "delete", "table": "pages", "id": pid}
rd = asyncio.run(reg.execute("read_document", {"page_id": pid}))
assert rd.status == "success"
assert rd.data["document"]["title"] == "Projet"
ws2 = asyncio.run(reg.execute("read_workspaces", {}))
row = next(w for w in ws2.data["workspaces"] if w["name"] == "TEST WS 1")
assert row["document_count"] == 1
search = asyncio.run(reg.execute("search_workspace", {"query": "Projet"}))
assert any(d["id"] == pid for d in search.data["documents"])
# write_blocks can also rename the document
upd = asyncio.run(reg.execute("write_blocks", {"page_id": pid, "title": "Projet v2"}))
assert upd.status == "success"
rd2 = asyncio.run(reg.execute("read_document", {"page_id": pid}))
assert rd2.data["document"]["title"] == "Projet v2"
# unknown workspace name → clear error, no document created
bad = asyncio.run(reg.execute("create_document",
{"title": "X", "workspace_name": "NOPE"}))
assert bad.status == "error"
assert "introuvable" in bad.message
def test_tool_delete_document_and_undo(client):
from app.db import get_conn
from app.services.tool_registry import ToolRegistry
from app.services.agent_engine import undo_action
reg = ToolRegistry()
res = asyncio.run(reg.execute("create_document", {"title": "À supprimer"}))
pid = res.data["document_id"]
dele = asyncio.run(reg.execute("delete_document", {"page_id": pid}))
assert dele.status == "success"
assert dele.undo == {"action": "softdelete", "table": "pages", "id": pid}
with get_conn() as conn:
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (pid,)).fetchone()
assert row is not None and row["deleted_at"] is not None
# store an audit row so undo_action can restore the document
conv_id = client.post("/api/agent/conversations", json={"title": "t"}).json()["id"]
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO agent_actions (conversation_id, tool_name, target_type,
target_id, payload_json, result_json, status, undo_snapshot_json, executed_by)
VALUES (?, 'delete_document', 'document', ?, '{}', '{}', 'success', ?, ?)""",
(conv_id, str(pid), json.dumps(dele.undo), _admin_id()),
)
conn.commit()
aid = cur.lastrowid
assert undo_action(aid) is True
with get_conn() as conn:
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (pid,)).fetchone()
assert row["deleted_at"] is None
# ── Permissions ──
def test_permission_manager_roles(client):
@@ -226,6 +323,37 @@ def test_engine_audit_and_undo(client):
assert a["status"] == "reverted"
def test_engine_run_creates_document_in_workspace_and_titles_conversation(client):
from app.services.agent_engine import AgentEngine
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('TEST WS 1', ?)",
(_admin_id(),))
conn.commit()
engine = AgentEngine(_admin_id(), workspace_id=None)
conv = client.post("/api/agent/conversations",
json={"title": "Nouvelle conversation"}).json()["id"]
objective = 'Faire la création d\'un document « Projet » dans le workspace « TEST WS 1 »'
events = asyncio.run(_run_engine(engine, conv, objective))
actions = [e for e in events if e["type"] == "action"]
assert any(a["tool"] == "create_document" and a["status"] == "success" for a in actions), actions
with get_conn() as conn:
doc = conn.execute("SELECT * FROM pages WHERE title='Projet'").fetchone()
ws = conn.execute("SELECT id FROM workspaces WHERE name='TEST WS 1'").fetchone()
assert doc is not None
assert doc["workspace_id"] == ws["id"]
# the conversation is renamed with a descriptive title
detail = client.get(f"/api/agent/conversations/{conv}").json()
title = detail["conversation"]["title"]
assert title.lower() != "nouvelle conversation"
assert "Projet" in title
def _make_conversation(client) -> int:
resp = client.post("/api/agent/conversations", json={"title": "test"})
return resp.json()["id"]
@@ -306,12 +434,92 @@ def test_router_skills(client):
assert apply.json()["conversation_id"]
def test_router_mentions(client):
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT INTO pages (workspace, title, content, content_format) "
"VALUES ('', 'Compte rendu projet', '## Résumé\nDécision prise.', 'markdown')")
doc_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("INSERT INTO collections (name, icon) VALUES ('Roadmap', '🗺️')")
coll_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO collection_pages (collection_id, title, property_values_json) "
"VALUES (?, 'Page Analyse', '{}')", (coll_id,))
page_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.commit()
r = client.get("/api/agent/mentions")
assert r.status_code == 200
items = r.json()["mentions"]
tokens = {it["token"] for it in items}
assert f"document:{doc_id}" in tokens
assert f"collection:{coll_id}" in tokens
assert f"page:{page_id}" in tokens
# search filter
r2 = client.get("/api/agent/mentions", params={"q": "Compte rendu"})
found = r2.json()["mentions"]
assert found and all(it["type"] == "document" and it["label"] == "Compte rendu projet" for it in found)
def test_router_feedback(client):
cid = _make_conversation(client)
ok = client.post("/api/agent/feedback", json={
"rating": "up", "conversation_id": cid, "content": "Merci, très utile",
})
assert ok.status_code == 200
assert ok.json()["status"] == "recorded"
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute("SELECT rating, snippet FROM agent_feedback").fetchall()
assert len(rows) == 1
assert rows[0]["rating"] == "up"
assert rows[0]["snippet"] == "Merci, très utile"
bad = client.post("/api/agent/feedback", json={"rating": "meh"})
assert bad.status_code == 400
def test_context_builder_document_mention(client):
from app.db import get_conn
from app.services.context_builder import ContextBuilder
import json as _json
with get_conn() as conn:
conn.execute(
"INSERT INTO pages (workspace, title, content, content_format) "
"VALUES ('ws', 'Note brainstorming', 'Contenu ultra secret du doc.', 'markdown')")
mid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO pages (workspace, title, content, content_format) "
"VALUES ('ws', 'Doc blocs', ?, 'blocks')",
(_json.dumps([
{"id": "b1", "type": "heading", "content": "Titre du bloc"},
{"id": "b2", "type": "paragraph", "content": "Paragraphe du bloc.",
"children": [{"id": "b3", "type": "paragraph", "content": "Enfant aussi."}]},
]),))
bid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.commit()
ctx = ContextBuilder(_admin_id()).build(mentions=[f"document:{mid}"])
assert "Note brainstorming" in ctx
assert "Contenu ultra secret du doc." in ctx
ctx2 = ContextBuilder(_admin_id()).build(mentions=[f"document:{bid}"])
assert "Titre du bloc" in ctx2
assert "Paragraphe du bloc." in ctx2
assert "Enfant aussi." in ctx2
def test_router_tools_and_providers(client):
tools = client.get("/api/agent/tools").json()["tools"]
assert len(tools) >= 10
names = {t["name"] for t in tools}
for expected in ("search_workspace", "create_collection", "create_page",
"update_page", "add_property", "create_view", "read_page"):
"update_page", "add_property", "create_view", "read_page",
"read_workspaces", "read_document", "create_document"):
assert expected in names
prov = client.get("/api/agent/providers").json()