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
+23
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
5.10.0
5.11.0
+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=?",
+313 -12
View File
@@ -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
View File
Binary file not shown.
+72
View File
@@ -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