diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba36ff..1851aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,72 @@ # Changelog — FlowDeck +## v4.15.0 (2026-09-06) — Agent : actions sous chaque réponse, mentions @ & skills / + +> Le panneau FlowDeck Agent gagne l'expérience « type Notion AI » : chaque réponse de l'agent expose +> des actions rapides (copier, insérer dans la page, feedback 👍/👎), on peut **épingler au contexte** +> plusieurs documents / pages / bases via la commande **@** (ou le bouton **+**), et la commande **/** +> propose des **skills** de travail réutilisables en plus des commandes admin existantes. + +### Sous chaque réponse de l'agent +- **⧉ copy response** : copie la réponse dans le presse-papiers. +- **➕ insert into this page** : insère la réponse dans la page ouverte (désactivé sans page éditable). +- **👍 / 👎 Share feedback** : enregistre le retour en base (`agent_feedback`) avec confirmation. + +### Contexte épinglé — commande « @ » / bouton « + » (style Notion AI) +- Le composeur est maintenant un éditeur **contenteditable** : les références sélectionnées + s'insèrent **inline** comme des pastilles cliquables (icône + nom) directement dans le message. +- Au-dessus du composeur, une **barre de contexte groupée** rassemble les fichiers épinglés + (rangée documents, icônes 📄) et les **skills** (rangée séparée, icône ✨), chacun retirable. +- **@** ouvre le sélecteur (icône + titre + chemin) listant documents, pages et bases ; l'élément + choisi devient un jeton inline **et** une puce de contexte. **+** épingle un élément au contexte + sans l'insérer inline. Les puces persistent jusqu'au retrait (et sont vidées en nouveau chat). +- À l'envoi, chaque référence est transmise comme **mention** ; le backend résout son **contenu réel**. + +### Skills — commande « / » +- Le menu **/** liste les **skills intégrés** (research, resume, brainstorm, plan, meeting-note, + livrable…), les **skills enregistrés** (table `agent_skills`) et les commandes admin classiques. +- Un skill intégré pré-remplit le composeur d'un modèle à compléter ; un skill en base s'épingle en + chip et est appliqué via `skill_id` à chaque envoi. +- Navigation clavier (↑/↓/Entrée/Échap) dans les menus ; aucun envoi parasite pour « / » seul. + +### Infra & API +- Nouvelle table `agent_feedback` (rating, conversation/message, extrait, commentaire). +- `GET /api/agent/mentions?q=` : recherche des éléments mentionnables du workspace. +- `POST /api/agent/feedback` : enregistre un retour 👍/👎. +- `VERSION` → 4.15.0 ; `app/main.py` version → 4.15.0. + +## v4.14.0 (2026-09-06) — Agent : création de documents dans un espace + nouveaux outils & titre auto + +> L'agent sait enfin **créer, lire, modifier et supprimer des documents** (pages éditeur) **dans un +> espace de travail** — la demande « crée un document « Projet » dans le workspace « TEST WS 1 » » +> ne se termine plus par un simple `search_workspace` vide. Il liste aussi les espaces, et **renomme +> automatiquement les conversations** avec un titre descriptif au lieu de « Nouvelle conversation ». + +### Outils (nouveaux / corrigés) +- **`create_document`** : crée une page éditeur (`pages`) dans un espace (par `workspace_id` ou + `workspace_name`), avec contenu Markdown optionnel — snapshot d'undo. +- **`read_document`** : lit un document (titre + contenu + métadonnées). +- **`read_workspaces`** : liste les espaces accessibles (rôle, nb de documents/collections). +- **`delete_document`** : suppression → corbeille (`deleted_at`), annulable (`softdelete`). +- **`write_blocks`** étendu : renommage de titre et/ou écriture des blocs ; undo restaure titre + contenu. +- **`search_workspace`** étendu : recherche désormais aussi les **documents** et les **espaces de travail**. + +### Contexte & planification +- Contexte de l'agent enrichi : sections « Documents » et « Espaces de travail » (avec ids) pour que + le LLM cible le bon espace. Guide mis à jour. Mock hors-ligne : détection + « créer un document dans un espace » / « lister les espaces ». + +### Conversations +- Auto-titre : après la première exécution d'une conversation encore nommée « Nouvelle conversation », + l'agent génère un titre descriptif (déterministe, hors-ligne sûr) ; l'historique devient navigable. + +### UX +- Indication claire dans le composeur du panneau : **Entrée ↵ = envoyer · Maj+Entrée = nouvelle ligne** + (le comportement existait déjà, il est maintenant visible + placeholder mis à jour). + +### Infra +- `VERSION` → 4.14.0 ; `app/main.py` version → 4.14.0. + ## v4.13.2 (2026-09-06) — Saisie libre « écrire un texte » → proposition à appliquer au document > Quand un **document est ouvert** et qu'on tape directement une demande de **rédaction/contenu** diff --git a/VERSION b/VERSION index 650edfe..5c517bf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.13.2 +4.15.0 diff --git a/app/db.py b/app/db.py index aa74549..dbab895 100644 --- a/app/db.py +++ b/app/db.py @@ -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)") diff --git a/app/main.py b/app/main.py index fd85ade..8c4b41c 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/routers/agent.py b/app/routers/agent.py index 1b53507..b2ba9de 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -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:``, ``collection:``, + ``page:``) 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: + 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: + 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: + 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 ── diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index 6bcf44c..8abd238 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -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: diff --git a/app/services/context_builder.py b/app/services/context_builder.py index b8dd155..a7bafc4 100644 --- a/app/services/context_builder.py +++ b/app/services/context_builder.py @@ -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() diff --git a/app/services/llm_client.py b/app/services/llm_client.py index 4a4b5e1..3f35f6a 100644 --- a/app/services/llm_client.py +++ b/app/services/llm_client.py @@ -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") diff --git a/app/services/permission_manager.py b/app/services/permission_manager.py index e74daf9..45251d4 100644 --- a/app/services/permission_manager.py +++ b/app/services/permission_manager.py @@ -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", } diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py index 9e8989d..fb328d1 100644 --- a/app/services/tool_registry.py +++ b/app/services/tool_registry.py @@ -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, ] diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index b7aa862..33b8cb9 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -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} @@ -196,6 +241,14 @@ Hors-ligne — configurez une clé API pour appliquer la modification. + @@ -207,26 +260,69 @@
-
-