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
+16
View File
@@ -723,6 +723,22 @@ def init_db():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# ─── v4.15.0: feedback des réponses de l'agent (👍 / 👎) ───────────────
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER REFERENCES agent_conversations(id) ON DELETE SET NULL,
message_id INTEGER REFERENCES agent_messages(id) ON DELETE SET NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
rating TEXT NOT NULL CHECK (rating IN ('up', 'down')),
snippet TEXT DEFAULT '',
comment TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_conv ON agent_feedback(conversation_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_user ON agent_feedback(user_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)")
+1 -1
View File
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="4.13.2",
version="4.15.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+92
View File
@@ -442,6 +442,98 @@ async def apply_skill(request: Request, skill_id: int):
return {"conversation_id": cur.lastrowid, "skill": skill["name"], "status": "ready"}
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
@router.get("/mentions")
async def list_mentions(request: Request, q: str = ""):
"""Éléments mentionnables dans le panneau agent (commande « @ » / bouton « + »).
Retourne un flux unique d'objets FlowDeck (documents éditeur, collections,
pages de collection) que l'utilisateur peut épingler au contexte du chat.
Chaque entrée porte un jeton ``token`` (``document:<id>``, ``collection:<id>``,
``page:<id>``) que ContextBuilder résout en contenu réel pour le LLM.
"""
query = (q or "").strip()[:60]
like = f"%{query}%"
limit = 8
items: list[dict] = []
with get_conn() as conn:
# 1) Documents (pages éditeur) — token document:<id>
sql_docs = (
"SELECT p.id, p.title, p.workspace_id, w.name AS ws_name "
"FROM pages p LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) "
"ORDER BY p.updated_at DESC LIMIT ?"
)
for r in conn.execute(sql_docs, (query, like, limit)).fetchall():
items.append({
"type": "document", "icon": "📄",
"id": r["id"], "label": r["title"] or "Sans titre",
"sub": r["ws_name"] or "", "token": f"document:{r['id']}",
})
# 2) Collections (bases de données) — token collection:<id>
sql_colls = (
"SELECT id, name, icon FROM collections "
"WHERE (? = '' OR name LIKE ?) ORDER BY updated_at DESC LIMIT ?"
)
for r in conn.execute(sql_colls, (query, like, limit)).fetchall():
items.append({
"type": "collection", "icon": r["icon"] or "🗄️",
"id": r["id"], "label": r["name"],
"sub": "Base de données", "token": f"collection:{r['id']}",
})
# 3) Pages de collection — token page:<id>
sql_pages = (
"SELECT cp.id, cp.title, cp.icon, c.name AS coll_name "
"FROM collection_pages cp LEFT JOIN collections c ON c.id=cp.collection_id "
"WHERE (? = '' OR cp.title LIKE ?) ORDER BY cp.updated_at DESC LIMIT ?"
)
for r in conn.execute(sql_pages, (query, like, limit)).fetchall():
items.append({
"type": "page", "icon": r["icon"] if str(r["icon"]).startswith(("📄", "📑", "🗂️", "📁")) else "📑",
"id": r["id"], "label": r["title"] or "Sans titre",
"sub": f"Page · {r['coll_name']}" if r["coll_name"] else "Page",
"token": f"page:{r['id']}",
})
return {"mentions": items}
@router.post("/feedback")
async def add_feedback(request: Request):
"""Enregistre le retour (👍 / 👎) porté sur une réponse de l'agent."""
user_id = await _current_user_id(request)
body = await request.json() if request.headers.get("content-type") else {}
rating = (body.get("rating") or "").strip().lower()
if rating not in ("up", "down"):
raise HTTPException(status_code=400, detail="rating doit être 'up' ou 'down'")
conversation_id = body.get("conversation_id")
message_id = body.get("message_id")
snippet = (body.get("content") or "")[:1000].strip()
comment = (body.get("comment") or "").strip()[:2000]
try:
conversation_id = int(conversation_id) if conversation_id not in (None, "") else None
except (TypeError, ValueError):
conversation_id = None
try:
message_id = int(message_id) if message_id not in (None, "") else None
except (TypeError, ValueError):
message_id = None
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO agent_feedback
(conversation_id, message_id, user_id, rating, snippet, comment)
VALUES (?,?,?,?,?,?)""",
(conversation_id, message_id, user_id, rating, snippet, comment),
)
conn.commit()
return {"id": cur.lastrowid, "rating": rating, "status": "recorded"}
# ── Triggers & tools ──
+119 -4
View File
@@ -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:
+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()
+57
View File
@@ -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")
+5 -3
View File
@@ -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
View File
@@ -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,
]
+719 -49
View File
@@ -69,8 +69,12 @@
.fd-ap-composer{padding:10px 14px 14px;border-top:1px solid var(--border,rgba(255,255,255,.09));position:relative}
.fd-ap-box{background:var(--bg-tertiary,#1f1f1f);border:1px solid var(--border,rgba(255,255,255,.13));border-radius:16px;padding:10px 12px 8px;transition:border-color .12s,box-shadow .12s}
.fd-ap-box:focus-within{border-color:rgba(35,131,226,.65);box-shadow:0 0 0 3px rgba(35,131,226,.12)}
.fd-ap-input{width:100%;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:14px;resize:none;font-family:inherit;line-height:1.5;max-height:220px;box-sizing:border-box}
.fd-ap-input::placeholder{color:var(--text-dim,#8a8a8a)}
.fd-ap-input{width:100%;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:14px;resize:none;font-family:inherit;line-height:1.6;max-height:220px;box-sizing:border-box;overflow-y:auto;white-space:pre-wrap;word-break:break-word;min-height:26px;position:relative}
.fd-ap-input.empty::before{content:attr(data-placeholder);color:var(--text-dim,#8a8a8a);position:absolute;left:0;top:0;pointer-events:none;white-space:pre-wrap;max-width:100%}
.ment-token{display:inline-flex;align-items:center;gap:4px;vertical-align:middle;background:rgba(35,131,226,.14);border:1px solid rgba(35,131,226,.35);color:var(--text,#eaf2ff);border-radius:6px;padding:0 3px 0 4px;font-size:13px;line-height:1.5;margin:0 1px;max-width:200px;overflow:hidden;white-space:nowrap}
.ment-token .tk-lbl{overflow:hidden;text-overflow:ellipsis}
.ment-token.skill{background:rgba(168,85,247,.16);border-color:rgba(168,85,247,.5);color:#f3e8ff}
.ment-token .tk-ico{flex-shrink:0;opacity:.9}
.fd-ap-toolbar{display:flex;align-items:center;gap:8px;margin-top:6px;flex-wrap:wrap}
.fd-ap-tip{font-size:11px;color:var(--text-dim,#8f8f8f);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-ap-tools{display:flex;align-items:center;gap:6px;margin-left:auto}
@@ -94,7 +98,48 @@
.fd-agent-cmd-item{padding:8px 12px;font-size:12px;color:var(--text,#eee);cursor:pointer;border-radius:7px;margin:3px;display:flex;gap:8px;align-items:baseline}
.fd-agent-cmd-item:hover{background:var(--bg-hover,#2b2b2b)}
.fd-agent-cmd-item b{color:var(--accent,#2383E2);font-family:monospace;white-space:nowrap}
.fd-agent-cmd-item b.k-cmd{color:var(--accent,#2383E2)}
.fd-agent-cmd-item b.k-skill{color:#c084fc}
.fd-agent-cmd-item span{color:var(--text-dim,#999);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fd-agent-cmd-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
/* Action bar under each assistant post */
.fd-agent-msg-actions{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px;align-items:center}
.fd-act-btn{display:inline-flex;align-items:center;gap:4px;background:transparent;border:1px solid var(--border,rgba(255,255,255,.09));color:var(--text-dim,#9aa);border-radius:7px;padding:3px 8px;font-size:11px;cursor:pointer;transition:background .12s,color .12s,border-color .12s;line-height:1.5}
.fd-act-btn:hover{background:var(--bg-hover,#262626);color:var(--text,#fff);border-color:rgba(255,255,255,.25)}
.fd-act-btn.on{background:rgba(35,131,226,.18);border-color:rgba(35,131,226,.55);color:#fff}
.fd-act-btn:disabled,.fd-act-btn.disabled{opacity:.4;cursor:not-allowed}
/* Chips (contexte épinglé @ / + / skills) */
.fd-ap-chips{display:flex;flex-direction:column;gap:5px;padding:8px 4px 4px;max-height:92px;overflow-y:auto}
.fd-chip-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.fd-chip{display:inline-flex;align-items:center;gap:6px;background:rgba(35,131,226,.10);border:1px solid rgba(35,131,226,.30);color:var(--text,#e8e8e8);border-radius:99px;padding:3px 5px 3px 9px;font-size:11.5px;max-width:220px}
.fd-chip.skill{background:rgba(168,85,247,.12);border-color:rgba(168,85,247,.38)}
.fd-chip-ico{flex-shrink:0;font-size:12px}
.fd-chip-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-chip-spacer{flex-basis:100%;height:0}
.fd-chip-x{background:none;border:none;color:var(--text-dim,#aaa);cursor:pointer;font-size:13px;line-height:1;padding:0 4px;border-radius:50%;flex-shrink:0}
.fd-chip-x:hover{color:#fff;background:rgba(255,255,255,.14)}
/* Add button (+ contexte) */
.fd-ap-add{width:30px;height:30px;border-radius:50%;background:var(--bg-tertiary,#262626);border:1px solid var(--border,rgba(255,255,255,.14));color:var(--text,#ddd);cursor:pointer;font-size:17px;display:flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1}
.fd-ap-add:hover{background:var(--bg-hover,#303030);color:#fff}
/* Mention menu (@ / +) */
.fd-ap-mention-menu{position:absolute;bottom:calc(100% + 4px);left:0;right:0;background:var(--bg-modal,#1e1e1e);border:1px solid var(--border,rgba(255,255,255,.12));border-radius:11px;max-height:220px;overflow-y:auto;box-shadow:0 -8px 26px rgba(0,0,0,.4);z-index:70;display:none}
.fd-ap-mention-menu.show{display:block}
.fd-mention-item{display:flex;align-items:center;gap:9px;padding:7px 12px;font-size:12.5px;color:var(--text,#eee);cursor:pointer;border-radius:7px;margin:2px 4px}
.fd-mention-item:hover,.fd-mention-item.focus{background:var(--bg-hover,#2b2b2b)}
.fd-mention-ico{width:24px;text-align:center;flex-shrink:0;font-size:14px}
.fd-mention-main{flex:1;min-width:0;display:flex;flex-direction:column}
.fd-mention-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-mention-sub{font-size:10.5px;color:var(--text-dim,#999);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
/* Toast */
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
.fd-ap-toast button{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:13px;padding:0}
[x-cloak]{display:none!important}
</style>
@@ -196,6 +241,14 @@
<span class="fd-proposal-note" x-show="m.offline" x-cloak>Hors-ligne — configurez une clé API pour appliquer la modification.</span>
</div>
</template>
<template x-if="m.role==='assistant' && !m.generating && m.content && !m.act">
<div class="fd-agent-msg-actions">
<button class="fd-act-btn" type="button" @click="copyResponse(m)" title="Copier la réponse">⧉ copy response</button>
<button class="fd-act-btn" type="button" @click="insertIntoPage(m)" :disabled="!canInsert" title="Insérer dans la page ouverte">➕ insert into this page</button>
<button class="fd-act-btn" :class="{on: m.fb==='up'}" type="button" @click="feedback(m,'up')" :disabled="!!m.fb && m.fb!=='up'">👍 Share feedback</button>
<button class="fd-act-btn" :class="{on: m.fb==='down'}" type="button" @click="feedback(m,'down')" :disabled="!!m.fb && m.fb!=='down'">👎 Share feedback</button>
</div>
</template>
</div>
</div>
</template>
@@ -207,26 +260,69 @@
<div class="fd-ap-composer">
<div style="position:relative;">
<div class="fd-agent-cmd-menu" :class="{show: commandOpen && commandSuggestions.length}">
<template x-for="c in commandSuggestions" :key="c.cmd">
<div class="fd-agent-cmd-item" @click="useSuggestion(c)">
<b x-text="c.cmd"></b><span x-text="c.desc"></span>
<div class="fd-ap-toast" x-show="toastMsg" x-cloak :class="{err: toastErr}">
<span x-text="toastMsg"></span>
<button type="button" @click="toastMsg=''">✕</button>
</div>
<div class="fd-agent-cmd-menu" :class="{show: commandOpen && commandSuggestions.length && !slashDismiss}">
<template x-for="c in commandSuggestions" :key="c.key">
<div>
<div class="fd-agent-cmd-sep" x-show="c._sep" x-text="c._sep"></div>
<div class="fd-agent-cmd-item" x-show="!c._sep" :class="{focus: c._i===cmdFocus}" @click="useSuggestion(c)" @mouseenter="cmdFocus=c._i">
<b :class="c._admin ? 'k-cmd' : 'k-skill'" x-text="(c._admin ? c.cmd : c.cmd)"></b>
<span x-text="c.desc"></span>
</div>
</div>
</template>
</div>
<div class="fd-ap-mention-menu" :class="{show: mentionOpen}">
<div class="fd-mention-load" x-show="mentionLoad" x-cloak>Recherche…</div>
<template x-if="!mentionLoad && !mentionItems.length">
<div class="fd-mention-empty">Aucun élément. Tapez un nom de document, page ou base.</div>
</template>
<template x-if="!mentionLoad" x-for="it in mentionItems" :key="it.token">
<div class="fd-mention-item" :class="{focus: it._i===mentionFocus}" @click="pickMention(it)" @mouseenter="mentionFocus=it._i">
<span class="fd-mention-ico" x-text="it.icon"></span>
<div class="fd-mention-main">
<div class="fd-mention-lbl" x-text="it.label"></div>
<div class="fd-mention-sub" x-text="it.sub"></div>
</div>
</div>
</template>
</div>
</div>
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
<div class="fd-chip-row" x-show="mentionChips.length">
<template x-for="chip in mentionChips" :key="chip.key">
<span class="fd-chip">
<span class="fd-chip-ico" x-text="chip.icon"></span>
<span class="fd-chip-lbl" x-text="chip.label"></span>
<button class="fd-chip-x" type="button" @click="removeChip(chip.token)" title="Retirer du contexte">✕</button>
</span>
</template>
</div>
<div class="fd-chip-row" x-show="skillChips.length">
<template x-for="chip in skillChips" :key="chip.key">
<span class="fd-chip skill">
<span class="fd-chip-ico" x-text="chip.icon"></span>
<span class="fd-chip-lbl" x-text="chip.label"></span>
<button class="fd-chip-x" type="button" @click="removeChip(chip.token)" title="Retirer le skill">✕</button>
</span>
</template>
</div>
</div>
<div class="fd-ap-box">
<textarea class="fd-ap-input" x-ref="cmdInput" rows="1"
placeholder="Demandez à l'agent (ex : crée une collection Projets)… Tapez / pour les commandes"
x-model="input" @input="resizeInput()" @keydown.enter.exact.prevent="send()"
@keydown.enter.shift.prevent="insertNewline()"></textarea>
<div class="fd-ap-input empty" x-ref="cmdInput" contenteditable="true" spellcheck="false" role="textbox" aria-multiline="true"
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
<div class="fd-ap-toolbar">
<button class="fd-ap-add" type="button" title="Ajouter un élément au contexte (@ / +)" @click="toggleAddPicker()">+</button>
<span class="fd-ap-tip" x-text="composerHint"></span>
<div class="fd-ap-tools">
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
<span x-text="autoLabel"></span> <span style="opacity:.6">▾</span>
</button>
<button class="fd-ap-send" @click="send()" :disabled="streaming || !input.trim()" title="Envoyer">▲</button>
<button class="fd-ap-send" @click="send()" :disabled="streaming || !canSend" title="Envoyer">▲</button>
</div>
</div>
</div>
@@ -251,6 +347,39 @@
<script>
(function(){
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
var FD_ADMIN_CMDS = [
{cmd:'/help', desc:'liste des commandes'},
{cmd:'/providers', desc:'liste les fournisseurs actifs'},
{cmd:'/provider <nom>', desc:'changer de fournisseur LLM'},
{cmd:'/model <nom>', desc:'changer de modèle LLM'},
{cmd:'/keys', desc:'fournisseurs avec clé API enregistrée'}
];
var FD_SKILLS = [
{cmd:'/research', icon:'🔎', desc:'recherche structurée + recommandation',
template:'Fais un état des lieux de [sujet] : contexte, avantages/inconvénients, options, puis une recommandation finale. Indique tes sources si tu en utilises.'},
{cmd:'/create-new-skill', icon:'🛠️', desc:'crée un workflow réutilisable (skill)',
template:'Je veux créer un skill réutilisable qui [décris le workflow]. Produis : nom, description courte, étapes et format de sortie attendu.'},
{cmd:'/resume', icon:'📄', desc:'résumé / synthèse structurée',
template:'Résume ce contenu de façon structurée et concise : points clés, décisions, TL;DR.'},
{cmd:'/actions', icon:'✅', desc:'extraire les actions & to-dos',
template:'À partir de ce contenu, liste les actions à mener avec responsable et échéance quand c\u2019est indiqué.'},
{cmd:'/reformuler', icon:'✍️', desc:'réécriture clarté / ton',
template:'Reformule ce contenu pour le rendre plus clair et plus concis en conservant le sens. Précise le ton souhaité (interne, client, formel…).'},
{cmd:'/correction', icon:'🔤', desc:'correction grammaire / orthographe / style',
template:'Corrige la grammaire, l\u2019orthographe et le style de ce contenu sans en changer le sens.'},
{cmd:'/brainstorm', icon:'💡', desc:'générer des idées, angles, variantes',
template:'Propose des idées variées sur [sujet] : angles, exemples concrets, variantes de messages (annonce, email, post).'},
{cmd:'/plan', icon:'🧭', desc:'planifier / structurer un document',
template:'Construis un plan structuré pour [sujet] : sections logiques, sous-points et ordre de rédaction.'},
{cmd:'/ask', icon:'💬', desc:'Q&A sur un contenu référencé',
template:'À partir du contenu référencé, réponds précisément à [ta question].'},
{cmd:'/meeting-note', icon:'📝', desc:'compte-rendu / note de réunion',
template:'Rédige une AI Meeting Note : Résumé, Décisions, Action items (avec responsables et échéances), Prochaines étapes.'},
{cmd:'/livrable', icon:'📨', desc:'email / compte-rendu / message Slack',
template:'Rédige un livrable professionnel (email, compte-rendu ou message Slack) sur [sujet / destinataire / contexte].'}
];
function agentPanel(){
return {
open: false, modelOpen: false, tab: 'chat',
@@ -260,6 +389,12 @@
allProviders: [], providers: [], providerModels: [], llmProvider: 'offline',
llmModel: '', llmKeys: [], defaultProvider: 'offline',
activeContext: null, ctxPinnedKey: null,
contextChips: [], skills: [],
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
cmdFocus: -1, slashDismiss: false,
toastMsg: '', toastErr: false, _toastTimer: null,
_slashSeq: 0,
init(){
var self = this;
@@ -282,6 +417,7 @@
this._applyState = applyState;
this.fetchProviders();
this.fetchAgents();
this.fetchSkills();
// Exposed imperative API (used by the FAB, the sidebar, Ask AI…)
window.fdAgent.open = function(){ self.openAgent(); };
@@ -291,7 +427,7 @@
var t = (text || '').trim();
if(!t) return;
self.openAgent();
self.input = t;
self.setComposerText(t);
setTimeout(function(){ self.send(); }, 60);
};
window.fdAgent.generate = function(message, context){
@@ -377,10 +513,14 @@
},
get composerHint(){
var c = this.activeContext;
if(!c) return 'Demandez ou tapez / pour les commandes';
if(c.kind === 'page') return 'Contexte : le document est pris en compte par l\u2019agent.';
return 'Contexte : ' + c.label;
var ctx;
if(!c) ctx = 'Demandez, tapez / pour un skill, @ pour référencer un élément';
else if(c.kind === 'page') ctx = 'Contexte : le document ouvert est pris en compte par l\u2019agent.';
else ctx = 'Contexte : ' + c.label;
var extra = this.contextChips.length ? ' · ' + this.contextChips.length + ' réf. épinglée(s)' : '';
return ctx + extra + ' · Entrée ↵ = envoyer · Maj+Entrée = ligne';
},
get canInsert(){ return !!(this.activeContext && this.activeContext.kind === 'page'); },
// ── Quick actions (contextual) ──
get quickIntro(){
@@ -418,7 +558,7 @@
if(q.docRun){ this.runDocAction(q); return; }
var self = this;
this.ensureConversation().then(function(){
self.input = q.prompt;
self.setComposerText(q.prompt);
self.send();
}).catch(function(){});
},
@@ -560,6 +700,12 @@
var self = this;
fetch('/api/agent').then(function(r){return r.json()}).then(function(d){ self.agents = d.agents || []; }).catch(function(){});
},
fetchSkills(){
var self = this;
fetch('/api/agent/skills').then(function(r){return r.json()}).then(function(d){
self.skills = d.skills || [];
}).catch(function(){ self.skills = []; });
},
loadConversations(){
var self = this;
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
@@ -593,6 +739,7 @@
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
self.$nextTick(function(){ self.resizeInput(); });
}).catch(function(){});
},
@@ -609,6 +756,7 @@
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
self._ensuring = null;
return conv;
}).catch(function(err){
@@ -629,42 +777,311 @@
}).catch(function(){});
},
// ── Input helpers ──
// ── Composer (contenteditable) helpers ──
_composerEl(){ return this.$refs && this.$refs.cmdInput; },
composerText(){
var el = this._composerEl();
if(!el) return (this.input || '');
var parts = [];
var nodes = el.childNodes;
for(var i=0;i<nodes.length;i++){
var n = nodes[i];
if(n.nodeType===3) parts.push(n.nodeValue);
else if(n.nodeType===1){
if(n.tagName === 'BR') parts.push('\n');
else if(n.hasAttribute && n.hasAttribute('data-ctoken')) parts.push(n.getAttribute('data-label')||'');
else parts.push(n.textContent||'');
}
}
return parts.join('').replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').trim();
},
composerMentions(){
var el = this._composerEl();
var out = [], seen = {};
if(el){
var spans = el.querySelectorAll('[data-ctoken]');
for(var i=0;i<spans.length;i++){
if(spans[i].getAttribute('data-kind') === 'skill') continue;
var t = spans[i].getAttribute('data-token');
if(t && !seen[t]){ seen[t]=1; out.push(t); }
}
}
for(i=0;i<this.contextChips.length;i++){
var ch = this.contextChips[i];
if(ch.kind==='mention' && !seen[ch.token]){ seen[ch.token]=1; out.push(ch.token); }
}
return out;
},
resizeInput(){
var t = this.$refs && this.$refs.cmdInput;
var t = this._composerEl();
if(!t) return;
t.style.height = 'auto';
t.style.height = Math.min(t.scrollHeight, 220) + 'px';
var h = Math.min(t.scrollHeight, 220);
t.style.height = h + 'px';
t.style.overflowY = (t.scrollHeight > 220) ? 'auto' : 'hidden';
},
_emptyCheck(){
var el = this._composerEl();
if(!el) return;
var empty = !el.textContent.trim() && !el.querySelector('[data-ctoken]');
el.classList.toggle('empty', !!empty);
},
setComposerText(str){
var el = this._composerEl();
if(!el){ this.input = str || ''; return; }
el.textContent = str || '';
this.input = str || '';
this.resizeInput();
this._emptyCheck();
},
clearComposer(){
var el = this._composerEl();
if(el){ el.textContent = ''; }
this.input = '';
this.resizeInput();
this._emptyCheck();
},
_focusComposer(){
var t = this._composerEl();
if(t) t.focus();
},
insertNewline(){
var t = this.$refs && this.$refs.cmdInput;
if(!t) return;
var s = t.selectionStart, e = t.selectionEnd;
var v = this.input || '';
this.input = v.slice(0, s) + '\n' + v.slice(e);
this.$nextTick(function(){ t.selectionStart = t.selectionEnd = s + 1; });
var el = this._composerEl();
if(!el) return;
try{
var sel = window.getSelection();
if(!sel.rangeCount) return;
var r = sel.getRangeAt(0);
r.deleteContents();
var br = document.createElement('br');
r.insertNode(br);
r.setStartAfter(br); r.collapse(true);
this.input = this.composerText();
}catch(e){}
this.resizeInput();
},
_caretQuery(){
try{
var sel = window.getSelection();
if(!sel || !sel.rangeCount || !sel.isCollapsed) return null;
var r = sel.getRangeAt(0);
var node = r.startContainer;
if(node.nodeType !== 3) return null;
var s = node.nodeValue, off = r.startOffset, i = off;
while(i > 0 && s[i-1] !== '@' && !/\s/.test(s[i-1])) i--;
if(i > 0 && s[i-1] === '@' && (i === 1 || /\s/.test(s[i-2]))){
return {node: node, at: i-1, raw: s.slice(i, off)};
}
}catch(e){}
return null;
},
_expandToAt(range){
var node = range.startContainer;
if(node.nodeType !== 3) return;
var s = node.nodeValue, off = range.startOffset, i = off;
while(i > 0 && s[i-1] !== '@' && !/\s/.test(s[i-1])) i--;
if(i > 0 && s[i-1] === '@') range.setStart(node, i-1);
},
_insertToken(item, range){
var el = this._composerEl();
if(!el) return;
try{
var span = document.createElement('span');
span.setAttribute('contenteditable','false');
span.setAttribute('data-ctoken','1');
span.setAttribute('data-token', item.token);
span.setAttribute('data-label', item.label);
span.setAttribute('data-kind', item.kind || 'mention');
span.className = 'ment-token' + (item.kind === 'skill' ? ' skill' : '');
var ico = document.createElement('span'); ico.className='tk-ico'; ico.textContent = ' ' + (item.icon || '');
var lbl = document.createElement('span'); lbl.className='tk-lbl'; lbl.textContent = ' ' + item.label + ' ';
span.appendChild(ico); span.appendChild(lbl);
var useRange = (range && range.startContainer) ? range : null;
if(!useRange){
var sel = window.getSelection();
if(sel && sel.rangeCount){
var cand = sel.getRangeAt(0);
if(cand && el.contains(cand.startContainer)) useRange = cand;
}
}
if(useRange){
if(this._mentionFrom !== '+' && !range) this._expandToAt(useRange);
useRange.deleteContents();
useRange.insertNode(span);
var sp = document.createTextNode(' ');
useRange.setStartAfter(span); useRange.collapse(true);
useRange.insertNode(sp);
useRange.setStart(sp, 1); useRange.setEnd(sp, 1);
try{ var s2 = window.getSelection(); if(s2){ s2.removeAllRanges(); s2.addRange(useRange); } }catch(e2){}
} else {
if(el.textContent) el.appendChild(document.createTextNode(' '));
el.appendChild(span);
el.appendChild(document.createTextNode(' '));
try{ var r2 = document.createRange(); r2.selectNodeContents(el); r2.collapse(false); var s3 = window.getSelection(); if(s3){ s3.removeAllRanges(); s3.addRange(r2); } }catch(e3){}
}
}catch(e){}
this.input = this.composerText();
this.resizeInput();
this._emptyCheck();
},
_insertMentionAt(item){
var node = this._atNode;
if(node && node.nodeType === 3 && node.parentNode){
try{
var s = node.nodeValue;
var idx = -1;
for(var k=0;k<s.length;k++){ if(s[k] === '@'){ idx = k; break; } }
if(idx >= 0){
var r = document.createRange();
r.setStart(node, idx);
r.setEnd(node, s.length);
this._insertToken(item, r);
return;
}
}catch(e){}
}
this._insertToken(item);
},
_removeTokenSpan(token){
var el = this._composerEl();
if(!el) return;
var spans = el.querySelectorAll('[data-ctoken][data-token="'+token+'"]');
for(var i=0;i<spans.length;i++){
var p = spans[i];
if(p.parentNode){
if(p.nextSibling && p.nextSibling.nodeType===3 && /^\s*$/.test(p.nextSibling.nodeValue)) p.parentNode.removeChild(p.nextSibling);
p.parentNode.removeChild(p);
}
}
this.input = this.composerText();
this.resizeInput();
this._emptyCheck();
},
_prevToken(node, off){
if(node.nodeType === 3 && off === 0){
var p = node.previousSibling;
while(p && p.nodeType===3 && !p.nodeValue.trim()) p = p.previousSibling;
if(p && p.nodeType===1 && p.hasAttribute && p.hasAttribute('data-ctoken')) return p;
}
return null;
},
_nextToken(node, off){
if(node.nodeType === 3 && off === node.nodeValue.length){
var p = node.nextSibling;
while(p && p.nodeType===3 && !p.nodeValue.trim()) p = p.nextSibling;
if(p && p.nodeType===1 && p.hasAttribute && p.hasAttribute('data-ctoken')) return p;
}
return null;
},
_removeTokenDom(span){
var token = span.getAttribute('data-token');
if(span.parentNode){
if(span.previousSibling && span.previousSibling.nodeType===3 && /^\s*$/.test(span.previousSibling.nodeValue)) span.parentNode.removeChild(span.previousSibling);
if(span.nextSibling && span.nextSibling.nodeType===3 && /^\s*$/.test(span.nextSibling.nodeValue)) span.parentNode.removeChild(span.nextSibling);
span.parentNode.removeChild(span);
}
for(var i=0;i<this.contextChips.length;i++){ if(this.contextChips[i].token === token){ this.contextChips.splice(i,1); break; } }
this.input = this.composerText();
this.resizeInput();
this._emptyCheck();
},
onPaste(e){
e.preventDefault();
var text = '';
try{ if(e.clipboardData) text = e.clipboardData.getData('text/plain') || ''; }catch(err){ }
if(!text) return;
try{
var sel = window.getSelection();
if(sel && sel.rangeCount){ sel.getRangeAt(0).deleteContents(); document.execCommand('insertText', false, text); }
}catch(err){}
this.input = this.composerText();
this.resizeInput();
},
get canSend(){ return !!((this.input || '').trim() || this.contextChips.length); },
get mentionChips(){ return (this.contextChips || []).filter(function(c){ return c.kind === 'mention'; }); },
get skillChips(){ return (this.contextChips || []).filter(function(c){ return c.kind === 'skill'; }); },
get commandOpen(){ return /^\s*\//.test(this.input) && !this.streaming; },
get commandSuggestions(){
var raw = (this.input||'').trim();
if(!/^\//.test(raw)) return [];
var q = raw.replace(/^\/+/, '').toLowerCase();
var first = (q.split(/\s+/).filter(Boolean)[0] || '');
var all = [
{cmd:'/help', desc:'liste des commandes'},
{cmd:'/providers', desc:'liste les fournisseurs actifs'},
{cmd:'/provider <nom>', desc:'changer de fournisseur LLM'},
{cmd:'/model <nom>', desc:'changer de modèle LLM'},
{cmd:'/keys', desc:'fournisseurs avec clé API enregistrée'}
];
if(!first) return all;
return all.filter(function(c){ return c.cmd.toLowerCase().replace(/^\/+/, '').indexOf(first) === 0; });
var first = (q.split(/\s+/).filter(Boolean)[0] || '').replace(/[^a-z0-9-]/g, '');
function qmatch(name){
var slug = String(name).toLowerCase().split(/[\s<]/)[0].replace(/^\/+/, '');
if(!first) return true;
return slug.indexOf(first) === 0 || slug.replace(/-/g, '').indexOf(first) === 0;
}
var out = [];
var seq = 0;
function pushItems(items){
items.forEach(function(o){ o._i = seq++; out.push(o); });
}
var skillsPresent = false, admPresent = false;
var built = [], dbs = [], adm = [];
for(var i=0;i<FD_SKILLS.length;i++){
if(qmatch(FD_SKILLS[i].cmd)){
var s = FD_SKILLS[i];
built.push({key:'bi-'+s.cmd, cmd:s.cmd, desc:s.desc, icon:s.icon, template:s.template, _type:'builtin'});
}
}
for(i=0;i<(this.skills||[]).length;i++){
var sk = this.skills[i];
if(qmatch(sk.name)){
dbs.push({key:'db-'+sk.id, cmd:'/'+sk.name, desc:(sk.description || 'Skill enregistré'), icon:'✨', _type:'db', skillId:sk.id});
}
}
for(i=0;i<FD_ADMIN_CMDS.length;i++){
if(qmatch(FD_ADMIN_CMDS[i].cmd)) adm.push({key:'adm-'+FD_ADMIN_CMDS[i].cmd, cmd:FD_ADMIN_CMDS[i].cmd, desc:FD_ADMIN_CMDS[i].desc});
}
skillsPresent = built.length || dbs.length;
admPresent = adm.length;
if(skillsPresent){
out.push({_sep:'Skills', key:'sep-skills'});
pushItems(built.concat(dbs));
}
if(skillsPresent && admPresent) out.push({_sep:'Commandes', key:'sep-adm'});
if(admPresent) pushItems(adm);
this._slashSeq = seq;
return out;
},
// Items slash « exécutables » (skills) — non séparateurs
_slashItems(){
return this.commandSuggestions.filter(function(o){ return !o._sep; });
},
_slugSkill(slug){
slug = String(slug || '').toLowerCase().replace(/^\/+/, '');
for(var i=0;i<FD_SKILLS.length;i++){
var c = FD_SKILLS[i].cmd.replace(/^\/+/, '').toLowerCase();
if(c === slug || c.replace(/-/g, '') === slug) return FD_SKILLS[i];
}
return null;
},
useSuggestion(c){
var v = c.cmd.replace(/\s*<[^>]*>\s*$/, ' ');
this.input = v;
var t = this.$refs && this.$refs.cmdInput;
if(t){ t.focus(); this.resizeInput(); }
var self = this;
if(!c || c._sep) return;
if(c._admin){
this.setComposerText(c.cmd.replace(/\s*<[^>]*>\s*$/, ' '));
this.cmdFocus = -1;
this._focusComposer();
return;
}
if(c._type === 'db'){
var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === c.skillId; });
if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); }
else {
var hasSkill = this.contextChips.some(function(ch){ return ch.kind === 'skill'; });
this.contextChips.push({key:'skill-'+c.skillId, kind:'skill', icon:c.icon||'✨',
label:String(c.cmd).replace(/^\//, ''), token:'skill:'+c.skillId, skillId:c.skillId});
this.clearComposer();
this._insertToken({token:'skill:'+c.skillId, label:String(c.cmd).replace(/^\//, ''), icon:c.icon||'✨', kind:'skill'});
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
}
} else {
this.setComposerText(c.template || '');
this.toast('Complétez la demande puis envoyez (Entrée).');
}
this.cmdFocus = -1; this.slashDismiss = false;
this._focusComposer();
},
echo(msg){
this.messages.push({id:'cmd-'+Date.now()+Math.random(), role:'assistant', content: msg, model:''});
@@ -676,7 +1093,7 @@
var cmd = (parts.shift() || '').toLowerCase();
var arg = parts.join(' ').trim();
if(cmd === 'help' || cmd === 'aide' || cmd === '?'){
this.echo('Commandes : /providers · /keys · /provider <nom> · /model <nom> · /help');
this.echo('Tapez / pour choisir un skill (research, resume, brainstorm, plan, meeting-note…). Commandes : /providers · /keys · /provider <nom> · /model <nom>');
return true;
}
if(cmd === 'providers'){
@@ -723,36 +1140,289 @@
return false;
},
// ── Toast ──
toast(msg, err){
var self = this;
this.toastMsg = msg || '';
this.toastErr = !!err;
if(this._toastTimer) clearTimeout(this._toastTimer);
if(!msg){ return; }
this._toastTimer = setTimeout(function(){ self.toastMsg = ''; self.toastErr = false; }, 3200);
},
// ── Mentions & contexte épinglé (@ / +) ──
toggleAddPicker(){
if(this.mentionOpen && this._mentionFrom === '+'){ this.mentionOpen = false; this.mentionQuery = ''; return; }
this.openMentionPicker('+');
},
openMentionPicker(from){
this.mentionOpen = true;
this._mentionFrom = from || '+';
this.mentionQuery = '';
this.mentionFocus = -1;
this.searchMentions('');
},
searchMentions(q){
var self = this;
q = (q || '').trim();
this.mentionQuery = q;
this.mentionLoad = true;
if(this._mentionTimer) clearTimeout(this._mentionTimer);
this._mentionTimer = setTimeout(function(){
fetch('/api/agent/mentions?q=' + encodeURIComponent(q))
.then(function(r){ return r.json(); })
.then(function(d){
self.mentionItems = (d.mentions || []).map(function(it, idx){
it._i = idx;
return it;
});
self.mentionLoad = false;
if(self.mentionItems.length && self.mentionFocus < 0) self.mentionFocus = 0;
})
.catch(function(){ self.mentionItems = []; self.mentionLoad = false; });
}, q ? 180 : 0);
},
addChip(item){
if(!item || !item.token) return;
var dup = this.contextChips.some(function(ch){ return ch.token === item.token; });
if(dup){ this.toast('Déjà dans le contexte'); return; }
this.contextChips.push({key: item.token, kind: 'mention', icon: item.icon || '📎',
label: item.label, token: item.token, type: item.type, refId: item.id});
},
pickMention(item){
if(!item) return;
this.addChip(item);
if(this._mentionFrom === '@'){
this._insertMentionAt(item);
}
this.mentionOpen = false;
this.mentionQuery = '';
this._mentionFrom = null;
this._atNode = null;
this._focusComposer();
},
removeChip(token){
var idx = -1;
for(var i=0;i<this.contextChips.length;i++){
if(this.contextChips[i].token === token){ idx = i; break; }
}
if(idx < 0) return;
var chip = this.contextChips[idx];
this.contextChips.splice(idx, 1);
this._removeTokenSpan(chip.token);
this._emptyCheck();
},
onInput(){
this.resizeInput();
this.slashDismiss = false;
var text = this.composerText();
this.input = text;
var cmdNow = /^\s*\//.test(text);
if(cmdNow){
if(!this._cmdWasOpen){
var firstSkill = (this.commandSuggestions || []).filter(function(o){ return !o._sep && !o._admin; })[0];
this.cmdFocus = firstSkill ? firstSkill._i : -1;
} else if(this.cmdFocus !== -1){
var present = (this.commandSuggestions || []).some(function(o){ return !o._sep && o._i === this.cmdFocus; }.bind(this));
if(!present){
var fs = (this.commandSuggestions || []).filter(function(o){ return !o._sep && !o._admin; })[0];
this.cmdFocus = fs ? fs._i : -1;
}
}
} else {
this.cmdFocus = -1;
}
this._cmdWasOpen = cmdNow;
// @ mention inline
var cq = this._caretQuery();
if(cq){
if(this._mentionFrom !== '@'){ this._mentionFrom = '@'; this._atNode = cq.node; }
this.mentionOpen = true;
if(this._mentionTimer) clearTimeout(this._mentionTimer);
this.mentionLoad = true;
this.searchMentions(cq.raw);
} else if(this._mentionFrom === '@'){
this.mentionOpen = false;
this.mentionQuery = '';
}
this._emptyCheck();
},
moveMentionFocus(delta){
if(!this.mentionItems.length) return;
var n = this.mentionItems.length;
var idx = this.mentionItems.findIndex(function(it){ return it._i === this.mentionFocus; }.bind(this));
if(idx < 0) idx = delta > 0 ? -1 : 0;
idx = (idx + delta + n) % n;
this.mentionFocus = this.mentionItems[idx]._i;
},
moveSlashFocus(delta){
var items = this._slashItems().filter(function(o){ return !o._admin; });
if(!items.length) return;
var idx = items.findIndex(function(o){ return o._i === this.cmdFocus; }.bind(this));
if(idx < 0) idx = delta > 0 ? -1 : 0;
idx = (idx + delta + items.length) % items.length;
this.cmdFocus = items[idx]._i;
},
onComposerKeydown(e){
var key = e.key;
if(key === 'Backspace' || key === 'Delete'){
try{
var sel = window.getSelection();
if(sel && sel.rangeCount){
var r = sel.getRangeAt(0);
if(r.collapsed){
var tok = key === 'Backspace'
? this._prevToken(r.startContainer, r.startOffset)
: this._nextToken(r.startContainer, r.startOffset);
if(tok){ e.preventDefault(); this._removeTokenDom(tok); return; }
}
}
}catch(err){}
}
if(key === 'Escape'){
if(this.mentionOpen){ e.preventDefault(); e.stopPropagation(); this.mentionOpen = false; this._mentionFrom = null; this._atNode = null; return; }
if(this.commandOpen){ e.preventDefault(); e.stopPropagation(); this.slashDismiss = true; this.cmdFocus = -1; return; }
return; // laisse fermer le panneau
}
if(key === 'ArrowDown' || key === 'ArrowUp'){
var delta = key === 'ArrowDown' ? 1 : -1;
if(this.mentionOpen && this.mentionItems.length){ e.preventDefault(); this.moveMentionFocus(delta); return; }
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ e.preventDefault(); this.moveSlashFocus(delta); return; }
return;
}
// Entrée / Tab avec un menu ouvert (skills / mentions)
if(this.mentionOpen && this.mentionItems.length){
if(key === 'Enter' || key === 'Tab'){
e.preventDefault();
var mi = this.mentionItems.find(function(it){ return it._i === this.mentionFocus; }.bind(this)) || this.mentionItems[0];
this.pickMention(mi);
return;
}
}
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){
var items = this._slashItems();
if(key === 'Enter' || key === 'Tab'){
var focusItem = items.find(function(o){ return o._i === this.cmdFocus; }.bind(this));
if(focusItem){
if(!focusItem._admin){ e.preventDefault(); this.useSuggestion(focusItem); return; }
e.preventDefault(); this.send(); return;
}
var queryTyped = (this.composerText() || '').replace(/^\/+/, '').trim().length > 0;
var onlyAdmin = items.every(function(o){ return o._admin; });
var onlySkill = !onlyAdmin && items.every(function(o){ return !o._admin; });
if(onlySkill && queryTyped){
e.preventDefault(); this.useSuggestion(items[0]); return;
}
if(onlyAdmin){
e.preventDefault(); this.send(); return;
}
e.preventDefault(); return;
}
}
// Entrée : envoyer (Maj+Entrée = nouvelle ligne)
if(key === 'Enter'){
if(e.shiftKey){ e.preventDefault(); this.insertNewline(); }
else { e.preventDefault(); this.send(); }
return;
}
},
// ── Actions sous chaque réponse agent ──
copyResponse(m){
var self = this;
var text = (m && m.content) || '';
if(!text) return;
function fallback(){
try{
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
document.body.removeChild(ta);
self.toast(ok ? 'Réponse copiée.' : 'Copie impossible.', !ok);
}catch(err){ self.toast('Copie impossible.', true); }
}
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(function(){ self.toast('Réponse copiée.'); }).catch(function(){ fallback(); });
} else { fallback(); }
},
insertIntoPage(m){
var c = FlowDeckCtx.current();
if(!c || c.kind !== 'page'){ this.toast('Aucune page ouverte pour insérer cette réponse.', true); return; }
if(!window.fdApplyDocument){ this.toast('Insertion indisponible ici.', true); return; }
var text = (m && m.content) || '';
if(!text){ return; }
var ok = window.fdApplyDocument(text, m && m.mode && !m.applied ? m.mode : 'insert');
this.toast(ok ? 'Réponse insérée dans la page.' : 'Insertion impossible (page non éditable).', !ok);
},
feedback(m, rating){
var self = this;
if(!m || m.fb) return;
var payload = {rating: rating, content: ((m.content) || '').slice(0, 1000)};
if(this.currentConv && this.currentConv.id) payload.conversation_id = this.currentConv.id;
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
fetch('/api/agent/feedback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
if(d && d.status === 'recorded'){ m.fb = rating; }
self.toast(rating === 'up' ? 'Merci pour votre retour 👍' : 'Retour enregistré 👎', false);
}).catch(function(){ self.toast('Retour non envoyé.', true); });
},
// ── Send / SSE ──
async send(){
var self = this;
if(self.streaming) return;
var text = (self.input || '').trim();
if(!text) return;
if(self.commandOpen && self.handleCommand(text)){ self.input=''; this.resizeInput(); return; }
if(!text && !self.contextChips.length) return;
if(self.commandOpen && self.handleCommand(text)){ self.clearComposer(); return; }
// skill intégré tapé « en dur » (« /research », « /meeting-note … ») → applique son modèle
if(self.commandOpen){
var firstTok = (text.split(/\s+/)[0] || '');
var sk = self._slugSkill(firstTok);
if(sk){
var rest = text.slice(String(firstTok).length).trim();
text = sk.template + (rest ? '\n\n' + rest : '');
}
}
// barre oblique seule (après Escape du menu /) → on ne l'envoie pas
if(/^\/+$/.test(text)){ self.clearComposer(); return; }
var ment = self.composerMentions();
if(!text){
if(ment.length){ text = 'Analyse le contexte référencé.'; }
else { return; }
}
try { await self.ensureConversation(); } catch(e){ return; }
self.input = '';
this.resizeInput();
var rawText = text;
self.clearComposer();
self.errorMsg = '';
self.mentionOpen = false;
// A writing/content request typed while a document is open → route it to
// headless text generation and offer the result with Apply / Reject,
// instead of sending the agent into tool calls (search/create).
if(self.activeContext && self.activeContext.kind === 'page' && self._isContentRequest(text)){
if(rawText && self.activeContext && self.activeContext.kind === 'page' && self._isContentRequest(rawText)){
self.refreshContext();
return self.runDocAction({prompt: text, label: text, mode: self._docModeFor(text)});
return self.runDocAction({prompt: rawText, label: rawText, mode: self._docModeFor(rawText)});
}
self.streaming = true;
self.liveReasoning = '…';
self.messages.push({id:'u-'+Date.now(), role:'user', content: text});
if(rawText) self.messages.push({id:'u-'+Date.now(), role:'user', content: rawText});
self.scrollBottom();
var payload = {message: text};
var payload = {message: rawText};
if(self.llmProvider) payload.provider = self.llmProvider;
if(self.llmModel) payload.model = self.llmModel;
var ctxText = self.contextPayload();
if(ctxText) payload.context = ctxText;
if(ment && ment.length) payload.mentions = ment;
var skillChip = (self.contextChips || []).find(function(ch){ return ch.kind === 'skill'; });
if(skillChip && skillChip.skillId) payload.skill_id = skillChip.skillId;
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST',
@@ -779,7 +1449,7 @@
var msg = ev.status==='success'
? (ev.tool+' ✓ '+(ev.message||''))
: (ev.tool+' ✗ '+(ev.detail||'erreur'));
self.messages.push({id:'a-'+Date.now()+Math.random(), role:'assistant', content: msg});
self.messages.push({id:'a-'+Date.now()+Math.random(), role:'assistant', content: msg, act:true});
}
else if(ev.type==='final'){
self.messages.push({id:'f-'+Date.now(), role:'assistant', content: ev.content});