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
|
# 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)
|
## v5.9.0 (2026-09-10) — AI Writing Assist (éditeur)
|
||||||
|
|
||||||
> L'IA Notion dans l'éditeur : commandes slash `/ai`, autocomplétion en ligne
|
> 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 … ? »)
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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 »).
|
- **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._persist_message(conversation_id, "user", objective)
|
||||||
self._update_conversation(conversation_id, status="running")
|
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)
|
tool_schema = self.tools.schema(scope)
|
||||||
final_text = None
|
final_text = None
|
||||||
used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
|
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) ──
|
# ── 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):
|
async def _autotitle(self, conversation_id: int, objective: str, final_text: str | None):
|
||||||
"""Rename a freshly created conversation with a descriptive title derived
|
"""Rename the conversation with a descriptive title derived from the
|
||||||
from its content. Only runs while the title is still a default placeholder,
|
*latest* user request. Runs after every AI call so the history list
|
||||||
so a user-renamed conversation is never overwritten."""
|
always reflects the current topic and stays easy to browse."""
|
||||||
try:
|
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)
|
suggested = self._suggest_title(objective, final_text)
|
||||||
if suggested:
|
if suggested:
|
||||||
self._update_conversation(conversation_id, title=suggested[:80])
|
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:
|
def _suggest_title(cls, objective: str | None, final_text: str | None) -> str:
|
||||||
"""Produce a short descriptive title from the user objective (offline-safe)."""
|
"""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()
|
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:
|
if not text:
|
||||||
return "Conversation"
|
return "Conversation"
|
||||||
low = text.lower()
|
low = text.lower()
|
||||||
|
|||||||
@@ -479,17 +479,90 @@ class UpdatePage(Tool):
|
|||||||
class WriteBlocks(Tool):
|
class WriteBlocks(Tool):
|
||||||
name = "write_blocks"
|
name = "write_blocks"
|
||||||
description = ("Met à jour un document (page éditeur `pages`) : remplace son contenu en blocs "
|
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 = {
|
parameters = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"page_id": {"type": "integer", "description": "id du document (page éditeur)"},
|
"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)"},
|
"title": {"type": "string", "description": "nouveau titre (optionnel)"},
|
||||||
},
|
},
|
||||||
"required": ["page_id"],
|
"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:
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||||
pid = args.get("page_id")
|
pid = args.get("page_id")
|
||||||
has_blocks = "blocks" in args
|
has_blocks = "blocks" in args
|
||||||
@@ -506,9 +579,10 @@ class WriteBlocks(Tool):
|
|||||||
"title": page["title"]}
|
"title": page["title"]}
|
||||||
final_title = new_title or page["title"]
|
final_title = new_title or page["title"]
|
||||||
if has_blocks:
|
if has_blocks:
|
||||||
|
normalized = self._normalize_blocks(args["blocks"])
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE pages SET content=?, content_format='blocks', title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
"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:
|
else:
|
||||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
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{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))}
|
.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 */
|
/* Empty / welcome state */
|
||||||
.fd-ap-empty{padding:6px 2px 4px}
|
.fd-ap-empty{padding:6px 2px 4px}
|
||||||
.fd-ap-empty-title{font-size:14px;font-weight:600;color:var(--text,#eee);margin-bottom:2px}
|
.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-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))}
|
.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 */
|
/* Composer */
|
||||||
.fd-ap-composer{padding:10px 14px 14px;border-top:1px solid var(--border,rgba(255,255,255,.09));position:relative}
|
.fd-ap-composer{padding:10px 14px 14px;border-top:1px solid var(--border,rgba(255,255,255,.09));position:relative}
|
||||||
.fd-ap-box{background:var(--bg-tertiary,#1f1f1f);border:1px solid var(--border,rgba(255,255,255,.13));border-radius:16px;padding:10px 12px 8px;transition:border-color .12s,box-shadow .12s}
|
.fd-ap-box{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">
|
<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>
|
<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-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 class="fd-ap-sub" x-text="providerModelLabel"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="fd-ap-headbtn" title="Nouvelle conversation" @click="newConversation()">+</button>
|
<button class="fd-ap-headbtn" title="Nouvelle conversation" @click="newConversation()">+</button>
|
||||||
@@ -223,7 +255,11 @@
|
|||||||
<template x-if="tab==='history'">
|
<template x-if="tab==='history'">
|
||||||
<div>
|
<div>
|
||||||
<template x-for="c in conversations" :key="c.id">
|
<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 x-text="c.title || 'New conversation'"></div>
|
||||||
<div style="font-size:11px;color:var(--text-dim)" x-text="c.updated_at"></div>
|
<div style="font-size:11px;color:var(--text-dim)" x-text="c.updated_at"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,6 +289,38 @@
|
|||||||
<div class="fd-agent-msg-avatar" x-text="m.role==='assistant'?'🤖':'👤'"></div>
|
<div class="fd-agent-msg-avatar" x-text="m.role==='assistant'?'🤖':'👤'"></div>
|
||||||
<div class="fd-agent-msg-body">
|
<div class="fd-agent-msg-body">
|
||||||
<div class="fd-agent-msg-meta" x-text="(m.role==='assistant'?'Agent':'Vous')+(m.model?' · '+m.model:'')"></div>
|
<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" 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 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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-notice" x-show="notice" x-cloak x-text="notice"></div>
|
||||||
<div class="fd-ap-err" x-show="errorMsg" x-text="errorMsg"></div>
|
<div class="fd-ap-err" x-show="errorMsg" x-text="errorMsg"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -372,6 +440,18 @@
|
|||||||
<a href="/settings" target="_blank" @click="modelOpen=false">⚙️ Configurer (clés API) →</a>
|
<a href="/settings" target="_blank" @click="modelOpen=false">⚙️ Configurer (clés API) →</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -423,6 +503,9 @@
|
|||||||
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
|
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
|
||||||
cmdFocus: -1, slashDismiss: false,
|
cmdFocus: -1, slashDismiss: false,
|
||||||
toastMsg: '', toastErr: false, _toastTimer: null,
|
toastMsg: '', toastErr: false, _toastTimer: null,
|
||||||
|
_livePh: null,
|
||||||
|
previewVisible: false, previewTitle: '', previewMeta: '', previewStart: '', previewEnd: '',
|
||||||
|
_previewCache: null, _previewTimer: null, _longPressTimer: null, _suppressNextClick: false,
|
||||||
_slashSeq: 0,
|
_slashSeq: 0,
|
||||||
|
|
||||||
init(){
|
init(){
|
||||||
@@ -498,6 +581,7 @@
|
|||||||
if(!this.open) return;
|
if(!this.open) return;
|
||||||
this.open = false;
|
this.open = false;
|
||||||
this.modelOpen = false;
|
this.modelOpen = false;
|
||||||
|
this.hidePreview();
|
||||||
if(this._applyState) this._applyState();
|
if(this._applyState) this._applyState();
|
||||||
},
|
},
|
||||||
toggle(){ this.open ? this.close() : this.openAgent(); },
|
toggle(){ this.open ? this.close() : this.openAgent(); },
|
||||||
@@ -740,6 +824,7 @@
|
|||||||
var self = this;
|
var self = this;
|
||||||
this.tab='chat';
|
this.tab='chat';
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
|
this.hidePreview();
|
||||||
fetch('/api/agent/conversations/'+id).then(function(r){return r.json()}).then(function(d){
|
fetch('/api/agent/conversations/'+id).then(function(r){return r.json()}).then(function(d){
|
||||||
self.currentConv = d.conversation;
|
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}; });
|
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; }
|
if(prov && (!self.providers.length || self.providers.some(function(x){ return x.id === prov; }))){ self.llmProvider = prov; }
|
||||||
self.applyModels();
|
self.applyModels();
|
||||||
if(d.conversation && d.conversation.model && self.providerModels.indexOf(d.conversation.model) !== -1){ self.llmModel = d.conversation.model; }
|
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(){});
|
}).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(){
|
newConversation(){
|
||||||
var self = this;
|
var self = this;
|
||||||
var body = {title:'Nouvelle conversation'};
|
var body = {title:'Nouvelle conversation'};
|
||||||
@@ -1592,6 +1788,16 @@
|
|||||||
}).catch(function(){ self.toast('Retour non envoyé.', true); });
|
}).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 ──
|
// ── Send / SSE ──
|
||||||
async send(){
|
async send(){
|
||||||
var self = this;
|
var self = this;
|
||||||
@@ -1642,6 +1848,23 @@
|
|||||||
|
|
||||||
self.streaming = true;
|
self.streaming = true;
|
||||||
self.liveReasoning = '…';
|
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.messages.push({id:'u-'+Date.now(), role:'user', content: displayText});
|
||||||
self.scrollBottom();
|
self.scrollBottom();
|
||||||
|
|
||||||
@@ -1673,19 +1896,31 @@
|
|||||||
if(!line) return;
|
if(!line) return;
|
||||||
try{
|
try{
|
||||||
var ev = JSON.parse(line);
|
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'){
|
else if(ev.type==='action'){
|
||||||
var msg = ev.status==='success'
|
addStep(agentPanel._actionStep(ev));
|
||||||
? (ev.tool+' ✓ '+(ev.message||''))
|
|
||||||
: (ev.tool+' ✗ '+(ev.detail||'erreur'));
|
|
||||||
self.messages.push({id:'a-'+Date.now()+Math.random(), role:'assistant', content: msg, act:true});
|
|
||||||
}
|
}
|
||||||
else if(ev.type==='final'){
|
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){} }
|
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==='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){}
|
}catch(e){}
|
||||||
});
|
});
|
||||||
self.scrollBottom();
|
self.scrollBottom();
|
||||||
@@ -1695,13 +1930,22 @@
|
|||||||
return read();
|
return read();
|
||||||
}).catch(function(err){ self.errorMsg = (err && err.message) ? err.message : 'Erreur agent'; })
|
}).catch(function(err){ self.errorMsg = (err && err.message) ? err.message : 'Erreur agent'; })
|
||||||
.finally(function(){
|
.finally(function(){
|
||||||
self.streaming = false; self.liveReasoning='';
|
self.streaming = false; self.liveReasoning=''; self._livePh = null;
|
||||||
self.fetchConversationsRefresh();
|
self.fetchConversationsRefresh();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
fetchConversationsRefresh(){
|
fetchConversationsRefresh(){
|
||||||
var self = this;
|
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(){
|
scrollBottom(){
|
||||||
this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; });
|
this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; });
|
||||||
@@ -1713,6 +1957,63 @@
|
|||||||
Alpine.data('agentPanel', agentPanel);
|
Alpine.data('agentPanel', agentPanel);
|
||||||
});
|
});
|
||||||
window.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) ═══════════
|
// ═══════════ Context reader — document (page editor) ═══════════
|
||||||
window.fdReadPageContext = function(){
|
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
|
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):
|
def test_tool_delete_document_and_undo(client):
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.services.agent_engine import undo_action
|
from app.services.agent_engine import undo_action
|
||||||
|
|||||||
Reference in New Issue
Block a user