feat: list an answer's sources under it, and keep them when copying
The list belongs to the answer rather than to the tool line, so it is drawn with tool calls hidden too. It waits for the answer to cite something before appearing, because otherwise it arrives in full with the first token and then shrinks as citations arrive. A copied conversation carries the same list, worked out from a plain scan: it is markdown on its way somewhere else, where a [1] inside a snippet counting as a citation costs nothing.
This commit is contained in:
@@ -1,10 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { STREAMING_CURSOR, buildThreadMarkdown, quote, sessionToPlainText } from "../lib/markdown";
|
||||||
STREAMING_CURSOR,
|
|
||||||
buildThreadMarkdown,
|
|
||||||
quote,
|
|
||||||
sessionToPlainText,
|
|
||||||
} from "../lib/markdown";
|
|
||||||
import type { ChatTurn, ThreadMessage } from "../lib/types";
|
import type { ChatTurn, ThreadMessage } from "../lib/types";
|
||||||
|
|
||||||
function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">): ThreadMessage {
|
function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">): ThreadMessage {
|
||||||
@@ -235,9 +230,7 @@ describe("buildThreadMarkdown", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("links a lone URL the model put in inline code", () => {
|
it("links a lone URL the model put in inline code", () => {
|
||||||
const markdown = buildThreadMarkdown([
|
const markdown = buildThreadMarkdown([message({ role: "assistant", text: "`https://example.com`" })]);
|
||||||
message({ role: "assistant", text: "`https://example.com`" }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(markdown).not.toContain("```");
|
expect(markdown).not.toContain("```");
|
||||||
expect(markdown).toContain("[https://example.com](https://example.com)");
|
expect(markdown).toContain("[https://example.com](https://example.com)");
|
||||||
@@ -655,3 +648,73 @@ describe("attachments in the transcript", () => {
|
|||||||
expect(text).toContain("_(screenshot attached)_");
|
expect(text).toContain("_(screenshot attached)_");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sources", () => {
|
||||||
|
const sources = [
|
||||||
|
{ index: 1, title: "First", url: "https://first.example" },
|
||||||
|
{ index: 2, title: "Second", url: "https://second.example" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** One search, and the answer that may or may not point at it. */
|
||||||
|
function answered(text: string, streaming = false): string {
|
||||||
|
return buildThreadMarkdown(
|
||||||
|
[
|
||||||
|
message({
|
||||||
|
id: "t1:assistant",
|
||||||
|
role: "assistant",
|
||||||
|
text,
|
||||||
|
isStreaming: streaming,
|
||||||
|
toolCalls: [{ name: "web_search", arguments: {}, result: "", isError: false, sources }],
|
||||||
|
}),
|
||||||
|
message({ id: "t1:user", role: "user", text: "When is the rain?" }),
|
||||||
|
],
|
||||||
|
{ showToolCalls: false, showReasoning: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("links the markers and lists the sources the answer leaned on", () => {
|
||||||
|
const text = answered("Rain is likely [1].");
|
||||||
|
|
||||||
|
expect(text).toContain("Rain is likely [\\[1\\]](https://first.example).");
|
||||||
|
expect(text).toContain("_Sources_");
|
||||||
|
expect(text).toContain("- [\\[1\\]](https://first.example) First");
|
||||||
|
// The answer did not lean on the second one.
|
||||||
|
expect(text).not.toContain("Second");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists everything the turn consulted when the answer cited nothing", () => {
|
||||||
|
const text = answered("Rain is likely.");
|
||||||
|
|
||||||
|
expect(text).toContain("- [\\[1\\]](https://first.example) First");
|
||||||
|
expect(text).toContain("- [\\[2\\]](https://second.example) Second");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for a citation before drawing anything", () => {
|
||||||
|
// Otherwise the list arrives in full with the first token and then shrinks as
|
||||||
|
// citations appear, which reads as a glitch rather than as settling.
|
||||||
|
expect(answered("Rain is", true)).not.toContain("_Sources_");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds nothing to a turn that consulted nothing", () => {
|
||||||
|
expect(buildThreadMarkdown([message({ role: "assistant", text: "Plain." })])).toBe("Plain.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the list into the copied conversation", () => {
|
||||||
|
const copied = sessionToPlainText([
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
prompt: "When is the rain?",
|
||||||
|
response: "Rain [1].",
|
||||||
|
thinking: "",
|
||||||
|
model: "m",
|
||||||
|
createdAt: 0,
|
||||||
|
toolCalls: [{ name: "web_search", arguments: {}, result: "", isError: false, sources }],
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(copied).toContain("**Lam**\n\nRain [1].");
|
||||||
|
expect(copied).toContain("_Sources_");
|
||||||
|
expect(copied).toContain("- [\\[1\\]](https://first.example) First");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+33
-6
@@ -1,5 +1,6 @@
|
|||||||
import { imageSource } from "./attachments";
|
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 { describeActivity } from "./thread";
|
||||||
import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types";
|
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. */
|
/** The answer, or whatever stands in for it while there is none. */
|
||||||
const answered = message.text.trim();
|
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
|
const body = answered
|
||||||
? // One pass, in one place: fences get their languages and links recovered,
|
? drawn.text
|
||||||
// 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
|
: !message.isStreaming
|
||||||
? "_No response._"
|
? "_No response._"
|
||||||
: activityLine
|
: activityLine
|
||||||
@@ -178,6 +188,14 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
|
|||||||
// something went wrong and should not be skimmed past.
|
// something went wrong and should not be skimmed past.
|
||||||
if (message.error) sections.push("", `**Error:** ${message.error}`);
|
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
|
* 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
|
* 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 attached = turn.attachments.length > 0 ? "\n\n_(screenshot attached)_" : "";
|
||||||
const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
|
const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
|
||||||
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
|
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");
|
.join("\n\n---\n\n");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user