Sources and citations
Show the sources a reply draws on — a chip at the end of each cited passage, a card, and the list under the reply — from OpenAI, OpenRouter, Perplexity, the AI SDK or your own provider.
<aparte-chat-bubble message-id="a1" data-role="assistant" streaming></aparte-chat-bubble>
<script>
// The text streams, then the provider's sources arrive: a chip at the end of each
// passage they back, a card on click, and the list under the reply.
const bubble = document.querySelector('aparte-chat-bubble');
const text = 'The sky is blue because air scatters short wavelengths more than long ones: **Rayleigh scattering**.\n\nSunsets look red for the same reason, through more air.';
bubble.setSegments([{ id: 's1', type: 'text', content: '' }]);
let i = 0;
const tick = setInterval(() => {
bubble.appendToSegment('s1', text.slice(i, i + 4));
i += 4;
if (i < text.length) return;
clearInterval(tick);
bubble.updateSegment('s1', {
isStreaming: false,
sources: [
{ id: 'w', url: 'https://en.wikipedia.org/wiki/Rayleigh_scattering', title: 'Rayleigh scattering — Wikipedia', snippet: 'Scattering of light by particles much smaller than its wavelength.' },
{ id: 'n', url: 'https://spaceplace.nasa.gov/blue-sky/', title: 'Why is the sky blue? — NASA Space Place' },
{ id: 'b', url: 'https://www.britannica.com/science/sunset', title: 'Sunset — Britannica' },
],
citations: [
{ end: text.indexOf('**.') + 3, sources: ['w', 'n'] },
{ end: text.length, sources: ['b'] },
],
});
bubble.removeAttribute('streaming');
}, 50);
</script>A model that searches the web returns its sources beside the text. aparté shows them the way people already read them:
- a chip at the end of the passage a source backs — its domain, and
+2when two more back the same passage. It sits after the passage’s punctuation, after the bold, link or code the passage ends in, and after a code block rather than inside it; - a card the chip opens, on a click, a tap or Enter: the source’s title as a link,
its domain and an excerpt, and a
‹ 1/3 ›pager when there are several. Escape, or Tab past its last control, closes it and gives the focus back to the chip; - the list under the reply: a closed “3 sources”, every source once, each a link.
Nothing is drawn for a reply that cites nothing. No favicon is fetched: an icon service would learn which sites your replies cite, so a source is shown by its domain, as text.
Where the sources come from
Section titled “Where the sources come from”The providers that receive citations read them on their own:
@aparte/provider-openai-compatreads theurl_citationannotations OpenAI’s search models and OpenRouter’s web search return (openai/gpt-4o:online, orplugins: [{ id: 'web' }]inextraBody), and Perplexity’ssearch_resultswith the[1]markers in its text — the chip takes the marker’s place. Aperplexitypreset is included.@aparte/provider-ai-sdkpasses on the AI SDK’ssourceparts, and thecitationsof a text part when youraiversion has them.
import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
// Perplexity's Sonar models search the web and cite what they read.
const perplexity = createOpenAICompatProvider(presets.PERPLEXITY);A source with no position — the AI SDK’s are message-level — goes in the list only. aparté never guesses where a chip should go.
From a provider of your own
Section titled “From a provider of your own”A provider sends one more stream event, after the text it cites: the sources, and where in that
text each passage ends. Offsets are into the text the model call has streamed — every text
delta, joined — in JavaScript string indices, end exclusive.
import type { AparteStreamEvent } from '@aparte/core';
const events: AparteStreamEvent[] = [
{ type: 'text', delta: 'The sky is blue.' },
{
type: 'sources',
sources: [{ id: 's1', url: 'https://en.wikipedia.org/wiki/Rayleigh_scattering', title: 'Rayleigh scattering' }],
citations: [{ end: 16, sources: ['s1'] }],
},
{ type: 'done' },
];Core places each citation in the text segment it falls in — a reply is cut into segments at its
code blocks — and rebases its offsets there. A non-streamed answer carries them too: a format
adapter’s parseSources(json) returns the same event, and the answer resolves to a stream.
On a segment
Section titled “On a segment”Without a provider at all — a backend that answers whole messages, a restored conversation — set the two fields on a text segment. They are saved with it, and never sent back to the model.
import type { AparteTextSegment } from '@aparte/core';
const segment: AparteTextSegment = {
id: 't1',
type: 'text',
content: 'The sky is blue[1].',
sources: [{ id: 's1', url: 'https://en.wikipedia.org/wiki/Rayleigh_scattering', title: 'Rayleigh scattering' }],
// `marker`: the text [15, 18) is the model's own "[1]" — the chip takes its place.
citations: [{ start: 15, end: 18, sources: ['s1'], marker: true }],
};| Field | On | What it says |
|---|---|---|
sources | AparteTextSegment | { id, url?, title?, snippet? } — url is linked only when it is an absolute http(s) address the sanitizer’s URL policy allows |
citations | AparteTextSegment | { start?, end, sources, marker? } — sources are ids, in the order the card pages through them |
How a chip finds its place
Section titled “How a chip finds its place”The offsets say where a passage ends in the segment’s Markdown; the screen shows that Markdown
rendered, by whichever provider you installed. So the chip is placed by text: the letters and
digits up to the passage’s end are found again in the rendered reply, at the same occurrence, and
the chip goes after them. That is why it works the same under marked, its streaming entry and
@aparte/plugin-streaming-markdown, and through lists, entities, maths and code. A passage that
cannot be found again gets no chip: its source stays in the list.
A renderer of your own for text segments places them with the same function, after writing its Markdown:
import { renderCitations, type AparteTextSegment } from '@aparte/core';
function drawText(contentEl: HTMLElement, segment: AparteTextSegment): void {
// … write the segment's Markdown into contentEl, then:
renderCitations(contentEl, segment);
}Styling
Section titled “Styling”Core is light DOM: the chip is .aparte-citation (it wears the tag recipe), the card
.aparte-citation-card (the popover recipe), the list .aparte-sources. To show the sources
without the list, .aparte-sources { display: none }. The words — “3 sources”, “Previous
source”, “(opens in a new tab)” — are locale keys, translated in @aparte/locales.