hello");
});
it("leaves a block holding an image alone, because that path loads it", () => {
- expect(renderModelOutput('
')).toBe(
- '
',
- );
+ expect(rendered('
')).toBe('
');
});
it("leaves a lone inline tag alone, because it is not a block", () => {
const markdown = "Try
bold for that.";
- expect(renderModelOutput(markdown)).toBe(markdown);
+ expect(rendered(markdown)).toBe(markdown);
});
it("keeps an answer that has nothing to render exactly as written", () => {
const plain = "Just a sentence, with a [link](https://example.com) and `code`.";
- expect(renderModelOutput(plain)).toBe(plain);
+ expect(rendered(plain)).toBe(plain);
});
it("repairs fences in model output but not in what a person typed", () => {
// A lone command is a fence when a model writes it, and a question about it
// is left as the person typed it.
- expect(renderModelOutput("`pwd`")).toBe("```bash\npwd\n```");
+ expect(rendered("`pwd`")).toBe("```bash\npwd\n```");
expect(renderInlineContent("`pwd`")).toBe("`pwd`");
});
@@ -133,3 +138,31 @@ describe("renderModelOutput", () => {
expect(renderInlineContent("```ts\nconst x = $a$;\n```")).toBe("```ts\nconst x = $a$;\n```");
});
});
+
+describe("citations in the pipeline", () => {
+ const sources = [{ index: 1, title: "First", url: "https://first.example" }];
+
+ it("reports which citations it linked", () => {
+ const result = renderModelOutput("Rain [1].", { sources });
+
+ expect(result.text).toBe("Rain [\\[1\\]](https://first.example).");
+ expect([...result.cited]).toEqual([1]);
+ });
+
+ it("links a marker that has to survive the maths and markup passes", () => {
+ expect(rendered("Let $x^2$ be [1], and nothing else.", sources)).toContain(
+ "x² be [\\[1\\]](https://first.example)",
+ );
+ });
+
+ it("leaves a marker inside a fence for the reader to copy", () => {
+ const text = rendered("```bash\ncurl [1]\n```\n\nSee [1].", sources);
+
+ expect(text).toContain("curl [1]");
+ expect(text).toContain("[\\[1\\]](https://first.example).");
+ });
+
+ it("changes nothing when no sources are given", () => {
+ expect(rendered("Rain [1].")).toBe("Rain [1].");
+ });
+});
diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts
index 6cdce72..8a01e31 100644
--- a/src/lib/markdown.ts
+++ b/src/lib/markdown.ts
@@ -154,7 +154,7 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
? // One pass, in one place: fences get their languages and links recovered,
// then TeX becomes something drawable and the HTML around it is filtered to
// the subset the renderer acts on. See `render.ts` for the order and why.
- renderModelOutput(answered)
+ renderModelOutput(answered).text
: !message.isStreaming
? "_No response._"
: activityLine
diff --git a/src/lib/render.ts b/src/lib/render.ts
index 0c23bab..a7164e7 100644
--- a/src/lib/render.ts
+++ b/src/lib/render.ts
@@ -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
;
+}
+
+/** 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();
+
+ /**
+ * 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 };
}