diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js index 47d9b1e3..ac7f4690 100644 --- a/public/js/assistant/citations.js +++ b/public/js/assistant/citations.js @@ -20,68 +20,316 @@ export function safeImageUrl(src) { } } -export function renderAssistantMarkdown(md, sources, options) { - var opts = options || {}; +// ─── Citations as tokens ─────────────────────────────────────────────── +// +// A citation is recognised where markdown-it runs inline rules — in prose — +// and nowhere else. A bracket inside a code span, a fence, an HTML tag, a link +// destination or a held math expression never reaches this rule, because the +// tokenizer never runs inline rules there. That is the whole design: the parser +// already knows where prose is, and nothing here re-derives it with patterns. +// +// What it replaced was five hundred lines that did exactly that re-deriving: +// hold code, hold links, hold HTML, hold math, rewrite the text, parse, then +// regex the HTML for what survived. Every literal context was another branch, +// and each one found was another that had been missed. The last two found were +// HTML attributes and array indexing in code. +// +// The stored text is never rewritten. `[7]` in the answer stays `[7]`, and it +// is the identity of that source everywhere — the chip's data-source-number, +// the card's id, a saved chat, an export. What changes is only what the reader +// sees: chips are numbered in order of first appearance, and the sources list +// is ordered the same way, both derived from the same walk of the same tokens. + +var CITE_RE = /^\\?\[((?:\d+\s*,\s*)*\d+)\\?\]/; + +function citationRule(state, silent) { + var src = state.src, pos = state.pos, c = src.charCodeAt(pos); + if (c !== 0x5B && !(c === 0x5C && src.charCodeAt(pos + 1) === 0x5B)) return false; + var m = CITE_RE.exec(src.slice(pos, pos + 64)); + if (!m) return false; + // [3](url) is a link and [3]: a definition. [3][2] is two citations — + // reference links with all-digit labels do not occur in model output, and + // adjacent clusters do, constantly. + var after = src.charAt(pos + m[0].length); + if (after === '(' || after === ':') return false; + var env = state.env || {}; + var sources = env.assistantSources || []; + var nums = m[1].split(',').map(function (n) { return Number(n.trim()); }); + // Every number must name a retrieved source. An invented one is not a + // citation: it stays as the text the model wrote, visibly unlinked, and the + // server's citation audit records it. Nothing here guesses. + if (!nums.every(function (n) { return n > 0 && sourceByNumber(sources, n); })) return false; + if (!silent) { + nums.forEach(function (n) { + var token = state.push('assistant_cite', 'a', 0); + token.meta = { source: n, display: displayNumber(env, n) }; + }); + } + state.pos += m[0].length; + return true; +} + +// First appearance wins and never moves: a later citation only appends, so a +// number assigned while streaming is the number it keeps. +function displayNumber(env, n) { + var order = env.assistantCiteOrder || (env.assistantCiteOrder = []); + var i = order.indexOf(n); + if (i === -1) { order.push(n); i = order.length - 1; } + return i + 1; +} + +function renderCitation(tokens, idx, o, env) { + var meta = tokens[idx].meta; + var opts = (env && env.assistantOpts) || {}; + var source = sourceByNumber((env && env.assistantSources) || [], meta.source); + // A list ordered by citation already says what the reader should see, and + // the panel shows the same number. Otherwise, first appearance in this text. + var display = source && source.sourceNumber != null ? source.number : meta.display; + var title = source ? source.title || source.resource || 'Source' : 'Source'; + var page = source && (source.page || source.page_number || source.pageNumber); + var label = 'Source ' + display + ': ' + title + (page ? ', page ' + page : ''); + var text = opts.citationLabel === 'text' ? 'src' : String(display); + // Chips in one cluster, or back to back, are separated by a space rather + // than run together. + var gap = idx > 0 && tokens[idx - 1].type === 'assistant_cite' ? ' ' : ''; + return gap + '' + text + ''; +} + +/** + * Teach a markdown-it instance the assistant's three renderer rules. Idempotent, + * so the same instance can be handed in on every render. + * + * Fences render mermaid and chart blocks to the placeholders the page then + * draws, and everything else to a plain
. Images render only from
+ * the safe allowlist; anything else falls back to its alt text. Both used to be
+ * done by holding the block out of the text before parsing and splicing HTML
+ * back in afterwards, which is what a renderer rule is for.
+ */
+export function installCitations(md) {
+ if (!md || md.__assistantCitations) return md;
+ md.__assistantCitations = true;
+ // Before 'escape', or \[1\] is consumed as an escaped bracket first.
+ md.inline.ruler.before('escape', 'assistant_cite', citationRule);
+ md.renderer.rules.assistant_cite = renderCitation;
+ var image = md.renderer.rules.image || function (t, i, o, e, self) { return self.renderToken(t, i, o); };
+ md.renderer.rules.image = function (tokens, idx, o, env, self) {
+ if (!safeImageUrl(tokens[idx].attrGet('src'))) return escapeHtml(tokens[idx].content || 'Image');
+ return image(tokens, idx, o, env, self);
+ };
+ // markdown-it writes column alignment as style="text-align:…"; the export,
+ // the stylesheet and a decade of email clients read the align attribute.
+ // Both are emitted so nothing that looks for either is wrong.
+ ['th_open', 'td_open'].forEach(function (name) {
+ var base = md.renderer.rules[name] || function (t, i, o, e, self) { return self.renderToken(t, i, o); };
+ md.renderer.rules[name] = function (tokens, idx, o, env, self) {
+ var style = tokens[idx].attrGet('style') || '';
+ var m = /text-align:\s*(left|right|center)/.exec(style);
+ if (m && !tokens[idx].attrGet('align')) tokens[idx].attrSet('align', m[1]);
+ return base(tokens, idx, o, env, self);
+ };
+ });
+ // Math is not markdown, so the parser needs telling where it is — and once
+ // told, a $ inside a URL, a code span or an HTML attribute is never math,
+ // by construction, which is what a text pre-pass could not promise.
+ md.inline.ruler.before('escape', 'assistant_math', mathInlineRule);
+ md.block.ruler.before('fence', 'assistant_math_block', mathBlockRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
+ md.renderer.rules.assistant_math = function (tokens, idx, o, env) { return renderMath(env, tokens[idx], false); };
+ md.renderer.rules.assistant_math_block = function (tokens, idx, o, env) { return renderMath(env, tokens[idx], true) + '\n'; };
+ md.renderer.rules.fence = function (tokens, idx) {
+ var lang = String(tokens[idx].info || '').trim().toLowerCase();
+ var code = String(tokens[idx].content || '').replace(/\n$/, '');
+ // Percent-encoding keeps Mermaid arrows intact through DOMPurify.
+ if (lang === 'mermaid') return 'Rendering graph...\n';
+ if (lang === 'chart' || lang === 'chartjs') return '\n';
+ return '' + escapeHtml(code) + '
\n';
+ };
+ return md;
+}
+
+// ─── Math as tokens ────────────────────────────────────────────────────
+// $…$ and \(…\) inline; $$…$$ and \[…\] display, on one line or spanning
+// several; \ce{…} and \pu{…} for mhchem. Rendered by KaTeX when the page has
+// it, otherwise escaped and shown as written.
+var MATH_INLINE_RE = /^(?:\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\$([^$\n]+?)\$|\\\((.+?)\\\)|\\(ce|pu)\{([^{}\n]*)\})/;
+
+function mathInlineRule(state, silent) {
+ var c = state.src.charCodeAt(state.pos);
+ if (c !== 0x24 /* $ */ && c !== 0x5C /* \ */) return false;
+ var m = MATH_INLINE_RE.exec(state.src.slice(state.pos));
+ if (!m) return false;
+ // A lone $ with a space either side is a dollar sign, not an equation.
+ if (m[3] !== undefined && (/^\s/.test(m[3]) || /\s$/.test(m[3]))) return false;
+ if (!silent) {
+ var token = state.push('assistant_math', 'span', 0);
+ var display = m[1] !== undefined || m[2] !== undefined;
+ token.content = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3]
+ : m[4] !== undefined ? m[4] : '\\' + m[5] + '{' + m[6] + '}';
+ // Without KaTeX the expression is shown exactly as written — \(a\) stays
+ // \(a\), not re-dressed as $a$ — so raw is kept beside the content.
+ token.meta = { display: display, raw: m[0] };
+ if (display) token.type = 'assistant_math_block';
+ }
+ state.pos += m[0].length;
+ return true;
+}
+
+// $$ or \[ opening a line, closing on the same or a later line.
+function mathBlockRule(state, startLine, endLine, silent) {
+ var start = state.bMarks[startLine] + state.tShift[startLine];
+ var max = state.eMarks[startLine];
+ var first = state.src.slice(start, max);
+ var open = /^\$\$/.test(first) ? '$$' : /^\\\[/.test(first) ? '\\[' : null;
+ if (!open) return false;
+ var close = open === '$$' ? '$$' : '\\]';
+ var rest = first.slice(open.length);
+ var content, next = startLine;
+ var closeAt = rest.indexOf(close);
+ if (closeAt !== -1 && rest.slice(closeAt + close.length).trim() === '') {
+ content = rest.slice(0, closeAt);
+ } else {
+ var lines = [rest];
+ for (next = startLine + 1; next < endLine; next++) {
+ var line = state.src.slice(state.bMarks[next] + state.tShift[next], state.eMarks[next]);
+ var at = line.indexOf(close);
+ if (at !== -1) { lines.push(line.slice(0, at)); break; }
+ lines.push(line);
+ }
+ if (next >= endLine) return false; // never closed: leave it to the paragraph rule
+ content = lines.join('\n');
+ }
+ if (silent) return true;
+ var token = state.push('assistant_math_block', 'div', 0);
+ token.content = content.trim();
+ token.meta = { display: true, raw: state.getLines(startLine, next + 1, 0, false).trim() };
+ token.map = [startLine, next + 1];
+ state.line = next + 1;
+ return true;
+}
+
+function renderMath(env, token, display) {
+ var katex = env && env.assistantOpts && env.assistantOpts.katex;
+ if (!katex) return escapeHtml((token.meta && token.meta.raw) || token.content);
+ return safeKatex(katex, token.content, display);
+}
+
+var defaultRenderer = null;
+function resolveRenderer(opts) {
+ if (opts && opts.markdownIt && typeof opts.markdownIt.render === 'function') return installCitations(opts.markdownIt);
+ if (!defaultRenderer) {
+ // The browser's window is globalThis. A test sandbox keeps window as a
+ // property of its own global, so both are looked at.
+ var g = typeof globalThis !== 'undefined' ? globalThis : {};
+ var factory = typeof g.markdownit === 'function' ? g.markdownit
+ : (g.window && typeof g.window.markdownit === 'function' ? g.window.markdownit : null);
+ // html:true, as marked rendered before it: an answer may carry a or
+ // a
, and the single sanitisation boundary is what makes that safe.
+ if (factory) defaultRenderer = installCitations(factory({ html: true, linkify: true, typographer: true, breaks: true }));
+ }
+ return defaultRenderer;
+}
+
+// The text as the parser should see it: placeholders dropped, math rendered and
+// held, fences and inline code held while the legacy table repair runs, then put
+// back so the parser's own fence rule renders them. Shared by rendering and by
+// ordering, so the two cannot disagree about what is a citation.
+function prepareMarkdown(md, opts, renderer) {
+ // The legacy table repair and the fence hold need a lexer to know where
+ // blocks are. It is the same parser that will render — one, not two.
+ opts = Object.assign({}, opts, { markdownIt: renderer });
var text = String(md || '')
// The model occasionally writes [src]/[source] placeholders instead of
// real citation numbers. Never invent numbers — drop the tokens; the
// sources panel still lists the actual sources.
- .replace(/\[(?:src|source)\]/gi, '');
+ .replace(/\[(?:src|source)\]/gi, '')
+ // A cluster the model ran out of room for: "[1][4][2][3" at the very end.
+ // Only when it directly follows complete brackets, so a lone "[3" is left.
+ .replace(/((?:\[\d+\]\s*)+)\[(\d+)\s*$/, '$1[$2]');
var embedded = textProtection(text, 'html');
- // Literal/embedded content bypasses prose, math and citation rewriting, then
- // rejoins the parsed HTML before the single sanitization boundary.
- text = protectBlocks(text, opts, function(code, lang) {
- lang = String(lang || '').toLowerCase();
- var html;
- // Percent-encoding keeps Mermaid arrows intact through DOMPurify.
- if (lang === 'mermaid') html = 'Rendering graph...';
- else if (lang === 'chart' || lang === 'chartjs') html = '';
- else html = '' + escapeHtml(code) + '
';
- return embedded.hold(html);
- }, true);
+ var fences = textProtection(text, 'fence');
+ text = protectBlocks(text, opts, function (code, lang) { return fences.hold('```' + (lang || '') + '\n' + code + '\n```'); }, true);
var inlineCode = textProtection(text, 'code');
text = text.replace(/(`+)[\s\S]*?\1/g, inlineCode.hold);
- var links = textProtection(text, 'links');
- text = text.replace(new RegExp(
- /!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/(?:\\[^\s]|[^\s<>|\\])+|<(?:https?:\/\/|mailto:)[^\s<>]+>|<[^\s<>@]+@[^\s<>@]+>/.source + '|' + HTML_TAG_PATTERN.source,
- 'gi'), function(match) {
- if (match.charAt(0) === '!') {
- var imageLink = match.match(/^!\[([^\]\n]*)\]\(([^\n]*?)\)$/);
- if (imageLink && !safeImageUrl(imageLink[2])) return links.hold(escapeHtml(imageLink[1] || 'Image'), match);
- }
- return links.hold(match);
- });
+ // \[1\] is a citation the model escaped, not display math — but \[ ... \] is
+ // the display-math delimiter, and the math pass runs next. A bracket holding
+ // nothing but numbers is never an equation, so it is unescaped here, before
+ // the math pass can hold it. This is the one place text syntax is genuinely
+ // ambiguous, and the parser cannot resolve it because math is not markdown.
text = text.replace(/\\\[((?:\d+\s*,\s*)*\d+)\\\]/g, '[$1]');
- text = renderLatexText(text, opts.katex, embedded.hold);
var limited = false;
text = normalizeMarkdownText(text, Object.assign({}, opts, {
- onUnrecoverable: function(raw) {
+ onUnrecoverable: function (raw) {
limited = true;
- raw = inlineCode.restore(links.restore(embedded.restore(raw, true)));
+ raw = inlineCode.restore(fences.restore(embedded.restore(raw, true)));
return embedded.hold('' + escapeHtml(raw) + '
', raw);
}
}));
- text = links.restore(normalizeAdjacentCitationClusters(text, sources || []));
+ text = fences.restore(inlineCode.restore(text));
+ return { text: text, embedded: embedded, limited: limited };
+}
- text = inlineCode.restore(text);
- var html;
- if (opts.marked && typeof opts.marked.parse === 'function') {
- html = opts.marked.parse(text, { breaks: true, gfm: true });
- } else if (opts.markdownIt && typeof opts.markdownIt.render === 'function') {
- html = opts.markdownIt.render(text);
- } else {
- html = fallbackMarkdown(text);
- }
-
- html = renderCitationLinks(html, sources || [], opts);
- html = embedded.restore(html);
+export function renderAssistantMarkdown(md, sources, options) {
+ var opts = options || {};
+ var renderer = resolveRenderer(opts);
+ if (!renderer) throw new Error('renderAssistantMarkdown: no markdown-it renderer is available');
+ var prepared = prepareMarkdown(md, opts, renderer);
+ var env = { assistantSources: sources || [], assistantOpts: opts, assistantCiteOrder: [] };
+ var html = renderer.render(prepared.text, env);
+ html = prepared.embedded.restore(html);
html = wrapTables(html);
- if (limited) html += 'Could not safely recover this flattened table. Stored text is shown unchanged; missing cells or rows cannot be recovered.
';
+ if (prepared.limited) html += 'Could not safely recover this flattened table. Stored text is shown unchanged; missing cells or rows may be present.
';
if (opts.notice) html += '' + escapeHtml(opts.notice) + '
';
-
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
}
+/**
+ * Link `[n]` markers in HTML that did not come from our renderer.
+ *
+ * The translation service is handed HTML and returns HTML, with the markers
+ * carried across as plain text. That cannot go through the tokenizer, but the
+ * document has structure of its own: only text nodes are visited, and none
+ * inside code, pre, a or script. An attribute value is not a text node, so it is
+ * never touched — the case that defeated the pattern approach.
+ */
+export function linkCitationsInHtml(html, sources, options) {
+ if (typeof document === 'undefined') return String(html || '');
+ var opts = options || {};
+ var tpl = document.createElement('template');
+ tpl.innerHTML = String(html || '');
+ var walker = document.createTreeWalker(tpl.content, 4 /* NodeFilter.SHOW_TEXT */);
+ var texts = [];
+ for (var node = walker.nextNode(); node; node = walker.nextNode()) {
+ var skip = false;
+ for (var el = node.parentNode; el && el !== tpl.content; el = el.parentNode) {
+ if (/^(code|pre|a|script|style)$/i.test(el.nodeName)) { skip = true; break; }
+ }
+ if (!skip && node.nodeValue.indexOf('[') !== -1) texts.push(node);
+ }
+ var env = { assistantSources: sources || [], assistantOpts: opts, assistantCiteOrder: [] };
+ texts.forEach(function (node) {
+ var parts = node.nodeValue.split(/(\\?\[(?:\d+\s*,\s*)*\d+\\?\])/);
+ if (parts.length === 1) return;
+ var frag = document.createDocumentFragment();
+ parts.forEach(function (part) {
+ var m = /^\\?\[((?:\d+\s*,\s*)*\d+)\\?\]$/.exec(part);
+ var nums = m ? m[1].split(',').map(function (n) { return Number(n.trim()); }) : null;
+ if (!nums || !nums.every(function (n) { return n > 0 && sourceByNumber(env.assistantSources, n); })) {
+ frag.appendChild(document.createTextNode(part));
+ return;
+ }
+ var span = document.createElement('span');
+ span.innerHTML = nums.map(function (n, i) {
+ return renderCitation([{ type: 'assistant_cite', meta: { source: n, display: displayNumber(env, n) } }], 0, {}, env)
+ .replace(/^ /, i ? ' ' : '');
+ }).join('');
+ while (span.firstChild) frag.appendChild(span.firstChild);
+ });
+ node.parentNode.replaceChild(frag, node);
+ });
+ return tpl.innerHTML;
+}
+
export function wrapTables(html) {
return String(html || '')
.replace(/