Files
lam-agent-vicinae/src/lib/markdown.ts
T
adrianbonpin 41de7e1cc6 feat: link citation markers in the render pipeline
renderModelOutput now takes the turn's sources and reports back which numbers it
linked, so the list has one source of truth for what the answer actually cited.

Maths runs before citations, not after. A link label has to escape its brackets,
and that escaped form is exactly LaTeX's own display delimiter, so converting the
model's maths first is what stops the maths pass from eating a marker and handing
back a bare 1.
2026-10-07 16:11:18 +08:00

283 lines
12 KiB
TypeScript

import { imageSource } from "./attachments";
import { renderInlineContent, renderModelOutput } from "./render";
import { describeActivity } from "./thread";
import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types";
/**
* Markdown rendering for the detail panel.
*
* Kept as pure functions so the transcript layout can be tested without
* mounting a component.
*
* ## Emphasis
*
* Nothing here is bold except an error. The answer is what is being read, so it
* is plain body text and carries no label. The question is a blockquote, which
* the renderer draws dimmed, italic, and with a left rule. Putting a `**You**`
* and a `**Lam**` header on each exchange gave both speakers the same visual
* weight and made the reader scan past chrome to reach the answer.
*
* Raycast solves the same problem by aligning messages: right-aligned bubbles
* for the user. Markdown offers no alignment control, so styling is the only
* lever available.
*/
export const STREAMING_CURSOR = "▍";
/**
* Renders attachments as markdown images.
*
* Vicinae does resolve a local file: `MdImage.qml` draws it block-level and
* centred, uses the alt text as an italic caption underneath, and caps the
* height at 200px no matter what is asked for. So this is a thumbnail rather
* than something readable, which is why the action panel also offers Open
* Screenshot for the full image.
*
* The alt text is short on purpose. Whatever is here is drawn centred under the
* image, so a sentence reads as a stray line of prose floating in the middle of
* the pane. It is also the only thing left if the file has since been deleted,
* which is why it is not empty.
*/
export function renderAttachments(attachments: Attachment[]): string {
return attachments.map((attachment) => `![Screenshot](${imageSource(attachment)})`).join("\n\n");
}
/** Which optional sections the transcript includes. Driven by preferences. */
export interface TranscriptOptions {
showToolCalls: boolean;
showReasoning: boolean;
}
const SHOW_EVERYTHING: TranscriptOptions = { showToolCalls: true, showReasoning: true };
/** Prefixes every line so a multi-line message stays one continuous quote block. */
export function quote(text: string): string {
return text
.split("\n")
.map((line) => `> ${line}`.trimEnd())
.join("\n");
}
/** How many calls failed for a reason other than being refused as a repeat. */
function countFailures(calls: ToolCallRecord[]): number {
// A refused repeat is an `isError` too, but it did not fail: it is the loop
// guard noticing a rut, which is a different thing and is said differently.
return calls.filter((call) => call.isError && !call.repeated).length;
}
/** Whether the turn was cut short because a call kept repeating itself. */
function wasStopped(calls: ToolCallRecord[]): boolean {
return calls.some((call) => call.repeated);
}
/**
* One italic line naming the tools a turn used, each mentioned once.
*
* Listing every call in order said nothing a name does not, and on a research
* turn it said it twenty times: `_Tools: web_search, web_search, web_search,
* web_fetch (failed), web_search…_` ran to three lines of the same two words.
* Names in order of first use, then a count of what went wrong, which is the
* only part of that repetition that carries information.
*/
function renderTools(message: ThreadMessage): string {
const names: string[] = [];
for (const call of message.toolCalls) {
if (!names.includes(call.name)) names.push(call.name);
}
const listed = names.map((name) => `\`${name}\``).join(", ");
const failed = countFailures(message.toolCalls);
const notes: string[] = [];
if (failed > 0) notes.push(`${failed} failed`);
if (wasStopped(message.toolCalls)) notes.push("stopped: the same call kept repeating");
const suffix = notes.length > 0 ? ` (${notes.join(", ")})` : "";
return `_Tools: ${listed}${suffix}_`;
}
/**
* The one line shown when tool calls are hidden but something went wrong.
*
* Hiding the list is the user's choice, and a list of names is what they turned
* off. A turn where a call failed or was refused as a repeat is different: the
* answer alone would not account for it, so it is said in a sentence rather than
* dropped, and the names stay hidden.
*/
function renderToolFailureNote(message: ThreadMessage): string | undefined {
const failed = countFailures(message.toolCalls);
const stopped = wasStopped(message.toolCalls);
if (failed === 0 && !stopped) return undefined;
const parts: string[] = [];
if (failed > 0) parts.push(`${failed} tool call${failed === 1 ? "" : "s"} failed`);
if (stopped) parts.push("a call kept repeating, so the turn was cut short");
return `_${parts.join(" and ")}._`;
}
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
// The question is identified by its styling alone. It goes through the same
// rendering pass as an answer, because a person pastes LaTeX into a question
// as readily as a model writes it, and a question full of raw HTML should be
// put behind the same fence.
if (message.role === "user") {
const images = renderAttachments(message.attachments);
const asked = quote(renderInlineContent(message.text));
return images ? `${asked}\n\n${images}` : asked;
}
const thinking = message.thinking.trim();
/**
* Live reasoning is shown while a turn is in flight whether or not the
* preference is on: it is the only sign of progress when a model is thinking
* rather than writing, and waiting on a spinner is worse than reading the
* thought. Once the turn ends it stays only if the user asked for it, so
* `Show Reasoning` still governs the finished transcript.
*/
const liveThinking = message.isStreaming && thinking.length > 0;
/** The stored trace, kept as an appendix under the answer it produced. */
const storedThinking = !message.isStreaming && options.showReasoning && thinking.length > 0;
const showThinking = liveThinking || storedThinking;
// While a tool is running there is no answer text yet, so the activity line
// stands in and the user can see what Lam is actually doing. It is shown
// whether or not tokens have arrived, because a tool that starts after the
// answer has begun would otherwise be invisible for the rest of the turn.
const activityLine =
message.isStreaming && message.activity ? `_${describeActivity(message.activity)}…_` : undefined;
/** The answer, or whatever stands in for it while there is none. */
const answered = message.text.trim();
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).text
: !message.isStreaming
? "_No response._"
: activityLine
? // The activity line above is already saying what is being waited on, so
// printing the placeholder as well put the same sentence on screen
// twice: once as a note above the transcript and once as the stand-in.
undefined
: liveThinking
? // The reasoning below is already saying the model is working, so
// "Thinking…" on top of it would only repeat it.
undefined
: message.status
? `_${message.status}…_`
: "_Thinking…_";
const sections: string[] = [];
const activityIndex = activityLine ? sections.push(activityLine) - 1 : -1;
const bodyIndex = body ? sections.push(body) - 1 : -1;
// The one thing allowed to break the plain-text rule, because it means
// something went wrong and should not be skimmed past.
if (message.error) sections.push("", `**Error:** ${message.error}`);
/**
* 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
* still gets one quiet sentence, because those are the cases where hiding the
* line would leave the user with an answer they cannot account for.
*/
if (options.showToolCalls && message.toolCalls.length > 0) {
sections.push("", renderTools(message));
} else if (!options.showToolCalls) {
const note = renderToolFailureNote(message);
if (note) sections.push("", note);
}
if (showThinking) {
// Only live reasoning can be the first thing in a message, so the blank
// separator is conditional: otherwise the transcript opens on an empty line.
if (sections.length > 0) sections.push("");
sections.push("_Reasoning_", "", quote(thinking));
}
/**
* The cursor marks what is arriving. The answer wins when there is one, then
* the live reasoning, and only then the activity line: reasoning on screen
* means the model is thinking, which is newer than the tool it called to get
* there. It is placed before the quiet sections so it never lands on a tool
* list or an appendix.
*/
if (message.isStreaming) {
const reasoningIndex = showThinking && liveThinking ? sections.length - 1 : -1;
const target = bodyIndex >= 0 ? bodyIndex : reasoningIndex >= 0 ? reasoningIndex : activityIndex;
if (target >= 0) sections[target] = `${sections[target]} ${STREAMING_CURSOR}`;
}
return sections.join("\n");
}
/**
* Renders the whole conversation, newest message first.
*
* The detail panel shows the entire thread rather than just one message on
* purpose. Vicinae's list resets its selection to the first row whenever the
* model changes, and streaming changes it on every flush, so the selected row
* is not something the extension can rely on staying put while an answer
* arrives. Showing the whole thread means the live answer is always on screen.
*
* Messages are grouped back into exchanges for layout: an answer and the
* question it responds to belong to the same block, so the horizontal rule only
* separates one exchange from the next rather than dividing every single
* message. That rule earns its place with newest-first ordering, where the
* boundary between two answers would otherwise be easy to miss.
*/
export function buildThreadMarkdown(
messages: ThreadMessage[],
options: TranscriptOptions = SHOW_EVERYTHING,
): string {
if (messages.length === 0) return "";
const blocks: string[] = [];
let currentTurn = "";
let buffer: string[] = [];
const flush = () => {
if (buffer.length > 0) blocks.push(buffer.join("\n\n"));
buffer = [];
};
for (const message of messages) {
// `turnId:role`, so the turn is the id without its role suffix.
const turnId = message.id.replace(/:(assistant|user)$/, "");
if (turnId !== currentTurn) {
flush();
currentTurn = turnId;
}
buffer.push(renderMessage(message, options));
}
flush();
return blocks.join("\n\n---\n\n");
}
/**
* Flattens a whole conversation into plain text for the clipboard.
*
* Unlike the transcript this reads oldest-first: as a standalone document, a
* conversation is easier to read in the order it happened.
*
* This needs labels because the transcript's styling (blockquotes and dimmed
* text) is gone by the time it reaches a clipboard.
*/
export function sessionToPlainText(turns: ChatTurn[]): string {
if (turns.length === 0) return "";
return turns
.map((turn) => {
const question = `**You**\n\n${turn.prompt}`;
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}`;
})
.join("\n\n---\n\n");
}