feat(ui): natural markdown rendering in editor blocks & agent panel
FlowDeck CI / test (push) Failing after 14s
FlowDeck CI / docker (push) Skipped

- Editor: applyAIBlocks() and fdApplyDocument(replace) now convert agent
  markdown into real blocks via md2b() (## -> heading_2, GFM tables ->
  table block, ` -> code, lists, to-do, quote, divider). No more literal
  '## ' shown.
- Editor: inline markdown rendered in blocks via mdEsc() (bold/italic/
  inline-code/links/strikethrough) in renderBlock for paragraphs, headings,
  lists, to-do, toggle, quote, callout, code.
- Agent panel: renderMarkdown() renders assistant responses as HTML (H1-H4,
  GFM tables, code blocks, lists+checkboxes, quotes, hr, inline marks);
  content escaped before rendering (XSS-safe vs LLM). CSS .fd-md-* added.
- VERSION + app.main -> 5.1.1; CHANGELOG updated.
- 271 tests pass (no regression); markdown render verified in Node; editor &
  panel render 200 with new components.
This commit is contained in:
2026-09-07 10:18:47 -04:00
parent e4b196a528
commit 4dc06eb203
5 changed files with 197 additions and 13 deletions
+146 -1
View File
@@ -146,6 +146,28 @@
.fd-ap-toast button{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:13px;padding:0}
[x-cloak]{display:none!important}
.fd-agent-msg-markdown{line-height:1.6}
.fd-agent-msg-markdown p{margin:0 0 8px}
.fd-agent-msg-markdown h1,.fd-agent-msg-markdown h2,.fd-agent-msg-markdown h3,.fd-agent-msg-markdown h4{margin:12px 0 6px;font-weight:700;line-height:1.25}
.fd-agent-msg-markdown h1{font-size:1.4em}
.fd-agent-msg-markdown h2{font-size:1.25em}
.fd-agent-msg-markdown h3{font-size:1.1em}
.fd-agent-msg-markdown h4{font-size:1em}
.fd-agent-msg-markdown ul,.fd-agent-msg-markdown ol{margin:0 0 8px;padding-left:20px}
.fd-agent-msg-markdown li{margin:2px 0}
.fd-agent-msg-markdown code{background:rgba(127,127,127,.18);border-radius:4px;padding:1px 5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
.fd-agent-msg-markdown pre{background:var(--bg-code,#111);border:1px solid var(--border,#333);border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0}
.fd-agent-msg-markdown pre code{background:none;padding:0;font-size:12px;line-height:1.5}
.fd-md-table-wrap{overflow-x:auto;margin:8px 0}
.fd-md-table{border-collapse:collapse;width:100%;font-size:13px}
.fd-md-table th,.fd-md-table td{border:1px solid var(--border,#333);padding:6px 10px;text-align:left}
.fd-md-table th{background:rgba(127,127,127,.14);font-weight:600}
.fd-md-table tr:nth-child(even) td{background:rgba(127,127,127,.06)}
.fd-md-quote{border-left:3px solid var(--accent,#2383E2);margin:8px 0;padding:2px 12px;color:var(--text-dim)}
.fd-md-quote p{margin:4px 0}
.fd-md-hr{border:none;border-top:1px solid var(--border,#333);margin:12px 0}
.fd-agent-msg-markdown a{color:var(--accent,#2383E2);text-decoration:none}
.fd-agent-msg-markdown a:hover{text-decoration:underline}
</style>
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
@@ -230,7 +252,9 @@
<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>
<div class="fd-agent-msg-content" x-text="m.generating ? 'Génération…' : m.content"></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>
<template x-if="m.proposal">
<div class="fd-proposal-bar">
<button class="fd-proposal-btn primary" type="button" @click="approveProposal(m)" :disabled="m.applied || m.offline">
@@ -1420,6 +1444,127 @@
},
// ── Actions sous chaque réponse agent ──
// Render agent markdown responses (headings, tables, code, lists, …) to
// HTML so the panel displays content like Notion (no raw `##` / `**`).
renderMarkdown(md){
var self = this;
function escHtml(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function inline(t){
t = escHtml(t);
return t
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>')
.replace(/\*([^*]+)\*/g,'<em>$1</em>')
.replace(/~~([^~]+)~~/g,'<s>$1</s>')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
}
if(!md) return '';
var src = String(md);
// Trim leading/trailing blank lines to avoid stray <p></p>
var lines = src.split(/\r?\n/);
var html = '';
var i = 0;
function flush(block){
if(!block) return '';
if(block.type === 'ul' || block.type === 'ol'){
var tag = block.type === 'ul' ? 'ul' : 'ol';
return '<'+tag+' class="fd-md-list">' + block.items.map(function(it){
var li = inline(it.text);
if(it.checked != null){
return '<li><input type="checkbox" disabled'+(it.checked?' checked':'')+'> '+li+'</li>';
}
return '<li>'+li+'</li>';
}).join('') + '</'+tag+'>';
}
if(block.type === 'quote'){
return '<blockquote class="fd-md-quote">'+block.items.map(function(t){ return '<p>'+inline(t)+'</p>'; }).join('')+'</blockquote>';
}
if(block.type === 'p'){
return block.text ? '<p>'+inline(block.text)+'</p>' : '';
}
return '';
}
while(i < lines.length){
var raw = lines[i];
var t = raw.trim();
// Fenced code block
if(t.startsWith('```')){
var lang = t.substring(3).trim();
var code = [];
i++;
while(i < lines.length && !lines[i].trim().startsWith('```')){
code.push(lines[i]); i++;
}
i++; // skip closing ```
html += '<pre class="fd-md-code"><code>' + escHtml(code.join('\n')) + '</code></pre>';
continue;
}
// Table: | a | b | followed by | --- | --- |
if(t.startsWith('|') && i+1 < lines.length && /^\|[\s\-:|]+\|?$/.test(lines[i+1].trim())){
var headerCells = t.replace(/^\||\|$/g,'').split('|').map(function(c){return c.trim();});
var rows = [];
var j = i+2;
while(j < lines.length && lines[j].trim().startsWith('|')){
rows.push(lines[j].trim().replace(/^\||\|$/g,'').split('|').map(function(c){return c.trim();}));
j++;
}
var thead = '<tr>'+headerCells.map(function(c){return '<th>'+inline(c)+'</th>';}).join('')+'</tr>';
var tb = rows.map(function(r){
var cells = [];
for(var k=0;k<headerCells.length;k++) cells.push(r[k]||'');
return '<tr>'+cells.map(function(c){return '<td>'+inline(c)+'</td>';}).join('')+'</tr>';
}).join('');
html += '<div class="fd-md-table-wrap"><table class="fd-md-table"><thead>'+thead+'</thead><tbody>'+tb+'</tbody></table></div>';
i = j;
continue;
}
// Horizontal rule
if(/^(-{3,}|\*{3,}|_{3,})$/.test(t)){ html += '<hr class="fd-md-hr">'; i++; continue; }
// Blockquote (accumulate consecutive)
if(t.startsWith('> ')){
var q = {type:'quote', items:[]};
while(i < lines.length && lines[i].trim().startsWith('> ')){
q.items.push(lines[i].trim().substring(2)); i++;
}
html += flush(q); continue;
}
// Lists (accumulate consecutive ul/ol, incl. checkboxes)
if(/^[-*+] /.test(t) || /^\d+\. /.test(t)){
var ul = /^[-*+] /.test(t) ? 'ul' : 'ol';
var b = {type: ul, items: []};
while(i < lines.length){
var lt = lines[i].trim();
if(ul === 'ul' && /^[-*+] /.test(lt)){
var mt = lt.substring(2);
var chk = mt.match(/^\[([ xX])\]\s*/);
if(chk){ b.items.push({text: mt.substring(3), checked: chk[1]!==' '}); }
else b.items.push({text: mt});
i++;
} else if(ul === 'ol' && /^\d+\. /.test(lt)){
b.items.push({text: lt.replace(/^\d+\. /,'')}); i++;
} else break;
}
html += flush(b); continue;
}
// Headings
var hm = t.match(/^(#{1,4})\s+(.*)$/);
if(hm){ html += '<h'+hm[1].length+'>'+inline(hm[2])+'</h'+hm[1].length+'>'; i++; continue; }
// Paragraph (accumulate consecutive non-empty non-special)
if(t){
var para = [];
while(i < lines.length){
var pt = lines[i].trim();
if(!pt) break;
if(/^(```|#{1,4}\s|> |[-*+] |\d+\. |\||-{3,}$)/.test(pt)) break;
para.push(pt); i++;
}
html += para.map(function(p){ return '<p>'+inline(p)+'</p>'; }).join('');
continue;
}
i++;
}
return html;
},
copyResponse(m){
var self = this;
var text = (m && m.content) || '';