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 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 12:15:25 +02:00
co-authored by Claude Opus 4.8
parent 4a036fe215
commit 4b3e66b4bd
2 changed files with 63 additions and 0 deletions
+53
View File
@@ -61,6 +61,24 @@
return s.replace(/\u0000(\d+)\u0000/g, (m, i) => parked[Number(i)]); 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) { function renderMarkdown(src) {
const esc = escapeHtml(src == null ? '' : String(src)).replace(/\r\n/g, '\n'); const esc = escapeHtml(src == null ? '' : String(src)).replace(/\r\n/g, '\n');
const lines = esc.split('\n'); const lines = esc.split('\n');
@@ -109,6 +127,41 @@
html += '<ol>' + items.join('') + '</ol>'; html += '<ol>' + items.join('') + '</ol>';
continue; 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 || '') + '</' + tag + '>';
};
let t = '<div class="chat-md-table"><table><thead><tr>';
kopf.forEach((c, idx) => { t += zelle('th', c, idx); });
t += '</tr></thead><tbody>';
for (const z of zeilen) {
t += '<tr>';
// 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 += '</tr>';
}
html += t + '</tbody></table></div>';
continue;
}
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { // Trennlinie
flushPara();
html += '<hr>';
i++; continue;
}
if (/^\s*$/.test(line)) { flushPara(); i++; continue; } if (/^\s*$/.test(line)) { flushPara(); i++; continue; }
para.push(line.trim()); para.push(line.trim());
i++; i++;
+10
View File
@@ -44,6 +44,16 @@
.dark .chat-md pre { background: rgba(0,0,0,0.35); } .dark .chat-md pre { background: rgba(0,0,0,0.35); }
.chat-md pre code { background: none; padding: 0; } .chat-md pre code { background: none; padding: 0; }
.chat-md a { text-decoration: underline; } .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); }
</style> </style>
</head> </head>
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body"> <body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">