From f1dd9d6181469842f314a3216e280fc2243f213e Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 11 Sep 2026 14:30:26 -0400 Subject: [PATCH] =?UTF-8?q?feat(agent):=20v5.11.0=20=E2=80=94=20auto-title?= =?UTF-8?q?=20per=20request,=20history=20hover=20preview,=20write=5Fblocks?= =?UTF-8?q?=20normalization,=20Notion-style=20steps=20accordion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 23 +++ VERSION | 2 +- app/services/agent_engine.py | 34 ++-- app/services/tool_registry.py | 80 +++++++- app/templates/agent_panel.html | 325 +++++++++++++++++++++++++++++++-- flowdeck.db | Bin 544768 -> 598016 bytes tests/test_agent.py | 72 ++++++++ 7 files changed, 507 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7311627..f3410c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog - FlowDeck +## v5.11.0 (2026-09-11) — FlowDeck Agent : UX chat améliorée + +> Quatre améliorations majeures du panneau Agent (v4.10.0 → v4.14.0) pour +> une expérience plus proche de Notion AI. + +- **Auto-titre à chaque requête** — Le titre de la conversation se met à jour + après chaque échange avec l'IA (pas seulement à la création). Il reflète + le sujet courant et rend l'historique facile à parcourir. +- **Aperçu au survol / appui long** — Dans l'onglet Historique, un survol + souris (desktop) ou un appui long (mobile) ouvre un pop-up avec le premier + message utilisateur et la dernière réponse de l'agent, sans quitter la liste. +- **Normalisation des blocs `write_blocks`** — L'outil tolère désormais les + erreurs fréquentes du LLM : `text`→`content`, `heading`→`heading_1`…`heading_4`, + `bullet/list`→`bulleted_list`, génération d'ID, types inconnus→paragraphe. + Le script PowerShell (ou tout code) s'écrit correctement dans un bloc `code`. +- **Accordéon style Notion** — Le raisonnement de l'agent s'affiche dans un + composant pliable : « Pensées » (chevron, contenu complet au clic) et + « Actions » (lignes simples, passé, non-cliquables). Le tout reste visible + en permanence sous la réponse, sans encombrer l'écran. +- **Tests** — `test_write_blocks_normalizes_llm_blocks`, + `test_engine_titles_conversation_on_each_request`, + `test_suggest_title_strips_composer_prefixes`. + ## v5.9.0 (2026-09-10) — AI Writing Assist (éditeur) > L'IA Notion dans l'éditeur : commandes slash `/ai`, autocomplétion en ligne diff --git a/VERSION b/VERSION index c355d6e..57f82f7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.10.0 \ No newline at end of file +5.11.0 \ No newline at end of file diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index 739130f..f9ea654 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -32,6 +32,7 @@ MAX_ITERATIONS = 12 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. +- **Format des blocs** (pour `write_blocks`) : chaque bloc est un objet `{"type": "...", "content": "texte"}`. Le champ du texte s'appelle **`content`** (jamais `text`). Un script / code s'écrit dans un bloc `{"type": "code", "content": "...", "language": "powershell"}`. Les titres sont `heading_1`, `heading_2`, `heading_3`, `heading_4`. Autres types : paragraph, bulleted_list, numbered_list, to_do, quote, divider, toggle, callout. - **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 »). @@ -151,6 +152,15 @@ class AgentEngine: self._persist_message(conversation_id, "user", objective) self._update_conversation(conversation_id, status="running") + # Update the history title right away (before the run finishes) and + # refine it once we have the final answer (_autotitle below). + try: + suggested = self._suggest_title(objective, None) + 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) + tool_schema = self.tools.schema(scope) final_text = None used_model = model or "" # peut être ajusté par un repli de modèle (404/410) @@ -328,21 +338,11 @@ class AgentEngine: # ── 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.""" + """Rename the conversation with a descriptive title derived from the + *latest* user request. Runs after every AI call so the history list + always reflects the current topic and stays easy to browse.""" 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]) @@ -353,6 +353,14 @@ class AgentEngine: 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" + # Strip the composer prefixes ("Contexte « X »", "Skill « Y »") that the + # frontend prepends before the real user text. + text = re.sub( + r"(?:Contexte\s*«[^»]*»|Skill\s*«[^»]*»|Skill\s+«[^»]*»)(?:\s*[,;\n.])+\s*", + "", text, + ).strip() if not text: return "Conversation" low = text.lower() diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py index ee54923..6d9ad2b 100644 --- a/app/services/tool_registry.py +++ b/app/services/tool_registry.py @@ -479,17 +479,90 @@ class UpdatePage(Tool): class WriteBlocks(Tool): name = "write_blocks" description = ("Met à jour un document (page éditeur `pages`) : remplace son contenu en blocs " - "et/ou renomme son titre.") + "et/ou renomme son titre. Chaque bloc est un objet JSON " + "{\"type\": \"...\", \"content\": \"texte du bloc\"}. Types autorisés : " + "paragraph, heading_1, heading_2, heading_3, heading_4, bulleted_list, " + "numbered_list, to_do, quote, divider, toggle, callout, code. " + "Utilise `type: \"code\"` (avec `language`) pour un script ou du code " + "multi-lignes, `heading_1`/`heading_2`/… pour les titres. " + "Le champ contenant le texte s'appelle `content`, PAS `text`.") parameters = { "type": "object", "properties": { "page_id": {"type": "integer", "description": "id du document (page éditeur)"}, - "blocks": {"type": "array", "description": "nouveau contenu en blocs (remplace le contenu)"}, + "blocks": {"type": "array", + "description": "nouveau contenu en blocs (remplace le contenu). " + "Exemple: [{\"type\": \"heading_1\", \"content\": \"Titre\"}, " + "{\"type\": \"code\", \"content\": \"Write-Host 'hi'\", \"language\": \"powershell\"}]"}, "title": {"type": "string", "description": "nouveau titre (optionnel)"}, }, "required": ["page_id"], } + _VALID_BLOCK_TYPES = frozenset({ + "paragraph", "heading_1", "heading_2", "heading_3", "heading_4", + "bulleted_list", "numbered_list", "to_do", "toggle", "quote", + "callout", "table_of_contents", "math", "columns", "divider", + "code", "table", "button", "image", "embed", "bookmark", + "video", "audio", "meeting", + }) + + @classmethod + def _normalize_blocks(cls, blocks: list) -> list: + """Normalize blocks coming from the LLM so the editor can render them. + + Common LLM mistakes handled: + - ``text`` instead of ``content`` + - ``heading`` instead of ``heading_1`` / ``heading_2`` / … + - ``bullet`` / ``list`` instead of ``bulleted_list`` + - ``numbered`` instead of ``numbered_list`` + - ``check`` / ``checkbox`` instead of ``to_do`` + - ``code_block`` instead of ``code`` + - ``h1`` … ``h6`` shorthand + - Missing ``id`` fields (editor assigns them on load but we add them + to keep the JSON self-contained) + """ + import uuid + + _TYPE_MAP = { + "heading": "heading_1", "h1": "heading_1", "h2": "heading_2", + "h3": "heading_3", "h4": "heading_4", "h5": "heading_4", + "h6": "heading_4", + "bullet": "bulleted_list", "bullets": "bulleted_list", "list": "bulleted_list", + "numbered": "numbered_list", "numbered_list": "numbered_list", + "check": "to_do", "checkbox": "to_do", "task": "to_do", + "code_block": "code", + "hr": "divider", "horizontal_rule": "divider", "horizontal rule": "divider", + } + + out = [] + for block in (blocks or []): + if not isinstance(block, dict): + continue + b = dict(block) + # --- normalize type --- + raw_type = (b.get("type") or "paragraph").strip().lower() + btype = _TYPE_MAP.get(raw_type, raw_type) + if btype not in cls._VALID_BLOCK_TYPES: + btype = "paragraph" + b["type"] = btype + # --- normalize content field (LLM often sends "text" instead of "content") --- + if "content" not in b and "text" in b: + b["content"] = b.pop("text") + elif "content" not in b: + # Try other common fields + for alt in ("value", "body", "source"): + if alt in b: + b["content"] = b.pop(alt) + break + else: + b.setdefault("content", "") + # --- generate id if missing --- + if not b.get("id"): + b["id"] = f"b_{uuid.uuid4().hex[:12]}" + out.append(b) + return out + async def execute(self, args, *, user_id=None) -> ToolResult: pid = args.get("page_id") has_blocks = "blocks" in args @@ -506,9 +579,10 @@ class WriteBlocks(Tool): "title": page["title"]} final_title = new_title or page["title"] if has_blocks: + normalized = self._normalize_blocks(args["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), + (json.dumps(normalized, ensure_ascii=False), final_title, pid), ) else: conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index d0ed6c1..4a14942 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -37,6 +37,15 @@ .fd-ap-hist-item{padding:8px 11px;border-radius:9px;cursor:pointer;color:var(--text,#e0e0e0);font-size:13px;border:1px solid transparent;margin-bottom:2px} .fd-ap-hist-item:hover,.fd-ap-hist-item.active{background:var(--bg-hover,#262626);border-color:var(--border,rgba(255,255,255,.08))} +/* History hover / long-press preview */ +.fd-ap-preview{position:fixed;width:290px;max-width:calc(100vw - 24px);background:var(--bg-modal,#1c1c1c);border:1px solid var(--border,rgba(255,255,255,.16));border-radius:12px;box-shadow:0 16px 44px rgba(0,0,0,.6);z-index:1990;padding:11px 13px;font-size:12px;color:var(--text,#e6e6e6);display:none;pointer-events:auto} +.fd-ap-preview.show{display:block} +.fd-ap-preview-title{font-weight:600;font-size:12.5px;margin-bottom:2px;color:var(--text,#fff);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fd-ap-preview-meta{font-size:10px;color:var(--text-dim,#999);margin-bottom:8px} +.fd-ap-preview-empty{font-size:11px;color:var(--text-dim,#aaa);padding:4px 0} +.fd-ap-preview-label{font-size:9.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim,#999);margin:7px 0 3px} +.fd-ap-preview-txt{white-space:pre-wrap;word-break:break-word;line-height:1.55;max-height:72px;overflow:hidden;color:var(--text-secondary,#d0d0d0)} + /* Empty / welcome state */ .fd-ap-empty{padding:6px 2px 4px} .fd-ap-empty-title{font-size:14px;font-weight:600;color:var(--text,#eee);margin-bottom:2px} @@ -66,6 +75,29 @@ .fd-ap-err{color:#ef8a8a;font-size:12px;padding:4px 2px} .fd-ap-notice{color:var(--text-secondary,#ccc);font-size:12px;padding:7px 10px;margin:2px 0 8px;border-radius:9px;background:rgba(128,128,128,.12);border:1px solid var(--border,rgba(255,255,255,.12))} +/* Steps accordion (Notion-style reasoning + actions audit trail) */ +.fd-steps{margin:0 0 8px;border:1px solid var(--border,rgba(255,255,255,.11));border-radius:10px;background:rgba(255,255,255,.03);overflow:hidden} +.fd-steps-head{display:flex;align-items:center;gap:7px;width:100%;padding:7px 11px;background:transparent;border:none;color:var(--text-secondary,#ccc);font-size:12px;cursor:pointer;text-align:left} +.fd-steps-head:hover{background:rgba(255,255,255,.05)} +.fd-steps-chev{display:inline-block;font-size:9px;color:var(--text-dim,#999);transition:transform .15s;width:12px;flex-shrink:0;text-align:center} +.fd-steps-chev.down{transform:rotate(90deg)} +.fd-steps-count{font-weight:600;color:var(--text,#eee)} +.fd-steps-elem{font-weight:400;color:var(--text-dim,#999);margin-left:6px} +.fd-steps-body{border-top:1px solid var(--border,rgba(255,255,255,.08));padding:6px 8px 8px;max-height:340px;overflow-y:auto} +.fd-step-thought{margin:3px 0} +.fd-step-thought-head{display:flex;align-items:center;gap:6px;width:100%;border:none;background:transparent;color:var(--text-dim,#b9b9b9);font-size:12px;text-align:left;cursor:pointer;padding:4px 5px;border-radius:7px} +.fd-step-thought-head:hover{background:rgba(128,128,128,.1);color:var(--text,#fff)} +.fd-step-thought-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fd-step-thought-title .fd-t-ico{opacity:.85} +.fd-step-thought-body{margin:2px 0 4px 27px;padding:7px 10px;font-size:12px;color:var(--text-secondary,#cfcfcf);background:rgba(128,128,128,.1);border-left:2px solid rgba(35,131,226,.45);border-radius:0 8px 8px 0;white-space:pre-wrap;word-break:break-word;line-height:1.55} +.fd-step-action{display:flex;align-items:flex-start;gap:7px;padding:4px 7px;font-size:12px;color:var(--text-secondary,#c9c9c9)} +.fd-step-action .fd-step-ico{flex-shrink:0;width:14px;text-align:center;font-size:11px;margin-top:1px} +.fd-step-action.success{color:var(--text,#d9d9d9)} +.fd-step-action.success .fd-step-ico{color:#58c47a} +.fd-step-action.error{color:#e78c8c} +.fd-step-action.error .fd-step-ico{color:#ef6a6a} +.fd-step-action-detail{font-size:11px;color:#e0a3a3;display:block;margin-top:1px} + /* Composer */ .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} @@ -199,7 +231,7 @@
-
How can I help you today?
+
@@ -223,7 +255,11 @@