feat(ui): natural markdown rendering in editor blocks & agent panel
- 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:
+28
-1
@@ -1,6 +1,33 @@
|
||||
# Changelog — FlowDeck
|
||||
|
||||
## v5.1.0 (2026-09-06) — Database Avancée : inline DB, templates prédéfinis & validation
|
||||
## v5.1.1 (2026-09-07) — Rendu Markdown naturel (éditeur + panneau Agent)
|
||||
|
||||
> L'agent FlowDeck et l'insertion dans les pages affichent désormais le markdown
|
||||
> rendu (titre, gras, tableaux, code…) au lieu des caractères bruts `##`, `**`…
|
||||
|
||||
### Éditeur de page (blocs)
|
||||
- **Insertion agent → vrais blocs** : `applyAIBlocks()` (et le mode *replace* de
|
||||
`fdApplyDocument`) convertit la réponse markdown en blocs réels via `md2b()` :
|
||||
`## titre` → bloc `heading_2`, tableaux GFM → bloc `table`, ` ``` ` → bloc `code`,
|
||||
listes `-`/`1.`, to-do, citations, séparateurs. Plus de `## ` affiché littéralement.
|
||||
- **Rendu inline** : les blocs de texte affichent désormais le markdown inline
|
||||
(gras `**`, italique `*`, code `` ` ``, lien `[t](url)`, barré `~~`) sans les
|
||||
caractères, via `mdEsc()` dans `renderBlock` (paragraphes, titres, listes, to-do,
|
||||
toggle, citation, callout, code).
|
||||
- Les raccourcis clavier markdown existants (`# ` + espace → H1…) restent inchangés.
|
||||
|
||||
### Panneau FlowDeck Agent
|
||||
- **`renderMarkdown()`** (agent_panel.html) : les réponses de l'agent s'affichent en
|
||||
HTML rendu — titres H1–H4, tableaux (GFM), blocs de code, listes à puces/numérotées
|
||||
(+ cases), citations, séparateurs, gras/italique/code/lien/barré. HTML échappé
|
||||
avant rendu (sécurité face au contenu LLM). CSS dédié (`.fd-md-*`, `pre`, tableaux).
|
||||
- Le bouton « Insérer dans la page ouverte » continue d'utiliser `fdApplyDocument`
|
||||
(maintenant → blocs réels).
|
||||
|
||||
### Tests / validation
|
||||
- 271 tests pytest verts (aucune régression). Rendu markdown vérifié fonctionnellement
|
||||
en Node (titres, tableau, code, listes) ; éditeur et panneau rendus 200 avec les
|
||||
nouveaux composants présents.
|
||||
|
||||
> Livre la **v5.3.0** du roadmap : databases inline dans les pages (slash `/database`),
|
||||
> galerie de **templates prédéfinis** (CRM, Project tracker, …) et **validation des
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="5.1.0",
|
||||
version="5.1.1",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
function ce(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(false);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
|
||||
function cs(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(true);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
|
||||
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
// Escape then render inline markdown (bold/italic/code/links/strikethrough)
|
||||
// so agent-generated content displays without raw `**`/backtick characters.
|
||||
function mdEsc(s){
|
||||
if(!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.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>');
|
||||
}
|
||||
|
||||
const CALLOUT_ICONS=['💡','💡','📌','⚠️','ℹ️','✅','❌','🚀','🔔','📣','⭐','❤️','🔥','💬','👀','🧠','🎯','🔑','📅','💰','💡','📚','🧪','🌍'];
|
||||
|
||||
@@ -223,11 +234,11 @@
|
||||
}
|
||||
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':'Press \'/\' for commands...';
|
||||
let inner='';
|
||||
if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
||||
else if(b.type==='toggle')inner=`<div class="toggle-title-row"><button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div class="toggle-title" data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div></div><div class="toggle-children" ${(b.expanded===false)?'style="display:none"':''}>${(b.children||[]).map((ch)=>renderNestedBlock(ch)).join('')}</div>`;
|
||||
else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${esc(b.content)}</code></pre>`;
|
||||
else if(b.type==='callout')inner=`<span class="callout-icon" onclick="E._toggleCalloutIcon('${b.id}')" title="Change icon">${b.icon||'💡'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div><div class="callout-icon-picker" id="callout-picker-${b.id}" style="display:none">${CALLOUT_ICONS.map(function(i){return '<button type="button" class="callout-icon-opt" onclick="E._setCalloutIcon(\''+b.id+'\',\''+i+'\')">'+i+'</button>';}).join('')}</div>`;
|
||||
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
||||
if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div>`;
|
||||
else if(b.type==='toggle')inner=`<div class="toggle-title-row"><button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div class="toggle-title" data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div></div><div class="toggle-children" ${(b.expanded===false)?'style="display:none"':''}>${(b.children||[]).map((ch)=>renderNestedBlock(ch)).join('')}</div>`;
|
||||
else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${mdEsc(b.content)}</code></pre>`;
|
||||
else if(b.type==='callout')inner=`<span class="callout-icon" onclick="E._toggleCalloutIcon('${b.id}')" title="Change icon">${b.icon||'💡'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div><div class="callout-icon-picker" id="callout-picker-${b.id}" style="display:none">${CALLOUT_ICONS.map(function(i){return '<button type="button" class="callout-icon-opt" onclick="E._setCalloutIcon(\''+b.id+'\',\''+i+'\')">'+i+'</button>';}).join('')}</div>`;
|
||||
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div>`;
|
||||
const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div';
|
||||
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
|
||||
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}"`:'';
|
||||
@@ -695,12 +706,13 @@
|
||||
self.showToast('FlowDeck Agent : génération en cours…');
|
||||
window.fdAgent.ask(prompt);
|
||||
},
|
||||
applyAIBlocks(text){
|
||||
const lines=(text||'').split(/\r?\n/).map(function(s){return s.trim();}).filter(Boolean);
|
||||
if(!lines.length) return;
|
||||
applyAIBlocks(text){
|
||||
// Convert agent markdown → real blocks (headings, lists, tables, code…)
|
||||
// so it renders naturally (Notion-style) instead of showing raw `## …`.
|
||||
const nb=this.md2b(text||'');
|
||||
if(!nb.length) return;
|
||||
this.sync();
|
||||
const startEmpty=this.blocks.length===1 && this.blocks[0].type==='paragraph' && !(this.blocks[0].content||'').trim();
|
||||
const nb=lines.map(l=>this.mkB('paragraph',l));
|
||||
this.blocks = startEmpty ? nb : this.blocks.concat(nb);
|
||||
this.dirty=true; this.autoSave(); this.render();
|
||||
},
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
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) || '';
|
||||
|
||||
Reference in New Issue
Block a user