feat: source citations with footnotes in the transcript (#21)

This commit was merged in pull request #21.
This commit is contained in:
2026-10-07 08:12:48 +00:00
parent 72a63e69e6
commit fcc6d26c9d
16 changed files with 2716 additions and 205 deletions
+50 -2
View File
@@ -1,6 +1,8 @@
import { linkCitations } from "./citations";
import { CLOSING_FENCE, OPENING_FENCE, mapOutsideCodeSpans, readCodeSpan, repairCodeBlocks } from "./code";
import { sanitizeInlineHtml } from "./html";
import { looksLikeMath, renderLatex } from "./latex";
import type { Source } from "./types";
/**
* The rendering pass for model output.
@@ -490,6 +492,30 @@ export function renderInlineContent(markdown: string): string {
return flatten(htmlPass(mathPass(splitSegments(markdown))));
}
/** What the pane will draw, and what the answer cited so the list can filter. */
export interface RenderedMarkdown {
text: string;
/**
* The numbers this answer cited that became links.
*
* Reported rather than worked out again by the caller: the pass already had to
* decide which markers were real and which were inside code, and a second reader
* of the same text could only disagree with it.
*/
cited: Set<number>;
}
/** Optional input to the pass. */
export interface RenderOptions {
/**
* The turn's sources, so a marker can become a link to the page it names.
*
* Absent for a question, and absent for an answer in a turn that consulted
* nothing, in which case the citation pass is the identity function.
*/
sources?: Source[];
}
/**
* The full rendering pass, from what the model wrote to what the pane draws.
*
@@ -497,6 +523,28 @@ export function renderInlineContent(markdown: string): string {
* markdown around each message (a blockquote for a question, a rule between
* exchanges) and none of that should be read as model output.
*/
export function renderModelOutput(markdown: string): string {
return renderInlineContent(repairCodeBlocks(markdown));
export function renderModelOutput(markdown: string, options: RenderOptions = {}): RenderedMarkdown {
const sources = options.sources ?? [];
const cited = new Set<number>();
/**
* Maths first, then citations, then markup.
*
* The order is forced by one collision. A link label has to escape its brackets
* or the parser ends the link text early, and `[\[1\]]` is exactly the shape of
* LaTeX's own `\[...\]` display delimiter, which the maths pass would eat and
* hand back as a bare `1`. So the model's maths is converted before any marker
* becomes a link: the citation pass writes final form markdown, which is not
* input for anything but the markup pass, and that one only reads angle brackets.
*/
const segments = mathPass(splitSegments(repairCodeBlocks(markdown))).map((segment) => {
if (segment.fence || sources.length === 0) return segment;
const result = linkCitations(segment.lines.join("\n"), sources);
for (const index of result.cited) cited.add(index);
return { fence: false, language: "", lines: result.text.split("\n") };
});
return { text: flatten(htmlPass(segments)), cited };
}