From 4b3e66b4bdbbfaa2630eca3c62f91963a76573a2 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Tue, 14 Jul 2026 12:15:25 +0200 Subject: [PATCH] KI-Chat: Markdown-Tabellen und Trennlinien werden gerendert Der Mini-Renderer kannte weder Tabellen noch ---, also landeten beide als roher Text in einem Absatz: aus einer Anforderung/Erfahrung-Tabelle wurde eine einzeilige Pipe-Wueste, aus --- blieb ---. Das Modell nutzt aber gerade fuer Passungs-Vergleiche gern Tabellen. - GFM-Tabellen inkl. Ausrichtung (:--:), auch ohne Rand-Pipes. - Krumme Zeilen werden auf die Kopfbreite gezogen (fehlende Zellen leer, ueberzaehlige raus), sonst zerreisst eine Zeile das Raster. - Wrapper scrollt horizontal, damit eine breite Tabelle die Chat-Blase auf dem Handy nicht auseinanderzieht. - Zellinhalte laufen durch dieselbe Escape-/inlineFmt-Kette wie der Rest, HTML aus dem Modell kann also weiterhin nichts injizieren. Co-Authored-By: Claude Opus 4.8 --- public/js/chat.js | 53 +++++++++++++++++++++++++++++++++++++++++++++++ views/chat.ejs | 10 +++++++++ 2 files changed, 63 insertions(+) diff --git a/public/js/chat.js b/public/js/chat.js index 97d7cc5..28f0df1 100644 --- a/public/js/chat.js +++ b/public/js/chat.js @@ -61,6 +61,24 @@ return s.replace(/\u0000(\d+)\u0000/g, (m, i) => parked[Number(i)]); } + // Trennzeile einer Markdown-Tabelle: |---|:---:|---:| (mind. zwei Striche je + // Spalte, Doppelpunkte für die Ausrichtung). + function isTableSeparator(line) { + return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line); + } + + function tableCells(line) { + return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim()); + } + + function alignOf(sep) { + const links = sep.startsWith(':'); + const rechts = sep.endsWith(':'); + if (links && rechts) return 'center'; + if (rechts) return 'right'; + return null; // linksbündig ist der Standard, kein style nötig + } + function renderMarkdown(src) { const esc = escapeHtml(src == null ? '' : String(src)).replace(/\r\n/g, '\n'); const lines = esc.split('\n'); @@ -109,6 +127,41 @@ html += '
    ' + items.join('') + '
'; continue; } + // Tabelle (GFM): Kopfzeile + Trennzeile (|---|:--:|), dann Datenzeilen. + // Das Modell nutzt Tabellen gern für Anforderung/Erfahrung-Vergleiche; + // ohne diesen Zweig landeten sie als Pipe-Wüste in einem Absatz. + if (line.indexOf('|') >= 0 && i + 1 < lines.length && isTableSeparator(lines[i + 1])) { + flushPara(); + const kopf = tableCells(line); + const ausricht = tableCells(lines[i + 1]).map(alignOf); + i += 2; + const zeilen = []; + while (i < lines.length && lines[i].indexOf('|') >= 0 && !/^\s*$/.test(lines[i])) { + zeilen.push(tableCells(lines[i])); + i++; + } + const zelle = (tag, inhalt, idx) => { + const a = ausricht[idx]; + return '<' + tag + (a ? ' style="text-align:' + a + '"' : '') + '>' + inlineFmt(inhalt || '') + ''; + }; + let t = '
'; + kopf.forEach((c, idx) => { t += zelle('th', c, idx); }); + t += ''; + for (const z of zeilen) { + t += ''; + // An der Kopfbreite ausrichten: fehlende Zellen leer, überzählige raus — + // sonst zerreißt eine krumme Zeile das Tabellenraster. + for (let c = 0; c < kopf.length; c++) t += zelle('td', z[c], c); + t += ''; + } + html += t + '
'; + continue; + } + if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { // Trennlinie + flushPara(); + html += '
'; + i++; continue; + } if (/^\s*$/.test(line)) { flushPara(); i++; continue; } para.push(line.trim()); i++; diff --git a/views/chat.ejs b/views/chat.ejs index 3f923fd..f3675b6 100644 --- a/views/chat.ejs +++ b/views/chat.ejs @@ -44,6 +44,16 @@ .dark .chat-md pre { background: rgba(0,0,0,0.35); } .chat-md pre code { background: none; padding: 0; } .chat-md a { text-decoration: underline; } + .chat-md hr { border: 0; border-top: 1px solid rgba(0,0,0,0.12); margin: 0.7rem 0; } + .dark .chat-md hr { border-top-color: rgba(255,255,255,0.15); } + /* Tabellen (z. B. Anforderung/Erfahrung-Vergleiche). Der Wrapper scrollt, + damit eine breite Tabelle nicht die Chat-Blase auseinanderzieht. */ + .chat-md-table { overflow-x: auto; margin: 0 0 0.5rem 0; } + .chat-md table { border-collapse: collapse; width: 100%; font-size: 0.875rem; } + .chat-md th, .chat-md td { border: 1px solid rgba(0,0,0,0.12); padding: 0.35rem 0.55rem; text-align: left; vertical-align: top; } + .chat-md th { background: rgba(0,0,0,0.04); font-weight: 600; } + .dark .chat-md th, .dark .chat-md td { border-color: rgba(255,255,255,0.15); } + .dark .chat-md th { background: rgba(255,255,255,0.06); }