Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
37ae07a
docs(plans): design for inline citations and source cards (#56)
PAMulligan Sep 28, 2026
fdc9c83
docs(plans): implementation plan for inline citations (#56)
PAMulligan Sep 28, 2026
b4fd0e6
feat(worker): attach a snippet to each RAG source
PAMulligan Sep 28, 2026
10fdcf2
feat(worker): number RAG excerpts and ask for citations when the requ…
PAMulligan Sep 28, 2026
df5cc59
feat(worker): announce RAG sources before the first streamed chunk
PAMulligan Sep 28, 2026
67ab530
feat(widget): request citations and read the early sources event
PAMulligan Sep 28, 2026
0a45385
feat(widget): keep announced sources on the streaming reply
PAMulligan Sep 28, 2026
d7a8b17
feat(widget): pure helpers for citation markers and the citations option
PAMulligan Sep 28, 2026
58e282a
feat(widget): strings for citation chips and the sources footer
PAMulligan Sep 28, 2026
bdb9246
feat(widget): collapsible source-card footer
PAMulligan Sep 29, 2026
9408779
feat(widget): render citation markers as chips with a source-card footer
PAMulligan Sep 29, 2026
0d6d131
feat(widget): wire citations through the chat window and its live region
PAMulligan Sep 29, 2026
a701de9
feat(widget): citations option on the component, ClaudiusConfig, and …
PAMulligan Sep 29, 2026
806c0ef
feat(cli): widget.citations in client configs, snippets, and the schema
PAMulligan Sep 29, 2026
9889adb
docs: inline citations guide, streaming events, and the citations req…
PAMulligan Sep 29, 2026
04738a6
test(widget): e2e for citation chips and the source-card reveal
PAMulligan Sep 29, 2026
c796736
fix(worker): announce sources with the first upstream event, not befo…
PAMulligan Sep 29, 2026
659b71c
fix(widget): detach a citation marker glued to the end of a URL
PAMulligan Sep 29, 2026
3935ee1
fix(widget): wrap long unbroken snippets inside a source card
PAMulligan Sep 29, 2026
5f92197
fix(widget): keep source timing unchanged for widgets without citations
PAMulligan Sep 29, 2026
52a9607
chore(widget): raise bundle budgets for inline citations
PAMulligan Sep 29, 2026
2c04566
fix(widget): hide an unfinished citation marker inside unclosed bold …
PAMulligan Sep 29, 2026
b6fdda0
fix(widget): do not replay the last reveal when maxSources changes
PAMulligan Sep 29, 2026
9bd8262
fix(worker): strip list markers, blockquotes, links, and italics from…
PAMulligan Sep 29, 2026
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
23 changes: 21 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ pnpm test # Run tests
| `MessageSpeechControls` | Read aloud / pause / resume / stop buttons under assistant replies |
| `AttachmentPreview` | Image thumbnail / file chip for pending and sent attachments |
| `HeaderMenu` | Generic accessible overflow menu in the chat header; hosts the export actions |
| `SourceCards` | Collapsible footer of source cards under a cited reply; reveals a card when its chip is clicked |

### useChat Hook

Expand Down Expand Up @@ -144,7 +145,7 @@ Worker can't stream.
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/chat` | POST | Send message, get AI response (JSON, or multipart when uploading files) |
| `/api/chat/stream` | POST | Same request; streams the reply as SSE (`chunk`/`tool`/`done`/`error` events) |
| `/api/chat/stream` | POST | Same request; streams the reply as SSE (`sources`/`chunk`/`tool`/`done`/`error` events) |
| `/api/attachments/*` | GET | Serve a stored attachment via signed URL (R2 mode only) |
| `/api/health` | GET | Health check |

Expand Down Expand Up @@ -218,11 +219,29 @@ Never assert on raw `Intl` output in tests: CI's Node 20 emits U+202F before
"PM" and local Node 24 does not. Docs: configuration/conversation-export.md;
design: docs/plans/2026-09-19-conversation-export-design.md.

### Citations

Widget-only opt-in (`citations` prop / `ClaudiusConfig` key /
`<claudius-chat citations citations-max-sources citations-favicons>` /
`widget.citations` in client configs), failing closed like `conversationExport`.
When on, the client sends `citations: true`, the worker numbers its RAG
excerpts to match `sources` and appends `CITATION_INSTRUCTIONS`
(`worker/src/rag/retrieval.ts`, `buildRagContext`), the stream route emits
`event: sources` before the first chunk, and `ChatMessage` renders in-range
`[n]` markers as chips with a `SourceCards` footer instead of the source icon.
Sources carry a 200-character `snippet`. Pure helpers live in
`widget/src/utils/citations.ts` (`parseCitations`, `stripCitationMarkers`,
`hideTrailingCitationOpener`, `resolveCitationsConfig`,
`citationsOptionsFromAttributes`). Only markers whose numbers are all within
`sources.length` become chips. Docs: configuration/citations.md; design:
docs/plans/2026-09-27-inline-citations-design.md.

### Chat Request/Response

```typescript
// Request
{
citations?: true, // widget asks for numbered, citable excerpts
messages: [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
Expand All @@ -240,7 +259,7 @@ design: docs/plans/2026-09-19-conversation-export-design.md.
{
reply: "How can I help you today?",
sources?: [
{ url: "https://...", title: "...", type: "blog" | "page" | "external" }
{ url: "https://...", title: "...", type: "blog" | "page" | "external", snippet?: "..." }
],
attachments?: [ { id: "att-1", key: "att/...", url: "https://.../api/attachments/...?exp=&sig=", expiresAt: "..." } ]
}
Expand Down
14 changes: 14 additions & 0 deletions clients/_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@
"conversationExport": {
"type": "boolean",
"description": "Let visitors copy the conversation as Markdown, or download it as Markdown or JSON, from a menu in the chat header. Off by default. Nothing is sent to the worker."
},
"citations": {
"description": "Render [n] citations in grounded replies as chips with a footer of source cards, and ask the worker to number its excerpts. true enables the defaults (5 cards before Show all, favicons on). Off by default; needs RAG on the worker.",
"oneOf": [
{ "type": "boolean" },
{
"type": "object",
"additionalProperties": false,
"properties": {
"maxSources": { "type": "integer", "minimum": 1 },
"favicons": { "type": "boolean" }
}
}
]
}
}
},
Expand Down
522 changes: 522 additions & 0 deletions docs/plans/2026-09-27-inline-citations-design.md

Large diffs are not rendered by default.

4,254 changes: 4,254 additions & 0 deletions docs/plans/2026-09-27-inline-citations-implementation.md

Large diffs are not rendered by default.

49 changes: 42 additions & 7 deletions docs/src/content/docs/api/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Send the conversation so far; receive the assistant's reply.
| `messages[].content` | string | May be empty only when the message has attachments |
| `messages[].attachments` | array, optional | Files on a **user** message; see below |
| `conversationId` | string, optional | Opaque id used only for [analytics](/deployment/worker/#analytics-with-d1-optional) correlation |
| `citations` | boolean, optional | `true` asks the worker to number its RAG excerpts to match `sources` and to have the model cite them as `[n]`. Anything else is ignored |

#### Attachments

Expand Down Expand Up @@ -82,18 +83,21 @@ curl https://<worker>/api/chat \
Stray file parts that match no attachment are rejected. The widget's client
switches to multipart automatically whenever a message carries inline bytes.

`POST /api/chat/stream` accepts the same JSON or multipart body. Attachment
errors are returned as plain JSON before the stream opens, and when the R2
backend stored uploads the `done` event carries the same `attachments` array
as the non-streaming response below.
[`POST /api/chat/stream`](#post-apichatstream) accepts the same JSON or
multipart body.

### Response `200`

```json
{
"reply": "We're available Monday through Friday, 9am to 5pm.",
"sources": [
{ "url": "https://example.com/contact", "title": "Contact", "type": "page" }
{
"url": "https://example.com/contact",
"title": "Contact",
"type": "page",
"snippet": "We're available Monday through Friday, 9am to 5pm."
}
],
"attachments": [
{
Expand All @@ -106,8 +110,9 @@ as the non-streaming response below.
}
```

`sources` is optional and reserved for retrieval-backed backends — the
bundled worker returns only `reply` today (see [RAG](/rag/)).
`sources` is optional and present when [RAG](/rag/) retrieved pages for the
reply. Each source has `url`, `title`, `type`, and, from 1.18.0, a `snippet`
of about 200 characters.

`attachments` is present only when the worker's
[R2 storage backend](/configuration/attachments/#r2) stored new uploads
Expand All @@ -134,6 +139,36 @@ All errors share one envelope:
| `503` | `SERVICE_ERROR` | Claude temporarily unavailable/overloaded | |
| `500` | `UNKNOWN_ERROR` | Anything else | |

## POST /api/chat/stream

Accepts the same JSON or multipart body as `/api/chat` and answers with
`text/event-stream`. Failures before the first byte (validation, rate limit,
attachments, a bad API key) return the same JSON errors as `/api/chat`, so
clients can share their error handling. When the R2 backend stored uploads,
the `done` event carries the same `attachments` array as the JSON response.

| Event | Data | When |
|-------|------|------|
| `sources` | `{ "sources": [...] }` | Once, before the first chunk, when retrieval found sources |
| `chunk` | `{ "text": "..." }` | One per model text delta |
| `tool` | a tool-use summary | One per executed tool call |
| `done` | `{ "reply", "sources"?, "toolUses"?, "attachments"? }` | Last event: the full reply plus everything the JSON response would carry |
| `error` | `{ "error", "code": "STREAM_ERROR" }` | A failure after streaming began; the stream ends |

```
event: sources
data: {"sources":[{"url":"https://example.com/contact","title":"Contact","type":"page","snippet":"We're available Monday through Friday."}]}

event: chunk
data: {"text":"We're available Monday through Friday [1]."}

event: done
data: {"reply":"We're available Monday through Friday [1].","sources":[...]}
```

`sources` on `done` is the authoritative list; the early event exists so a
widget can render citation chips while the text is still arriving.

## GET /api/attachments/{key}

Serves a stored attachment (R2 mode only). The full URL, including the `exp`
Expand Down
146 changes: 146 additions & 0 deletions docs/src/content/docs/configuration/citations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
title: Inline citations
description: Show numbered citation chips in grounded replies and a footer of source cards, and understand what the option asks of the worker.
sidebar:
order: 10
---

When the worker grounds replies in your own content with [RAG](/rag/), each
reply carries the pages it drew on as `sources`. By default the widget shows
them behind a small source icon that opens a sidebar. With **citations** on,
the reply itself shows its evidence:

- The model cites the excerpts it used, and each `[1]`, `[2]` in the reply
renders as a small numbered chip in the accent colour.
- A collapsible **Sources** footer under the reply lists one card per source:
favicon, title, a short snippet, the domain, and a link that opens in a new
tab.
- Clicking a chip opens the footer, scrolls to the matching card, and moves
focus to it.
- Only the first five cards show until the visitor chooses **Show all**.

Citations are **off by default**. They need a worker from 1.18.0 or later
with RAG switched on. With an older worker the widget still shows the cards it
can build, but no chips, because that worker never asks the model to cite.

## Enabling it

```tsx
<ChatWidget apiUrl="https://your-worker.workers.dev" citations />
```

```html
<script>
window.ClaudiusConfig = {
apiUrl: "https://your-worker.workers.dev",
citations: true,
};
</script>
```

```html
<claudius-chat api-url="https://your-worker.workers.dev" citations></claudius-chat>
```

In a [client config](/configuration/clients/), set `widget.citations` to `true`
or to the options object below and regenerate the snippet.

Only the literal `true` or an options object enables it. A string such as
`"true"` or `"false"`, which a CMS template can produce, leaves it off. The web
component attribute follows the same rule as `conversation-export`: it enables
citations only when `citations` is present with no value or set to `"true"`
(case and surrounding spaces do not matter). `"False"`, `"0"`, `"no"`, and
`"1"` all leave it off.

| Option | Default | Description |
|--------|---------|-------------|
| `maxSources` | `5` | Cards shown before the **Show all** control. A positive integer; anything else uses the default |
| `favicons` | `true` | Fetch each source's `/favicon.ico`. Set to `false` to make no such requests |

```tsx
<ChatWidget apiUrl="…" citations={{ maxSources: 3, favicons: false }} />
```

| Attribute | Example |
|-----------|---------|
| `citations` | `citations` or `citations="true"` to enable |
| `citations-max-sources` | `citations-max-sources="3"` |
| `citations-favicons` | `citations-favicons="false"` |

## How it works

1. With citations on, every request the widget sends carries
`"citations": true`. That is the only change to what leaves the browser.
2. The worker retrieves its excerpts as usual, then numbers them in the system
prompt to match the `sources` it will return: two chunks of the same page
share one number, and the number is that page's position in the list.
It appends an instruction to cite with `[n]`, to invent no numbers, and
not to list the sources itself, since the widget shows them.
3. The model answers. Whether and where it cites is up to the model.
4. The streaming endpoint announces the sources once, before the first text
chunk, so chips render while the reply is still arriving. The `done` event
and the blocking endpoint carry `sources` as before.
5. The widget renders a `[n]` as a chip only when every number in it is
between 1 and the number of sources. `[7]` with three sources, `[0]`, and
`[01]` stay literal text, and so does every bracket in a reply that has no
sources. `[1][3]` and `[1, 3]` both work.

## What a source card shows

- **Favicon**, requested from `/favicon.ico` on the source's own origin, never
from a third-party service, and with `referrerPolicy="no-referrer"`. When
the request fails, or the host page's policy blocks it, a generic icon shows
instead. `favicons: false` skips the request entirely.
- **Title**, linking to the source in a new tab.
- **Snippet**: the first 200 characters of the first excerpt retrieved from
that page, with Markdown markers removed. It comes from the chunk text
stored at [ingestion](/rag/#quick-start-vectorize), so a page's snippet
depends on which of its chunks matched the question.
- **Domain**.

A source whose URL is not `http:` or `https:` still gets a card, so the
numbering holds, but its title is plain text and it has no favicon.

## Styling and accessibility

Everything uses [theme tokens](/configuration/theming/). Chips and number
badges are `accent` under `accentText`, the one pairing every theme guarantees
to be readable. Cards use `surfaceMuted`, `border`, `text`, and `textMuted`;
**Show all** uses `link`. Dark mode and custom themes need no extra work.

Chips are buttons named "Source 1: Pricing". The footer toggle and **Show
all** expose their state with `aria-expanded`. Cards form an ordered list, so
a screen reader announces "1 of 3" and the position matches the chip. Clicking
a chip moves focus to its card, which then flashes an accent ring for a
moment; the scroll respects `prefers-reduced-motion`. The live region that
announces new replies, and the [read-aloud](/configuration/voice/) voice, skip
the markers, so a visitor hears "Plans start at $10." rather than "left
bracket one right bracket".

## Privacy posture

- **The `citations` flag is the only thing added to requests.** No new data
about the visitor leaves the browser.
- **Favicons are requests to the source origins.** Each card with a favicon
fetches one image from the page it links to, with no referrer. For RAG
sources those are normally your own pages. If any of your sources are
third-party sites and you would rather not tell them a visitor saw the card,
set `favicons: false`.

## Customizing the text

Five [translation keys](/configuration/localization/): `sources` (footer label),
`showAllSources` (takes `{count}`), `showFewerSources`, `citation` (the chip's
accessible name; takes `{n}` and `{title}`), and `opensInNewTab` (the hidden
hint on links that open a new tab, also used by links in message text).

## Limitations

- The model decides whether to cite. A reply may cite nothing, in which case
the footer still lists the sources, or cite a number that does not exist, in
which case the bracket stays as text.
- A bracketed number in code or a quotation, such as `items[1]`, becomes a chip
when it is in range.
- Snippets come from the chunk that matched, not from the top of the page.
- The sidebar's own labels ("View sources", "Close sources") are not yet
translatable; the footer's are.
2 changes: 1 addition & 1 deletion docs/src/content/docs/configuration/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ pnpm claudius snippet acme # generate the embed snippet(s)
| `slug` | Yes | URL-safe identifier; must match the filename |
| `apiUrl` | Yes | The client's worker chat endpoint |
| `allowedDomains` | Yes | Domains where the widget may be embedded |
| `widget` | No | Appearance: `title`, `subtitle`, `welcomeMessage`, `placeholder`, `theme`, `position`, `accentColor`; `attachments` (`true` or `{ maxSizeBytes, maxCount, allowedTypes }`); `voice` (`true` or `{ input, output, mode, autoSubmit, lang }`, see [Voice](/configuration/voice/)); `conversationExport` (`true` to enable, see [Conversation export](/configuration/conversation-export/)) |
| `widget` | No | Appearance: `title`, `subtitle`, `welcomeMessage`, `placeholder`, `theme`, `position`, `accentColor`; `attachments` (`true` or `{ maxSizeBytes, maxCount, allowedTypes }`); `voice` (`true` or `{ input, output, mode, autoSubmit, lang }`, see [Voice](/configuration/voice/)); `conversationExport` (`true` to enable, see [Conversation export](/configuration/conversation-export/)); `citations` (`true` or `{ maxSources, favicons }`, see [Inline citations](/configuration/citations/)) |
| `worker` | No | `model`, `maxTokens` (1–8192), `rateLimitMinute`, `rateLimitHour`, `systemPrompt` (path to a markdown file), `attachments` (`enabled`, `maxBytes`, `maxCount`, `maxRequestBytes`, `allowedTypes`, `storage`, `retentionHours`, `quotaIpBytesPerDay`, `quotaTenantBytesPerDay`) |

`widget.attachments` flows into the generated embed snippet. `worker.attachments`
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/configuration/localization.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ window.ClaudiusConfig = {
| Errors | `errorGeneric`, `errorConnection`, `errorTimeout`, `errorRateLimitMinute`, `errorRateLimitHour`, `errorRetry` |
| [Voice](/configuration/voice/) | `voiceInput`, `voiceInputHold`, `voiceListening`, `voicePermissionDenied`, `voiceNoSpeech`, `voiceNoMicrophone`, `voiceUnavailable`, `readAloud`, `pauseReading`, `resumeReading`, `stopReading` |
| [Conversation export](/configuration/conversation-export/) | `moreOptions`, `copyAsMarkdown`, `downloadAsMarkdown`, `downloadAsJson`, `copiedToClipboard`, `copyFailed`, `transcriptTitle`, `transcriptExported`, `transcriptUser`, `transcriptAssistant`, `transcriptAttachments`, `transcriptSources` |
| [Inline citations](/configuration/citations/) | `sources`, `showAllSources`, `showFewerSources`, `citation`, `opensInNewTab` |

Note these localize the widget UI only. The AI's reply language follows the
conversation and your [system prompt](/configuration/worker/#system-prompt) —
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/configuration/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ palette of whichever mode is active.

| Key | CSS property | Used for | Light default | Dark default |
|-----|--------------|----------|---------------|--------------|
| `accent` | `--cl-color-accent` | Header, toggle bubble, send button, focus rings | `#2563eb` | `#2563eb` |
| `accent` | `--cl-color-accent` | Header, toggle bubble, send button, focus rings, citation chips | `#2563eb` | `#2563eb` |
| `accentText` | `--cl-color-accent-text` | Text/icons on accent surfaces | `#ffffff` | `#ffffff` |
| `accentSoft` | `--cl-color-accent-soft` | Avatar circle, hover overlay on the header | `rgb(255 255 255 / 0.2)` | same |
| `accentTextMuted` | `--cl-color-accent-text-muted` | Dimmed header icons | `rgb(255 255 255 / 0.7)` | same |
Expand Down
8 changes: 6 additions & 2 deletions docs/src/content/docs/configuration/widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ attributes on the `<claudius-chat>` web component.
| `attachments` | `boolean \| AttachmentsOptions` | `false` | Let visitors attach images and PDFs; `true` for the defaults (5 MB, 5 files) or an object with `maxSizeBytes`, `maxCount`, `allowedTypes`. See [Attachments](/configuration/attachments/) |
| `voice` | `boolean \| VoiceOptions` | `false` | Mic button for dictation and a read-aloud button on replies, using the browser's Web Speech API; `true` for the defaults or an object with `input`, `output`, `mode`, `autoSubmit`, `lang`. See [Voice](/configuration/voice/) |
| `conversationExport` | `boolean` | `false` | Header menu that copies the conversation as Markdown, or downloads it as Markdown or JSON. Runs in the browser; nothing is sent to the worker. See [Conversation export](/configuration/conversation-export/) |
| `citations` | `boolean \| CitationsOptions` | `false` | Render `[n]` markers in grounded replies as chips with a footer of source cards, and ask the worker to number its excerpts; `true` for the defaults or an object with `maxSources`, `favicons`. Needs RAG on the worker. See [Inline citations](/configuration/citations/) |

## Web component attributes

Expand All @@ -38,9 +39,12 @@ attributes on the `<claudius-chat>` web component.
`accent-color`, `position`, `attachments` (`attachments` or
`attachments="true"` enables the defaults), `voice` with its companions
`voice-mode`, `voice-auto-submit`, `voice-input`, `voice-output`, and
`voice-lang` (see [Voice](/configuration/voice/#enable-voice)), and
`voice-lang` (see [Voice](/configuration/voice/#enable-voice)),
`conversation-export` (see
[Conversation export](/configuration/conversation-export/#enabling-it)).
[Conversation export](/configuration/conversation-export/#enabling-it)), and
`citations` with its companions `citations-max-sources` and
`citations-favicons` (see
[Inline citations](/configuration/citations/#enabling-it)).

```html
<claudius-chat
Expand Down
Loading
Loading