Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to this project will be documented in this file.

## [0.10.2] - 08/09/2026

Datasets in an AI answer are now cited the way a paper cites its references:

- A dataset mentioned in an answer is a small link that names the repository it comes from and opens the dataset's
page in a new tab. Ctrl+click (Cmd+click on a Mac) opens it in a background tab, so several can be opened in a row.
Nothing pops up on hover any more.
- Each mention starts with a bracketed number, [1], [2] and so on, in order of first mention. Clicking the number
jumps to that dataset in a new "Datasets cited in this answer" list under the answer and highlights it.
- The list shows every cited dataset as a compact row: its number, the repository logo, title, year and authors, and
the same Play, Source and Cite buttons as a search result. Expand a row to see the full description, subjects and
relevance score. The list can be hidden, and comes back when a number is clicked.
- A "Cited from" line above the list shows which repositories the cited datasets come from, with a count for each.
- The list fills in while the answer is still streaming, and a number never changes once it has been assigned.
- Matomo records clicks on a cited dataset's link and on the bracketed numbers, as `citation_source_clicked` and
`citation_marker_clicked` under the Dataset and Chat categories.

## [0.10.1] - 17/08/2026

File previews work again, and the chat is usable on a phone:
Expand Down
112 changes: 65 additions & 47 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions src/components/BotMessageBody.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {describe, it, expect} from "vitest";
import {render} from "@testing-library/react";
import {MemoryRouter} from "react-router";
import {makeDataset} from "@/test/fixtures/datasets";
import {normalizeDatasetUrl} from "@/lib/datasetCitations";
import type {Message} from "@/types/chat";
import {BotMessageBody} from "./BotMessageBody";

const DATASET_URL = "https://doi.org/10.5281/zenodo.1234567";
const datasets = new Map([[normalizeDatasetUrl(DATASET_URL), makeDataset({dataset_url: DATASET_URL})]]);

const message = (isStreaming: boolean): Message => ({
sender: "bot",
content: "",
isStreaming,
blocks: [
{kind: "text", text: "Looking."},
{kind: "tool", toolCall: {id: "c1", name: "search_data", args: "", done: true}},
{kind: "text", text: `Found [it](${DATASET_URL}).`},
],
});

const renderBody = (msg: Message) => render(
<MemoryRouter><BotMessageBody message={msg} datasets={datasets} isLoggedIn={false}/></MemoryRouter>,
);

describe("BotMessageBody", () => {
it("marks the end of the newest text only while the answer streams", () => {
const {container} = renderBody(message(true));
const anchors = container.querySelectorAll("[data-streaming-end]");
expect(anchors).toHaveLength(1);
// After the last text block, not the first one.
expect(anchors[0].previousElementSibling?.textContent).toContain("Found");
});

it("has no streaming mark once the answer has ended", () => {
const {container} = renderBody(message(false));
expect(container.querySelector("[data-streaming-end]")).toBeNull();
});
});
68 changes: 68 additions & 0 deletions src/components/BotMessageBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {useCallback, useMemo, useRef, useState} from "react";
import type {Message} from "../types/chat.ts";
import type {BackendDataset} from "../types/commons.ts";
import {collectCitations} from "../lib/datasetCitations.ts";
import {MessageMarkdown} from "./MessageMarkdown.tsx";
import {ToolCallEntry} from "./ToolCallEntry.tsx";
import {CitedDatasets, type JumpRequest} from "./CitedDatasets.tsx";

interface BotMessageBodyProps {
message: Message;
// Datasets citable in this thread, keyed by normalized URL (see buildDatasetUrlMap).
datasets: Map<string, BackendDataset>;
isLoggedIn: boolean;
}

/**
* Body of an assistant message: its tool calls and text blocks in the order the
* agent produced them, followed by the reference list of every dataset the text
* cites. Citation numbers are assigned here, across all of the message's text
* blocks, so the [n] markers and the list always agree.
*/
export const BotMessageBody = ({message, datasets, isLoggedIn}: BotMessageBodyProps) => {
const blocks = useMemo(
() => message.blocks ?? (message.content ? [{kind: 'text' as const, text: message.content}] : []),
[message.blocks, message.content]
);
const citations = useMemo(() => collectCitations(blocks, datasets), [blocks, datasets]);
const lastTextIndex = blocks.reduce((acc, b, i) => (b.kind === 'text' ? i : acc), -1);

const [jump, setJump] = useState<JumpRequest | null>(null);
const jumpSeq = useRef(0);
const onCite = useCallback((number: number) => {
jumpSeq.current += 1;
setJump({number, seq: jumpSeq.current});
}, []);

return (
<>
{blocks.map((block, blockIndex) => {
if (block.kind === 'tool') {
return (
<ToolCallEntry
key={`tool-${block.toolCall.id}`}
toolCall={block.toolCall}
isLoggedIn={isLoggedIn}
/>
);
}
if (!block.text.trim()) return null;
const streaming = !!message.isStreaming && blockIndex === lastTextIndex;
return (
<div key={`text-${blockIndex}`}>
<MessageMarkdown
text={block.text}
datasets={datasets}
citations={citations}
onCite={onCite}
streaming={streaming}
/>
{/* Where the words being written end; the chat page keeps this in view. */}
{streaming && <div data-streaming-end aria-hidden="true"/>}
</div>
);
})}
<CitedDatasets citations={citations} isLoggedIn={isLoggedIn} jump={jump}/>
</>
);
};
8 changes: 5 additions & 3 deletions src/components/CitationExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import useMatomo from '../hooks/useMatomo';

interface CitationExportProps {
dataset: BackendDataset;
// Smaller button, to sit in a list row next to Play and Source.
compact?: boolean;
}

type CitationFormat = 'bibtex' | 'ris' | 'csljson';
Expand All @@ -22,7 +24,7 @@ const GENERATORS: Record<CitationFormat, (d: BackendDataset) => string> = {
csljson: generateCSLJSON
};

export const CitationExport = ({dataset}: CitationExportProps) => {
export const CitationExport = ({dataset, compact = false}: CitationExportProps) => {
const [open, setOpen] = useState(false);
const [format, setFormat] = useState<CitationFormat>('bibtex');
const [copied, setCopied] = useState(false);
Expand Down Expand Up @@ -115,9 +117,9 @@ export const CitationExport = ({dataset}: CitationExportProps) => {
}}
aria-haspopup="true"
aria-expanded={open}
className="inline-flex items-center justify-center gap-1 rounded-md bg-gray-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600 transition-colors cursor-pointer"
className={`inline-flex items-center justify-center gap-1 rounded-md bg-gray-600 ${compact ? 'px-2.5 py-1 text-xs' : 'px-3 py-1.5 text-sm'} font-medium text-white shadow-sm hover:bg-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600 transition-colors cursor-pointer`}
>
<BookOpenIcon className="h-4 w-4"/>
<BookOpenIcon className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'}/>
<span className="leading-none">Cite</span>
</button>

Expand Down
Loading
Loading