feat(agent): v5.11.0 — auto-title per request, history hover preview, write_blocks normalization, Notion-style steps accordion

This commit is contained in:
2026-09-11 14:30:26 -04:00
parent 6fe5d2f723
commit f1dd9d6181
7 changed files with 507 additions and 29 deletions
+21 -13
View File
@@ -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()
+77 -3
View File
@@ -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=?",