Borrowed from the quiz app's AI Mode, where validating citations and ordering them fall out of the same pass: it collects the sources an answer actually used into an insertion-ordered map, so the list comes back in first-citation order for free. Ours listed sources in retrieval order — an order the reader never sees and has no way to follow. An answer whose first citation was [7] opened a list that began at [1], so matching a marker to a source meant hunting. Reference lists in published writing are numbered by first appearance for exactly this reason. Cited sources now come first, renumbered by first appearance, and the markers in the text are rewritten to match. Anything retrieved and not cited keeps its place after them, labelled "not cited" — the panel is also a view of what the search returned, which is worth keeping, but it should not sit among the numbers the answer used. The marker itself now shows its number instead of the word "src". Every citation read identically, so the only way to tell one from another was to hover it — which made the numbered list beneath useless to match against. The export has shown numbers since the day "src" was introduced, with no recorded reason for the difference. Renumbering happens once the whole answer is known, never while streaming: the order is the order of first citation, so a citation that has not arrived yet cannot take its place, and numbers would shuffle under the reader mid-sentence. The text is rewritten in a single pass — number by number would turn 2 into 1 and then that 1 into whatever 1 maps to. An invented citation reserves no position and is left exactly as it was. It is still not turned into a link, and citation_audit still records it; what matters here is that it cannot push a real source down the list. Accuracy was already held: a marker with no matching source never becomes a link. This changes what a reader can do with the ones that are real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
39 lines
2.2 KiB
JavaScript
39 lines
2.2 KiB
JavaScript
import { escapeAttr, escapeHtml } from './citations.js';
|
|
|
|
export function renderSourcesList(sources) {
|
|
if (!sources || sources.length === 0) return '<p class="assistant-muted">No citations returned.</p>';
|
|
return sources.map(function (s, idx) {
|
|
var n = s.number || idx + 1;
|
|
var page = s.page || s.page_number || s.pageNumber;
|
|
var meta = [];
|
|
if (page) meta.push('page ' + page);
|
|
if (s.visual_caption_source) meta.push('caption: ' + s.visual_caption_source);
|
|
if (s.visual_kind || s.source_priority) meta.push(s.visual_kind || s.source_priority);
|
|
if (s.category) meta.push(s.category);
|
|
if (s.doc_type || s.type) meta.push(s.doc_type || s.type);
|
|
if (s.score != null) meta.push('score ' + Number(s.score).toFixed(3));
|
|
// Retrieved, but the answer did not lean on it. Worth showing — the panel
|
|
// is also a view of what the search found — but worth distinguishing from
|
|
// the numbers the answer actually used.
|
|
if (s.uncited) meta.unshift('not cited');
|
|
return '<div class="assistant-source" id="assistant-source-' + escapeAttr(n) + '">' +
|
|
'<strong>[' + escapeHtml(n) + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '</strong>' +
|
|
renderSourceBadges(s) +
|
|
'<div class="assistant-source-meta">' + escapeHtml(meta.join(' · ') || 'indexed source') + '</div>' +
|
|
(s.excerpt ? '<div class="assistant-source-excerpt"><p style="white-space: pre-wrap">' + escapeHtml(s.excerpt) + '</p></div>' : '') +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function renderSourceBadges(source) {
|
|
var badges = [];
|
|
if (source.source_priority) badges.push(source.source_priority);
|
|
if (source.visual_kind && badges.indexOf(source.visual_kind) === -1) badges.push(source.visual_kind);
|
|
if (source.visual_caption_source && badges.indexOf(source.visual_caption_source) === -1) badges.push(source.visual_caption_source);
|
|
if (source.category) badges.push(source.category);
|
|
if (source.source_boost && Number(source.source_boost) !== 1) badges.push(Number(source.source_boost).toFixed(2) + 'x');
|
|
if (!badges.length) return '';
|
|
return '<div class="assistant-source-badges">' + badges.slice(0, 4).map(function (badge) {
|
|
return '<span>' + escapeHtml(badge) + '</span>';
|
|
}).join('') + '</div>';
|
|
}
|