openvibers/openvibe.chat Commits

Vibe's answers render tables and nested lists (model answers use both; they showed as pipes and flat bullets) (#200)

OP OpenVibers committed 18 days ago · 0377217f10 · 4 files, +111 −11

Changes

ddiff --git a/public/web/embed.css b/public/web/embed.css
iindex 48c3d9f..2fdce3c 100644
---- a/public/web/embed.css
++++ b/public/web/embed.css
@@@ -26,6 +26,12 @@ a { color: var(--accent); }
.em-bubble ul, .em-bubble ol { margin: .2em 0 .5em; padding-left: 1.2em; }
.em-bubble code { font: .88em ui-monospace, Menlo, Consolas, monospace; background: rgba(127, 127, 127, .15); padding: .1em .3em; border-radius: 5px; }
.em-bubble pre { white-space: pre-wrap; background: rgba(127, 127, 127, .12); padding: 8px 10px; border-radius: 10px; }
++.em-bubble .md-table { overflow-x: auto; margin: .3em 0 .6em; border: 1px solid rgba(127, 127, 127, .3); border-radius: 8px; }
++.em-bubble .md-table table { border-collapse: collapse; width: 100%; font-size: .92em; }
++.em-bubble .md-table th, .em-bubble .md-table td { padding: 5px 8px; border-bottom: 1px solid rgba(127, 127, 127, .25); text-align: left; vertical-align: top; }
++.em-bubble .md-table thead th { background: rgba(127, 127, 127, .12); }
++.em-bubble .md-table .al-c { text-align: center; }
++.em-bubble .md-table .al-r { text-align: right; }
.em-system { justify-self: center; max-width: 92%; text-align: center; font-size: .8rem; color: var(--muted); }
.em-typing .em-bubble { color: var(--muted); }
.em-dots { display: inline-flex; gap: 4px; } .em-dots i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .4; animation: emDot 1.2s infinite; }
ddiff --git a/public/web/vibe-md.js b/public/web/vibe-md.js
iindex eb42108..94b1d4d 100644
---- a/public/web/vibe-md.js
++++ b/public/web/vibe-md.js
@@@ -1,6 +1,7 @@
/* Vibe's answers as HTML (server/web/vibe-pages.js renders them, public/web/vibe.js draws live ones): a small Markdown
* subset, every character of the source escaped first, so the only markup in the output is the markup made here.
-- * blocks: ``` fenced code ```, # / ## / ### headings (drawn as h3/h4), - * • lists, 1. lists, > quotes, paragraphs
++ * blocks: ``` fenced code ```, # / ## / ### headings (drawn as h3/h4), - * • lists and 1. lists (nested by indenting,
++ * 4 deep), | pipe | tables | (with :-: alignment; they scroll inside their own box), > quotes, paragraphs
* inline: `code`, **bold**, *italic* / _italic_, [text](https://…), bare https:// links (rel nofollow, new tab)
* Links are http(s) only. One file for both sides: CommonJS on the server, window.VibeMarkdown in the browser. */
(function (root) {
@@@ -32,14 +33,55 @@
return s.replace(/\u0000(\d+)\u0000/g, function (_, i) { return '<code>' + codes[Number(i)] + '</code>'; });
}
++ // A table row's cells: split on pipes not escaped as \|, one optional outer pipe on each side.
++ function cells(line) {
++ var t = line.trim();
++ if (t.charAt(0) === '|') t = t.slice(1);
++ if (t.slice(-1) === '|' && t.slice(-2) !== '\\|') t = t.slice(0, -1);
++ var out = [];
++ var cur = '';
++ for (var k = 0; k < t.length; k++) {
++ if (t.charAt(k) === '\\' && t.charAt(k + 1) === '|') { cur += '|'; k++; continue; }
++ if (t.charAt(k) === '|') { out.push(cur.trim()); cur = ''; continue; }
++ cur += t.charAt(k);
++ }
++ out.push(cur.trim());
++ return out;
++ }
++ // The alignments of a table's delimiter row (| --- | :-: | --: |), or null when the line is not one.
++ function delimiter(line) {
++ if (!line || line.indexOf('|') < 0 || !/^[\s|:-]+$/.test(line)) return null;
++ var cs = cells(line);
++ var al = [];
++ for (var k = 0; k < cs.length; k++) {
++ if (!/^:?-+:?$/.test(cs[k])) return null;
++ var l = cs[k].charAt(0) === ':', r = cs[k].slice(-1) === ':';
++ al.push(l && r ? 'c' : r ? 'r' : l ? 'l' : '');
++ }
++ return al.length <= 32 ? al : null;
++ }
++ function cell(tag, text, a) { return '<' + tag + (a ? ' class="al-' + a + '"' : '') + '>' + inline(esc(text || '')) + '</' + tag + '>'; }
++
++ var MAX_DEPTH = 4; // lists nest this deep; a deeper item joins the deepest list
++
function render(src) {
-- // The two placeholder characters inline() uses can never come from the text itself.
-- var lines = String(src == null ? '' : src).replace(/[\u0000\u0001]/g, '').replace(/\r\n?/g, '\n').split('\n');
++ // The two placeholder characters inline() uses can never come from the text itself. Leading tabs count 4 columns.
++ var lines = String(src == null ? '' : src).replace(/[\u0000\u0001]/g, '').replace(/\r\n?/g, '\n').split('\n')
++ .map(function (l) { return l.replace(/^\t+/, function (t) { return new Array(t.length + 1).join(' '); }); });
var out = [];
var para = [];
-- var list = null; // { tag, items: [] }
++ // Open lists, outermost first: { tag, indent, items: [{ text, sub }] }. A deeper item opens a list inside the last one.
++ var stack = [];
function flushPara() { if (para.length) { out.push('<p>' + para.map(function (l) { return inline(esc(l)); }).join('<br>') + '</p>'); para = []; } }
-- function flushList() { if (list) { out.push('<' + list.tag + '>' + list.items.map(function (i) { return '<li>' + inline(esc(i)) + '</li>'; }).join('') + '</' + list.tag + '>'); list = null; } }
++ function listHtml(l) { return '<' + l.tag + '>' + l.items.map(function (it) { return '<li>' + inline(esc(it.text)) + it.sub + '</li>'; }).join('') + '</' + l.tag + '>'; }
++ function closeDeeperThan(indent) {
++ while (stack.length > 1 && stack[stack.length - 1].indent > indent) {
++ var done = stack.pop();
++ var parent = stack[stack.length - 1];
++ parent.items[parent.items.length - 1].sub += listHtml(done);
++ }
++ }
++ function flushList() { if (stack.length) { closeDeeperThan(-1); out.push(listHtml(stack[0])); stack = []; } }
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
var fence = /^\s*```\s*([\w+#.-]{0,20})\s*$/.exec(line);
@@@ -53,19 +95,47 @@
if (!line.trim()) { flushPara(); flushList(); continue; }
var h = /^\s{0,3}(#{1,3})\s+(.+?)\s*#*\s*$/.exec(line);
if (h) { flushPara(); flushList(); out.push((h[1].length === 1 ? '<h3>' : '<h4>') + inline(esc(h[2])) + (h[1].length === 1 ? '</h3>' : '</h4>')); continue; }
-- var ul = /^\s{0,6}[-*•]\s+(.+)$/.exec(line);
-- var ol = /^\s{0,6}\d{1,3}[.)]\s+(.+)$/.exec(line);
++ // A pipe table: a header row whose cells match the delimiter row under it; rows run to a blank or pipe-less line.
++ var al = line.indexOf('|') >= 0 ? delimiter(lines[i + 1]) : null;
++ var head = al ? cells(line) : null;
++ if (head && head.length === al.length) {
++ flushPara(); flushList();
++ var rows = [];
++ for (i += 2; i < lines.length && lines[i].trim() && lines[i].indexOf('|') >= 0 && rows.length < 500; i++) rows.push(cells(lines[i]));
++ i--;
++ // A wide table scrolls inside its own box on a phone: a labelled region keyboard users can reach.
++ out.push('<div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr>'
++ + head.map(function (c, k) { return cell('th', c, al[k]); }).join('') + '</tr></thead><tbody>'
++ + rows.map(function (r) { return '<tr>' + al.map(function (a, k) { return cell('td', r[k], a); }).join('') + '</tr>'; }).join('')
++ + '</tbody></table></div>');
++ continue;
++ }
++ var ul = /^(\s{0,24})[-*•]\s+(.+)$/.exec(line);
++ var ol = ul ? null : /^(\s{0,24})\d{1,3}[.)]\s+(.+)$/.exec(line);
if (ul || ol) {
flushPara();
++ var m = ul || ol;
var tag = ul ? 'ul' : 'ol';
-- if (list && list.tag !== tag) flushList();
-- if (!list) list = { tag: tag, items: [] };
-- list.items.push((ul || ol)[1]);
++ var indent = m[1].length;
++ var top = stack[stack.length - 1];
++ if (top && indent > top.indent + 1 && top.items.length && stack.length < MAX_DEPTH) {
++ stack.push({ tag: tag, indent: indent, items: [] });
++ } else if (top) {
++ closeDeeperThan(indent);
++ top = stack[stack.length - 1];
++ if (top.tag !== tag) {
++ if (stack.length === 1) { flushList(); stack.push({ tag: tag, indent: indent, items: [] }); }
++ else { var done = stack.pop(); stack[stack.length - 1].items[stack[stack.length - 1].items.length - 1].sub += listHtml(done); stack.push({ tag: tag, indent: indent, items: [] }); }
++ }
++ } else {
++ stack.push({ tag: tag, indent: indent, items: [] });
++ }
++ stack[stack.length - 1].items.push({ text: m[2], sub: '' });
continue;
}
var q = /^\s{0,3}>\s?(.*)$/.exec(line);
if (q) { flushPara(); flushList(); out.push('<blockquote>' + inline(esc(q[1])) + '</blockquote>'); continue; }
-- if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ' ' + line.trim(); continue; }
++ if (stack.length && /^\s{2,}\S/.test(line)) { var last = stack[stack.length - 1]; last.items[last.items.length - 1].text += ' ' + line.trim(); continue; }
flushList();
para.push(line.trim());
}
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex 6f02658..6576f44 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -135,6 +135,15 @@ body.oc-app-body { overflow: hidden; }
.vb-md pre { margin: 0 0 1em; padding: 14px 16px; border-radius: 14px; background: color-mix(in srgb, var(--oc-panel) 80%, #000); border: 1px solid var(--oc-line); overflow-x: auto; }
.vb-md pre code { background: none; padding: 0; font-size: .86rem; white-space: pre; }
.vb-md blockquote { margin: 0 0 .9em; padding: 2px 0 2px 14px; border-left: 3px solid var(--oc-line); color: var(--oc-muted); }
++.vb-md li > ul, .vb-md li > ol { margin: .2em 0 .3em; }
++.md-table { overflow-x: auto; margin: 0 0 1em; border: 1px solid var(--oc-line); border-radius: 12px; }
++.md-table:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
++.md-table table { border-collapse: collapse; width: 100%; font-size: .94em; line-height: 1.45; }
++.md-table th, .md-table td { padding: 8px 12px; border-bottom: 1px solid var(--oc-line); text-align: left; vertical-align: top; min-width: 6em; }
++.md-table thead th { background: var(--oc-panel-2); font-weight: 650; }
++.md-table tbody tr:last-child td { border-bottom: 0; }
++.md-table .al-c { text-align: center; }
++.md-table .al-r { text-align: right; }
.vb-dots { display: inline-flex; gap: 5px; align-items: center; height: 26px; }
.vb-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--oc-muted); opacity: .35; animation: vbDot 1.2s infinite ease-in-out; }
ddiff --git a/test/vibe.test.js b/test/vibe.test.js
iindex 6d0902a..3a3bd97 100644
---- a/test/vibe.test.js
++++ b/test/vibe.test.js
@@@ -179,6 +179,21 @@ t('answers render as escaped Markdown with http(s) links only', () => {
assert.ok(md.render('see https://openvibe.live/docs.').includes('<a href="https://openvibe.live/docs" rel="nofollow ugc noopener" target="_blank">https://openvibe.live/docs</a>.'));
assert.ok(!md.render('[x](https://a.com" onmouseover="alert(1))').includes('onmouseover="'));
assert.strictEqual(md.render('```\n<i>\n```'), '<pre><code>&lt;i&gt;</code></pre>');
++ // Tables and nested lists (answers are model-written Markdown, which uses both): escaped like everything else.
++ assert.strictEqual(md.render('| Plan | Cost |\n|---|--:|\n| Rice | $2 |\n| <b>x</b> | 1 \\| 2 |'),
++ '<div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr><th>Plan</th><th class="al-r">Cost</th></tr></thead>'
++ + '<tbody><tr><td>Rice</td><td class="al-r">$2</td></tr><tr><td>&lt;b&gt;x&lt;/b&gt;</td><td class="al-r">1 | 2</td></tr></tbody></table></div>');
++ assert.strictEqual(md.render('Intro\n| a | b |\n| - | - |\n| 1 | 2 |\nafter'), '<p>Intro</p><div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></div><p>after</p>');
++ assert.strictEqual(md.render('a | b\nc | d'), '<p>a | b<br>c | d</p>', 'pipes without a delimiter row are text');
++ assert.strictEqual(md.render('- a\n - a1\n - a2\n- b'), '<ul><li>a<ul><li>a1</li><li>a2</li></ul></li><li>b</li></ul>');
++ assert.strictEqual(md.render('1. one\n - x\n2. two'), '<ol><li>one<ul><li>x</li></ul></li><li>two</li></ol>');
++ assert.strictEqual(md.render('- a\n continued'), '<ul><li>a continued</li></ul>');
++ const deep = md.render(Array.from({ length: 10 }, (_, k) => `${' '.repeat(k)}- l${k}`).join('\n'));
++ assert.ok((deep.match(/<ul>/g) || []).length === 4 && deep.includes('l9'), deep);
++ const started = Date.now();
++ md.render(`${'|a'.repeat(20000)}|\n${'|-'.repeat(20000)}|\n${'| x | y |\n'.repeat(20000)}`);
++ md.render(Array.from({ length: 5000 }, (_, k) => `${' '.repeat(k % 24)}- item ${k}`).join('\n'));
++ assert.ok(Date.now() - started < 1500, `pathological Markdown took ${Date.now() - started} ms`);
});
t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });