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
+13
View File
@@ -1,3 +1,4 @@
import { createSourceList } from "./citations";
import { DEFAULT_MAX_TOOL_ROUNDS, MAX_REPEATED_TOOL_CALLS } from "./constants";
import { streamChat } from "./ollama";
import { executeTool, toolLabel, type ToolContext } from "./tools";
@@ -137,6 +138,14 @@ function exhaustedNotice(reason: StopReason): string {
export async function runAgent(options: AgentOptions): Promise<AgentResult> {
const conversation: ApiMessage[] = [...options.messages];
const toolCalls: ToolCallRecord[] = [];
/**
* The turn's sources, so one number means one page for the whole turn.
*
* Created here rather than passed in: it is scoped to a single run of this loop,
* and every call the loop makes shares it, which is what keeps three searches in
* one answer from each starting again at 1.
*/
const sources = createSourceList();
/** `Infinity` needs no special case: the loop simply never reaches its own end. */
const rounds =
options.maxToolRounds === null ? Infinity : (options.maxToolRounds ?? DEFAULT_MAX_TOOL_ROUNDS);
@@ -239,6 +248,7 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
place: options.toolContext.place,
now: options.toolContext.now,
signal: options.signal,
sources,
});
const record: ToolCallRecord = {
@@ -246,6 +256,9 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
arguments: args,
result: result.content,
isError: result.isError,
// Only what this call added. The turn's whole list is the flattening of
// its records, so the running total is deliberately not repeated here.
sources: result.sources,
};
toolCalls.push(record);
options.events?.onToolCall?.(record);
+229
View File
@@ -0,0 +1,229 @@
import { mapOutsideCodeSpans, markdownLink } from "./code";
import type { Source, ToolCallRecord } from "./types";
/**
* Numbered sources for one turn, and the citation markers that point at them.
*
* Vicinae has no footnote support to lean on: it parses markdown with
* `CMARK_OPT_DEFAULT` and attaches only the table and strikethrough extensions
* (`src/server/src/ui/quick/markdown-model.cpp` upstream), so a `[^1]` written by
* a model arrives in the pane as literal text. Everything a footnote would do
* therefore has to happen here, on the way to the renderer.
*
* Imports no Vicinae module, so it unit tests with no runtime.
*/
/** What a turn uses to hand out numbers, one per page it consults. */
export interface SourceList {
/**
* The number for a URL, registering it the first time it is seen.
*
* `index` is 0 for a result with no usable address, which is the caller's
* signal to print no marker at all. `source` comes back only when this call
* registered something new, so a caller records each page exactly once.
*/
add(title: string, url: string): { index: number; source?: Source };
}
/**
* A fresh list, numbering from one.
*
* Scoped to a turn rather than a conversation: a new question starts again at
* one, which is what lets a marker mean one thing for the whole answer beside
* it, and lets the transcript list sources per answer rather than per session.
*/
export function createSourceList(): SourceList {
const byUrl = new Map<string, Source>();
let next = 1;
return {
add(title, url) {
const address = url.trim();
if (address.length === 0) return { index: 0 };
const known = byUrl.get(address);
if (known) return { index: known.index };
const source: Source = { index: next, title: title.trim() || address, url: address };
next += 1;
byUrl.set(address, source);
return { index: source.index, source };
},
};
}
/**
* Every source a turn consulted, in number order.
*
* A record holds only what its own call added, so the turn's list is the
* flattening of its records. That keeps the running total from being stored once
* per later call, and it means the list can be rebuilt from a stored turn without
* re-reading a tool result string and hoping its format held.
*/
export function collectSources(calls: ToolCallRecord[]): Source[] {
const byIndex = new Map<number, Source>();
for (const call of calls) {
for (const source of call.sources ?? []) {
// First wins. A number is assigned once, so a second record carrying it is
// describing the same page, and the first one is what named it.
if (!byIndex.has(source.index)) byIndex.set(source.index, source);
}
}
return [...byIndex.values()].sort((left, right) => left.index - right.index);
}
/**
* The numbers an answer cites that are actually sources.
*
* A plain scan, without the fence awareness the linking pass has, because its one
* caller is the clipboard export: what a copied document needs is the pages the
* answer leaned on, and a `[1]` inside a snippet there costs a reader nothing.
*/
export function citedIndices(answer: string, sources: Source[]): Set<number> {
const known = new Set(sources.map((source) => source.index));
const cited = new Set<number>();
for (const match of answer.matchAll(/\[\^?(\d+(?:\s*,\s*\d+)*)\]/g)) {
for (const part of match[1].split(",")) {
const index = Number(part.trim());
if (known.has(index)) cited.add(index);
}
}
return cited;
}
/** What linking did to one run of prose. */
export interface CitationResult {
text: string;
/** The numbers that became links, which is what the list filters on. */
cited: Set<number>;
}
/**
* A citation marker the model wrote, in the two shapes it reaches for.
*
* The trailing guards are what keep this off markdown the model wrote on purpose:
* `](` starts a link, and `]:` is a reference definition. The digit list allows
* `[1, 3]`, which is how a sentence resting on two sources is usually written.
*/
const CITATION_MARKER = /\[\^?(\d+(?:\s*,\s*\d+)*)\](?![(:])/g;
/**
* A footnote definition line, which is `[^1]: title, url` on a line of its own.
*
* The caret is required rather than optional, and the two shapes mean different
* things. `[1]: something` is markdown's own reference definition syntax, which
* backs a link the model wrote, so dropping one because it happens to carry a
* source number would break that link. `[^1]:` is a footnote definition, which is
* a duplicate of an entry we render ourselves.
*/
const FOOTNOTE_DEFINITION = /^\s*\[\^(\d+)\]:\s*\S/;
/**
* A destination this window can safely be asked to open, or nothing.
*
* A search result is untrusted input, so the scheme is checked here for the same
* reason `SAFE_URL` in `html.ts` checks it: a clickable `javascript:` link would
* be a real vulnerability rather than a rendering quirk. The rest of the test is
* characters that would break the link or smuggle a second one out of it.
*/
function linkableUrl(url: string): string | undefined {
const address = url.trim();
if (!/^https?:\/\//i.test(address)) return undefined;
if (/[\s<>\\]/.test(address)) return undefined;
// Control characters by code point. A character class would say the same thing
// and trip `no-control-regex`, and a NUL or a DEL in a destination is never
// anything but damage, so it is refused either way.
for (const character of address) {
const code = character.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) return undefined;
}
return address;
}
/**
* Drops a definition line for a source the turn already lists.
*
* The entry is drawn from what the tool returned, so the model's copy of the same
* thing is a duplicate line rather than information. One for a number we know
* nothing about is left alone, because then it is the only place that text exists.
*/
function stripDefinitions(text: string, known: Set<number>): string {
return text
.split("\n")
.filter((line) => {
const definition = FOOTNOTE_DEFINITION.exec(line);
return !definition || !known.has(Number(definition[1]));
})
.join("\n");
}
/**
* `[1]`, `[^1]` and `[1, 3]` as links, plus the numbers that became links.
*
* One run of prose only: fences are the caller's business, because the caller
* already walks the document in segments for the maths pass and there is no
* reason for two ideas of where code starts.
*/
export function linkCitations(text: string, sources: Source[]): CitationResult {
const cited = new Set<number>();
if (sources.length === 0) return { text, cited };
const byIndex = new Map(sources.map((source) => [source.index, source]));
const prose = stripDefinitions(text, new Set(byIndex.keys()));
const linked = mapOutsideCodeSpans(prose, (chunk) =>
chunk.replace(CITATION_MARKER, (whole: string, digits: string) => {
const wanted = digits.split(",").map((part) => Number(part.trim()));
const links: string[] = [];
for (const index of wanted) {
const source = byIndex.get(index);
const href = source ? linkableUrl(source.url) : undefined;
// All or nothing, and nothing at all if a destination is unusable: a
// marker that is partly a link reads as a citation that works.
if (!href) return whole;
links.push(markdownLink(`[${index}]`, href));
}
for (const index of wanted) cited.add(index);
return links.join(", ");
}),
);
return { text: linked, cited };
}
/**
* The sources under an answer, or nothing when there is nothing to say.
*
* The marker is the link, matching the inline marker character for character, so
* one token means one thing in both places. Numbers are the model's rather than
* the reader's: `[3]` is the third source the turn found whether or not the answer
* cited the two before it, which keeps the transcript agreeing with the tool
* output and with anything the model said about its own numbering.
*/
export function renderSources(sources: Source[], cited: Set<number>, streaming: boolean): string {
// While an answer is arriving the cited set is still filling up, so the list
// waits rather than appearing in full and then dropping most of itself. An
// answer that cites nothing at all is the one case left to fall back on
// everything consulted, because there is then nothing to filter by.
const shown =
cited.size > 0 ? sources.filter((source) => cited.has(source.index)) : streaming ? [] : sources;
if (shown.length === 0) return "";
const entries = shown.map((source) => {
const href = linkableUrl(source.url);
const marker = href ? markdownLink(`[${source.index}]`, href) : `[${source.index}]`;
return `- ${marker} ${source.title}`;
});
return ["_Sources_", "", ...entries].join("\n");
}
+2
View File
@@ -96,6 +96,8 @@ Fence every command and every snippet, and always name the language: bash for sh
A web address is a link, never code. A markdown link, written as [label](https://example.com), is clickable in this window, while a URL inside backticks or a fenced block is dead text the user cannot open. So never fence a URL, never put one in inline code, and never answer "here are the links" with a snippet. Label a link with what it is ("OpenClickly on GitHub"), and when you give several, put each on its own bullet with its own link.
When a tool result gives you a source, cite it: put its number in square brackets right after the sentence it supports, like a claim resting on a search result [1]. Cite the page that carries the point rather than the search that found it, cite one number at a time when a sentence rests on two of them (write [1][2], not [1,2]), and never cite a number you were not shown. The window turns that number into a link and lists the sources under your answer, so write no footnote definitions and no sources or references list of your own.
A fenced block is never a whole answer on its own. Say in one plain sentence what the code does or when to reach for it, so the reply reads like something a person wrote rather than a bare snippet.
Call a tool when it makes the answer more accurate. Anything time-sensitive or past your cutoff should be checked rather than guessed, and never invent tool output. If a tool fails, say so and answer with what you know.
+33 -6
View File
@@ -1,5 +1,6 @@
import { imageSource } from "./attachments";
import { renderInlineContent, renderModelOutput } from "./render";
import { citedIndices, collectSources, renderSources } from "./citations";
import { renderInlineContent, renderModelOutput, type RenderedMarkdown } from "./render";
import { describeActivity } from "./thread";
import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types";
@@ -150,11 +151,20 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
/** The answer, or whatever stands in for it while there is none. */
const answered = message.text.trim();
/**
* The turn's sources, read back off its records rather than out of a tool result
* string, so a reopened conversation draws the same list with the same numbers.
*/
const sources = collectSources(message.toolCalls);
const drawn: RenderedMarkdown = answered
? // One pass, in one place: fences get their languages and links recovered, then
// TeX becomes something drawable, a citation marker becomes a link to the page
// it names, and the HTML around all of it is filtered to the subset the
// renderer acts on. See `render.ts` for the order and why.
renderModelOutput(answered, { sources })
: { text: "", cited: new Set<number>() };
const body = answered
? // 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)
? drawn.text
: !message.isStreaming
? "_No response._"
: activityLine
@@ -178,6 +188,14 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
// something went wrong and should not be skimmed past.
if (message.error) sections.push("", `**Error:** ${message.error}`);
/**
* Sources belong to the answer rather than to the tool list, so they are drawn
* whether or not the user asked to see tool calls. Hiding the activity line is
* about chrome; a citation list is the answer's own provenance.
*/
const list = renderSources(sources, drawn.cited, message.isStreaming);
if (list) sections.push("", list);
/**
* Tool calls are a display preference. When they are on, every tool is named
* once. When they are off, nothing is listed; a failure or a refused repeat
@@ -276,7 +294,16 @@ export function sessionToPlainText(turns: ChatTurn[]): string {
const attached = turn.attachments.length > 0 ? "\n\n_(screenshot attached)_" : "";
const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}`;
/**
* The same list the transcript draws, worked out from a plain scan rather than
* from the panel's own set. A copied conversation is markdown on its way
* somewhere else, where a `[1]` inside a snippet counting as a citation costs a
* reader nothing.
*/
const sources = collectSources(turn.toolCalls);
const list = renderSources(sources, citedIndices(turn.response, sources), false);
const cited = list ? `\n\n${list}` : "";
return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}${cited}`;
})
.join("\n\n---\n\n");
}
+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 };
}
+11
View File
@@ -7,12 +7,23 @@ import { z } from "zod";
* older or hand-edited payload gets rejected instead of crashing a render.
*/
export const SourceSchema = z.object({
index: z.number(),
title: z.string(),
url: z.string(),
});
export const ToolCallRecordSchema = z.object({
name: z.string(),
arguments: z.record(z.string(), z.unknown()),
result: z.string(),
isError: z.boolean(),
repeated: z.boolean().optional(),
// Absent on turns stored before sources were numbered, which is honest: those
// answers never cited a number, so there is nothing to draw for them. zod drops
// unknown keys, so without this line the field would be lost on the first
// reload and a reopened conversation would lose every link.
sources: z.array(SourceSchema).optional(),
});
export const AttachmentSchema = z.object({
+47 -6
View File
@@ -1,7 +1,8 @@
import { createSourceList, type SourceList } from "./citations";
import { formatNow, getLocationContext, weekdayName } from "./location";
import { webFetch, webSearch } from "./ollama";
import type { GeoLookup } from "./geo";
import type { ToolDefinition } from "./types";
import type { Source, ToolDefinition } from "./types";
import { errorMessage } from "./utils";
/**
@@ -35,11 +36,34 @@ export interface ToolContext {
* for a location on its own.
*/
ipPlace?: (options: { signal?: AbortSignal }) => Promise<GeoLookup>;
/**
* The turn's source list, when the caller keeps one.
*
* Optional, so a caller with no interest in citations (a unit test, or any
* future tool that returns no sources) still gets the same numbered output: a
* list is made on the spot when none is supplied.
*/
sources?: SourceList;
}
/**
* What a tool returned.
*
* `sources` is separate from `content` on purpose. The content is prose written
* for a model to read, while the sources are data the transcript renders, so a
* change to one format cannot quietly break the other.
*/
export interface ToolResult {
content: string;
isError: boolean;
/**
* The sources this call added, for the transcript to list under the answer.
*
* Only what this call found, never the turn's running total: that total is the
* flattening of the turn's records, so storing it here would write the same page
* once per later call.
*/
sources?: Source[];
}
/** Search snippets are truncated so a chatty result set cannot swallow the model's context window. */
@@ -272,8 +296,11 @@ async function runWebSearch(args: Record<string, unknown>, context: ToolContext)
return { content: `No web results for "${query}".`, isError: false };
}
const list = context.sources ?? createSourceList();
const added: Source[] = [];
const rendered = results
.map((result, index) => {
.map((result) => {
const collapsed = result.content.replace(/\s+/g, " ").trim();
// Mark the cut rather than silently dropping the tail: the model should
// know a snippet was trimmed, and this matches what web_fetch does.
@@ -281,11 +308,19 @@ async function runWebSearch(args: Record<string, unknown>, context: ToolContext)
collapsed.length > MAX_SNIPPET_CHARS
? `${collapsed.slice(0, MAX_SNIPPET_CHARS)} …[truncated]`
: collapsed;
return `${index + 1}. ${result.title}\n ${result.url}\n ${snippet}`;
const { index, source } = list.add(result.title, result.url);
if (source) added.push(source);
// The number is what the model cites, so it is printed rather than left to be
// inferred from position: three searches in one turn would otherwise each
// start again at 1, and a marker in the answer would point at three pages.
const marker = index > 0 ? `[${index}] ` : "";
return `${marker}${result.title}\n ${result.url}\n ${snippet}`;
})
.join("\n\n");
return { content: `Web results for "${query}":\n\n${rendered}`, isError: false };
return { content: `Web results for "${query}":\n\n${rendered}`, isError: false, sources: added };
}
async function runWebFetch(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
@@ -305,10 +340,16 @@ async function runWebFetch(args: Record<string, unknown>, context: ToolContext):
const truncated = page.content.length > MAX_FETCH_CHARS ? "\n\n[content truncated]" : "";
const links = page.links.slice(0, 20);
const sections = [`Title: ${page.title}`, `URL: ${url}`, "", content + truncated];
const { index, source } = (context.sources ?? createSourceList()).add(page.title, url);
const heading = index > 0 ? `[${index}] ${page.title}` : page.title;
// The address keeps its label here, unlike in a search result: the body after it
// can run for thousands of characters, and a reader scrolling that wants to be
// able to find the page's own URL again.
const sections = [heading, `URL: ${url}`, "", content + truncated];
if (links.length > 0) {
sections.push("", `Links on page:\n${links.map((link) => `- ${link}`).join("\n")}`);
}
return { content: sections.join("\n"), isError: false };
return { content: sections.join("\n"), isError: false, sources: source ? [source] : [] };
}
+27
View File
@@ -84,12 +84,39 @@ export interface PendingAttachment extends Attachment {
/** Base64 of the image, with no `data:` URL prefix. Ollama wants it bare. */
base64: string;
}
/**
* One numbered source, as a web tool found it.
*
* The number is the whole point: it is assigned once per turn, printed to the
* model in the tool result, and rendered as the marker the reader clicks. The
* model and the reader therefore agree on what `[3]` means without either of them
* being asked to remember a URL.
*/
export interface Source {
/** The number the model cites. One-based, and never reused for a different page. */
index: number;
/** Page title, or the URL when the page had none. */
title: string;
url: string;
}
/** What a tool actually returned during a turn, kept for display and debugging. */
export interface ToolCallRecord {
name: string;
arguments: Record<string, unknown>;
result: string;
isError: boolean;
/**
* The sources this call added to the turn, if it consulted any.
*
* Only what this call found, never the turn's running total: the whole list is
* the flattening of a turn's records, so storing the total here would write
* the same page once per later call.
*
* Absent on turns stored before sources were numbered, which is honest rather
* than empty: those answers never cited a number, so there is nothing to draw.
*/
sources?: Source[];
/**
* Set when the call was refused because the turn had already made it twice.
*