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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""FlowDeck — AgentEngine: ReAct orchestrator (v4.10.0).
|
||||
"""FlowDeck — AgentEngine: ReAct orchestrator (v4.14.0).
|
||||
|
||||
`objective → comprehension → context → reasoning ↔ action → result`.
|
||||
|
||||
The engine drives the LLM (which only emits tool intentions), gates each call
|
||||
through PermissionManager, executes it via ToolRegistry, journals every action
|
||||
to `agent_actions` with an undo snapshot, and yields a stream of SSE events so
|
||||
the UI can render reasoning + actions live.
|
||||
the UI can render reasoning + actions live. Since v4.14.0 a freshly created
|
||||
conversation is automatically renamed with a descriptive title derived from its
|
||||
content so the history stays easy to browse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
@@ -29,6 +32,7 @@ MAX_ITERATIONS = 12
|
||||
# when no document is attached to the conversation (generic help / onboarding).
|
||||
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? »)
|
||||
- **Pages** : le contenu est organisé en blocs (paragraphes, titres, listes, to-do, tableaux, images, formules, bases embarquées). La barre latérale liste les pages récentes, favoris, agents, partagées et publiées.
|
||||
- **Documents & espaces de travail** : un « document » est une page éditeur (type Notion) qui vit dans un espace de travail. Pour créer un document dans un espace : appelle `read_workspaces` (reprends le `workspace_name` ou l'id exact), puis `create_document`. Pour modifier un document existant : `read_document` puis `write_blocks` (blocs et/ou titre). Pour supprimer : `delete_document` (corbeille). `search_workspace` retrouve aussi les documents et les espaces par titre.
|
||||
- **Collections (bases de données)** : des ensembles de pages structurées avec des propriétés (texte, nombre, sélection, dates…). Chaque collection peut avoir plusieurs vues : tableau, board (kanban), calendrier, galerie, liste, timeline, graphique, formulaire, carte, flux, gantt. Ajouter une propriété ou une vue = outils add_property / create_view.
|
||||
- **Créer du contenu** : « crée une collection X », « crée une page », « ajoute une propriété Statut à la collection Y » sont des actions que l'agent peut exécuter directement avec ses outils.
|
||||
- **Espaces de travail** : FlowDeck gère des espaces locaux et des dépôts Gitea/GitHub (pages privées dans un dépôt, issues reliées via read_gitea_issues). On change d'espace depuis le menu en bas à gauche (« Switch workspace »).
|
||||
@@ -38,6 +42,38 @@ APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questio
|
||||
- **Agent IA** : ouvrable via le bouton 🤖 en bas à droite ou la section « Agents » du sidebar. On peut lui parler de la page ouverte, ou lui poser des questions générales sur l'utilisation de l'application.
|
||||
Quand la question est générale (« comment créer un kanban ? », « où sont mes favoris ? »), réponds de façon concise et guidée à partir de ces informations, sans inventer de fonctionnalités absentes."""
|
||||
|
||||
# Deterministic auto-title heuristics (used when no real LLM is configured, and
|
||||
# as a fallback when the generated title is unusable). Ordered by priority: the
|
||||
# first matching intent wins.
|
||||
_TITLE_INTENTS = (
|
||||
("Création", ("créer", "crée", "crées", "création", "nouveau", "nouvelle",
|
||||
"create", "creation")),
|
||||
("Ajout", ("ajouter", "ajoute", "ajout d", "ajoutons", "add")),
|
||||
("Renommage", ("renommer", "renomme", "renommage", "rename")),
|
||||
("Suppression", ("supprimer", "supprime", "suppression", "delete")),
|
||||
("Déplacement", ("déplacer", "déplace", "déplacement", "move")),
|
||||
("Mise à jour", ("modifier", "modifie", "modification", "mets à jour",
|
||||
"met à jour", "mettre à jour", "update", "éditer")),
|
||||
("Analyse", ("analyser", "analyse")),
|
||||
("Résumé", ("résumer", "résume", "résumé", "resume")),
|
||||
("Traduction", ("traduire", "traduis", "traduit", "traduction", "translate")),
|
||||
("Planification", ("planifier", "planifie", "préparer", "prépare", "organiser",
|
||||
"organise", "sprint", "agenda")),
|
||||
("Recherche", ("chercher", "cherche", "rechercher", "recherche", "trouver",
|
||||
"trouve", "liste", "lister", "search", "find")),
|
||||
)
|
||||
|
||||
_TITLE_TYPES = (
|
||||
("collection", "collection", ("collection", "base de données", "database", "db")),
|
||||
("propriété", "propriété", ("propriété", "property")),
|
||||
("vue", "vue", (" vue", "view")),
|
||||
("board", "board", ("board", "kanban")),
|
||||
("sprint", "sprint", ("sprint",)),
|
||||
("document", "document", ("document", "note de réunion", "compte-rendu", "compte rendu")),
|
||||
("tâche", "tâche", ("tâche", "task", "tache")),
|
||||
("issue", "issue", ("issue",)),
|
||||
)
|
||||
|
||||
|
||||
class AgentEngine:
|
||||
def __init__(self, user_id: int, workspace_id: int | None = None,
|
||||
@@ -168,6 +204,7 @@ class AgentEngine:
|
||||
|
||||
self._persist_message(conversation_id, "assistant", final_text,
|
||||
model=model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AgentEngine run failed")
|
||||
@@ -245,6 +282,74 @@ class AgentEngine:
|
||||
detail = getattr(exc, "detail", None)
|
||||
return detail if isinstance(detail, str) else str(exc)
|
||||
|
||||
# ── Auto-title (v4.14.0) ──
|
||||
|
||||
_DEFAULT_CONV_TITLES = {"", "new conversation", "nouvelle conversation", "new chat", "conversation"}
|
||||
|
||||
async def _autotitle(self, conversation_id: int, objective: str, final_text: str | None):
|
||||
"""Rename a freshly created conversation with a descriptive title derived
|
||||
from its content. Only runs while the title is still a default placeholder,
|
||||
so a user-renamed conversation is never overwritten."""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT title FROM agent_conversations WHERE id=?", (conversation_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return
|
||||
if (row["title"] or "").strip().lower() not in self._DEFAULT_CONV_TITLES:
|
||||
return
|
||||
suggested = self._suggest_title(objective, final_text)
|
||||
if suggested:
|
||||
self._update_conversation(conversation_id, title=suggested[:80])
|
||||
except Exception: # noqa: BLE001 — never break a run because of the title
|
||||
logger.exception("Auto-title failed for conversation #%s", conversation_id)
|
||||
|
||||
@classmethod
|
||||
def _suggest_title(cls, objective: str | None, final_text: str | None) -> str:
|
||||
"""Produce a short descriptive title from the user objective (offline-safe)."""
|
||||
text = (objective or "").split("\n# Contexte", 1)[0].strip() or (final_text or "").strip()
|
||||
if not text:
|
||||
return "Conversation"
|
||||
low = text.lower()
|
||||
|
||||
intent = None
|
||||
for label, words in _TITLE_INTENTS:
|
||||
if any(w in low for w in words):
|
||||
intent = label
|
||||
break
|
||||
|
||||
type_label = next(
|
||||
(t for t, _noun, words in _TITLE_TYPES if any(w in low for w in words)), None
|
||||
)
|
||||
has_workspace = any(w in low for w in ("workspace", "espace de travail"))
|
||||
quotes = [q.strip() for q in re.findall(r'[«"]([^«»"]{1,80})[»"]', text) if q.strip()]
|
||||
subject = quotes[0] if quotes else None
|
||||
ws = quotes[-1] if (has_workspace and len(quotes) > 1) else None
|
||||
|
||||
def _clean(s: str) -> str:
|
||||
return re.sub(r"\s+", " ", s).strip(" .;:-")
|
||||
|
||||
if not subject and type_label:
|
||||
noun = next((n for t, n, _w in _TITLE_TYPES if t == type_label), type_label)
|
||||
m = re.search(
|
||||
rf"\b{noun}\b\s*(?:nomm[ée]e?\s+|appel[ée]e?\s+|intitul[ée]e?\s+)?"
|
||||
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60}?)\s*[»"]?',
|
||||
text, re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
subject = m.group(1).strip()
|
||||
|
||||
if intent and subject:
|
||||
core = f"{intent} {type_label or 'élément'} « {subject} »" \
|
||||
if type_label else f"{intent} « {subject} »"
|
||||
if ws:
|
||||
core += f" (dans {ws})"
|
||||
return _clean(core)
|
||||
|
||||
generic = _clean(text)
|
||||
return generic[:70] if generic else "Conversation"
|
||||
|
||||
|
||||
def undo_action(action_id: int) -> bool:
|
||||
"""Reverse a single agent action using its stored undo snapshot.
|
||||
@@ -262,6 +367,8 @@ def undo_action(action_id: int) -> bool:
|
||||
|
||||
if op == "delete":
|
||||
conn.execute(f"DELETE FROM {table} WHERE id=?", (rid,))
|
||||
elif op == "softdelete":
|
||||
conn.execute(f"UPDATE {table} SET deleted_at=NULL WHERE id=?", (rid,))
|
||||
elif op == "insert":
|
||||
snapshot = undo.get("snapshot")
|
||||
if not snapshot:
|
||||
@@ -277,8 +384,16 @@ def undo_action(action_id: int) -> bool:
|
||||
(json.dumps(snapshot, ensure_ascii=False), undo.get("title", ""), rid),
|
||||
)
|
||||
elif table == "pages":
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(snapshot, rid))
|
||||
if isinstance(snapshot, dict):
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(snapshot.get("content", ""),
|
||||
snapshot.get("content_format", "markdown"),
|
||||
snapshot.get("title", ""), rid),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(snapshot, rid))
|
||||
else:
|
||||
raise ValueError(f"Table non gérée pour rollback: {table}")
|
||||
else:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""FlowDeck — Agent context builder (v4.10.0).
|
||||
"""FlowDeck — Agent context builder (v4.14.0).
|
||||
|
||||
Collects a compact, permission-filtered snapshot of the active workspace so the
|
||||
LLM can reason about real entities (collections, pages, Gitea issues) without
|
||||
touching the database directly.
|
||||
LLM can reason about real entities (workspaces, documents, collections, pages,
|
||||
Gitea issues) without touching the database directly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,6 +27,8 @@ class ContextBuilder:
|
||||
if include_collections:
|
||||
sections.append(self._collections_context())
|
||||
sections.append(self._pages_context())
|
||||
sections.append(self._documents_context())
|
||||
sections.append(self._workspaces_context())
|
||||
if mentions:
|
||||
sections.append(self._mentions_context(mentions))
|
||||
if files:
|
||||
@@ -68,11 +70,66 @@ class ContextBuilder:
|
||||
lines.append(f"- page #{r['id']} in collection #{r['collection_id']}: **{r['title']}**{(' — ' + summary) if summary else ''}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _documents_context(self, limit: int = 30) -> str:
|
||||
"""Recent editor documents (`pages`), usable with create/read/update tools."""
|
||||
with get_conn() as conn:
|
||||
ws_names = dict(
|
||||
conn.execute("SELECT id, name FROM workspaces").fetchall()
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace_id, content_format, parent_id "
|
||||
"FROM pages WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Documents\n(aucun document)"
|
||||
lines = ["## Documents (pages éditeur — outils: read_document, write_blocks, create_document)"]
|
||||
for r in rows:
|
||||
ws_name = ws_names.get(r["workspace_id"], str(r["workspace_id"]) if r["workspace_id"] else "racine")
|
||||
lines.append(f"- document #{r['id']} **{r['title'] or 'Sans titre'}** (espace: {ws_name})")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _workspaces_context(self) -> str:
|
||||
"""Workspaces accessible to the current user (with counts)."""
|
||||
base_sql = (
|
||||
"SELECT w.id, w.name, {role} AS role, "
|
||||
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id=w.id AND p.deleted_at IS NULL) AS document_count "
|
||||
"FROM workspaces w {join} {where} ORDER BY w.name"
|
||||
)
|
||||
with get_conn() as conn:
|
||||
if self.user_id is None:
|
||||
rows = conn.execute(
|
||||
base_sql.format(role="'owner'", join="", where=""), []
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
base_sql.format(
|
||||
role="COALESCE(wm.role, CASE WHEN w.owner_id=? THEN 'owner' ELSE 'viewer' END)",
|
||||
join="LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=?",
|
||||
where="WHERE w.owner_id=? OR wm.user_id IS NOT NULL",
|
||||
),
|
||||
(self.user_id, self.user_id, self.user_id),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Espaces de travail\n(aucun espace)"
|
||||
lines = ["## Espaces de travail (outil: read_workspaces)"]
|
||||
for r in rows:
|
||||
lines.append(f"- espace #{r['id']} **{r['name']}** ({r['role']}, {r['document_count']} document(s))")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _mentions_context(self, mentions: list[str]) -> str:
|
||||
"""Resolve @collection:x / @page:y / @repo:o/r mentions to entity snapshots."""
|
||||
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
|
||||
|
||||
Mentions bring the *actual content* of the referenced object into the
|
||||
context so the LLM can summarise / rewrite / analyse it directly without
|
||||
needing a read tool round-trip (and so the offline mock stays useful).
|
||||
"""
|
||||
lines = ["## Mentioned context"]
|
||||
for m in mentions:
|
||||
if m.startswith("collection:"):
|
||||
if m.startswith("document:"):
|
||||
pid = m.split(":", 1)[1]
|
||||
lines.append(self._single_document(pid))
|
||||
elif m.startswith("collection:"):
|
||||
cid = m.split(":", 1)[1]
|
||||
lines.append(self._single_collection(cid))
|
||||
elif m.startswith("page:"):
|
||||
@@ -84,6 +141,74 @@ class ContextBuilder:
|
||||
lines.append("- @ws: full workspace context included above")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _blocks_to_text(content: str, limit: int = 9000) -> str:
|
||||
"""Flatten a ``blocks`` document JSON into plain readable text.
|
||||
|
||||
Collects the textual payload of each block (content, title, caption,
|
||||
children, meeting notes/summary) — enough for the LLM to reason about a
|
||||
mentioned editor page without the full block schema.
|
||||
"""
|
||||
try:
|
||||
blocks = json.loads(content or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return ""
|
||||
if not isinstance(blocks, list):
|
||||
return ""
|
||||
|
||||
_TEXT_KEYS = ("content", "title", "caption", "plain_text", "notes", "summary")
|
||||
out: list[str] = []
|
||||
total = 0
|
||||
|
||||
def walk(node):
|
||||
nonlocal total
|
||||
if total >= limit:
|
||||
return
|
||||
if isinstance(node, dict):
|
||||
for k in _TEXT_KEYS:
|
||||
v = node.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
line = v.replace("\r\n", "\n").strip()
|
||||
out.append(line)
|
||||
total += len(line) + 1
|
||||
if total >= limit:
|
||||
return
|
||||
for v in node.values():
|
||||
walk(v)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
walk(item)
|
||||
|
||||
walk(blocks)
|
||||
return "\n".join(out)[:limit]
|
||||
|
||||
def _single_document(self, pid: str) -> str:
|
||||
"""Full editor-document mention: title + workspace + real content."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT p.*, w.name AS ws_name FROM pages p "
|
||||
"LEFT JOIN workspaces w ON w.id=p.workspace_id "
|
||||
"WHERE p.id=? AND p.deleted_at IS NULL",
|
||||
(pid,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return f"- document #{pid}: not found"
|
||||
title = row["title"] or "Sans titre"
|
||||
ws = row["ws_name"] or ""
|
||||
loc = f" (espace: {ws})" if ws else ""
|
||||
fmt = row["content_format"] or "blocks"
|
||||
raw = row["content"] or ""
|
||||
if fmt == "markdown":
|
||||
body = raw.strip()
|
||||
elif fmt == "file":
|
||||
body = ""
|
||||
else:
|
||||
body = self._blocks_to_text(raw)
|
||||
head = f"- document #{row['id']} **{title}**{loc}"
|
||||
if body:
|
||||
return f"{head}:\n{body[:9000]}"
|
||||
return head
|
||||
|
||||
def _single_collection(self, cid: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
|
||||
@@ -227,6 +227,23 @@ class LLMClient:
|
||||
if draft:
|
||||
return LLMResponse(text=draft, model=model)
|
||||
|
||||
# Documents / espaces de travail (offline): unambiguous intents resolved
|
||||
# from the objective — create a document (optionally in a named
|
||||
# workspace) or list the accessible workspaces.
|
||||
doc_args = self._document_create_args(objective)
|
||||
if doc_args is not None:
|
||||
return LLMResponse(
|
||||
tool_calls=[{"name": "create_document", "arguments": doc_args}],
|
||||
text="Plan: création d'un document.",
|
||||
model=model,
|
||||
)
|
||||
if self._is_workspaces_request(objective):
|
||||
return LLMResponse(
|
||||
tool_calls=[{"name": "read_workspaces", "arguments": {}}],
|
||||
text="Plan: lister les espaces de travail.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Exact keyword → tool intent resolution (objective only).
|
||||
for regex, builder in _CREATE_PATTERNS:
|
||||
m = regex.search(objective)
|
||||
@@ -276,6 +293,46 @@ class LLMClient:
|
||||
args["collection_id"] = ids[0]
|
||||
return args
|
||||
|
||||
@staticmethod
|
||||
def _document_create_args(content: str) -> dict | None:
|
||||
"""Deterministic `create_document` intent for the offline mock.
|
||||
|
||||
Only fires when the user clearly asks to *create* a document (a content
|
||||
rewrite such as « résume / traduis ce document » is left to `_draft_reply`).
|
||||
Returns None when the message is not a create-document intent.
|
||||
"""
|
||||
low = content.lower()
|
||||
if "document" not in low:
|
||||
return None
|
||||
if not any(k in low for k in ("création", "créer", "crée", "crées", "create",
|
||||
"nouveau document", "nouvelle page", "faire un")):
|
||||
return None
|
||||
quotes = re.findall(r'[«"]([^«»"]{1,80})[»"]', content)
|
||||
title = quotes[0].strip() if quotes else None
|
||||
if not title:
|
||||
m = re.search(
|
||||
r"\bdocument\b\s*(?:nomm[ée]e?\s+|intitul[ée]e?\s+|appel[ée]e?\s+)?"
|
||||
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60})',
|
||||
content, re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
if not title:
|
||||
return None
|
||||
args = {"title": title}
|
||||
if len(quotes) > 1 and re.search(r"\b(workspace|espace de travail)\b", low):
|
||||
args["workspace_name"] = quotes[-1].strip()
|
||||
return args
|
||||
|
||||
@staticmethod
|
||||
def _is_workspaces_request(content: str) -> bool:
|
||||
"""True when the user asks to list / locate the workspaces."""
|
||||
low = content.lower()
|
||||
has_ws = any(w in low for w in ("workspace", "espace de travail", "espaces de travail"))
|
||||
has_verb = any(v in low for v in ("liste", "lister", "list", "quels", "montre",
|
||||
"affiche", "mes espaces", "ou sont", "où sont"))
|
||||
return has_ws and has_verb
|
||||
|
||||
@staticmethod
|
||||
def _system_content(messages) -> str:
|
||||
return "\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
|
||||
|
||||
@@ -22,13 +22,15 @@ DESTRUCTIVE_ROLES = {"admin", "owner"}
|
||||
# Tools that mutate state and therefore require at least an editor role.
|
||||
WRITE_TOOLS = {
|
||||
"create_collection", "create_view", "create_page", "update_page",
|
||||
"write_blocks", "add_property", "add_relation", "create_sub_item",
|
||||
"add_dependency", "sync_gitea", "create_gitea_issue", "apply_template",
|
||||
"write_blocks", "create_document", "add_property", "add_relation",
|
||||
"create_sub_item", "add_dependency", "sync_gitea", "create_gitea_issue",
|
||||
"apply_template",
|
||||
}
|
||||
|
||||
# Tools that delete / are destructive → admin/owner (or confirm mode).
|
||||
DESTRUCTIVE_TOOLS = {
|
||||
"delete_page", "delete_collection", "delete_property", "delete_view",
|
||||
"delete_page", "delete_collection", "delete_document",
|
||||
"delete_property", "delete_view",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+216
-21
@@ -1,9 +1,15 @@
|
||||
"""FlowDeck — Agent tool registry (v4.10.0).
|
||||
"""FlowDeck — Agent tool registry (v4.14.0).
|
||||
|
||||
The agent never re-invents FlowDeck: each tool is a thin wrapper over the same
|
||||
operations the human-facing routers perform (create collection/page/property,
|
||||
manage views, dependencies, Gitea issues, templates). Every mutating tool
|
||||
returns an *undo snapshot* so AgentEngine can journal + roll back each action.
|
||||
manage views, dependencies, Gitea issues, templates, workspaces & documents).
|
||||
Every mutating tool returns an *undo snapshot* so AgentEngine can journal and
|
||||
roll back each action.
|
||||
|
||||
Since v4.14.0 the registry also covers *documents* (Notion-style pages that
|
||||
live inside a workspace, table ``pages``) and lets the agent read the list of
|
||||
workspaces — fixing the case where "crée un document dans le workspace X"
|
||||
used to end with no action.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -43,7 +49,8 @@ class Tool:
|
||||
|
||||
class SearchWorkspace(Tool):
|
||||
name = "search_workspace"
|
||||
description = "Recherche full-text (par titre) des collections et pages FlowDeck."
|
||||
description = ("Recherche full-text (par titre) dans tout FlowDeck : collections, pages de "
|
||||
"collection, documents (pages éditeur) et espaces de travail.")
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "terme recherché"}},
|
||||
@@ -55,13 +62,24 @@ class SearchWorkspace(Tool):
|
||||
with get_conn() as conn:
|
||||
colls = conn.execute("SELECT id, name, icon FROM collections WHERE name LIKE ? ORDER BY name", (q,)).fetchall()
|
||||
pages = conn.execute("SELECT id, collection_id, title FROM collection_pages WHERE title LIKE ? ORDER BY updated_at DESC LIMIT 20", (q,)).fetchall()
|
||||
docs = conn.execute(
|
||||
"SELECT id, title, workspace_id, content_format FROM pages "
|
||||
"WHERE deleted_at IS NULL AND title LIKE ? ORDER BY updated_at DESC LIMIT 20",
|
||||
(q,),
|
||||
).fetchall()
|
||||
workspaces = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE name LIKE ? ORDER BY name", (q,),
|
||||
).fetchall()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="search",
|
||||
data={
|
||||
"collections": [dict(c) for c in colls],
|
||||
"pages": [dict(p) for p in pages],
|
||||
"documents": [dict(d) for d in docs],
|
||||
"workspaces": [dict(w) for w in workspaces],
|
||||
},
|
||||
message=f"{len(colls)} collection(s) et {len(pages)} page(s) trouvée(s)",
|
||||
message=(f"{len(colls)} collection(s), {len(pages)} page(s), "
|
||||
f"{len(docs)} document(s), {len(workspaces)} espace(s) trouvé(s)"),
|
||||
)
|
||||
|
||||
|
||||
@@ -116,6 +134,66 @@ class ReadPage(Tool):
|
||||
)
|
||||
|
||||
|
||||
class ReadWorkspaces(Tool):
|
||||
name = "read_workspaces"
|
||||
description = ("Liste les espaces de travail accessibles (id, nom, rôle) avec le nombre de "
|
||||
"documents et de collections qu'ils contiennent — utile pour savoir où créer "
|
||||
"ou chercher un document.")
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "filtre optionnel sur le nom"}},
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
query = (args.get("query") or "").strip()
|
||||
base_sql = (
|
||||
"SELECT w.id, w.name, {role} AS role, "
|
||||
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id=w.id AND p.deleted_at IS NULL) AS document_count, "
|
||||
"(SELECT COUNT(*) FROM collections c WHERE c.workspace_id=w.id) AS collection_count "
|
||||
"FROM workspaces w {join} {where} ORDER BY w.name"
|
||||
)
|
||||
with get_conn() as conn:
|
||||
if user_id is None:
|
||||
rows = conn.execute(
|
||||
base_sql.format(role="'owner'", join="", where=""), []
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
base_sql.format(
|
||||
role="COALESCE(wm.role, CASE WHEN w.owner_id=? THEN 'owner' ELSE 'viewer' END)",
|
||||
join="LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=?",
|
||||
where="WHERE w.owner_id=? OR wm.user_id IS NOT NULL",
|
||||
),
|
||||
(user_id, user_id, user_id),
|
||||
).fetchall()
|
||||
data = [dict(r) for r in rows]
|
||||
if query:
|
||||
q = query.lower()
|
||||
data = [w for w in data if q in str(w.get("name", "")).lower()]
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="workspaces",
|
||||
data={"workspaces": data},
|
||||
message=f"{len(data)} espace(s) de travail",
|
||||
)
|
||||
|
||||
|
||||
class ReadDocument(Tool):
|
||||
name = "read_document"
|
||||
description = "Lit un document (page éditeur) : titre, contenu et métadonnées."
|
||||
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
pid = args.get("page_id")
|
||||
with get_conn() as conn:
|
||||
doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (pid,)).fetchone()
|
||||
if not doc:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Document #{pid} introuvable")
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
data={"document": dict(doc)},
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════ Write tools (with undo) ══════════════════════════
|
||||
|
||||
|
||||
@@ -282,6 +360,83 @@ class CreatePage(Tool):
|
||||
return out
|
||||
|
||||
|
||||
class CreateDocument(Tool):
|
||||
name = "create_document"
|
||||
description = ("Crée un document (page éditeur type Notion) dans un espace de travail, ou à la "
|
||||
"racine. Le contenu initial est optionnel (Markdown).")
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "titre du document"},
|
||||
"content": {"type": "string", "description": "contenu initial en Markdown (optionnel)"},
|
||||
"workspace_id": {"type": "integer", "description": "id de l'espace de travail cible (optionnel)"},
|
||||
"workspace_name": {"type": "string", "description": "nom exact de l'espace de travail cible (optionnel)"},
|
||||
"parent_id": {"type": "integer", "description": "document parent (sous-page) optionnel"},
|
||||
},
|
||||
"required": ["title"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
title = (args.get("title") or "").strip()
|
||||
if not title:
|
||||
return ToolResult(status="error", tool=self.name, message="title est requis")
|
||||
content = (args.get("content") or "").strip()
|
||||
ws_id = args.get("workspace_id")
|
||||
ws_name = (args.get("workspace_name") or "").strip()
|
||||
parent_id = args.get("parent_id") or None
|
||||
|
||||
with get_conn() as conn:
|
||||
workspace = None
|
||||
if ws_id is not None:
|
||||
workspace = conn.execute("SELECT * FROM workspaces WHERE id=?", (ws_id,)).fetchone()
|
||||
if not workspace:
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Workspace #{ws_id} introuvable")
|
||||
elif ws_name:
|
||||
workspace = conn.execute(
|
||||
"SELECT * FROM workspaces WHERE lower(name)=lower(?) ORDER BY id LIMIT 1",
|
||||
(ws_name,),
|
||||
).fetchone()
|
||||
if not workspace:
|
||||
available = conn.execute("SELECT name FROM workspaces ORDER BY name").fetchall()
|
||||
names = ", ".join(r["name"] for r in available) or "aucun"
|
||||
return ToolResult(
|
||||
status="error", tool=self.name,
|
||||
message=f"Workspace « {ws_name} » introuvable. Disponibles : {names}",
|
||||
)
|
||||
if parent_id:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(parent_id,)).fetchone():
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Document parent #{parent_id} introuvable")
|
||||
|
||||
w = dict(workspace) if workspace else None
|
||||
ws_id_val = w["id"] if w else (ws_id if ws_id is not None else None)
|
||||
ws_key = (w["name"] if w else "") if ws_id_val is not None else ""
|
||||
|
||||
if content:
|
||||
fmt, store = "markdown", content
|
||||
else:
|
||||
fmt, store = "blocks", "[]"
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format,
|
||||
parent_section, parent_id, sort_order)
|
||||
VALUES (?,?,?,?,?,?,?,0)""",
|
||||
(ws_key, ws_id_val, title, store, fmt, "Private", parent_id),
|
||||
)
|
||||
pid = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
loc = f" dans « {ws_key} »" if ws_key else ""
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
data={"document_id": pid, "title": title,
|
||||
"workspace_id": ws_id_val, "workspace": ws_key},
|
||||
message=f"Document « {title} » créé{loc}",
|
||||
undo={"action": "delete", "table": "pages", "id": pid},
|
||||
)
|
||||
|
||||
|
||||
class UpdatePage(Tool):
|
||||
name = "update_page"
|
||||
description = "Modifie le titre et/ou les valeurs de propriétés d'une page."
|
||||
@@ -323,32 +478,46 @@ class UpdatePage(Tool):
|
||||
|
||||
class WriteBlocks(Tool):
|
||||
name = "write_blocks"
|
||||
description = "Écrit du contenu (blocs éditeur) sur une page éditeur (`pages`)."
|
||||
description = ("Met à jour un document (page éditeur `pages`) : remplace son contenu en blocs "
|
||||
"et/ou renomme son titre.")
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_id": {"type": "integer"},
|
||||
"blocks": {"type": "array"},
|
||||
"page_id": {"type": "integer", "description": "id du document (page éditeur)"},
|
||||
"blocks": {"type": "array", "description": "nouveau contenu en blocs (remplace le contenu)"},
|
||||
"title": {"type": "string", "description": "nouveau titre (optionnel)"},
|
||||
},
|
||||
"required": ["page_id", "blocks"],
|
||||
"required": ["page_id"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
pid = args.get("page_id")
|
||||
blocks = args.get("blocks", [])
|
||||
has_blocks = "blocks" in args
|
||||
new_title = (args.get("title") or "").strip()
|
||||
if not has_blocks and not new_title:
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message="Fournir `blocks` et/ou `title`")
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
page = conn.execute("SELECT * FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
if not page:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Page éditeur #{pid} introuvable")
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(blocks, ensure_ascii=False), pid),
|
||||
)
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Document (page éditeur) #{pid} introuvable")
|
||||
snapshot = {"content": page["content"], "content_format": page["content_format"],
|
||||
"title": page["title"]}
|
||||
final_title = new_title or page["title"]
|
||||
if has_blocks:
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(args["blocks"], ensure_ascii=False), final_title, pid),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(final_title, pid))
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="page", target_id=pid,
|
||||
data={"page_id": pid, "blocks": len(blocks)},
|
||||
undo={"action": "update", "table": "pages", "id": pid, "snapshot": page["content"]},
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
data={"document_id": pid, "title": final_title, "blocks": len(args.get("blocks", []))},
|
||||
undo={"action": "update", "table": "pages", "id": pid, "snapshot": snapshot},
|
||||
)
|
||||
|
||||
|
||||
@@ -486,6 +655,30 @@ class ApplyTemplate(Tool):
|
||||
# ══════════════════════════ Destructive tools ══════════════════════════
|
||||
|
||||
|
||||
class DeleteDocument(Tool):
|
||||
name = "delete_document"
|
||||
description = "Supprime un document (page éditeur) — envoi à la corbeille (destructif, mode confirm requis)."
|
||||
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from datetime import datetime
|
||||
pid = args.get("page_id")
|
||||
with get_conn() as conn:
|
||||
doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(pid,)).fetchone()
|
||||
if not doc:
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Document #{pid} introuvable")
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), pid))
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
message=f"Document #{pid} déplacé vers la corbeille",
|
||||
undo={"action": "softdelete", "table": "pages", "id": pid},
|
||||
)
|
||||
|
||||
|
||||
class DeletePage(Tool):
|
||||
name = "delete_page"
|
||||
description = "Supprime une page d'une collection (destructif, mode confirm requis)."
|
||||
@@ -612,11 +805,13 @@ class CreateGiteaIssue(Tool):
|
||||
# ══════════════════════════ Registry ══════════════════════════
|
||||
|
||||
TOOL_CLASSES = [
|
||||
SearchWorkspace, ReadCollection, ReadPage,
|
||||
CreateCollection, CreateView, AddProperty, CreatePage, UpdatePage, WriteBlocks,
|
||||
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
||||
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
||||
UpdatePage, WriteBlocks,
|
||||
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
|
||||
DeletePage, DeleteCollection,
|
||||
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
||||
DeleteDocument,
|
||||
]
|
||||
|
||||
|
||||
|
||||
+719
-49
@@ -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});
|
||||
|
||||
Reference in New Issue
Block a user