feat(agent): v5.11.0 — auto-title per request, history hover preview, write_blocks normalization, Notion-style steps accordion
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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=?",
|
||||
|
||||
+313
-12
@@ -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 @@
|
||||
<div class="fd-ap-head">
|
||||
<span class="fd-ap-logo"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3Z"/></svg></span>
|
||||
<div class="fd-ap-head-main">
|
||||
<div class="fd-ap-title">How can I help you today?</div>
|
||||
<div class="fd-ap-title" x-text="currentConv && currentConv.title ? currentConv.title : 'How can I help you today?'"></div>
|
||||
<div class="fd-ap-sub" x-text="providerModelLabel"></div>
|
||||
</div>
|
||||
<button class="fd-ap-headbtn" title="Nouvelle conversation" @click="newConversation()">+</button>
|
||||
@@ -223,7 +255,11 @@
|
||||
<template x-if="tab==='history'">
|
||||
<div>
|
||||
<template x-for="c in conversations" :key="c.id">
|
||||
<div class="fd-ap-hist-item" :class="{active: currentConv && currentConv.id===c.id}" @click="loadConversation(c.id)">
|
||||
<div class="fd-ap-hist-item" :class="{active: currentConv && currentConv.id===c.id}"
|
||||
@click="historyClick(c.id)"
|
||||
@mouseenter="previewConversation(c, $event)" @mouseleave="schedulePreviewHide()"
|
||||
@touchstart="startLongPress(c, $event)" @touchend="onTouchEnd()"
|
||||
@touchmove="onTouchCancel()" @touchcancel="onTouchCancel()">
|
||||
<div x-text="c.title || 'New conversation'"></div>
|
||||
<div style="font-size:11px;color:var(--text-dim)" x-text="c.updated_at"></div>
|
||||
</div>
|
||||
@@ -253,6 +289,38 @@
|
||||
<div class="fd-agent-msg-avatar" x-text="m.role==='assistant'?'🤖':'👤'"></div>
|
||||
<div class="fd-agent-msg-body">
|
||||
<div class="fd-agent-msg-meta" x-text="(m.role==='assistant'?'Agent':'Vous')+(m.model?' · '+m.model:'')"></div>
|
||||
<!-- Steps accordion (reasoning thoughts + tool actions) -->
|
||||
<div class="fd-steps" x-show="m.steps && m.steps.length" x-cloak>
|
||||
<button class="fd-steps-head" type="button" @click="toggleSteps(m)">
|
||||
<span class="fd-steps-chev" :class="{down: m.stepsOpen}">▶</span>
|
||||
<span class="fd-steps-count" x-text="m.steps.length === 1 ? '1 étape' : m.steps.length + ' étapes'"></span>
|
||||
<span class="fd-steps-elem" x-text="m.generating ? 'en cours…' : (m.stepsOpen ? 'réduire' : 'voir le déroulé')"></span>
|
||||
</button>
|
||||
<div class="fd-steps-body" x-show="m.stepsOpen" x-cloak>
|
||||
<template x-for="(s, si) in m.steps" :key="si">
|
||||
<div>
|
||||
<template x-if="s.type==='thought'">
|
||||
<div class="fd-step-thought">
|
||||
<button class="fd-step-thought-head" type="button" @click="toggleStep(s)">
|
||||
<span class="fd-steps-chev" :class="{down: s.open}">▶</span>
|
||||
<span class="fd-step-thought-title"><span class="fd-t-ico">💭</span> <span x-text="s.title || 'Réflexion'"></span></span>
|
||||
</button>
|
||||
<div class="fd-step-thought-body" x-show="s.open" x-text="s.content"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.type==='action'">
|
||||
<div class="fd-step-action" :class="s.status || 'success'" :title="(s.detail && s.status==='error') ? s.detail : s.tool || ''">
|
||||
<span class="fd-step-ico" x-text="(s.status==='success') ? '✓' : '✗'"></span>
|
||||
<div>
|
||||
<div x-text="s.label"></div>
|
||||
<div class="fd-step-action-detail" x-show="s.status==='error' && s.detail" x-text="s.detail" x-cloak></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fd-agent-msg-content" x-show="m.generating" x-cloak>Génération…</div>
|
||||
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-html="renderMarkdown(m.content)"></div>
|
||||
<div class="fd-agent-msg-content" x-show="!m.generating && m.role!=='assistant' && m.content" x-text="m.content"></div>
|
||||
@@ -276,7 +344,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="fd-agent-thinking" x-show="streaming">L'agent réfléchit et agit… <span x-text="liveReasoning"></span></div>
|
||||
<div class="fd-agent-thinking" x-show="streaming && !(_livePh && _livePh.steps && _livePh.steps.length)">L'agent réfléchit et agit… <span x-text="liveReasoning"></span></div>
|
||||
<div class="fd-ap-notice" x-show="notice" x-cloak x-text="notice"></div>
|
||||
<div class="fd-ap-err" x-show="errorMsg" x-text="errorMsg"></div>
|
||||
</div>
|
||||
@@ -372,6 +440,18 @@
|
||||
<a href="/settings" target="_blank" @click="modelOpen=false">⚙️ Configurer (clés API) →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Historique : aperçu (hover / long-press) #}
|
||||
<div class="fd-ap-preview" x-ref="convPreview" :class="{show: previewVisible}"
|
||||
@mouseenter="cancelPreviewHide()" @mouseleave="schedulePreviewHide()" x-cloak>
|
||||
<div class="fd-ap-preview-title" x-text="previewTitle"></div>
|
||||
<div class="fd-ap-preview-meta" x-text="previewMeta"></div>
|
||||
<div class="fd-ap-preview-empty" x-show="!previewStart && !previewEnd" x-text="previewEmptyHint"></div>
|
||||
<div class="fd-ap-preview-label" x-show="previewStart" x-cloak>Début</div>
|
||||
<div class="fd-ap-preview-txt" x-show="previewStart" x-text="previewStart"></div>
|
||||
<div class="fd-ap-preview-label" x-show="previewEnd" x-cloak>Réponse</div>
|
||||
<div class="fd-ap-preview-txt" x-show="previewEnd" x-text="previewEnd"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -423,6 +503,9 @@
|
||||
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
|
||||
cmdFocus: -1, slashDismiss: false,
|
||||
toastMsg: '', toastErr: false, _toastTimer: null,
|
||||
_livePh: null,
|
||||
previewVisible: false, previewTitle: '', previewMeta: '', previewStart: '', previewEnd: '',
|
||||
_previewCache: null, _previewTimer: null, _longPressTimer: null, _suppressNextClick: false,
|
||||
_slashSeq: 0,
|
||||
|
||||
init(){
|
||||
@@ -498,6 +581,7 @@
|
||||
if(!this.open) return;
|
||||
this.open = false;
|
||||
this.modelOpen = false;
|
||||
this.hidePreview();
|
||||
if(this._applyState) this._applyState();
|
||||
},
|
||||
toggle(){ this.open ? this.close() : this.openAgent(); },
|
||||
@@ -740,6 +824,7 @@
|
||||
var self = this;
|
||||
this.tab='chat';
|
||||
this.errorMsg = '';
|
||||
this.hidePreview();
|
||||
fetch('/api/agent/conversations/'+id).then(function(r){return r.json()}).then(function(d){
|
||||
self.currentConv = d.conversation;
|
||||
self.messages = (d.messages||[]).map(function(m){ return {id:m.id, role:m.role, content:m.content, model:m.model, tool_calls:m.tool_calls_json}; });
|
||||
@@ -747,8 +832,119 @@
|
||||
if(prov && (!self.providers.length || self.providers.some(function(x){ return x.id === prov; }))){ self.llmProvider = prov; }
|
||||
self.applyModels();
|
||||
if(d.conversation && d.conversation.model && self.providerModels.indexOf(d.conversation.model) !== -1){ self.llmModel = d.conversation.model; }
|
||||
// Rebuild the steps accordion from the audit trail (actions only —
|
||||
// the reasoning thoughts are not persisted, only shown live).
|
||||
self.attachPersistedSteps(id);
|
||||
}).catch(function(){});
|
||||
},
|
||||
attachPersistedSteps(id){
|
||||
var self = this;
|
||||
fetch('/api/agent/conversations/'+id+'/actions').then(function(r){return r.json()}).then(function(d){
|
||||
var acts = d.actions || [];
|
||||
if(!acts.length) return;
|
||||
var lastAsst = null;
|
||||
for(var i=self.messages.length-1;i>=0;i--){
|
||||
var m = self.messages[i];
|
||||
if(m.role==='assistant' && !m.proposal){ lastAsst = m; break; }
|
||||
}
|
||||
if(!lastAsst) return;
|
||||
var steps = acts.map(function(a){
|
||||
var res = null;
|
||||
try{ res = a.result_json ? JSON.parse(a.result_json) : {}; }catch(e){ res = {}; }
|
||||
var msg = (res && res.message) ? res.message : '';
|
||||
return agentPanel._actionStep({
|
||||
tool: a.tool_name, status: a.status, target_id: a.target_id,
|
||||
message: msg, detail: msg || ''
|
||||
});
|
||||
});
|
||||
lastAsst.steps = steps;
|
||||
lastAsst.stepsOpen = false;
|
||||
}).catch(function(){});
|
||||
},
|
||||
get previewEmptyHint(){ return 'Aucun échange enregistré pour cette conversation.'; },
|
||||
historyClick(id){
|
||||
if(this._suppressNextClick){ this._suppressNextClick = false; return; }
|
||||
this.loadConversation(id);
|
||||
},
|
||||
_previewSummary(msgs){
|
||||
var first = '', last = '';
|
||||
for(var i=0;i<msgs.length;i++){
|
||||
var r = msgs[i].role;
|
||||
var t = (msgs[i].content || '');
|
||||
if(r==='user'){ if(!first && t.trim()) first = agentPanel._strip(t); }
|
||||
else if(r==='assistant'){ if(t.trim()) last = agentPanel._strip(t); }
|
||||
}
|
||||
return { first: first, last: last, total: msgs.length };
|
||||
},
|
||||
previewConversation(c, ev){
|
||||
var self = this;
|
||||
// On touch: a quick tap triggers a synthetic mouseenter AFTER touchend —
|
||||
// skip it so we don't flash the preview before navigating (the long-press
|
||||
// timer handles the preview itself).
|
||||
if(ev && ev.type === 'mouseenter' && self._suppressEnterUntil && Date.now() < self._suppressEnterUntil) return;
|
||||
var rect = (ev && ev.currentTarget) ? ev.currentTarget.getBoundingClientRect() : null;
|
||||
var cache = self._previewCache || (self._previewCache = {});
|
||||
function show(sum){
|
||||
if(!sum) return;
|
||||
self.previewTitle = c.title || 'New conversation';
|
||||
var dateStr = (c.updated_at || '').replace('T', ' ').slice(0, 16);
|
||||
self.previewMeta = sum.total + ' message(s)' + (dateStr ? ' · ' + dateStr : '');
|
||||
self.previewStart = sum.first || '';
|
||||
self.previewEnd = sum.last || '';
|
||||
self.previewVisible = true;
|
||||
self.cancelLongPress();
|
||||
self.$nextTick(function(){
|
||||
var el = self.$refs && self.$refs.convPreview;
|
||||
if(!el) return;
|
||||
var pw = el.offsetWidth || 290;
|
||||
var m = 10;
|
||||
var x = rect ? (rect.left - pw - m) : m;
|
||||
if(x < m && rect) x = rect.right + m;
|
||||
var y = rect ? Math.min(rect.top, window.innerHeight - el.offsetHeight - m) : m;
|
||||
if(y < m) y = m;
|
||||
el.style.left = Math.round(x) + 'px';
|
||||
el.style.top = Math.round(y) + 'px';
|
||||
});
|
||||
}
|
||||
if(cache[c.id]){ show(cache[c.id]); return; }
|
||||
fetch('/api/agent/conversations/'+c.id).then(function(r){ return r.json(); }).then(function(d){
|
||||
var sum = self._previewSummary(d.messages || []);
|
||||
cache[c.id] = sum;
|
||||
show(sum);
|
||||
}).catch(function(){ self.schedulePreviewHide(); });
|
||||
},
|
||||
schedulePreviewHide(){
|
||||
var self = this;
|
||||
if(this._previewTimer) clearTimeout(this._previewTimer);
|
||||
this._previewTimer = setTimeout(function(){ self.previewVisible = false; self._previewTimer = null; }, 220);
|
||||
},
|
||||
cancelPreviewHide(){
|
||||
if(this._previewTimer){ clearTimeout(this._previewTimer); this._previewTimer = null; }
|
||||
},
|
||||
startLongPress(c, ev){
|
||||
var self = this;
|
||||
this.cancelLongPress();
|
||||
this._longPressTimer = setTimeout(function(){
|
||||
self._suppressNextClick = true;
|
||||
self.previewConversation(c, ev);
|
||||
}, 550);
|
||||
},
|
||||
cancelLongPress(){
|
||||
if(this._longPressTimer){ clearTimeout(this._longPressTimer); this._longPressTimer = null; }
|
||||
},
|
||||
onTouchEnd(){
|
||||
// A quick tap: suppress the synthetic mouseenter preview flash right
|
||||
// before the click navigates into the conversation.
|
||||
this.cancelLongPress();
|
||||
this._suppressEnterUntil = Date.now() + 400;
|
||||
},
|
||||
onTouchCancel(){
|
||||
this.cancelLongPress();
|
||||
},
|
||||
hidePreview(){
|
||||
if(this._previewTimer){ clearTimeout(this._previewTimer); this._previewTimer = null; }
|
||||
this.previewVisible = false;
|
||||
},
|
||||
newConversation(){
|
||||
var self = this;
|
||||
var body = {title:'Nouvelle conversation'};
|
||||
@@ -1592,6 +1788,16 @@
|
||||
}).catch(function(){ self.toast('Retour non envoyé.', true); });
|
||||
},
|
||||
|
||||
toggleSteps(m){
|
||||
if(!m) return;
|
||||
m.stepsOpen = !m.stepsOpen;
|
||||
this.$nextTick(function(){ this.scrollBottom(); });
|
||||
},
|
||||
toggleStep(s){
|
||||
if(!s) return;
|
||||
s.open = !s.open;
|
||||
},
|
||||
|
||||
// ── Send / SSE ──
|
||||
async send(){
|
||||
var self = this;
|
||||
@@ -1642,6 +1848,23 @@
|
||||
|
||||
self.streaming = true;
|
||||
self.liveReasoning = '…';
|
||||
self._livePh = null;
|
||||
var runSteps = [];
|
||||
var ph = null;
|
||||
function ensurePlaceholder(finalContent){
|
||||
if(ph) return ph;
|
||||
ph = {id:'r-'+Date.now(), role:'assistant', generating: !finalContent,
|
||||
content: finalContent || '', steps: [], stepsOpen: true,
|
||||
model: self.autoLabel || ''};
|
||||
self.messages.push(ph);
|
||||
self._livePh = ph;
|
||||
return ph;
|
||||
}
|
||||
function addStep(s){
|
||||
runSteps.push(s);
|
||||
if(ph){ ph.steps = runSteps.slice(); ph.generating = true; }
|
||||
if(self._livePh){ self._livePh.steps = runSteps.slice(); }
|
||||
}
|
||||
self.messages.push({id:'u-'+Date.now(), role:'user', content: displayText});
|
||||
self.scrollBottom();
|
||||
|
||||
@@ -1673,19 +1896,31 @@
|
||||
if(!line) return;
|
||||
try{
|
||||
var ev = JSON.parse(line);
|
||||
if(ev.type==='reasoning'){ self.liveReasoning = ev.content; }
|
||||
if(ev.type==='reasoning'){
|
||||
// A thought: collapsed by default, expandable to its full text.
|
||||
addStep({type:'thought', title:agentPanel._thoughtTitle(ev.content),
|
||||
content:(ev.content||'').replace(/\s+$/,''), open:false});
|
||||
}
|
||||
else if(ev.type==='action'){
|
||||
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, act:true});
|
||||
addStep(agentPanel._actionStep(ev));
|
||||
}
|
||||
else if(ev.type==='final'){
|
||||
self.messages.push({id:'f-'+Date.now(), role:'assistant', content: ev.content});
|
||||
var fph = ensurePlaceholder(ev.content);
|
||||
fph.generating = false;
|
||||
fph.content = ev.content;
|
||||
fph.steps = runSteps.slice();
|
||||
fph.stepsOpen = false;
|
||||
fph.model = self.autoLabel || '';
|
||||
if(window.__fdInsertAI){ try{ var h=window.__fdInsertAI; window.__fdInsertAI=null; h(ev.content||''); }catch(e){} }
|
||||
}
|
||||
else if(ev.type==='notice'){ self.notice = ev.message; }
|
||||
else if(ev.type==='error'){ self.errorMsg = ev.message; }
|
||||
else if(ev.type==='error'){
|
||||
var eph = ensurePlaceholder();
|
||||
eph.generating = false;
|
||||
eph.content = '⚠️ ' + ev.message;
|
||||
eph.steps = runSteps.slice();
|
||||
self.errorMsg = ev.message;
|
||||
}
|
||||
}catch(e){}
|
||||
});
|
||||
self.scrollBottom();
|
||||
@@ -1695,13 +1930,22 @@
|
||||
return read();
|
||||
}).catch(function(err){ self.errorMsg = (err && err.message) ? err.message : 'Erreur agent'; })
|
||||
.finally(function(){
|
||||
self.streaming = false; self.liveReasoning='';
|
||||
self.streaming = false; self.liveReasoning=''; self._livePh = null;
|
||||
self.fetchConversationsRefresh();
|
||||
});
|
||||
},
|
||||
fetchConversationsRefresh(){
|
||||
var self = this;
|
||||
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){ self.conversations = d.conversations || []; }).catch(function(){});
|
||||
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
|
||||
self.conversations = d.conversations || [];
|
||||
// keep the open conversation in sync so its auto-updated title shows
|
||||
// immediately in the header and in a later History screen.
|
||||
var cur = self.currentConv;
|
||||
if(cur && cur.id){
|
||||
var match = self.conversations.find(function(c){ return c.id === cur.id; });
|
||||
if(match){ cur.title = match.title; cur.updated_at = match.updated_at; }
|
||||
}
|
||||
}).catch(function(){});
|
||||
},
|
||||
scrollBottom(){
|
||||
this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; });
|
||||
@@ -1713,6 +1957,63 @@
|
||||
Alpine.data('agentPanel', agentPanel);
|
||||
});
|
||||
window.agentPanel = agentPanel;
|
||||
// Plain-text helper for the history preview (strip markdown noise).
|
||||
agentPanel._strip = function(t){
|
||||
if(!t) return '';
|
||||
return String(t)
|
||||
.replace(/```[\s\S]*?```/g, ' [code] ')
|
||||
.replace(/[#*_>`~]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 280);
|
||||
};
|
||||
// Short title for a reasoning thought (first non-empty line, truncated).
|
||||
agentPanel._thoughtTitle = function(text){
|
||||
var s = (text || '').trim();
|
||||
if(!s) return 'Réflexion';
|
||||
// Skip leading blank lines and get first meaningful line
|
||||
var lines = s.split(/\r?\n/);
|
||||
var first = '';
|
||||
for(var i=0;i<lines.length;i++){
|
||||
var ln = lines[i].trim();
|
||||
if(ln){ first = ln; break; }
|
||||
}
|
||||
if(!first) return 'Réflexion';
|
||||
first = first.replace(/^#+\s*/, ''); // strip markdown heading prefix
|
||||
return first.length <= 72 ? first : first.slice(0,72).trimEnd() + '…';
|
||||
};
|
||||
// Past-tense label for each known agent action.
|
||||
agentPanel._actionLabel = {
|
||||
read_workspaces:'Espaces de travail listés', search_workspace:'Recherche effectuée',
|
||||
read_document:'Document lu', read_collection:'Collection lue', read_page:'Page lue',
|
||||
create_document:'Document créé', create_collection:'Collection créée',
|
||||
create_page:'Page créée', create_view:'Vue créée', add_property:'Propriété ajoutée',
|
||||
add_relation:'Relation ajoutée', update_page:'Page mise à jour',
|
||||
write_blocks:'Contenu écrit', delete_document:'Document supprimé',
|
||||
delete_page:'Page supprimée', delete_collection:'Collection supprimée',
|
||||
apply_template:'Template appliqué', create_sub_item:'Sous-élément créé',
|
||||
add_dependency:'Dépendance ajoutée', read_gitea_issues:'Issues Gitea lues',
|
||||
create_gitea_issue:'Issue Gitea créée', sync_gitea:'Issues Gitea synchronisées'
|
||||
};
|
||||
// Build an action step object from an SSE event or a persisted action row.
|
||||
agentPanel._actionStep = function(ev){
|
||||
var ok = ev.status !== 'error';
|
||||
var tool = String(ev.tool || '');
|
||||
var label = agentPanel._actionLabel[tool] || tool.replace(/_/g,' ');
|
||||
var extra = '';
|
||||
if(ok && ev.target_id != null && ev.target_id !== '' && ev.target_id !== 0){
|
||||
extra = ' → #' + ev.target_id;
|
||||
}
|
||||
if(ok && ev.message && ev.message.length <= 46){
|
||||
extra += (extra ? ' — ' : ' · ') + ev.message;
|
||||
}
|
||||
return {
|
||||
type:'action', tool:tool,
|
||||
status: ok ? 'success' : 'error',
|
||||
label: label + extra,
|
||||
detail: ok ? '' : (ev.detail || ev.message || '')
|
||||
};
|
||||
};
|
||||
|
||||
// ═══════════ Context reader — document (page editor) ═══════════
|
||||
window.fdReadPageContext = function(){
|
||||
|
||||
BIN
Binary file not shown.
@@ -207,6 +207,78 @@ def test_tool_workspace_document_crud_and_search(client):
|
||||
assert "introuvable" in bad.message
|
||||
|
||||
|
||||
def test_write_blocks_normalizes_llm_blocks(client):
|
||||
"""LLM blocks are normalized: `text`→`content`, `heading`→`heading_1`,
|
||||
missing ids are generated, unknown types fall back to paragraph."""
|
||||
from app.services.tool_registry import ToolRegistry
|
||||
|
||||
reg = ToolRegistry()
|
||||
doc = asyncio.run(reg.execute("create_document", {"title": "Script"}))
|
||||
pid = doc.data["document_id"]
|
||||
|
||||
bad_blocks = [
|
||||
{"type": "heading", "text": "test.ps1 — Script de test PowerShell"},
|
||||
{"type": "paragraph", "text": "# Test script\nWrite-Host 'Hello, World!'"},
|
||||
{"type": "code", "text": "Write-Host 'hi'", "language": "powershell"},
|
||||
{"type": "made_up_type", "content": "inconnu"},
|
||||
]
|
||||
res = asyncio.run(reg.execute("write_blocks", {"page_id": pid, "blocks": bad_blocks}))
|
||||
assert res.status == "success", res.message
|
||||
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
assert row["content_format"] == "blocks"
|
||||
stored = json.loads(row["content"])
|
||||
|
||||
assert stored[0]["type"] == "heading_1", stored[0]
|
||||
assert stored[0]["content"] == "test.ps1 — Script de test PowerShell"
|
||||
assert stored[0]["id"], "id must be generated"
|
||||
|
||||
assert stored[1]["type"] == "paragraph"
|
||||
assert stored[1]["content"] == "# Test script\nWrite-Host 'Hello, World!'"
|
||||
|
||||
assert stored[2]["type"] == "code"
|
||||
assert stored[2]["content"] == "Write-Host 'hi'"
|
||||
assert stored[2]["language"] == "powershell"
|
||||
|
||||
assert stored[3]["type"] == "paragraph" # unknown → paragraph
|
||||
assert stored[3]["content"] == "inconnu"
|
||||
|
||||
|
||||
def test_engine_titles_conversation_on_each_request(client):
|
||||
"""The conversation title is refreshed after EVERY AI request (not only
|
||||
while the title is a default placeholder)."""
|
||||
from app.services.agent_engine import AgentEngine
|
||||
|
||||
engine = AgentEngine(_admin_id(), workspace_id=None)
|
||||
conv = client.post("/api/agent/conversations",
|
||||
json={"title": "Nouvelle conversation"}).json()["id"]
|
||||
first = "crée un document « Rapport Q3 »"
|
||||
asyncio.run(_run_engine(engine, conv, first))
|
||||
detail = client.get(f"/api/agent/conversations/{conv}").json()
|
||||
title1 = detail["conversation"]["title"]
|
||||
assert "Rapport Q3" in title1
|
||||
|
||||
second = "crée une collection « Launches »"
|
||||
asyncio.run(_run_engine(engine, conv, second))
|
||||
detail = client.get(f"/api/agent/conversations/{conv}").json()
|
||||
title2 = detail["conversation"]["title"]
|
||||
assert "Launches" in title2, title2
|
||||
assert title2 != title1, "title must be updated on the second request too"
|
||||
|
||||
|
||||
def test_suggest_title_strips_composer_prefixes(client):
|
||||
from app.services.agent_engine import AgentEngine
|
||||
title = AgentEngine._suggest_title(
|
||||
"Contexte « test.ps1 »\n\nÉcrire un script de test en powershell dans la page courante",
|
||||
None,
|
||||
)
|
||||
assert title != "Contexte", title
|
||||
assert "Contexte" not in title
|
||||
assert title
|
||||
|
||||
|
||||
def test_tool_delete_document_and_undo(client):
|
||||
from app.db import get_conn
|
||||
from app.services.agent_engine import undo_action
|
||||
|
||||
Reference in New Issue
Block a user