diff --git a/CLAUDE.md b/CLAUDE.md index 4be3b17..a5d9673 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 | @@ -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 / +`` / +`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!" }, @@ -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: "..." } ] } diff --git a/clients/_schema.json b/clients/_schema.json index 8c13213..67760c5 100644 --- a/clients/_schema.json +++ b/clients/_schema.json @@ -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" } + } + } + ] } } }, diff --git a/docs/plans/2026-09-27-inline-citations-design.md b/docs/plans/2026-09-27-inline-citations-design.md new file mode 100644 index 0000000..927ada0 --- /dev/null +++ b/docs/plans/2026-09-27-inline-citations-design.md @@ -0,0 +1,522 @@ +# Inline citations and source cards: design + +Issue: [#56](https://github.com/PMDevSolutions/Claudius/issues/56) +Date: 2026-09-27 +Status: implemented in the same PR as this document. The maintainer was not +available while it was written, so every judgement call is listed under +"Decisions made without the maintainer" for review. + +## Summary + +Add an opt-in `citations` option to the widget. When it is on and the worker +returns `sources` with a reply, the widget renders `[1]`, `[2]` markers in the +reply as small numbered chips, and puts a collapsible "Sources" footer under +the bubble with one card per source: favicon, title, snippet, domain, and an +external link. Clicking a chip opens the footer, scrolls to the matching card, +and focuses it. Only the first `maxSources` cards (default 5) show until the +visitor chooses "Show all". + +The worker learns two things. It attaches a `snippet` to each source, and, +when a request carries `citations: true`, it numbers the retrieved excerpts in +the system prompt to match `sources` and tells the model to cite them. The +streaming endpoint also emits the sources once at the start of the reply, so +chips render while the text is still arriving. The `done` event carries +`sources` exactly as before. + +## Facts that constrain the design + +Checked against the code and git history on 2026-09-27. + +1. **The issue predates both streaming and RAG.** It was filed on + 2026-05-19. Streaming (`ab0360b`) and RAG (`6f88a46`) landed on + 2026-08-30. Since then the `done` SSE event has carried `sources` after the + last text chunk, so the first acceptance criterion, "worker streams + `sources` as a final SSE event after the assistant text", is already met. + The question left for this design is whether sources are also needed + *before* the text, for streaming (see fact 6). +2. **Sources today are `{ url, title, type }` and nothing else.** The worker's + `ragDocumentsToSources` drops the chunk text, so there is no snippet on the + wire. A source card with a snippet needs a new optional field on both the + worker's `ChatSource` and the widget's `Source`. +3. **The model is not asked to cite.** The default RAG context template lists + excerpts under `### [Title](url)` headings with no numbers and no + instruction to cite them. Without numbering that matches `sources`, a + `[1]` in the reply would point at nothing in particular. +4. **Sources can name pages the model never saw.** `formatRagContext` drops + the lowest-ranked excerpts past `maxContextChars`, but `prepareRag` builds + `sources` from every retrieved document. With the defaults (four excerpts + of about 1,200 characters against a 6,000 character budget) nothing is + dropped, so this has not mattered. With numbered citations it would: the + numbers in the prompt and the numbers on the cards must come from the same + list. +5. **Embeds load a floating tag.** The quick start documents + `cdn.jsdelivr.net/gh/PMDevSolutions/Claudius@1/cdn/claudius.iife.js`, so a + feature that is on by default appears on every live site the moment the + release is tagged. The same fact decided `attachments`, `voice`, and + `conversationExport`, which are all opt-in and fail closed. +6. **Streaming text renders per token.** `stabilizeStreamingMarkdown` exists + so that a `**` does not flash as literal asterisks before its partner + arrives. If sources arrive only on `done`, every `[1]` renders as plain + text for the whole stream and then turns into a chip when the reply + settles. The repo already treats that kind of pop as a defect. +7. **Unknown SSE events and unknown request fields are ignored on both + sides.** The widget's `readSseStream` handles `chunk`, `tool`, `done`, and + `error` and skips anything else. The worker's `validateMessages` keeps only + `role`, `content`, and `attachments` from each message, and `parseChatRequest` + already tolerates a top-level `conversationId` that only analytics read. So + a new event and a new top-level request field are safe in both directions: + old widget with new worker, new widget with old worker. +8. **The first `stream.next()` happens before the SSE response opens.** The + stream route pulls one event before calling `streamSSE`, so that a bad API + key or an upstream connection failure returns a JSON 500 instead of a 200 + stream that dies. Anything yielded before the model connection is + established would defeat that. +9. **The renderer is hand-rolled.** `ChatMessage` splits each line on bold, + then italic, then URLs, and renders everything else as text. Text inside a + bold or italic span is rendered as a plain string, so URLs inside `**` are + not linked today. Citation markers have to be found in the same leaf + segments. +10. **The widget is embedded in someone else's page.** An in-page anchor + (``) would change the host page's URL fragment and push a + history entry. jsdom, where the unit tests run, has no `scrollIntoView` + (the test setup stubs it) and no layout. +11. **A favicon is a request to the source's origin.** There is no way to show + one without fetching it, either from the origin itself or from a + third-party favicon service. RAG sources are almost always the client's + own pages, but `type: "external"` exists for a reason. +12. **The accent colour is not guaranteed to read on the surface.** The + default accent `#2563eb` sits at about 3.5:1 against the default dark + surface `#111827`, below the 4.5:1 needed for small text. The one pairing + every theme guarantees is `accent` under `accentText`, which is why the + header, the send button, and the source-count badge all use it. Chips + that use the accent as a text colour would fail in dark mode. +13. **`Source[]` also comes from plugins.** `PluginReply.sources` lets an + `onBeforeSend` or `onError` plugin attach sources with no worker involved, + so the widget cannot assume snippets are short or URLs are safe. +14. **Sources already appear in the export.** `conversationToMarkdown` writes + a message's sources as a numbered list, `1. [Title](url)`, in array order. + Citation numbers in the message text therefore already agree with the + transcript's list. The JSON export is the raw message array and will + include `snippet`. + +## Approaches considered + +**A. Widget option that also drives the worker; numbering by the worker +(chosen).** The widget's `citations` option turns on the rendering and makes +the client send `citations: true` with each request. The worker numbers the +excerpts and adds the citing instruction only when asked. Nothing changes for +any deployment that has not opted in, on either side, and a client that +upgrades only its worker sees no `[1]` markers appear in replies. + +**B. Worker environment variable (`RAG_CITATIONS`).** Rejected. It puts the +same switch in two places that have to agree, and a worker with it on serves +literal `[1]` markers to every widget that has not been updated. The request +field is one switch that the side doing the rendering owns. + +**C. Always number and always instruct.** Rejected for the same reason: it +changes model output for existing RAG deployments as soon as the worker is +redeployed, with no way to turn it off short of a custom context template. + +**D. Widget-only, matching `[n]` against whatever `sources` arrive, without +teaching the model to cite.** Rejected. Today's prompt never produces `[n]`, +so the chips would never appear. Snippets also need worker support. + +For the source cards, replacing the existing sidebar outright was considered +and rejected: the sidebar is what every current embed shows, and removing it +is a visible change for sites that never asked for one. When `citations` is +on, the footer replaces the source icon and sidebar for that widget, because +the cards show a superset of what the sidebar shows. When it is off, nothing +changes. + +For favicons, a third-party favicon service was rejected: it reports every +source domain a visitor sees to that service. The widget requests +`/favicon.ico` from the source's own origin, with `referrerPolicy="no-referrer"`, +falls back to a generic icon when that fails, and can be switched off. + +## Public API + +```ts +// ChatWidgetProps / window.ClaudiusConfig / clients/*.json widget +citations?: boolean | CitationsOptions; // default false + +interface CitationsOptions { + /** Cards shown before "Show all". Positive integer. Default 5. */ + maxSources?: number; + /** Fetch each source's /favicon.ico. Default true. */ + favicons?: boolean; +} +type ResolvedCitationsConfig = Required; + +// Source (widget) and ChatSource (worker) +snippet?: string; + +// ChatRequest (widget and worker) +citations?: boolean; + +// ChatApiClientOptions +citations?: boolean; + +// ChatStreamOptions +onSources?: (sources: Source[]) => void; +``` + +- **Off by default and fails closed.** Only the literal `true` or a plain + object enables it. `"true"`, `"false"`, `1`, and arrays leave it off. This + departs from the issue, which describes the new rendering as *the* way + sources appear. Fact 5 is why: switching it on later is not a breaking + change, switching it off later would be. The option also causes network + requests (favicons) and changes what the worker asks of the model, both of + which a site should choose. +- `maxSources` must be a positive integer; anything else uses 5. `favicons` is + on unless it is exactly `false`, mirroring how `voice.input` and + `voice.output` read their sub-options. +- `` gains `citations`, `citations-max-sources`, and + `citations-favicons`. `citations` follows the strict rule + `conversation-export` set: on only when present with no value or with + `"true"`, trimmed and lowercased. `citations-max-sources` is parsed as an + integer and ignored unless positive. `citations-favicons="false"` (trimmed, + lowercased) turns favicons off; any other value leaves them on. +- `clients/_schema.json`, the CLI validator, and both snippet generators + accept `widget.citations` as a boolean or the options object. The script + snippet passes the value through; the web component snippet emits + `citations="true"` plus `citations-max-sources` and + `citations-favicons="false"` when set. +- `Source.snippet` is optional, so persisted conversations and plugin-made + sources stay valid. The JSON export includes it. +- `ChatRequest.citations` travels next to `messages`. The worker reads it in + `getChatConfig`, and only the literal `true` counts. +- `ChatApiClient` gets a `citations` option because the request body is its + business; `useChat` passes it through. `onSources` is a new stream callback + beside `onChunk` and `onToolUse`. + +## Architecture + +``` +ChatWidget citations -> resolveCitationsConfig -> { maxSources, favicons } | null + useChat citations: config !== null + ChatApiClient body.citations = true; onSources from the early SSE event + ChatWindow passes the config plus labels to each assistant message + ChatMessage renderFormattedContent(text, citationContext) -> chips + SourceCards footer: toggle, cards, "Show all", reveal on chip click + +worker + index.ts getChatConfig: citations = body.citations === true + stream route: event "sources" once, before the first chunk + chat.ts prepareRag -> buildRagContext({ citations }) + rag/retrieval buildRagContext: numbered headers, citing instruction, + sources with snippets, both computed from the excerpts that fit +``` + +| Unit | Responsibility | +|------|----------------| +| `widget/src/utils/citations.ts` | Pure: `resolveCitationsConfig`, `citationsOptionsFromAttributes`, `parseCitations`, `stripCitationMarkers`, `hideTrailingCitationOpener`, `truncateSnippet`, `faviconUrl`, `sourceDomain`. No React, no DOM. | +| `widget/src/components/SourceCards.tsx` | The footer: collapsed toggle, ordered list of cards, "Show all", and the reveal behaviour (expand, scroll, focus, highlight). | +| `widget/src/components/ChatMessage.tsx` | Threads a citation context into the renderer, renders chips, owns the `reveal` request, renders `SourceCards` instead of `SourceIcon` when citations are on. | +| `widget/src/components/ChatWindow.tsx` | Builds the labels, chooses footer or sidebar per message, strips markers from the live region and read-aloud text. | +| `widget/src/api/client.ts` | Sends `citations: true`; handles the `sources` event; falls back to early sources when `done` has none. | +| `widget/src/hooks/useChat.ts` | Holds early sources until the placeholder message exists. | +| `worker/src/rag/retrieval.ts` | `buildRagContext`, `snippetFromContent`, `CITATION_INSTRUCTIONS`; `formatRagContext` stays as a wrapper. | +| `worker/src/chat.ts` | `citations` on the request and config; a `sources` stream event. | +| `worker/src/index.ts` | Maps the event to SSE; reads `citations` from the body. | + +## Behaviour + +### Worker: numbering, instruction, snippet + +`buildRagContext(documents, rag, { citations })` replaces the body of +`formatRagContext` and returns `{ context, sources }` computed from the same +loop, which fixes fact 4: a document that does not fit the budget contributes +neither an excerpt nor a source. + +- Numbers are assigned per page, in order of first appearance among the + excerpts that fit, so two chunks of one page share a number and the number + equals the page's 1-based position in `sources`. The header for a numbered + excerpt is `### [1] [Pricing](https://example.com/pricing)`. Excerpts + without a URL cannot be cited and keep today's `### [Title]` header. +- When `citations` is off, headers are exactly what they are today, so the + existing `formatRagContext` tests keep passing unchanged. +- When `citations` is on and at least one source exists, this instruction is + appended after the template output, so custom templates get it too: + + > The excerpts above are numbered. When a sentence draws on one, end it with + > the excerpt's number in square brackets, like [1], or [1][3] for several. + > Cite only numbers that appear above and never invent one. Add no citation + > when the excerpts do not apply, and do not list the sources or their URLs + > yourself: the reader sees them beside your answer. + +- `ragDocumentsToSources` sets `snippet` from the first chunk seen for each + URL: heading markers, `**`, and backticks removed, whitespace collapsed, + cut to 200 characters at the last space (or hard if there is none) with a + trailing ellipsis (U+2026). An empty result leaves `snippet` off. +- `prepareRag` passes `config.citations`, and `getChatConfig` sets it from + `body.citations === true`. The blocking endpoint gets the same numbering + through the same path. + +### Wire format + +Streaming, with RAG and sources present: + +``` +event: sources data: {"sources":[{"url":"…","title":"…","type":"page","snippet":"…"}]} +event: chunk data: {"text":"Plans start at $10 "} +event: chunk data: {"text":"[1]."} +event: done data: {"reply":"Plans start at $10 [1].","sources":[…]} +``` + +- The `sources` event is emitted once, after the model connection for the + first round is established and before its first delta, so fact 8 still + holds: a bad API key is still a JSON 500. It is emitted whether or not the + request asked for citations, because an old widget ignores it (fact 7) and + a new one uses it to know that `[1]` is a citation. +- `done` carries `sources` as it does today. It is the authoritative copy; the + client uses the early copy only when `done` has none, and keeps it on the + partial reply when the visitor stops the stream. +- The blocking endpoint's response is unchanged apart from `snippet`. + +### Widget: chips + +`parseCitations(text, sourceCount)` tokenizes one text segment into text and +citation groups using `\[([1-9]\d{0,2}(?:\s*,\s*[1-9]\d{0,2})*)\]`. A match +is a citation group only when every number is between 1 and `sourceCount`; +otherwise it stays literal text, so `[7]` with three sources, `[0]`, `[01]`, +and every bracket in a reply with no sources render as written. Duplicates in +a group collapse. `[1][3]` is two groups; `[1, 3]` is one group with two +chips. + +The renderer gains a leaf step: after the URL split, each remaining text run +goes through `parseCitations`. Bold and italic inner text now goes through +the same leaf renderer, so `**Plans start at $10 [1]**` gets its chip. A side +effect is that URLs inside bold are linked from now on; that is an +improvement and is noted in the changelog. + +A chip is a ` + {expanded && ( + <> +
    + {visible.map((source, index) => ( + { + if (el) cardRefs.current.set(index, el); + else cardRefs.current.delete(index); + }} + source={source} + number={index + 1} + favicons={favicons} + labels={labels} + highlighted={highlighted?.index === index} + /> + ))} +
+ {count > maxSources && ( + + )} + + )} + + ); +}); +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd widget && ./node_modules/.bin/vitest run src/components/__tests__/SourceCards.test.tsx && ./node_modules/.bin/tsc --noEmit && ./node_modules/.bin/eslint src/components/SourceCards.tsx` +Expected: PASS, no type or lint errors. If `jsx-a11y/no-noninteractive-tabindex` fires on the `
  • `, the plugin version differs from the one checked; a negative `tabIndex` is meant to be allowed, so fix the rule's options rather than the markup only if you have confirmed the rule's documentation says so. + +- [ ] **Step 5: Format and commit** + +```bash +cd widget && ./node_modules/.bin/prettier --write src/components/SourceCards.tsx src/components/__tests__/SourceCards.test.tsx && ./node_modules/.bin/eslint src/components/SourceCards.tsx src/components/__tests__/SourceCards.test.tsx && cd .. +git add widget/src/components/SourceCards.tsx widget/src/components/__tests__/SourceCards.test.tsx +git commit -m "feat(widget): collapsible source-card footer + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 9: Citation chips in `ChatMessage` + +**Files:** +- Modify: `widget/src/components/ChatMessage.tsx` +- Test: `widget/src/components/__tests__/ChatMessage.citations.test.tsx` (create) + +**Interfaces:** +- Consumes: `SourceCards`, `SourceCardsLabels`, `SourceReveal` (Task 8); `parseCitations`, `hideTrailingCitationOpener`, `ResolvedCitationsConfig` (Task 6); `interpolate`. +- Produces: `ChatMessage` props `citations?: MessageCitations` and `linkNewTabLabel?: string`, where `export interface MessageCitations extends ResolvedCitationsConfig { labels: SourceCardsLabels & { citation: string } }`. Task 10 builds that object. + +- [ ] **Step 1: Write the failing tests** + +Create `widget/src/components/__tests__/ChatMessage.citations.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { ChatMessage, type MessageCitations } from "../ChatMessage"; +import type { Source } from "../../api/types"; + +const sources: Source[] = [ + { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "Plans start at $10 a month.", + }, + { url: "https://example.com/faq", title: "FAQ", type: "page" }, +]; + +const citations: MessageCitations = { + maxSources: 5, + favicons: false, + labels: { + sources: "Sources", + showAllSources: "Show all ({count})", + showFewerSources: "Show fewer", + citation: "Source {n}: {title}", + opensInNewTab: "(opens in a new tab)", + }, +}; + +type Props = React.ComponentProps; + +function renderCited(content: string, props: Partial = {}) { + return render( + , + ); +} + +describe("ChatMessage citations", () => { + it("renders an in-range marker as a chip named after its source", () => { + renderCited("Plans start at $10 [1]."); + const chip = screen.getByRole("button", { name: "Source 1: Pricing" }); + expect(chip).toHaveTextContent("1"); + expect(chip).toHaveAttribute("title", "Pricing"); + expect(screen.queryByText(/\[1\]/)).toBeNull(); + expect(screen.getByText(/Plans start at \$10/)).toBeInTheDocument(); + }); + + it("leaves out-of-range, zero, and leading-zero markers as text", () => { + renderCited("See [3], [0] and [01]."); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + expect(screen.getByText("See [3], [0] and [01].")).toBeInTheDocument(); + }); + + it("renders a chip inside bold text", () => { + renderCited("**Plans start at $10 [1]**"); + const chip = screen.getByRole("button", { name: "Source 1: Pricing" }); + expect(chip.closest("strong")).not.toBeNull(); + }); + + it("renders each number of a group as its own chip", () => { + renderCited("Both apply [1, 2]."); + expect(screen.getByRole("button", { name: "Source 1: Pricing" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Source 2: FAQ" })).toBeInTheDocument(); + }); + + it("renders no chips and no footer without the citations prop, keeping the source icon", () => { + render( + , + ); + expect(screen.getByText("Plans start at $10 [1].")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Sources/ })).toBeNull(); + expect(screen.getByRole("button", { name: /view sources/i })).toBeInTheDocument(); + }); + + it("replaces the source icon with the footer when citations are on", () => { + renderCited("Plans start at $10 [1].", { + onSourceClick: vi.fn(), + isSourceActive: false, + }); + expect(screen.queryByRole("button", { name: /view sources/i })).toBeNull(); + expect(screen.getByRole("button", { name: /^Sources/ })).toBeInTheDocument(); + }); + + it("renders no chips for a user message", () => { + render( + , + ); + expect(screen.getByText("I read [1].")).toBeInTheDocument(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("shows the footer, collapsed, even when the reply cites nothing", () => { + renderCited("Plans start at $10."); + expect(screen.getByRole("button", { name: /^Sources/ })).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + }); + + it("a chip click opens the footer and focuses the matching card", async () => { + const user = userEvent.setup(); + renderCited("Plans start at $10 [1]. See the FAQ [2]."); + await user.click(screen.getByRole("button", { name: "Source 2: FAQ" })); + expect(screen.getByRole("button", { name: /^Sources/ })).toHaveAttribute( + "aria-expanded", + "true", + ); + const cards = screen.getAllByRole("listitem"); + expect(cards).toHaveLength(2); + expect(cards[1]).toHaveFocus(); + expect(cards[1]).toHaveTextContent("FAQ"); + }); + + it("clicking the same chip again focuses the card again", async () => { + const user = userEvent.setup(); + renderCited("Plans start at $10 [1]."); + const chip = screen.getByRole("button", { name: "Source 1: Pricing" }); + await user.click(chip); + const card = screen.getByRole("listitem"); + expect(card).toHaveFocus(); + chip.focus(); + expect(card).not.toHaveFocus(); + await user.click(chip); + expect(card).toHaveFocus(); + }); + + it("hides an unfinished marker at the end of a streaming reply", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("Plans start at $10")).toBeInTheDocument(); + expect(screen.queryByText(/\[1/)).toBeNull(); + + rerender( + , + ); + expect(screen.getByText("Plans start at $10 [1")).toBeInTheDocument(); + }); + + it("keeps a trailing bracket while streaming a reply without sources", () => { + render(); + expect(screen.getByText("See [1")).toBeInTheDocument(); + }); + + it("announces new-tab links with the given label", () => { + render( + , + ); + expect( + screen.getByRole("link", { name: "example.com/a (ouvre un nouvel onglet)" }), + ).toBeInTheDocument(); + }); + + it("links a URL inside bold text", () => { + render(); + expect(screen.getByRole("link")).toHaveAttribute("href", "https://example.com/a"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd widget && ./node_modules/.bin/vitest run src/components/__tests__/ChatMessage.citations.test.tsx` +Expected: FAIL. `MessageCitations` does not exist; no chips render. + +- [ ] **Step 3: Implement** + +In `widget/src/components/ChatMessage.tsx`, change the imports: + +```tsx +import { Fragment, memo, useRef, useState, type ReactNode } from "react"; +import { SourceIcon } from "./SourceIcon"; +import { + SourceCards, + type SourceCardsLabels, + type SourceReveal, +} from "./SourceCards"; +import { AttachmentPreview } from "./AttachmentPreview"; +import { + MessageSpeechControls, + type MessageSpeechState, +} from "./MessageSpeechControls"; +import type { ChatAttachment, Source, ToolUse } from "../api/types"; +import { sanitizeUrl } from "../utils/sanitize"; +import { stabilizeStreamingMarkdown } from "../utils/stabilizeStreamingMarkdown"; +import { + hideTrailingCitationOpener, + parseCitations, + type ResolvedCitationsConfig, +} from "../utils/citations"; +import { interpolate } from "../utils/interpolate"; +``` + +Add the props and the exported type: + +```tsx +/** Citation rendering for one message: the resolved option plus its strings. */ +export interface MessageCitations extends ResolvedCitationsConfig { + labels: SourceCardsLabels & { + /** Accessible name of a chip; takes `{n}` and `{title}`. */ + citation: string; + }; +} + +interface ChatMessageProps { + // ...existing props... + /** + * Render `[n]` markers as chips and the sources as a card footer. Omit to + * keep the source icon and sidebar. + */ + citations?: MessageCitations; + /** Visually hidden hint appended to links that open a new tab. */ + linkNewTabLabel?: string; +} +``` + +Replace everything from `const URL_REGEX` through the end of `renderFormattedContent` with: + +```tsx +const URL_REGEX = /(https?:\/\/[^\s)]+)/; +const BOLD_REGEX = /(\*\*[^*]+\*\*)/; +const ITALIC_REGEX = /(\*[^*]+\*)/; + +/** Everything the inline renderer needs beyond the text itself. */ +interface RenderContext { + /** Visually hidden hint appended to links that open a new tab. */ + newTabLabel: string; + /** Present only when `[n]` markers should become chips. */ + citations?: CitationContext; +} + +interface CitationContext { + sources: Source[]; + /** Chip accessible name; takes `{n}` and `{title}`. */ + label: string; + onCite: (index: number) => void; +} + +function renderLink(rawUrl: string, key: string, ctx: RenderContext): ReactNode { + // Strip trailing punctuation that's likely not part of the URL + const trailingPunct = rawUrl.match(/[.,;:!?'"]+$/); + const url = trailingPunct + ? rawUrl.slice(0, -trailingPunct[0].length) + : rawUrl; + const suffix = trailingPunct ? trailingPunct[0] : ""; + + // Validate URL scheme to prevent javascript:, data:, vbscript: attacks + const safeUrl = sanitizeUrl(url); + if (!safeUrl) { + // If URL is not safe, render as plain text + return rawUrl; + } + + return ( + + + {safeUrl.replace(/^https?:\/\//, "")} + {ctx.newTabLabel} + + {suffix} + + ); +} + +/** `[n]` markers become chips; everything else stays text. */ +function renderCitations( + text: string, + keyPrefix: string, + ctx: RenderContext, +): ReactNode[] { + const cites = ctx.citations; + if (!cites) return text ? [text] : []; + return parseCitations(text, cites.sources.length).map((segment, index) => { + if (segment.type === "text") return segment.value; + return ( + + {segment.indexes.map((sourceIndex) => ( + + ))} + + ); + }); +} + +/** Text with no bold or italic left in it: links first, then citation chips. */ +function renderLeaf(text: string, keyPrefix: string, ctx: RenderContext): ReactNode[] { + const result: ReactNode[] = []; + text.split(URL_REGEX).forEach((part, index) => { + if (URL_REGEX.test(part)) { + result.push(renderLink(part, `${keyPrefix}-u${index}`, ctx)); + } else if (part) { + result.push(...renderCitations(part, `${keyPrefix}-u${index}`, ctx)); + } + }); + return result; +} + +function renderInlineFormatting( + text: string, + keyPrefix: string, + ctx: RenderContext, +): ReactNode[] { + const result: ReactNode[] = []; + text.split(BOLD_REGEX).forEach((part, bIdx) => { + if (BOLD_REGEX.test(part)) { + // Strip the ** markers; the inside still gets links and chips. + result.push( + + {renderLeaf(part.slice(2, -2), `${keyPrefix}-b${bIdx}`, ctx)} + , + ); + return; + } + part.split(ITALIC_REGEX).forEach((iPart, iIdx) => { + const key = `${keyPrefix}-b${bIdx}-i${iIdx}`; + if (ITALIC_REGEX.test(iPart)) { + result.push({renderLeaf(iPart.slice(1, -1), key, ctx)}); + } else { + result.push(...renderLeaf(iPart, key, ctx)); + } + }); + }); + return result; +} + +function renderFormattedContent(content: string, ctx: RenderContext): ReactNode[] { + const lines = content.split("\n"); + + return lines.map((line, lineIndex) => ( + + {renderInlineFormatting(line, `l${lineIndex}`, ctx)} + {lineIndex < lines.length - 1 &&
    } +
    + )); +} +``` + +In the component, destructure the new props (`citations`, `linkNewTabLabel = "(opens in a new tab)"`) and replace the body's opening lines up to `showBubble` with: + +```tsx + const isUser = role === "user"; + const [reveal, setReveal] = useState(null); + const revealCount = useRef(0); + // Chips and the footer need both the option and something to cite. + const cited = + !isUser && citations && sources && sources.length > 0 + ? { config: citations, sources } + : null; + const hasAttachments = !!attachments && attachments.length > 0; + const streamed = isStreaming ? stabilizeStreamingMarkdown(content) : content; + // A half-typed `[1` would flash as text before its chip; hide it like the + // bold stabilizer hides a bare `**`. + const displayContent = + isStreaming && cited ? hideTrailingCitationOpener(streamed) : streamed; + const renderContext: RenderContext = { + newTabLabel: linkNewTabLabel, + citations: cited + ? { + sources: cited.sources, + label: cited.config.labels.citation, + onCite: (index) => { + revealCount.current += 1; + setReveal({ index, key: revealCount.current }); + }, + } + : undefined, + }; + // While a tool runs before any reply text streams in, there is nothing to + // put in a bubble yet — show only the tool chips. + const showBubble = isUser || content !== "" || !toolUses?.length; +``` + +Change the render call to `renderFormattedContent(displayContent, renderContext)`, and replace the `SourceIcon` block with: + +```tsx + {cited ? ( + + ) : ( + !isUser && + sources && + sources.length > 0 && + onSourceClick && ( +
    + +
    + ) + )} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd widget && ./node_modules/.bin/vitest run src/components && ./node_modules/.bin/tsc --noEmit` +Expected: PASS, including the existing `ChatMessage.test.tsx`, `ChatMessage.attachments.test.tsx`, and `ChatMessage.voice.test.tsx`. + +- [ ] **Step 5: Format and commit** + +```bash +cd widget && ./node_modules/.bin/prettier --write src/components/ChatMessage.tsx src/components/__tests__/ChatMessage.citations.test.tsx && ./node_modules/.bin/eslint src/components/ChatMessage.tsx src/components/__tests__/ChatMessage.citations.test.tsx && cd .. +git add widget/src/components/ChatMessage.tsx widget/src/components/__tests__/ChatMessage.citations.test.tsx +git commit -m "feat(widget): render citation markers as chips with a source-card footer + +Text inside bold and italic now goes through the same link step as the +rest of a line, so URLs inside ** are linked too. + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 10: `ChatWindow` wiring and the live region + +**Files:** +- Modify: `widget/src/components/ChatWindow.tsx` +- Test: `widget/src/components/__tests__/ChatWindow.citations.test.tsx` (create) + +**Interfaces:** +- Consumes: `MessageCitations` (Task 9), `ResolvedCitationsConfig`, `stripCitationMarkers` (Task 6), the five strings (Task 7). +- Produces: `ChatWindow` prop `citations?: ResolvedCitationsConfig | null` (default `null`). Task 11 passes it. + +- [ ] **Step 1: Write the failing tests** + +Create `widget/src/components/__tests__/ChatWindow.citations.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { ChatWindow } from "../ChatWindow"; +import { de } from "../../locales/de"; +import type { ChatMessage, Source } from "../../api/types"; + +const sources: Source[] = [ + { url: "https://example.com/pricing", title: "Pricing", type: "page" }, +]; + +const messages: ChatMessage[] = [ + { id: "msg-1", role: "user", content: "What are your prices?" }, + { + id: "msg-2", + role: "assistant", + content: "Plans start at $10 [1].", + sources, + }, +]; + +const config = { maxSources: 5, favicons: false }; + +function renderWindow( + props: Partial> = {}, +) { + render( + , + ); + return userEvent.setup(); +} + +const liveText = () => + document.querySelector('[data-claudius-live="assistant"]')!.textContent; + +describe("ChatWindow citations", () => { + it("renders chips and the footer, and never the sidebar", async () => { + const user = renderWindow(); + await user.click(screen.getByRole("button", { name: "Source 1: Pricing" })); + expect(screen.getByRole("list", { name: "Sources" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /view sources/i })).toBeNull(); + expect(screen.queryByText("1 source found")).toBeNull(); + }); + + it("keeps the source icon and sidebar when citations are off", async () => { + const user = renderWindow({ citations: null }); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + await user.click(screen.getByRole("button", { name: /view sources/i })); + expect(screen.getByText("1 source found")).toBeInTheDocument(); + }); + + it("strips citation markers from the live region", () => { + renderWindow(); + expect(liveText()).toBe("Plans start at $10."); + }); + + it("leaves the markers in the live region when citations are off", () => { + renderWindow({ citations: null }); + expect(liveText()).toBe("Plans start at $10 [1]."); + }); + + it("labels chips and the footer in the widget's language", () => { + renderWindow({ translations: de }); + expect(screen.getByRole("button", { name: "Quelle 1: Pricing" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Quellen/ })).toBeInTheDocument(); + }); + + it("each cited reply keeps its own footer state", async () => { + const user = renderWindow({ + messages: [ + ...messages, + { id: "msg-3", role: "user", content: "And support?" }, + { + id: "msg-4", + role: "assistant", + content: "Email support is included [1].", + sources: [{ url: "https://example.com/support", title: "Support", type: "page" }], + }, + ], + }); + const toggles = screen.getAllByRole("button", { name: /^Sources/ }); + expect(toggles).toHaveLength(2); + await user.click(toggles[0]); + expect(toggles[0]).toHaveAttribute("aria-expanded", "true"); + expect(toggles[1]).toHaveAttribute("aria-expanded", "false"); + expect(screen.getAllByRole("list", { name: "Sources" })).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd widget && ./node_modules/.bin/vitest run src/components/__tests__/ChatWindow.citations.test.tsx` +Expected: FAIL. The prop is unknown, chips do not render, the live region keeps `[1]`. + +- [ ] **Step 3: Implement** + +In `widget/src/components/ChatWindow.tsx`: + +```tsx +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { ChatMessage, type MessageCitations } from "./ChatMessage"; +// ... +import { + stripCitationMarkers, + type ResolvedCitationsConfig, +} from "../utils/citations"; + +interface ChatWindowProps { + // ...existing... + /** Citation rendering, or `null` to keep the source icon and sidebar. */ + citations?: ResolvedCitationsConfig | null; +} + +export function ChatWindow({ + // ...existing... + citations = null, +}: ChatWindowProps) { +``` + +After the `exporter` block, add: + +```tsx + const newTabLabel = translations?.opensInNewTab ?? "(opens in a new tab)"; + // One object per config or language change, so memoized messages do not + // re-render on every keystroke elsewhere in the window. + const messageCitations = useMemo( + () => + citations + ? { + ...citations, + labels: { + sources: translations?.sources ?? "Sources", + showAllSources: + translations?.showAllSources ?? "Show all ({count})", + showFewerSources: translations?.showFewerSources ?? "Show fewer", + citation: translations?.citation ?? "Source {n}: {title}", + opensInNewTab: newTabLabel, + }, + } + : undefined, + [citations, translations, newTabLabel], + ); + + // What a screen reader or the read-aloud voice gets: no markdown markers, + // hostnames for URLs, and no citation markers when chips render them. + const announceText = (message: ChatMessageData) => + stripAnnouncementFormatting( + messageCitations && message.sources?.length + ? stripCitationMarkers(message.content, message.sources.length) + : message.content, + ); +``` + +In the `` element, add `citations={messageCitations}` and `linkNewTabLabel={newTabLabel}`, and make the sidebar wiring conditional: + +```tsx + isSourceActive={ + messageCitations ? undefined : activeSources?.messageId === msg.id + } + onSourceClick={ + messageCitations + ? undefined + : () => { + if (activeSources?.messageId === msg.id) { + setActiveSources(null); + } else if (msg.sources && msg.sources.length > 0) { + setActiveSources({ + messageId: msg.id, + sources: msg.sources, + }); + } + } + } +``` + +Replace `stripAnnouncementFormatting(msg.content)` in `onPlay` with `announceText(msg)`, and `stripAnnouncementFormatting(lastAssistantMessage.content)` in the live region with `announceText(lastAssistantMessage)`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd widget && ./node_modules/.bin/vitest run src/components && ./node_modules/.bin/tsc --noEmit` +Expected: PASS, including `ChatWindow.test.tsx`, `ChatWindow.export.test.tsx`, and `ChatWindow.voice.test.tsx`. + +- [ ] **Step 5: Format and commit** + +```bash +cd widget && ./node_modules/.bin/prettier --write src/components/ChatWindow.tsx src/components/__tests__/ChatWindow.citations.test.tsx && ./node_modules/.bin/eslint src/components/ChatWindow.tsx src/components/__tests__/ChatWindow.citations.test.tsx && cd .. +git add widget/src/components/ChatWindow.tsx widget/src/components/__tests__/ChatWindow.citations.test.tsx +git commit -m "feat(widget): wire citations through the chat window and its live region + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 11: `ChatWidget`, the embed, exports, dev app, and story + +**Files:** +- Modify: `widget/src/components/ChatWidget.tsx` +- Modify: `widget/src/embed.tsx` +- Modify: `widget/src/index.ts` +- Modify: `widget/src/main.tsx` +- Modify: `widget/src/components/ChatMessage.stories.tsx` +- Test: `widget/src/components/__tests__/ChatWidget.citations.test.tsx` (create), `widget/src/__tests__/embed.test.tsx` + +**Interfaces:** +- Consumes: `resolveCitationsConfig`, `citationsOptionsFromAttributes`, `CitationsOptions`, `ResolvedCitationsConfig`, `DEFAULT_CITATIONS_OPTIONS` (Task 6); `UseChatOptions.citations` (Task 5); `ChatWindow.citations` (Task 10). +- Produces: `ChatWidgetProps.citations?: boolean | CitationsOptions`; `ClaudiusConfig.citations`; ``; package exports `CitationsOptions`, `ResolvedCitationsConfig`, `DEFAULT_CITATIONS_OPTIONS`. + +- [ ] **Step 1: Write the failing tests** + +Create `widget/src/components/__tests__/ChatWidget.citations.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ChatWidget } from "../ChatWidget"; +import type { Source } from "../../api/types"; + +const sources: Source[] = [ + { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "Plans start at $10.", + }, + { url: "https://example.com/faq", title: "FAQ", type: "page" }, +]; + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +// A JSON reply on the stream endpoint: the client treats it as a blocking +// reply, so one mock serves both endpoints. +function jsonReply(reply: string) { + return { + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + json: () => Promise.resolve({ reply, sources }), + }; +} + +async function ask(user: ReturnType) { + await user.click(screen.getByRole("button", { name: /open chat/i })); + await user.type(screen.getByLabelText(/type your message/i), "Prices?"); + await user.keyboard("{Enter}"); + await screen.findByText(/Plans start at/); +} + +function sentBody(): Record { + return JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string); +} + +describe("ChatWidget citations option", () => { + beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockResolvedValue(jsonReply("Plans start at $10 [1].")); + sessionStorage.clear(); + }); + + it("is off by default: literal markers, the source icon, and no request flag", async () => { + const user = userEvent.setup(); + render(); + await ask(user); + expect(screen.getByText("Plans start at $10 [1].")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + expect(screen.getByRole("button", { name: /view sources/i })).toBeInTheDocument(); + expect(sentBody()).not.toHaveProperty("citations"); + }); + + it("renders chips and asks the worker for citations when enabled", async () => { + const user = userEvent.setup(); + render(); + await ask(user); + expect(screen.getByRole("button", { name: "Source 1: Pricing" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /view sources/i })).toBeNull(); + expect(sentBody().citations).toBe(true); + }); + + it.each([["true"], ["false"], [1], [[]]])( + "fails closed for %j, which is neither true nor an options object", + async (value) => { + const user = userEvent.setup(); + render( + , + ); + await ask(user); + expect(screen.getByText("Plans start at $10 [1].")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + expect(sentBody()).not.toHaveProperty("citations"); + }, + ); + + it("applies maxSources and favicons from an options object", async () => { + const user = userEvent.setup(); + render( + , + ); + await ask(user); + await user.click(screen.getByRole("button", { name: "Source 1: Pricing" })); + expect(screen.getAllByRole("listitem")).toHaveLength(1); + expect(screen.getByRole("button", { name: "Show all (2)" })).toBeInTheDocument(); + expect(screen.getByRole("listitem").querySelector("img")).toBeNull(); + }); + + it("labels chips in the widget's language", async () => { + const user = userEvent.setup(); + render(); + await ask(user); + expect(screen.getByRole("button", { name: "Quelle 1: Pricing" })).toBeInTheDocument(); + }); +}); +``` + +Append to `widget/src/__tests__/embed.test.tsx`: + +```tsx +describe("embed citations option", () => { + const mockFetch = vi.fn(); + const sources = [ + { url: "https://example.com/pricing", title: "Pricing", type: "page" }, + { url: "https://example.com/faq", title: "FAQ", type: "page" }, + ]; + + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ""; + window.sessionStorage.clear(); + window.ClaudiusConfig = undefined; + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + json: () => Promise.resolve({ reply: "Plans start at $10 [1].", sources }), + }); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + window.ClaudiusConfig = undefined; + }); + + function mountElement(attributes: Record) { + const el = document.createElement("claudius-chat"); + el.setAttribute("api-url", "https://test.example/api"); + for (const [name, value] of Object.entries(attributes)) { + el.setAttribute(name, value); + } + document.body.appendChild(el); + } + + async function ask() { + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: /open chat/i })); + await user.type(screen.getByLabelText(/type your message/i), "Prices?"); + await user.keyboard("{Enter}"); + await screen.findByText(/Plans start at/); + return user; + } + + function sentBody(): Record { + return JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string); + } + + it("enables chips from ClaudiusConfig", async () => { + window.ClaudiusConfig = { apiUrl: "https://test.example/api", citations: true }; + await import("../embed"); + await ask(); + expect(screen.getByRole("button", { name: "Source 1: Pricing" })).toBeInTheDocument(); + expect(sentBody().citations).toBe(true); + }); + + it("enables chips for a bare citations attribute", async () => { + await import("../embed"); + mountElement({ citations: "" }); + await ask(); + expect(screen.getByRole("button", { name: "Source 1: Pricing" })).toBeInTheDocument(); + expect(sentBody().citations).toBe(true); + }); + + it("stays off for citations=\"false\"", async () => { + await import("../embed"); + mountElement({ citations: "false" }); + await ask(); + expect(screen.getByText("Plans start at $10 [1].")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + expect(sentBody()).not.toHaveProperty("citations"); + }); + + it("reads the companion attributes", async () => { + await import("../embed"); + mountElement({ + citations: "true", + "citations-max-sources": "1", + "citations-favicons": "false", + }); + const user = await ask(); + await user.click(screen.getByRole("button", { name: "Source 1: Pricing" })); + expect(screen.getAllByRole("listitem")).toHaveLength(1); + expect(screen.getByRole("button", { name: "Show all (2)" })).toBeInTheDocument(); + expect(screen.getByRole("listitem").querySelector("img")).toBeNull(); + }); +}); +``` + +The file already imports `screen` and the vitest helpers; add `import userEvent from "@testing-library/user-event";` at the top if it is not there. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd widget && ./node_modules/.bin/vitest run src/components/__tests__/ChatWidget.citations.test.tsx src/__tests__/embed.test.tsx` +Expected: the "enabled" cases FAIL (no chips, no flag). The "off" cases pass already and pin today's behaviour. + +- [ ] **Step 3: Implement** + +`widget/src/components/ChatWidget.tsx`: + +```tsx +import { + resolveCitationsConfig, + type CitationsOptions, +} from "../utils/citations"; + +// in ChatWidgetProps, after conversationExport: + /** + * Render `[n]` citations in grounded replies as numbered chips, with a + * collapsible footer of source cards under the reply. `true` enables the + * defaults (five cards before "Show all", favicons on); pass a + * {@link CitationsOptions} to tune them. The widget also asks the worker to + * number its retrieved excerpts, so this needs a worker from 1.18.0 or + * later with RAG on to have any effect. Only `true` or an object enables + * it. See the Inline citations guide. + * @defaultValue `false` + */ + citations?: boolean | CitationsOptions; + +// destructure with default + citations = false, + +// after voiceConfig + const citationsConfig = useMemo( + () => resolveCitationsConfig(citations), + [citations], + ); + +// useChat call + citations: citationsConfig !== null, + +// ChatWindow element + citations={citationsConfig} +``` + +`widget/src/embed.tsx`: + +```tsx +import { + citationsOptionsFromAttributes, + type CitationsOptions, +} from "./utils/citations"; + +// ClaudiusConfig + citations?: boolean | CitationsOptions; + +// init(): pass it through + citations={config.citations} + +// observedAttributes: add after "conversation-export" + "citations", + "citations-max-sources", + "citations-favicons", + +// render(): after the voice prop + citations={citationsOptionsFromAttributes((name) => + this.getAttribute(name), + )} +``` + +`widget/src/index.ts`, after the voice type export: + +```ts +export { DEFAULT_CITATIONS_OPTIONS } from "./utils/citations"; +export type { + CitationsOptions, + ResolvedCitationsConfig, +} from "./utils/citations"; +``` + +`widget/src/main.tsx`: add `citations` to the `` props, after `conversationExport`. + +`widget/src/components/ChatMessage.stories.tsx`, after `WithSources`: + +```tsx +// Inline citations: [n] markers become chips, and a collapsible footer lists +// one card per source. favicons is off so Storybook makes no network requests. +export const WithCitations: Story = { + args: { + role: "assistant", + content: + "Plans start at $10 a month [1].\n" + + "Every plan includes support by email [2].", + sources: [ + { + url: "https://pmds.info/pricing", + title: "Pricing", + type: "page", + snippet: + "Plans start at $10 a month, billed annually. Every plan includes support by email and a 14-day trial.", + }, + { + url: "https://pmds.info/support", + title: "Support", + type: "page", + snippet: "Email support answers within one business day.", + }, + ], + citations: { + maxSources: 5, + favicons: false, + labels: { + sources: "Sources", + showAllSources: "Show all ({count})", + showFewerSources: "Show fewer", + citation: "Source {n}: {title}", + opensInNewTab: "(opens in a new tab)", + }, + }, + }, +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd widget && ./node_modules/.bin/vitest run && ./node_modules/.bin/tsc --noEmit && ./node_modules/.bin/typedoc --emit none` +Expected: every widget test PASS; no type errors; typedoc reports no undocumented exports. + +- [ ] **Step 5: Format and commit** + +```bash +cd widget && ./node_modules/.bin/prettier --write src/components/ChatWidget.tsx src/embed.tsx src/index.ts src/main.tsx src/components/ChatMessage.stories.tsx src/components/__tests__/ChatWidget.citations.test.tsx src/__tests__/embed.test.tsx && ./node_modules/.bin/eslint src/ && cd .. +git add widget/src/components/ChatWidget.tsx widget/src/embed.tsx widget/src/index.ts widget/src/main.tsx widget/src/components/ChatMessage.stories.tsx widget/src/components/__tests__/ChatWidget.citations.test.tsx widget/src/__tests__/embed.test.tsx +git commit -m "feat(widget): citations option on the component, ClaudiusConfig, and + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 12: Client configs, snippets, and the schema + +**Files:** +- Modify: `scripts/lib/config.ts` +- Modify: `scripts/lib/snippet.ts` +- Modify: `clients/_schema.json` +- Test: `scripts/lib/__tests__/citations-config.test.ts` (create) + +**Interfaces:** +- Produces: `WidgetConfig.citations?: boolean | WidgetCitationsConfig` with `WidgetCitationsConfig = { maxSources?: number; favicons?: boolean }`; `widget.citations` in the JSON schema; both snippet generators emit it. + +- [ ] **Step 1: Write the failing tests** + +Create `scripts/lib/__tests__/citations-config.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { validateConfig } from "../config.js"; +import type { ClientConfig, ValidationError } from "../config.js"; +import { generateScriptSnippet, generateWebComponentSnippet } from "../snippet.js"; + +function base(overrides: Partial = {}): Record { + return { + name: "Test Client", + slug: "test-client", + apiUrl: "https://api.example.com", + allowedDomains: ["example.com"], + ...overrides, + }; +} + +function fields(errors: ValidationError[]): string[] { + return errors.map((e) => e.field); +} + +const SCRIPT_URL = "https://cdn.example.com/claudius.js"; + +describe("validateConfig: citations", () => { + it("accepts a boolean or an options object", () => { + for (const citations of [true, false, {}, { maxSources: 3 }, { favicons: false }]) { + expect(validateConfig(base({ widget: { citations } }), "test-client")).toEqual([]); + } + }); + + it("rejects other shapes, naming the field", () => { + for (const value of ["true", 1, null, []]) { + const errors = validateConfig( + base({ widget: { citations: value as never } }), + "test-client" + ); + expect(fields(errors)).toEqual(["widget.citations"]); + expect(errors[0].message).toBe("widget.citations must be a boolean or an object"); + } + }); + + it("requires a positive integer maxSources and a boolean favicons", () => { + for (const maxSources of [0, -1, 2.5, "3"]) { + const errors = validateConfig( + base({ widget: { citations: { maxSources: maxSources as never } } }), + "test-client" + ); + expect(fields(errors)).toEqual(["widget.citations.maxSources"]); + } + const errors = validateConfig( + base({ widget: { citations: { favicons: "false" as never } } }), + "test-client" + ); + expect(fields(errors)).toEqual(["widget.citations.favicons"]); + expect(errors[0].message).toBe("widget.citations.favicons must be a boolean"); + }); +}); + +describe("snippets: citations", () => { + it("passes true and the options object into the script snippet", () => { + const on = base({ widget: { citations: true } }) as unknown as ClientConfig; + expect(generateScriptSnippet(on, SCRIPT_URL)).toContain('"citations": true'); + + const tuned = base({ + widget: { citations: { maxSources: 3, favicons: false } }, + }) as unknown as ClientConfig; + const snippet = generateScriptSnippet(tuned, SCRIPT_URL); + expect(snippet).toContain('"maxSources": 3'); + expect(snippet).toContain('"favicons": false'); + }); + + it("emits the attributes for the web component", () => { + const on = base({ widget: { citations: true } }) as unknown as ClientConfig; + const plain = generateWebComponentSnippet(on, SCRIPT_URL); + expect(plain).toContain('citations="true"'); + expect(plain).not.toContain("citations-max-sources"); + expect(plain).not.toContain("citations-favicons"); + + const tuned = base({ + widget: { citations: { maxSources: 3, favicons: false } }, + }) as unknown as ClientConfig; + const snippet = generateWebComponentSnippet(tuned, SCRIPT_URL); + expect(snippet).toContain('citations="true"'); + expect(snippet).toContain('citations-max-sources="3"'); + expect(snippet).toContain('citations-favicons="false"'); + }); + + it("emits nothing when off or unset", () => { + for (const widget of [{ citations: false }, {}]) { + const off = base({ widget }) as unknown as ClientConfig; + expect(generateScriptSnippet(off, SCRIPT_URL)).not.toContain("citations"); + expect(generateWebComponentSnippet(off, SCRIPT_URL)).not.toContain("citations"); + } + }); +}); + +describe("_schema.json: citations", () => { + it("declares widget.citations as a boolean or a closed options object", () => { + const schema = JSON.parse( + readFileSync(resolve(__dirname, "../../../clients/_schema.json"), "utf-8") + ); + const citations = schema.properties.widget.properties.citations; + expect(citations.oneOf[0]).toEqual({ type: "boolean" }); + expect(citations.oneOf[1].additionalProperties).toBe(false); + expect(citations.oneOf[1].properties.maxSources).toEqual({ type: "integer", minimum: 1 }); + expect(citations.oneOf[1].properties.favicons).toEqual({ type: "boolean" }); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm test:scripts` +Expected: FAIL. Snippets do not emit `citations`; the schema has no such property. + +- [ ] **Step 3: Implement** + +`scripts/lib/config.ts`, types: + +```ts +export interface WidgetCitationsConfig { + maxSources?: number; + favicons?: boolean; +} + +export interface WidgetConfig { + // ...existing... + conversationExport?: boolean; + citations?: boolean | WidgetCitationsConfig; +} +``` + +Validation, after the `conversationExport` check inside `if (config.widget !== undefined)`: + +```ts + if (widget.citations !== undefined) { + const citations = widget.citations; + if (typeof citations === "boolean") { + // fine + } else if (citations && typeof citations === "object" && !Array.isArray(citations)) { + const options = citations as Record; + if (options.maxSources !== undefined && !isPositiveInt(options.maxSources)) { + errors.push({ + field: "widget.citations.maxSources", + message: "widget.citations.maxSources must be a positive integer", + }); + } + if (options.favicons !== undefined && typeof options.favicons !== "boolean") { + errors.push({ + field: "widget.citations.favicons", + message: "widget.citations.favicons must be a boolean", + }); + } + } else { + errors.push({ + field: "widget.citations", + message: "widget.citations must be a boolean or an object", + }); + } + } +``` + +`scripts/lib/snippet.ts`, script snippet, after the `conversationExport` block: + +```ts + // `true` or the options object both pass straight through to ClaudiusConfig. + if (config.widget.citations !== undefined && config.widget.citations !== false) { + configObj.citations = config.widget.citations; + } +``` + +Web component snippet, after the `conversation-export` block: + +```ts + const citations = config.widget.citations; + if (citations !== undefined && citations !== false) { + attrs.push(["citations", "true"]); + if (citations !== true) { + if (citations.maxSources !== undefined) { + attrs.push(["citations-max-sources", String(citations.maxSources)]); + } + if (citations.favicons === false) attrs.push(["citations-favicons", "false"]); + } + } +``` + +`clients/_schema.json`, in `properties.widget.properties`, after `conversationExport`: + +```json + "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" } + } + } + ] + } +``` + +Match the file's existing indentation (check with `python3 -m json.tool clients/_schema.json > /dev/null` that it still parses). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm test:scripts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/lib/config.ts scripts/lib/snippet.ts clients/_schema.json scripts/lib/__tests__/citations-config.test.ts +git commit -m "feat(cli): widget.citations in client configs, snippets, and the schema + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 13: Documentation + +**Files:** +- Create: `docs/src/content/docs/configuration/citations.md` +- Modify: `docs/src/content/docs/configuration/widget.md`, `docs/src/content/docs/configuration/localization.md`, `docs/src/content/docs/configuration/clients.md`, `docs/src/content/docs/configuration/theming.md`, `docs/src/content/docs/rag/index.md`, `docs/src/content/docs/api/rest.md`, `CLAUDE.md` + +- [ ] **Step 1: Write the new page** + +Create `docs/src/content/docs/configuration/citations.md`: + +````markdown +--- +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 + +``` + +```html + +``` + +```html + +``` + +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 + +``` + +| 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. +```` + +- [ ] **Step 2: Update the existing pages** + +`docs/src/content/docs/configuration/widget.md`: add a table row after `conversationExport`: + +```markdown +| `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/) | +``` + +and extend the attributes paragraph so the list ends with `... conversation-export (see [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)).` Keep the existing link formatting. + +`docs/src/content/docs/configuration/localization.md`, add a row to the "Available keys" table: + +```markdown +| [Inline citations](/configuration/citations/) | `sources`, `showAllSources`, `showFewerSources`, `citation`, `opensInNewTab` | +``` + +`docs/src/content/docs/configuration/clients.md`, in the `widget` row, append after the `conversationExport` entry: `; \`citations\` (\`true\` or \`{ maxSources, favicons }\`, see [Inline citations](/configuration/citations/))`. + +`docs/src/content/docs/configuration/theming.md`, token table: change the `accent` row's "Used for" to `Header, toggle bubble, send button, focus rings, citation chips` and the `surfaceMuted` row's to `Assistant bubble, hovers, source cards`. + +`docs/src/content/docs/rag/index.md`: +- Intro paragraph: replace "which the widget renders as a source icon with a slide-out sidebar." with "which the widget renders as a source icon with a slide-out sidebar, or, with [citations](/configuration/citations/) on, as numbered chips in the reply and a footer of source cards." +- "How it works", step 4: replace with "Returns deduplicated `sources` (one per page, each with a 200-character `snippet` of its first matching chunk) on the response: the JSON body for `/api/chat`, and for `/api/chat/stream` a `sources` event before the first chunk plus the `done` event. When the request carries `citations: true`, the excerpts in step 3 are numbered to match and the model is asked to cite them as `[n]`." +- Add a note after that list: "Only excerpts that fit `maxContextChars` become sources, so the model never gets credit for a page it did not see." +- "Related": replace the #56 bullet with "**Inline citations and source cards** rendered from these sources are described in [Inline citations](/configuration/citations/)." + +`docs/src/content/docs/api/rest.md`: +- Request table: add `| \`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 |` after `conversationId`. +- Response example: add `"snippet": "We're available Monday through Friday, 9am to 5pm."` to the source object, and after the example paragraph about `sources` say: "Each source has `url`, `title`, `type`, and, from 1.18.0, a `snippet` of about 200 characters." +- Replace the paragraph starting "`POST /api/chat/stream` accepts the same JSON or multipart body." with a new section placed after "### Errors": + +````markdown +## 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. + +| 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. +```` + +`CLAUDE.md`: +- Widget components table: add `| \`SourceCards\` | Collapsible footer of source cards under a cited reply; reveals a card when its chip is clicked |`. +- Worker API table: after the `/api/chat/stream` row's description, change it to "Same request; streams the reply as SSE (`sources`/`chunk`/`tool`/`done`/`error` events)". +- Chat request example: add `citations?: true` as a top-level field with a comment `// widget asks for numbered, citable excerpts`; response `sources` entries gain `snippet?: "..."`. +- Add a section after "### Conversation Export": + +```markdown +### Citations + +Widget-only opt-in (`citations` prop / `ClaudiusConfig` key / +`` / +`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. +``` + +- [ ] **Step 3: Build the docs site to check the page and its anchors** + +Run: `cd docs && ./node_modules/.bin/astro build 2>&1 | tail -5 && grep -c 'id="enabling-it"' dist/configuration/citations/index.html` +Expected: the build succeeds and the grep prints `1`. Then confirm the new page has no em dashes: `grep -c $'\u2014' docs/src/content/docs/configuration/citations.md` must print `0`. + +- [ ] **Step 4: Commit** + +```bash +git add docs/src/content/docs/configuration/citations.md docs/src/content/docs/configuration/widget.md docs/src/content/docs/configuration/localization.md docs/src/content/docs/configuration/clients.md docs/src/content/docs/configuration/theming.md docs/src/content/docs/rag/index.md docs/src/content/docs/api/rest.md CLAUDE.md +git commit -m "docs: inline citations guide, streaming events, and the citations request field + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 14: Playwright spec + +**Files:** +- Create: `widget/e2e/citations.spec.ts` + +**Interfaces:** +- Consumes: the dev app (`widget/src/main.tsx`) with `citations` on (Task 11); `mockChatApi` from `widget/e2e/helpers.ts`, whose `enqueueReply(reply, sources)` already takes sources. + +- [ ] **Step 1: Write the spec** + +```ts +import { test, expect } from "@playwright/test"; +import { mockChatApi } from "./helpers"; + +/** + * Inline citations in a real browser: the chip, the request flag, and the + * reveal (scroll plus focus), which jsdom cannot exercise. Marker parsing and + * the footer's states are covered by the unit tests. + */ +test.describe("inline citations", () => { + test("a chip reveals and focuses its source card", async ({ page }) => { + // No real request to example.com for the favicon: abort it so the + // fallback icon renders and the run stays offline. + await page.route("https://example.com/**", (route) => route.abort()); + + const api = await mockChatApi(page); + api.enqueueReply("Plans start at $10 a month [1].", [ + { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "Plans start at $10 a month, billed annually.", + }, + ]); + const bodies: string[] = []; + page.on("request", (request) => { + if (request.method() === "POST" && request.url().endsWith("/api/chat")) { + bodies.push(request.postData() ?? ""); + } + }); + + await page.goto("/"); + await page.getByRole("button", { name: /open chat/i }).click(); + const input = page.getByLabel(/type your message/i); + await input.fill("What are your prices?"); + await input.press("Enter"); + + const chip = page.getByRole("button", { name: "Source 1: Pricing" }); + await expect(chip).toBeVisible(); + await expect(chip).toHaveText("1"); + expect(bodies).toHaveLength(1); + expect(JSON.parse(bodies[0])).toMatchObject({ citations: true }); + + const toggle = page.getByRole("button", { name: /^Sources/ }); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await chip.click(); + + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + const card = page.getByRole("listitem").filter({ hasText: "Pricing" }); + await expect(card).toHaveCount(1); + await expect(card).toBeFocused(); + await expect(card).toBeInViewport(); + await expect(card).toContainText("Plans start at $10 a month, billed annually."); + const link = card.getByRole("link", { name: /Pricing/ }); + await expect(link).toHaveAttribute("href", "https://example.com/pricing"); + await expect(link).toHaveAttribute("target", "_blank"); + }); +}); +``` + +- [ ] **Step 2: Run it** + +Start the dev server yourself first if `pnpm dev` is unreliable under pnpm 11: `cd widget && ./node_modules/.bin/vite --port 5173 &`. Then: + +Run: `cd widget && ./node_modules/.bin/playwright test e2e/citations.spec.ts --project=chromium-desktop` +Expected: 1 passed. If Chromium is not installed, `./node_modules/.bin/playwright test --project=chromium-desktop e2e/citations.spec.ts` with `channel: "chrome"` per the local Playwright notes in memory. + +- [ ] **Step 3: Format and commit** + +```bash +cd widget && ./node_modules/.bin/prettier --write e2e/citations.spec.ts && cd .. +git add widget/e2e/citations.spec.ts +git commit -m "test(widget): e2e for citation chips and the source-card reveal + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 15: Verification, review, budget, PR + +- [ ] **Step 1: Run everything CI runs** + +```bash +cd widget && ./node_modules/.bin/eslint src/ && ./node_modules/.bin/prettier --check "src/**/*.{ts,tsx,css}" && ./node_modules/.bin/tsc --noEmit && ./node_modules/.bin/typedoc --emit none && ./node_modules/.bin/vitest run && ./node_modules/.bin/vite build && ./node_modules/.bin/vite build --config vite.config.embed.ts && node scripts/emit-dts-cts.mjs && cd .. +cd worker && ./node_modules/.bin/vitest run && ./node_modules/.bin/tsc --noEmit -p . && cd .. +pnpm test:scripts +``` + +Expected: everything green except `size-limit`, which is expected to exceed the old budgets. + +- [ ] **Step 2: Independent review, then fixes** + +Use `superpowers:requesting-code-review` on the whole branch against `main`, with the spec and this plan as the requirements. Apply every fix in its own commit, re-running the affected tests. Per the maintainer's history, the review must finish before the PR opens. + +- [ ] **Step 3: Measure and bump the bundle budgets (last)** + +Follow the bundle-budget procedure from memory: build `main` in a detached worktree under `.superpowers/` that symlinks `widget/node_modules`, run `./node_modules/.bin/size-limit --json` there and on the branch, compute per-artifact raw/gzip/brotli deltas, set every limit in `widget/.size-limit.json` to `ceil(branch_size * 1.05)` bytes, and quote the exact byte deltas in the commit message. + +```bash +git add widget/.size-limit.json +git commit -m "chore(widget): raise bundle budgets for inline citations + + + +Co-Authored-By: Claude Fable 5.1 " +``` + +- [ ] **Step 4: Push and open the PR** + +```bash +git push -u origin 56-inline-citations +gh pr create --title "feat: inline citations and source cards for RAG results" --body "$(cat <<'BODY' +Closes #56 + +## What + +- ... (summarize from the commit log: worker numbering + snippet + sources event; widget chips + footer + option; CLI + schema; docs; e2e) + +## Decisions to review + +(copy the "Decisions made without the maintainer" list from the design doc) + +## Verification + +(list the commands run and their results, the size deltas, and the e2e run) + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +BODY +)" +``` + +Then watch `gh pr checks` until green and report the URL. diff --git a/docs/src/content/docs/api/rest.md b/docs/src/content/docs/api/rest.md index 7f80fda..e0d2f8d 100644 --- a/docs/src/content/docs/api/rest.md +++ b/docs/src/content/docs/api/rest.md @@ -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 @@ -82,10 +83,8 @@ curl https:///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` @@ -93,7 +92,12 @@ as the non-streaming response below. { "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": [ { @@ -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 @@ -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` diff --git a/docs/src/content/docs/configuration/citations.md b/docs/src/content/docs/configuration/citations.md new file mode 100644 index 0000000..432ebc2 --- /dev/null +++ b/docs/src/content/docs/configuration/citations.md @@ -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 + +``` + +```html + +``` + +```html + +``` + +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 + +``` + +| 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. diff --git a/docs/src/content/docs/configuration/clients.md b/docs/src/content/docs/configuration/clients.md index 2f1c750..118ea89 100644 --- a/docs/src/content/docs/configuration/clients.md +++ b/docs/src/content/docs/configuration/clients.md @@ -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` diff --git a/docs/src/content/docs/configuration/localization.md b/docs/src/content/docs/configuration/localization.md index 5b0ba56..f7bb969 100644 --- a/docs/src/content/docs/configuration/localization.md +++ b/docs/src/content/docs/configuration/localization.md @@ -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) — diff --git a/docs/src/content/docs/configuration/theming.md b/docs/src/content/docs/configuration/theming.md index 069cb11..d62dccf 100644 --- a/docs/src/content/docs/configuration/theming.md +++ b/docs/src/content/docs/configuration/theming.md @@ -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 | diff --git a/docs/src/content/docs/configuration/widget.md b/docs/src/content/docs/configuration/widget.md index e427440..b33e82d 100644 --- a/docs/src/content/docs/configuration/widget.md +++ b/docs/src/content/docs/configuration/widget.md @@ -29,6 +29,7 @@ attributes on the `` 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 @@ -38,9 +39,12 @@ attributes on the `` 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 = {}): Record { + return { + name: "Test Client", + slug: "test-client", + apiUrl: "https://api.example.com", + allowedDomains: ["example.com"], + ...overrides, + }; +} + +function fields(errors: ValidationError[]): string[] { + return errors.map((e) => e.field); +} + +const SCRIPT_URL = "https://cdn.example.com/claudius.js"; + +describe("validateConfig: citations", () => { + it("accepts a boolean or an options object", () => { + for (const citations of [true, false, {}, { maxSources: 3 }, { favicons: false }]) { + expect(validateConfig(base({ widget: { citations } }), "test-client")).toEqual([]); + } + }); + + it("rejects other shapes, naming the field", () => { + for (const value of ["true", 1, null, []]) { + const errors = validateConfig( + base({ widget: { citations: value as never } }), + "test-client" + ); + expect(fields(errors)).toEqual(["widget.citations"]); + expect(errors[0].message).toBe("widget.citations must be a boolean or an object"); + } + }); + + it("requires a positive integer maxSources and a boolean favicons", () => { + for (const maxSources of [0, -1, 2.5, "3"]) { + const errors = validateConfig( + base({ widget: { citations: { maxSources: maxSources as never } } }), + "test-client" + ); + expect(fields(errors)).toEqual(["widget.citations.maxSources"]); + } + const errors = validateConfig( + base({ widget: { citations: { favicons: "false" as never } } }), + "test-client" + ); + expect(fields(errors)).toEqual(["widget.citations.favicons"]); + expect(errors[0].message).toBe("widget.citations.favicons must be a boolean"); + }); +}); + +describe("snippets: citations", () => { + it("passes true and the options object into the script snippet", () => { + const on = base({ widget: { citations: true } }) as unknown as ClientConfig; + expect(generateScriptSnippet(on, SCRIPT_URL)).toContain('"citations": true'); + + const tuned = base({ + widget: { citations: { maxSources: 3, favicons: false } }, + }) as unknown as ClientConfig; + const snippet = generateScriptSnippet(tuned, SCRIPT_URL); + expect(snippet).toContain('"maxSources": 3'); + expect(snippet).toContain('"favicons": false'); + }); + + it("emits the attributes for the web component", () => { + const on = base({ widget: { citations: true } }) as unknown as ClientConfig; + const plain = generateWebComponentSnippet(on, SCRIPT_URL); + expect(plain).toContain('citations="true"'); + expect(plain).not.toContain("citations-max-sources"); + expect(plain).not.toContain("citations-favicons"); + + const tuned = base({ + widget: { citations: { maxSources: 3, favicons: false } }, + }) as unknown as ClientConfig; + const snippet = generateWebComponentSnippet(tuned, SCRIPT_URL); + expect(snippet).toContain('citations="true"'); + expect(snippet).toContain('citations-max-sources="3"'); + expect(snippet).toContain('citations-favicons="false"'); + }); + + it("emits nothing when off or unset", () => { + for (const widget of [{ citations: false }, {}]) { + const off = base({ widget }) as unknown as ClientConfig; + expect(generateScriptSnippet(off, SCRIPT_URL)).not.toContain("citations"); + expect(generateWebComponentSnippet(off, SCRIPT_URL)).not.toContain("citations"); + } + }); +}); + +describe("_schema.json: citations", () => { + it("declares widget.citations as a boolean or a closed options object", () => { + const schema = JSON.parse( + readFileSync(resolve(__dirname, "../../../clients/_schema.json"), "utf-8") + ); + const citations = schema.properties.widget.properties.citations; + expect(citations.oneOf[0]).toEqual({ type: "boolean" }); + expect(citations.oneOf[1].additionalProperties).toBe(false); + expect(citations.oneOf[1].properties.maxSources).toEqual({ type: "integer", minimum: 1 }); + expect(citations.oneOf[1].properties.favicons).toEqual({ type: "boolean" }); + }); +}); diff --git a/scripts/lib/config.ts b/scripts/lib/config.ts index bd94997..31fa309 100644 --- a/scripts/lib/config.ts +++ b/scripts/lib/config.ts @@ -17,6 +17,11 @@ export interface WidgetVoiceConfig { lang?: string; } +export interface WidgetCitationsConfig { + maxSources?: number; + favicons?: boolean; +} + export interface WidgetConfig { title?: string; subtitle?: string; @@ -28,6 +33,7 @@ export interface WidgetConfig { attachments?: boolean | WidgetAttachmentsConfig; voice?: boolean | WidgetVoiceConfig; conversationExport?: boolean; + citations?: boolean | WidgetCitationsConfig; } export interface WorkerAttachmentsConfig { @@ -296,6 +302,32 @@ export function validateConfig( message: "widget.conversationExport must be a boolean", }); } + + if (widget.citations !== undefined) { + const citations = widget.citations; + if (typeof citations === "boolean") { + // fine + } else if (citations && typeof citations === "object" && !Array.isArray(citations)) { + const options = citations as Record; + if (options.maxSources !== undefined && !isPositiveInt(options.maxSources)) { + errors.push({ + field: "widget.citations.maxSources", + message: "widget.citations.maxSources must be a positive integer", + }); + } + if (options.favicons !== undefined && typeof options.favicons !== "boolean") { + errors.push({ + field: "widget.citations.favicons", + message: "widget.citations.favicons must be a boolean", + }); + } + } else { + errors.push({ + field: "widget.citations", + message: "widget.citations must be a boolean or an object", + }); + } + } } // worker (optional) diff --git a/scripts/lib/snippet.ts b/scripts/lib/snippet.ts index a11ce1a..da32c5c 100644 --- a/scripts/lib/snippet.ts +++ b/scripts/lib/snippet.ts @@ -44,6 +44,10 @@ export function generateScriptSnippet( if (config.widget.conversationExport === true) { configObj.conversationExport = true; } + // `true` or the options object both pass straight through to ClaudiusConfig. + if (config.widget.citations !== undefined && config.widget.citations !== false) { + configObj.citations = config.widget.citations; + } } // Build indented JSON: each line of the JSON body is indented to align under @@ -101,6 +105,16 @@ export function generateWebComponentSnippet( if (config.widget.conversationExport === true) { attrs.push(["conversation-export", "true"]); } + const citations = config.widget.citations; + if (citations !== undefined && citations !== false) { + attrs.push(["citations", "true"]); + if (citations !== true) { + if (citations.maxSources !== undefined) { + attrs.push(["citations-max-sources", String(citations.maxSources)]); + } + if (citations.favicons === false) attrs.push(["citations-favicons", "false"]); + } + } } const attrLines = attrs.map(([key, val]) => ` ${key}="${val}"`).join("\n"); diff --git a/widget/.size-limit.json b/widget/.size-limit.json index 86c1f77..d349dab 100644 --- a/widget/.size-limit.json +++ b/widget/.size-limit.json @@ -3,51 +3,51 @@ "name": "claudius.iife.js (raw)", "path": "dist/claudius.iife.js", "brotli": false, - "limit": "233284 B" + "limit": "244066 B" }, { "name": "claudius.iife.js (gzip)", "path": "dist/claudius.iife.js", "gzip": true, - "limit": "75250 B" + "limit": "78177 B" }, { "name": "claudius.iife.js (brotli)", "path": "dist/claudius.iife.js", - "limit": "65587 B" + "limit": "68240 B" }, { "name": "claudius.js (raw)", "path": "dist/claudius.js", "brotli": false, - "limit": "120020 B" + "limit": "135776 B" }, { "name": "claudius.js (gzip)", "path": "dist/claudius.js", "gzip": true, - "limit": "33058 B" + "limit": "36694 B" }, { "name": "claudius.js (brotli)", "path": "dist/claudius.js", - "limit": "28237 B" + "limit": "31020 B" }, { "name": "claudius.css (raw)", "path": "dist/claudius.css", "brotli": false, - "limit": "25835 B" + "limit": "26219 B" }, { "name": "claudius.css (gzip)", "path": "dist/claudius.css", "gzip": true, - "limit": "5436 B" + "limit": "5499 B" }, { "name": "claudius.css (brotli)", "path": "dist/claudius.css", - "limit": "4784 B" + "limit": "4835 B" } ] diff --git a/widget/e2e/citations.spec.ts b/widget/e2e/citations.spec.ts new file mode 100644 index 0000000..922ae42 --- /dev/null +++ b/widget/e2e/citations.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from "@playwright/test"; +import { mockChatApi } from "./helpers"; + +/** + * Inline citations in a real browser: the chip, the request flag, and the + * reveal (scroll plus focus), which jsdom cannot exercise. Marker parsing and + * the footer's states are covered by the unit tests. + */ +test.describe("inline citations", () => { + test("a chip reveals and focuses its source card", async ({ page }) => { + // No real request to example.com for the favicon: abort it so the + // fallback icon renders and the run stays offline. + await page.route("https://example.com/**", (route) => route.abort()); + + const api = await mockChatApi(page); + api.enqueueReply("Plans start at $10 a month [1].", [ + { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "Plans start at $10 a month, billed annually.", + }, + ]); + const bodies: string[] = []; + page.on("request", (request) => { + if (request.method() === "POST" && request.url().endsWith("/api/chat")) { + bodies.push(request.postData() ?? ""); + } + }); + + await page.goto("/"); + await page.getByRole("button", { name: /open chat/i }).click(); + const input = page.getByLabel(/type your message/i); + await input.fill("What are your prices?"); + await input.press("Enter"); + + const chip = page.getByRole("button", { name: "Source 1: Pricing" }); + await expect(chip).toBeVisible(); + await expect(chip).toHaveText("1"); + expect(bodies).toHaveLength(1); + expect(JSON.parse(bodies[0])).toMatchObject({ citations: true }); + + const toggle = page.getByRole("button", { name: /^Sources/ }); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await chip.click(); + + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + const card = page.getByRole("listitem").filter({ hasText: "Pricing" }); + await expect(card).toHaveCount(1); + await expect(card).toBeFocused(); + await expect(card).toBeInViewport(); + await expect(card).toContainText( + "Plans start at $10 a month, billed annually.", + ); + const link = card.getByRole("link", { name: /Pricing/ }); + await expect(link).toHaveAttribute("href", "https://example.com/pricing"); + await expect(link).toHaveAttribute("target", "_blank"); + }); +}); diff --git a/widget/src/__tests__/embed.test.tsx b/widget/src/__tests__/embed.test.tsx index da4ae7a..e1e32b4 100644 --- a/widget/src/__tests__/embed.test.tsx +++ b/widget/src/__tests__/embed.test.tsx @@ -1,4 +1,5 @@ -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // embed.tsx auto-initializes on import by reading window.ClaudiusConfig, so @@ -221,3 +222,108 @@ describe("embed conversationExport option", () => { expectNoMenu(); }); }); + +describe("embed citations option", () => { + const mockFetch = vi.fn(); + const sources = [ + { url: "https://example.com/pricing", title: "Pricing", type: "page" }, + { url: "https://example.com/faq", title: "FAQ", type: "page" }, + ]; + + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ""; + window.sessionStorage.clear(); + window.ClaudiusConfig = undefined; + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + json: () => + Promise.resolve({ reply: "Plans start at $10 [1].", sources }), + }); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + window.ClaudiusConfig = undefined; + }); + + function mountElement(attributes: Record) { + const el = document.createElement("claudius-chat"); + el.setAttribute("api-url", "https://test.example/api"); + for (const [name, value] of Object.entries(attributes)) { + el.setAttribute(name, value); + } + document.body.appendChild(el); + } + + async function ask() { + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: /open chat/i })); + await user.type(screen.getByLabelText(/type your message/i), "Prices?"); + await user.keyboard("{Enter}"); + // Scoped to the log: the live region repeats the reply text. + await within(screen.getByRole("log")).findByText(/Plans start at/); + return user; + } + + function sentBody(): Record { + return JSON.parse( + (mockFetch.mock.calls[0][1] as RequestInit).body as string, + ); + } + + it("enables chips from ClaudiusConfig", async () => { + window.ClaudiusConfig = { + apiUrl: "https://test.example/api", + citations: true, + }; + await import("../embed"); + await ask(); + expect( + screen.getByRole("button", { name: "Source 1: Pricing" }), + ).toBeInTheDocument(); + expect(sentBody().citations).toBe(true); + }); + + it("enables chips for a bare citations attribute", async () => { + await import("../embed"); + mountElement({ citations: "" }); + await ask(); + expect( + screen.getByRole("button", { name: "Source 1: Pricing" }), + ).toBeInTheDocument(); + expect(sentBody().citations).toBe(true); + }); + + it('stays off for citations="false"', async () => { + await import("../embed"); + mountElement({ citations: "false" }); + await ask(); + expect( + within(screen.getByRole("log")).getByText("Plans start at $10 [1]."), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + expect(sentBody()).not.toHaveProperty("citations"); + }); + + it("reads the companion attributes", async () => { + await import("../embed"); + mountElement({ + citations: "true", + "citations-max-sources": "1", + "citations-favicons": "false", + }); + const user = await ask(); + await user.click(screen.getByRole("button", { name: "Source 1: Pricing" })); + expect(screen.getAllByRole("listitem")).toHaveLength(1); + expect( + screen.getByRole("button", { name: "Show all (2)" }), + ).toBeInTheDocument(); + expect(screen.getByRole("listitem").querySelector("img")).toBeNull(); + }); +}); diff --git a/widget/src/api/__tests__/client.citations.test.ts b/widget/src/api/__tests__/client.citations.test.ts new file mode 100644 index 0000000..a852153 --- /dev/null +++ b/widget/src/api/__tests__/client.citations.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ChatApiClient } from "../client"; +import type { ChatMessage, Source } from "../types"; + +const BASE_URL = "https://test.workers.dev"; +const messages: ChatMessage[] = [{ id: "1", role: "user", content: "Prices?" }]; +const pricing: Source = { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "Plans start at $10.", +}; +const faq: Source = { + url: "https://example.com/faq", + title: "FAQ", + type: "page", +}; + +const encoder = new TextEncoder(); + +function sseResponse(chunks: string[]) { + return { + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + body: new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }), + }; +} + +function jsonResponse(body: Record) { + return { + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + json: () => Promise.resolve(body), + }; +} + +const sourcesFrame = `event: sources\ndata: ${JSON.stringify({ sources: [pricing] })}\n\n`; + +describe("ChatApiClient citations", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function sentBody(call = 0): Record { + const init = mockFetch.mock.calls[call][1] as RequestInit; + return JSON.parse(init.body as string); + } + + it("sends citations: true in the JSON body of both endpoints when the option is on", async () => { + mockFetch.mockResolvedValue(jsonResponse({ reply: "Hi" })); + const client = new ChatApiClient(BASE_URL, { + debounceMs: 0, + citations: true, + }); + + await client.sendMessage(messages); + expect(sentBody(0)).toEqual({ messages, citations: true }); + + await client.streamMessage(messages); + expect(sentBody(1)).toEqual({ messages, citations: true }); + }); + + it("omits the citations key entirely when the option is off", async () => { + mockFetch.mockResolvedValue(jsonResponse({ reply: "Hi" })); + const client = new ChatApiClient(BASE_URL, { debounceMs: 0 }); + + await client.sendMessage(messages); + expect(sentBody(0)).toEqual({ messages }); + expect(sentBody(0)).not.toHaveProperty("citations"); + }); + + it("carries citations: true inside the multipart payload", async () => { + mockFetch.mockResolvedValue(jsonResponse({ reply: "Hi" })); + const client = new ChatApiClient(BASE_URL, { + debounceMs: 0, + citations: true, + }); + const withFile: ChatMessage[] = [ + { + id: "1", + role: "user", + content: "What is this?", + attachments: [ + { + id: "f1", + name: "a.png", + mediaType: "image/png", + size: 1, + data: "iVBORw0=", + }, + ], + }, + ]; + + await client.sendMessage(withFile); + + const init = mockFetch.mock.calls[0][1] as RequestInit; + expect(init.body).toBeInstanceOf(FormData); + const payload = JSON.parse( + (init.body as FormData).get("payload") as string, + ); + expect(payload.citations).toBe(true); + expect(payload.messages[0].attachments[0]).not.toHaveProperty("data"); + }); + + it("reports a sources event through onSources and uses it when done has none", async () => { + mockFetch.mockResolvedValueOnce( + sseResponse([ + sourcesFrame, + 'event: chunk\ndata: {"text":"Plans start at $10 [1]."}\n\n', + 'event: done\ndata: {"reply":"Plans start at $10 [1]."}\n\n', + ]), + ); + const onSources = vi.fn(); + const client = new ChatApiClient(BASE_URL, { debounceMs: 0 }); + + const result = await client.streamMessage(messages, { onSources }); + + expect(onSources).toHaveBeenCalledExactlyOnceWith([pricing]); + expect(result.sources).toEqual([pricing]); + }); + + it("prefers the sources on the done event over the early announcement", async () => { + mockFetch.mockResolvedValueOnce( + sseResponse([ + sourcesFrame, + 'event: chunk\ndata: {"text":"Hi"}\n\n', + `event: done\ndata: ${JSON.stringify({ reply: "Hi", sources: [pricing, faq] })}\n\n`, + ]), + ); + const client = new ChatApiClient(BASE_URL, { debounceMs: 0 }); + + const result = await client.streamMessage(messages); + + expect(result.sources).toEqual([pricing, faq]); + }); + + it("keeps the announced sources on a reply the caller aborted", async () => { + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(c) { + controller = c; + c.enqueue( + encoder.encode( + sourcesFrame + 'event: chunk\ndata: {"text":"Partial"}\n\n', + ), + ); + }, + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + body, + }); + const abort = new AbortController(); + const client = new ChatApiClient(BASE_URL, { debounceMs: 0 }); + + const result = await client.streamMessage(messages, { + signal: abort.signal, + onChunk: () => abort.abort(), + }); + + expect(result).toEqual({ + reply: "Partial", + aborted: true, + sources: [pricing], + }); + expect(controller).toBeDefined(); + }); + + it("ignores a sources event whose data is not an array", async () => { + mockFetch.mockResolvedValueOnce( + sseResponse([ + 'event: sources\ndata: {"sources":"nope"}\n\n', + 'event: done\ndata: {"reply":"Hi"}\n\n', + ]), + ); + const onSources = vi.fn(); + const client = new ChatApiClient(BASE_URL, { debounceMs: 0 }); + + const result = await client.streamMessage(messages, { onSources }); + + expect(onSources).not.toHaveBeenCalled(); + expect(result.sources).toBeUndefined(); + }); +}); diff --git a/widget/src/api/client.ts b/widget/src/api/client.ts index b8fc71c..a0bf661 100644 --- a/widget/src/api/client.ts +++ b/widget/src/api/client.ts @@ -4,6 +4,7 @@ import type { ChatErrorResponse, ChatStreamOptions, ChatStreamResult, + Source, ToolUse, } from "./types"; import { ChatApiError, DebounceError } from "./errors"; @@ -31,6 +32,12 @@ export interface ChatApiClientOptions { * @defaultValue `30000` */ timeoutMs?: number; + /** + * Send `citations: true` with every request, asking the worker to number + * its retrieved excerpts and have the model cite them as `[n]`. + * @defaultValue `false` + */ + citations?: boolean; } const DEFAULT_TIMEOUT_MS = 30_000; @@ -66,6 +73,7 @@ export class ChatApiClient { private readonly maxRetries: number; private readonly debounceMs: number; private readonly timeoutMs: number; + private readonly citations: boolean; private lastSendTime = 0; /** @@ -79,6 +87,7 @@ export class ChatApiClient { this.maxRetries = options?.maxRetries ?? 2; this.debounceMs = options?.debounceMs ?? 300; this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.citations = options?.citations ?? false; } /** @@ -174,10 +183,12 @@ export class ChatApiClient { const needsMultipart = messages.some((m) => m.attachments?.some((a) => !!a.data), ); + // Widgets with citations on ask the worker to number its excerpts. + const flags = this.citations ? { citations: true } : {}; if (!needsMultipart) { return { headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages }), + body: JSON.stringify({ messages, ...flags }), }; } @@ -193,7 +204,10 @@ export class ChatApiClient { }), }; }); - form.append("payload", JSON.stringify({ messages: payloadMessages })); + form.append( + "payload", + JSON.stringify({ messages: payloadMessages, ...flags }), + ); for (const m of messages) { for (const a of m.attachments ?? []) { if (a.data) { @@ -333,6 +347,8 @@ export class ChatApiClient { let buffer = ""; let fullText = ""; const toolUses: ToolUse[] = []; + // Announced ahead of the text; `done` carries the authoritative list. + let earlySources: Source[] | undefined; let done: ChatStreamResult | undefined; const abort = () => { @@ -376,6 +392,11 @@ export class ChatApiClient { toolUses.push(toolUse); options.onToolUse?.(toolUse, toolUses); } + } else if (parsed.event === "sources") { + if (Array.isArray(parsed.data.sources)) { + earlySources = parsed.data.sources as Source[]; + options.onSources?.(earlySources); + } } else if (parsed.event === "done") { const doneToolUses = Array.isArray(parsed.data.toolUses) ? (parsed.data.toolUses as ToolUse[]) @@ -385,7 +406,9 @@ export class ChatApiClient { typeof parsed.data.reply === "string" ? parsed.data.reply : fullText, - sources: parsed.data.sources as ChatStreamResult["sources"], + sources: (Array.isArray(parsed.data.sources) + ? parsed.data.sources + : earlySources) as ChatStreamResult["sources"], ...(doneToolUses.length > 0 ? { toolUses: doneToolUses } : {}), ...(Array.isArray(parsed.data.attachments) ? { @@ -415,6 +438,7 @@ export class ChatApiClient { reply: fullText, aborted: true, ...(toolUses.length > 0 ? { toolUses } : {}), + ...(earlySources ? { sources: earlySources } : {}), }; } diff --git a/widget/src/api/types.ts b/widget/src/api/types.ts index 47b91b2..603f306 100644 --- a/widget/src/api/types.ts +++ b/widget/src/api/types.ts @@ -9,6 +9,11 @@ export interface Source { title: string; /** Origin category, used to group and label the source. */ type: "blog" | "page" | "external"; + /** + * Short plain-text excerpt of the page, shown on its source card. About + * 200 characters. Absent from workers that predate citations. + */ + snippet?: string; } /** @@ -98,6 +103,11 @@ export interface ChatMessage { export interface ChatRequest { /** The full conversation so far, oldest message first. */ messages: ChatMessage[]; + /** + * Ask the worker to number its retrieved excerpts to match `sources` and + * to have the model cite them as `[n]`. Sent by widgets with citations on. + */ + citations?: boolean; } /** @@ -130,6 +140,12 @@ export interface ChatStreamOptions { * show a "used tool" affordance before the reply completes. */ onToolUse?: (toolUse: ToolUse, allToolUses: ToolUse[]) => void; + /** + * Called when the worker announces the reply's sources ahead of the text, + * so citation chips can render while the reply streams. The `done` event + * carries the final list. + */ + onSources?: (sources: Source[]) => void; /** * Cancels the stream when aborted. Cancellation is not an error: the * promise resolves with the partial reply and `aborted: true`. diff --git a/widget/src/components/ChatMessage.stories.tsx b/widget/src/components/ChatMessage.stories.tsx index 73b5712..bc5075e 100644 --- a/widget/src/components/ChatMessage.stories.tsx +++ b/widget/src/components/ChatMessage.stories.tsx @@ -60,6 +60,43 @@ export const WithSources: Story = { }, }; +// Inline citations: [n] markers become chips, and a collapsible footer lists +// one card per source. favicons is off so Storybook makes no network requests. +export const WithCitations: Story = { + args: { + role: "assistant", + content: + "Plans start at $10 a month [1].\n" + + "Every plan includes support by email [2].", + sources: [ + { + url: "https://pmds.info/pricing", + title: "Pricing", + type: "page", + snippet: + "Plans start at $10 a month, billed annually. Every plan includes support by email and a 14-day trial.", + }, + { + url: "https://pmds.info/support", + title: "Support", + type: "page", + snippet: "Email support answers within one business day.", + }, + ], + citations: { + maxSources: 5, + favicons: false, + labels: { + sources: "Sources", + showAllSources: "Show all ({count})", + showFewerSources: "Show fewer", + citation: "Source {n}: {title}", + opensInNewTab: "(opens in a new tab)", + }, + }, + }, +}; + // A user message carrying an image preview and a PDF chip. export const WithAttachments: Story = { args: { diff --git a/widget/src/components/ChatMessage.tsx b/widget/src/components/ChatMessage.tsx index 43ea4f4..023fdc0 100644 --- a/widget/src/components/ChatMessage.tsx +++ b/widget/src/components/ChatMessage.tsx @@ -1,5 +1,10 @@ -import { memo, useState, type ReactNode } from "react"; +import { Fragment, memo, useRef, useState, type ReactNode } from "react"; import { SourceIcon } from "./SourceIcon"; +import { + SourceCards, + type SourceCardsLabels, + type SourceReveal, +} from "./SourceCards"; import { AttachmentPreview } from "./AttachmentPreview"; import { MessageSpeechControls, @@ -8,6 +13,20 @@ import { import type { ChatAttachment, Source, ToolUse } from "../api/types"; import { sanitizeUrl } from "../utils/sanitize"; import { stabilizeStreamingMarkdown } from "../utils/stabilizeStreamingMarkdown"; +import { + hideTrailingCitationOpener, + parseCitations, + type ResolvedCitationsConfig, +} from "../utils/citations"; +import { interpolate } from "../utils/interpolate"; + +/** Citation rendering for one message: the resolved option plus its strings. */ +export interface MessageCitations extends ResolvedCitationsConfig { + labels: SourceCardsLabels & { + /** Accessible name of a chip; takes `{n}` and `{title}`. */ + citation: string; + }; +} interface ChatMessageProps { role: "user" | "assistant"; @@ -34,6 +53,13 @@ interface ChatMessageProps { * does for messages that are still streaming or have nothing to read. */ speech?: MessageSpeech; + /** + * Render `[n]` markers as chips and the sources as a card footer. Omit to + * keep the source icon and sidebar. + */ + citations?: MessageCitations; + /** Visually hidden hint appended to links that open a new tab. */ + linkNewTabLabel?: string; } /** Read-aloud wiring for one assistant message. */ @@ -131,8 +157,29 @@ function ToolUseChip({ const URL_REGEX = /(https?:\/\/[^\s)]+)/; const BOLD_REGEX = /(\*\*[^*]+\*\*)/; const ITALIC_REGEX = /(\*[^*]+\*)/; +// One or more `[n]` / `[n, m]` groups at the very end of a URL match. +const TRAILING_MARKERS = /(?:\[[1-9]\d{0,2}(?:\s*,\s*[1-9]\d{0,2})*\])+$/; -function renderLink(rawUrl: string, key: string): ReactNode { +/** Everything the inline renderer needs beyond the text itself. */ +interface RenderContext { + /** Visually hidden hint appended to links that open a new tab. */ + newTabLabel: string; + /** Present only when `[n]` markers should become chips. */ + citations?: CitationContext; +} + +interface CitationContext { + sources: Source[]; + /** Chip accessible name; takes `{n}` and `{title}`. */ + label: string; + onCite: (index: number) => void; +} + +function renderLink( + rawUrl: string, + key: string, + ctx: RenderContext, +): ReactNode { // Strip trailing punctuation that's likely not part of the URL const trailingPunct = rawUrl.match(/[.,;:!?'"]+$/); const url = trailingPunct @@ -140,73 +187,126 @@ function renderLink(rawUrl: string, key: string): ReactNode { : rawUrl; const suffix = trailingPunct ? trailingPunct[0] : ""; + // With citations on, a marker glued to the end of an address belongs to + // the sentence, not the URL: "see https://example.com/pricing[1]." + const glued = ctx.citations ? url.match(TRAILING_MARKERS) : null; + const address = glued ? url.slice(0, -glued[0].length) : url; + // Validate URL scheme to prevent javascript:, data:, vbscript: attacks - const safeUrl = sanitizeUrl(url); + const safeUrl = sanitizeUrl(address); if (!safeUrl) { // If URL is not safe, render as plain text return rawUrl; } return ( - <> + {safeUrl.replace(/^https?:\/\//, "")} - (opens in a new tab) + {ctx.newTabLabel} + {glued && renderCitations(glued[0], `${key}-m`, ctx)} {suffix} - + ); } -function renderInlineFormatting(text: string, keyPrefix: string): ReactNode[] { - // First split by bold markers - const boldParts = text.split(BOLD_REGEX); +/** `[n]` markers become chips; everything else stays text. */ +function renderCitations( + text: string, + keyPrefix: string, + ctx: RenderContext, +): ReactNode[] { + const cites = ctx.citations; + if (!cites) return text ? [text] : []; + return parseCitations(text, cites.sources.length).map((segment, index) => { + if (segment.type === "text") return segment.value; + return ( + + {segment.indexes.map((sourceIndex) => ( + + ))} + + ); + }); +} + +/** Text with no bold or italic left in it: links first, then citation chips. */ +function renderLeaf( + text: string, + keyPrefix: string, + ctx: RenderContext, +): ReactNode[] { const result: ReactNode[] = []; + text.split(URL_REGEX).forEach((part, index) => { + if (URL_REGEX.test(part)) { + result.push(renderLink(part, `${keyPrefix}-u${index}`, ctx)); + } else if (part) { + result.push(...renderCitations(part, `${keyPrefix}-u${index}`, ctx)); + } + }); + return result; +} - boldParts.forEach((part, bIdx) => { +function renderInlineFormatting( + text: string, + keyPrefix: string, + ctx: RenderContext, +): ReactNode[] { + const result: ReactNode[] = []; + text.split(BOLD_REGEX).forEach((part, bIdx) => { if (BOLD_REGEX.test(part)) { - // Strip the ** markers and render as strong - const inner = part.slice(2, -2); - result.push({inner}); - } else { - // Within non-bold segments, split by italic markers - const italicParts = part.split(ITALIC_REGEX); - italicParts.forEach((iPart, iIdx) => { - if (ITALIC_REGEX.test(iPart)) { - const inner = iPart.slice(1, -1); - result.push({inner}); - } else { - // Within non-formatted segments, handle URLs - const urlParts = iPart.split(URL_REGEX); - urlParts.forEach((uPart, uIdx) => { - if (URL_REGEX.test(uPart)) { - result.push( - renderLink(uPart, `${keyPrefix}-b${bIdx}-i${iIdx}-u${uIdx}`), - ); - } else if (uPart) { - result.push(uPart); - } - }); - } - }); + // Strip the ** markers; the inside still gets links and chips. + result.push( + + {renderLeaf(part.slice(2, -2), `${keyPrefix}-b${bIdx}`, ctx)} + , + ); + return; } + part.split(ITALIC_REGEX).forEach((iPart, iIdx) => { + const key = `${keyPrefix}-b${bIdx}-i${iIdx}`; + if (ITALIC_REGEX.test(iPart)) { + result.push( + {renderLeaf(iPart.slice(1, -1), key, ctx)}, + ); + } else { + result.push(...renderLeaf(iPart, key, ctx)); + } + }); }); - return result; } -function renderFormattedContent(content: string): ReactNode[] { +function renderFormattedContent( + content: string, + ctx: RenderContext, +): ReactNode[] { const lines = content.split("\n"); return lines.map((line, lineIndex) => ( - {renderInlineFormatting(line, `l${lineIndex}`)} + {renderInlineFormatting(line, `l${lineIndex}`, ctx)} {lineIndex < lines.length - 1 &&
    }
    )); @@ -224,12 +324,38 @@ export const ChatMessage = memo(function ChatMessage({ onSourceClick, isSourceActive, speech, + citations, + linkNewTabLabel = "(opens in a new tab)", }: ChatMessageProps) { const isUser = role === "user"; + const [reveal, setReveal] = useState(null); + const revealCount = useRef(0); + // Chips and the footer need both the option and something to cite. + const cited = + !isUser && citations && sources && sources.length > 0 + ? { config: citations, sources } + : null; const hasAttachments = !!attachments && attachments.length > 0; + // A half-typed `[1` would flash as text before its chip; hide it before + // the bold stabilizer runs, so it is hidden inside an unclosed ** as well. + const hidden = + isStreaming && cited ? hideTrailingCitationOpener(content) : content; const displayContent = isStreaming - ? stabilizeStreamingMarkdown(content) - : content; + ? stabilizeStreamingMarkdown(hidden) + : hidden; + const renderContext: RenderContext = { + newTabLabel: linkNewTabLabel, + citations: cited + ? { + sources: cited.sources, + label: cited.config.labels.citation, + onCite: (index) => { + revealCount.current += 1; + setReveal({ index, key: revealCount.current }); + }, + } + : undefined, + }; // While a tool runs before any reply text streams in, there is nothing to // put in a bubble yet — show only the tool chips. const showBubble = isUser || content !== "" || !toolUses?.length; @@ -257,7 +383,7 @@ export const ChatMessage = memo(function ChatMessage({ )} {(content !== "" || !hasAttachments) && - renderFormattedContent(displayContent)} + renderFormattedContent(displayContent, renderContext)} )} {!isUser && toolUses && toolUses.length > 0 && ( @@ -272,14 +398,27 @@ export const ChatMessage = memo(function ChatMessage({ ))} )} - {!isUser && sources && sources.length > 0 && onSourceClick && ( -
    - -
    + {cited ? ( + + ) : ( + !isUser && + sources && + sources.length > 0 && + onSourceClick && ( +
    + +
    + ) )} {!isUser && speech && (
    diff --git a/widget/src/components/ChatWidget.tsx b/widget/src/components/ChatWidget.tsx index 28e7ee9..5f87a98 100644 --- a/widget/src/components/ChatWidget.tsx +++ b/widget/src/components/ChatWidget.tsx @@ -23,6 +23,10 @@ import { resolveVoiceConfig, type VoiceOptions, } from "../utils/voice"; +import { + resolveCitationsConfig, + type CitationsOptions, +} from "../utils/citations"; /** Corner of the viewport the widget docks to. */ export type WidgetPosition = @@ -117,6 +121,17 @@ export interface ChatWidgetProps { * @defaultValue `false` */ conversationExport?: boolean; + /** + * Render `[n]` citations in grounded replies as numbered chips, with a + * collapsible footer of source cards under the reply. `true` enables the + * defaults (five cards before "Show all", favicons on); pass a + * {@link CitationsOptions} to tune them. The widget also asks the worker to + * number its retrieved excerpts, so this needs a worker from 1.18.0 or + * later with RAG on to have any effect. Only `true` or an object enables + * it. See the Inline citations guide. + * @defaultValue `false` + */ + citations?: boolean | CitationsOptions; } function readDismissed(): boolean { @@ -167,6 +182,7 @@ export function ChatWidget({ attachments = false, voice = false, conversationExport = false, + citations = false, }: ChatWidgetProps) { const [isOpen, setIsOpen] = useState(false); const attachmentsConfig = useMemo( @@ -180,6 +196,10 @@ export function ChatWidget({ () => resolveVoiceConfig(voice, activeLocale), [voice, activeLocale], ); + const citationsConfig = useMemo( + () => resolveCitationsConfig(citations), + [citations], + ); // The regional variant the visitor uses (en-GB, fr-CA), for dates. const dateLocale = useMemo( () => resolveSpeechLang(activeLocale), @@ -213,6 +233,7 @@ export function ChatWidget({ translations, plugins, streaming, + citations: citationsConfig !== null, }); const toggleRef = useRef(null); const prevOpenRef = useRef(isOpen); @@ -339,6 +360,7 @@ export function ChatWidget({ // Fail closed: a templated "false" string is truthy. conversationExport={conversationExport === true} locale={dateLocale} + citations={citationsConfig} /> )} {!(isOpen && isMobile) && ( diff --git a/widget/src/components/ChatWindow.tsx b/widget/src/components/ChatWindow.tsx index c34625c..7fae945 100644 --- a/widget/src/components/ChatWindow.tsx +++ b/widget/src/components/ChatWindow.tsx @@ -1,5 +1,5 @@ -import { useEffect, useId, useRef, useState } from "react"; -import { ChatMessage } from "./ChatMessage"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { ChatMessage, type MessageCitations } from "./ChatMessage"; import { ChatInput } from "./ChatInput"; import { ChatSources } from "./ChatSources"; import { ChatHeader } from "./ChatHeader"; @@ -20,6 +20,10 @@ import type { } from "../api/types"; import type { ResolvedAttachmentsConfig } from "../utils/attachments"; import type { ResolvedVoiceConfig } from "../utils/voice"; +import { + stripCitationMarkers, + type ResolvedCitationsConfig, +} from "../utils/citations"; interface ChatWindowProps { messages: ChatMessageData[]; @@ -50,6 +54,8 @@ interface ChatWindowProps { conversationExport?: boolean; /** BCP-47 tag for the dates in an exported transcript. */ locale?: string; + /** Citation rendering, or `null` to keep the source icon and sidebar. */ + citations?: ResolvedCitationsConfig | null; } const windowPositionClasses: Record = { @@ -81,6 +87,7 @@ export function ChatWindow({ voice = null, conversationExport = false, locale = "en-US", + citations = null, }: ChatWindowProps) { const titleId = useId(); const dialogRef = useRef(null); @@ -110,6 +117,36 @@ export function ChatWindow({ translations, }); + const newTabLabel = translations?.opensInNewTab ?? "(opens in a new tab)"; + // One object per config or language change, so memoized messages do not + // re-render on every keystroke elsewhere in the window. + const messageCitations = useMemo( + () => + citations + ? { + ...citations, + labels: { + sources: translations?.sources ?? "Sources", + showAllSources: + translations?.showAllSources ?? "Show all ({count})", + showFewerSources: translations?.showFewerSources ?? "Show fewer", + citation: translations?.citation ?? "Source {n}: {title}", + opensInNewTab: newTabLabel, + }, + } + : undefined, + [citations, translations, newTabLabel], + ); + + // What a screen reader or the read-aloud voice gets: no markdown markers, + // hostnames for URLs, and no citation markers when chips render them. + const announceText = (message: ChatMessageData) => + stripAnnouncementFormatting( + messageCitations && message.sources?.length + ? stripCitationMarkers(message.content, message.sources.length) + : message.content, + ); + const { offsetY } = useSwipeToDismiss( messagesContainerRef, onClose, @@ -236,14 +273,27 @@ export function ChatWindow({ toolUses={msg.toolUses} toolUsedLabel={translations?.toolUsed} toolDetailsLabel={translations?.toolDetails} - isSourceActive={activeSources?.messageId === msg.id} - onSourceClick={() => { - if (activeSources?.messageId === msg.id) { - setActiveSources(null); - } else if (msg.sources && msg.sources.length > 0) { - setActiveSources({ messageId: msg.id, sources: msg.sources }); - } - }} + citations={messageCitations} + linkNewTabLabel={newTabLabel} + isSourceActive={ + messageCitations + ? undefined + : activeSources?.messageId === msg.id + } + onSourceClick={ + messageCitations + ? undefined + : () => { + if (activeSources?.messageId === msg.id) { + setActiveSources(null); + } else if (msg.sources && msg.sources.length > 0) { + setActiveSources({ + messageId: msg.id, + sources: msg.sources, + }); + } + } + } speech={ // Only settled replies with something to say: reading a // message that is still streaming would stop mid-sentence. @@ -261,11 +311,7 @@ export function ChatWindow({ labels: speechLabels, // Same cleanup as the screen-reader announcement: // no literal asterisks, URLs shortened to hostnames. - onPlay: () => - reader.speak( - msg.id, - stripAnnouncementFormatting(msg.content), - ), + onPlay: () => reader.speak(msg.id, announceText(msg)), onPause: reader.pause, onResume: reader.resume, onStop: reader.cancel, @@ -314,7 +360,7 @@ export function ChatWindow({ {/* Announce only settled messages: reading a reply that's still streaming would re-announce the whole text on every token. */} {lastAssistantMessage && lastAssistantMessage.id !== streamingMessageId - ? stripAnnouncementFormatting(lastAssistantMessage.content) + ? announceText(lastAssistantMessage) : ""}
    diff --git a/widget/src/components/SourceCards.tsx b/widget/src/components/SourceCards.tsx new file mode 100644 index 0000000..a574c97 --- /dev/null +++ b/widget/src/components/SourceCards.tsx @@ -0,0 +1,296 @@ +import { forwardRef, memo, useEffect, useId, useRef, useState } from "react"; +import type { Source } from "../api/types"; +import { sanitizeUrl } from "../utils/sanitize"; +import { interpolate } from "../utils/interpolate"; +import { faviconUrl, sourceDomain, truncateSnippet } from "../utils/citations"; + +/** The strings the footer is rendered with. */ +export interface SourceCardsLabels { + /** Footer toggle text and the list's accessible name. */ + sources: string; + /** Reveals the remaining cards; takes `{count}`. */ + showAllSources: string; + /** Hides the extra cards again. */ + showFewerSources: string; + /** Visually hidden hint appended to each card's link. */ + opensInNewTab: string; +} + +/** A request to open the footer and bring one card into view and focus. */ +export interface SourceReveal { + /** Zero-based source index. */ + index: number; + /** Changes on every request, so the same card can be revealed twice. */ + key: number; +} + +interface SourceCardsProps { + sources: Source[]; + /** Cards shown before the "Show all" control. */ + maxSources: number; + /** Fetch `/favicon.ico` from each source's origin. */ + favicons: boolean; + labels: SourceCardsLabels; + /** Set by a citation chip; each new `key` reveals its card again. */ + reveal?: SourceReveal | null; +} + +const HIGHLIGHT_MS = 1500; + +function prefersReducedMotion(): boolean { + return typeof window !== "undefined" && + typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-reduced-motion: reduce)").matches + : false; +} + +const BADGE_CLASS = + "flex h-4 min-w-4 shrink-0 items-center justify-center rounded-claudius-full bg-claudius-accent px-1 text-[10px] font-bold leading-none text-claudius-accent-text"; + +interface SourceCardProps { + source: Source; + /** One-based, matching the chips in the reply. */ + number: number; + favicons: boolean; + labels: SourceCardsLabels; + highlighted: boolean; +} + +const SourceCard = forwardRef( + function SourceCard({ source, number, favicons, labels, highlighted }, ref) { + // An unsafe URL still gets a card, so the numbering holds, but no link. + const safeUrl = sanitizeUrl(source.url); + const [iconFailed, setIconFailed] = useState(false); + const icon = favicons && safeUrl ? faviconUrl(safeUrl) : null; + const snippet = source.snippet ? truncateSnippet(source.snippet) : ""; + + return ( +
  • + +
    +
    + {icon && !iconFailed ? ( + setIconFailed(true)} + className="h-3.5 w-3.5 shrink-0 rounded-sm" + /> + ) : ( + + )} + {safeUrl ? ( + + {source.title} + {labels.opensInNewTab} + + ) : ( + + {source.title} + + )} + {safeUrl && ( + + )} +
    + {snippet &&

    {snippet}

    } + {safeUrl && ( +

    {sourceDomain(safeUrl)}

    + )} +
    +
  • + ); + }, +); + +/** + * Collapsible "Sources" footer under a cited reply: one card per source with + * favicon, title link, snippet, and domain. A `reveal` request from a chip + * opens it, shows the whole list when the card is past `maxSources`, scrolls + * the card into view, focuses it, and flashes a ring around it. + */ +export const SourceCards = memo(function SourceCards({ + sources, + maxSources, + favicons, + labels, + reveal = null, +}: SourceCardsProps) { + const listId = useId(); + const [expanded, setExpanded] = useState(false); + const [showAll, setShowAll] = useState(false); + const [pending, setPending] = useState(null); + const [highlighted, setHighlighted] = useState(null); + const cardRefs = useRef(new Map()); + // Read at reveal time only: a later change to the cut must not replay the + // last reveal. + const maxSourcesRef = useRef(maxSources); + maxSourcesRef.current = maxSources; + + // A reveal opens the footer and, when the card is past the cut, the whole + // list. The scroll and focus wait for the render that adds the card. + useEffect(() => { + if (!reveal) return; + setPending(reveal); + setExpanded(true); + if (reveal.index >= maxSourcesRef.current) setShowAll(true); + }, [reveal]); + + useEffect(() => { + if (!pending) return; + const card = cardRefs.current.get(pending.index); + if (!card) return; + if (typeof card.scrollIntoView === "function") { + card.scrollIntoView({ + block: "nearest", + behavior: prefersReducedMotion() ? "auto" : "smooth", + }); + } + card.focus({ preventScroll: true }); + setHighlighted(pending); + setPending(null); + }, [pending]); + + // The ring is a flash, not a state. A second reveal of the same card is a + // new object, so the timer restarts. + useEffect(() => { + if (!highlighted) return; + const timer = setTimeout(() => setHighlighted(null), HIGHLIGHT_MS); + return () => clearTimeout(timer); + }, [highlighted]); + + const count = sources.length; + const visible = showAll ? sources : sources.slice(0, maxSources); + + return ( +
    + + {expanded && ( + <> +
      + {visible.map((source, index) => ( + { + if (el) cardRefs.current.set(index, el); + else cardRefs.current.delete(index); + }} + source={source} + number={index + 1} + favicons={favicons} + labels={labels} + highlighted={highlighted?.index === index} + /> + ))} +
    + {count > maxSources && ( + + )} + + )} +
    + ); +}); diff --git a/widget/src/components/__tests__/ChatMessage.citations.test.tsx b/widget/src/components/__tests__/ChatMessage.citations.test.tsx new file mode 100644 index 0000000..6904515 --- /dev/null +++ b/widget/src/components/__tests__/ChatMessage.citations.test.tsx @@ -0,0 +1,239 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { ChatMessage, type MessageCitations } from "../ChatMessage"; +import type { Source } from "../../api/types"; + +const sources: Source[] = [ + { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "Plans start at $10 a month.", + }, + { url: "https://example.com/faq", title: "FAQ", type: "page" }, +]; + +const citations: MessageCitations = { + maxSources: 5, + favicons: false, + labels: { + sources: "Sources", + showAllSources: "Show all ({count})", + showFewerSources: "Show fewer", + citation: "Source {n}: {title}", + opensInNewTab: "(opens in a new tab)", + }, +}; + +type Props = React.ComponentProps; + +function renderCited(content: string, props: Partial = {}) { + return render( + , + ); +} + +describe("ChatMessage citations", () => { + it("renders an in-range marker as a chip named after its source", () => { + renderCited("Plans start at $10 [1]."); + const chip = screen.getByRole("button", { name: "Source 1: Pricing" }); + expect(chip).toHaveTextContent("1"); + expect(chip).toHaveAttribute("title", "Pricing"); + expect(screen.queryByText(/\[1\]/)).toBeNull(); + expect(screen.getByText(/Plans start at \$10/)).toBeInTheDocument(); + }); + + it("leaves out-of-range, zero, and leading-zero markers as text", () => { + renderCited("See [3], [0] and [01]."); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + expect(screen.getByText("See [3], [0] and [01].")).toBeInTheDocument(); + }); + + it("renders a chip inside bold text", () => { + renderCited("**Plans start at $10 [1]**"); + const chip = screen.getByRole("button", { name: "Source 1: Pricing" }); + expect(chip.closest("strong")).not.toBeNull(); + }); + + it("detaches a marker glued to the end of a URL", () => { + renderCited("See https://example.com/pricing[1]."); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + "https://example.com/pricing", + ); + expect( + screen.getByRole("button", { name: "Source 1: Pricing" }), + ).toBeInTheDocument(); + }); + + it("renders each number of a group as its own chip", () => { + renderCited("Both apply [1, 2]."); + expect( + screen.getByRole("button", { name: "Source 1: Pricing" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Source 2: FAQ" }), + ).toBeInTheDocument(); + }); + + it("renders no chips and no footer without the citations prop, keeping the source icon", () => { + render( + , + ); + expect(screen.getByText("Plans start at $10 [1].")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Sources/ })).toBeNull(); + expect( + screen.getByRole("button", { name: /view sources/i }), + ).toBeInTheDocument(); + }); + + it("replaces the source icon with the footer when citations are on", () => { + renderCited("Plans start at $10 [1].", { + onSourceClick: vi.fn(), + isSourceActive: false, + }); + expect(screen.queryByRole("button", { name: /view sources/i })).toBeNull(); + expect( + screen.getByRole("button", { name: /^Sources/ }), + ).toBeInTheDocument(); + }); + + it("renders no chips for a user message", () => { + render( + , + ); + expect(screen.getByText("I read [1].")).toBeInTheDocument(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("shows the footer, collapsed, even when the reply cites nothing", () => { + renderCited("Plans start at $10."); + expect(screen.getByRole("button", { name: /^Sources/ })).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + }); + + it("a chip click opens the footer and focuses the matching card", async () => { + const user = userEvent.setup(); + renderCited("Plans start at $10 [1]. See the FAQ [2]."); + await user.click(screen.getByRole("button", { name: "Source 2: FAQ" })); + expect(screen.getByRole("button", { name: /^Sources/ })).toHaveAttribute( + "aria-expanded", + "true", + ); + const cards = screen.getAllByRole("listitem"); + expect(cards).toHaveLength(2); + expect(cards[1]).toHaveFocus(); + expect(cards[1]).toHaveTextContent("FAQ"); + }); + + it("clicking the same chip again focuses the card again", async () => { + const user = userEvent.setup(); + renderCited("Plans start at $10 [1]."); + const chip = screen.getByRole("button", { name: "Source 1: Pricing" }); + await user.click(chip); + const card = screen.getAllByRole("listitem")[0]; + expect(card).toHaveFocus(); + chip.focus(); + expect(card).not.toHaveFocus(); + await user.click(chip); + expect(card).toHaveFocus(); + }); + + it("hides an unfinished marker at the end of a streaming reply", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("Plans start at $10")).toBeInTheDocument(); + expect(screen.queryByText(/\[1/)).toBeNull(); + + rerender( + , + ); + expect(screen.getByText("Plans start at $10 [1")).toBeInTheDocument(); + }); + + it("hides an unfinished marker inside unclosed bold while streaming", () => { + render( + , + ); + const bold = screen.getByText("Plans start at $10"); + expect(bold.tagName).toBe("STRONG"); + expect(screen.queryByText(/\[1/)).toBeNull(); + }); + + it("keeps a trailing bracket while streaming a reply without sources", () => { + render( + , + ); + expect(screen.getByText("See [1")).toBeInTheDocument(); + }); + + it("announces new-tab links with the given label", () => { + render( + , + ); + expect( + screen.getByRole("link", { + name: "example.com/a (ouvre un nouvel onglet)", + }), + ).toBeInTheDocument(); + }); + + it("links a URL inside bold text", () => { + render( + , + ); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + "https://example.com/a", + ); + }); +}); diff --git a/widget/src/components/__tests__/ChatWidget.citations.test.tsx b/widget/src/components/__tests__/ChatWidget.citations.test.tsx new file mode 100644 index 0000000..1770c02 --- /dev/null +++ b/widget/src/components/__tests__/ChatWidget.citations.test.tsx @@ -0,0 +1,125 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ChatWidget } from "../ChatWidget"; +import type { Source } from "../../api/types"; + +const sources: Source[] = [ + { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "Plans start at $10.", + }, + { url: "https://example.com/faq", title: "FAQ", type: "page" }, +]; + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +// A JSON reply on the stream endpoint: the client treats it as a blocking +// reply, so one mock serves both endpoints. +function jsonReply(reply: string) { + return { + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + json: () => Promise.resolve({ reply, sources }), + }; +} + +// Locale-independent: the toggle is the only button before the chat opens, +// and the composer is the only textbox after. +async function ask(user: ReturnType) { + await user.click(screen.getByRole("button")); + await user.type(screen.getByRole("textbox"), "Prices?"); + await user.keyboard("{Enter}"); + // Scoped to the log: the live region repeats the reply text. + await log().findByText(/Plans start at/); +} + +const log = () => within(screen.getByRole("log")); + +function sentBody(): Record { + return JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string); +} + +describe("ChatWidget citations option", () => { + beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockResolvedValue(jsonReply("Plans start at $10 [1].")); + sessionStorage.clear(); + }); + + it("is off by default: literal markers, the source icon, and no request flag", async () => { + const user = userEvent.setup(); + render(); + await ask(user); + expect(log().getByText("Plans start at $10 [1].")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + expect( + screen.getByRole("button", { name: /view sources/i }), + ).toBeInTheDocument(); + expect(sentBody()).not.toHaveProperty("citations"); + }); + + it("renders chips and asks the worker for citations when enabled", async () => { + const user = userEvent.setup(); + render( + , + ); + await ask(user); + expect( + screen.getByRole("button", { name: "Source 1: Pricing" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /view sources/i })).toBeNull(); + expect(sentBody().citations).toBe(true); + }); + + it.each([["true"], ["false"], [1], [[]]])( + "fails closed for %j, which is neither true nor an options object", + async (value) => { + const user = userEvent.setup(); + render( + , + ); + await ask(user); + expect(log().getByText("Plans start at $10 [1].")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + expect(sentBody()).not.toHaveProperty("citations"); + }, + ); + + it("applies maxSources and favicons from an options object", async () => { + const user = userEvent.setup(); + render( + , + ); + await ask(user); + await user.click(screen.getByRole("button", { name: "Source 1: Pricing" })); + expect(screen.getAllByRole("listitem")).toHaveLength(1); + expect( + screen.getByRole("button", { name: "Show all (2)" }), + ).toBeInTheDocument(); + expect(screen.getByRole("listitem").querySelector("img")).toBeNull(); + }); + + it("labels chips in the widget's language", async () => { + const user = userEvent.setup(); + render( + , + ); + await ask(user); + expect( + screen.getByRole("button", { name: "Quelle 1: Pricing" }), + ).toBeInTheDocument(); + }); +}); diff --git a/widget/src/components/__tests__/ChatWindow.citations.test.tsx b/widget/src/components/__tests__/ChatWindow.citations.test.tsx new file mode 100644 index 0000000..927af6c --- /dev/null +++ b/widget/src/components/__tests__/ChatWindow.citations.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { ChatWindow } from "../ChatWindow"; +import { de } from "../../locales/de"; +import type { ChatMessage, Source } from "../../api/types"; + +const sources: Source[] = [ + { url: "https://example.com/pricing", title: "Pricing", type: "page" }, +]; + +const messages: ChatMessage[] = [ + { id: "msg-1", role: "user", content: "What are your prices?" }, + { + id: "msg-2", + role: "assistant", + content: "Plans start at $10 [1].", + sources, + }, +]; + +const config = { maxSources: 5, favicons: false }; + +function renderWindow( + props: Partial> = {}, +) { + render( + , + ); + return userEvent.setup(); +} + +const liveText = () => + document.querySelector('[data-claudius-live="assistant"]')!.textContent; + +describe("ChatWindow citations", () => { + it("renders chips and the footer, and never the sidebar", async () => { + const user = renderWindow(); + await user.click(screen.getByRole("button", { name: "Source 1: Pricing" })); + expect(screen.getByRole("list", { name: "Sources" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /view sources/i })).toBeNull(); + expect(screen.queryByText("1 source found")).toBeNull(); + }); + + it("keeps the source icon and sidebar when citations are off", async () => { + const user = renderWindow({ citations: null }); + expect(screen.queryByRole("button", { name: /^Source \d/ })).toBeNull(); + await user.click(screen.getByRole("button", { name: /view sources/i })); + expect(screen.getByText("1 source found")).toBeInTheDocument(); + }); + + it("strips citation markers from the live region", () => { + renderWindow(); + expect(liveText()).toBe("Plans start at $10."); + }); + + it("leaves the markers in the live region when citations are off", () => { + renderWindow({ citations: null }); + expect(liveText()).toBe("Plans start at $10 [1]."); + }); + + it("labels chips and the footer in the widget's language", () => { + renderWindow({ translations: de }); + expect( + screen.getByRole("button", { name: "Quelle 1: Pricing" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /^Quellen/ }), + ).toBeInTheDocument(); + }); + + it("each cited reply keeps its own footer state", async () => { + const user = renderWindow({ + messages: [ + ...messages, + { id: "msg-3", role: "user", content: "And support?" }, + { + id: "msg-4", + role: "assistant", + content: "Email support is included [1].", + sources: [ + { + url: "https://example.com/support", + title: "Support", + type: "page", + }, + ], + }, + ], + }); + const toggles = screen.getAllByRole("button", { name: /^Sources/ }); + expect(toggles).toHaveLength(2); + await user.click(toggles[0]); + expect(toggles[0]).toHaveAttribute("aria-expanded", "true"); + expect(toggles[1]).toHaveAttribute("aria-expanded", "false"); + expect(screen.getAllByRole("list", { name: "Sources" })).toHaveLength(1); + }); +}); diff --git a/widget/src/components/__tests__/SourceCards.test.tsx b/widget/src/components/__tests__/SourceCards.test.tsx new file mode 100644 index 0000000..56f0942 --- /dev/null +++ b/widget/src/components/__tests__/SourceCards.test.tsx @@ -0,0 +1,284 @@ +import { render, screen, within, fireEvent, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { SourceCards } from "../SourceCards"; +import type { Source } from "../../api/types"; + +const labels = { + sources: "Sources", + showAllSources: "Show all ({count})", + showFewerSources: "Show fewer", + opensInNewTab: "(opens in a new tab)", +}; + +function source(n: number, overrides: Partial = {}): Source { + return { + url: `https://example.com/page-${n}`, + title: `Page ${n}`, + type: "page", + snippet: `Snippet ${n}.`, + ...overrides, + }; +} + +const many = (count: number) => + Array.from({ length: count }, (_, i) => source(i + 1)); + +type Props = React.ComponentProps; + +function renderCards(props: Partial = {}) { + const all: Props = { + sources: many(2), + maxSources: 5, + favicons: true, + labels, + ...props, + }; + const view = render(); + return { + ...view, + rerender: (next: Partial) => + view.rerender(), + }; +} + +const toggle = () => screen.getByRole("button", { name: /Sources/ }); + +describe("SourceCards", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("starts collapsed, with the count on the toggle", () => { + renderCards(); + expect(toggle()).toHaveAttribute("aria-expanded", "false"); + expect(toggle()).toHaveTextContent("2"); + expect(screen.queryByRole("list")).toBeNull(); + }); + + it("expands to an ordered list of cards and collapses again", async () => { + const user = userEvent.setup(); + renderCards(); + await user.click(toggle()); + expect(toggle()).toHaveAttribute("aria-expanded", "true"); + const list = screen.getByRole("list", { name: "Sources" }); + expect(list.tagName).toBe("OL"); + expect(toggle()).toHaveAttribute("aria-controls", list.id); + expect(screen.getAllByRole("listitem")).toHaveLength(2); + await user.click(toggle()); + expect(screen.queryByRole("list")).toBeNull(); + }); + + it("shows the title link, snippet, and domain on a card", async () => { + const user = userEvent.setup(); + renderCards({ sources: [source(1)] }); + await user.click(toggle()); + const link = screen.getByRole("link", { + name: "Page 1 (opens in a new tab)", + }); + expect(link).toHaveAttribute("href", "https://example.com/page-1"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + expect(screen.getByText("Snippet 1.")).toBeInTheDocument(); + expect(screen.getByText("example.com")).toBeInTheDocument(); + }); + + it("renders no snippet paragraph when the source has none", async () => { + const user = userEvent.setup(); + renderCards({ sources: [source(1, { snippet: undefined })] }); + await user.click(toggle()); + const card = screen.getByRole("listitem"); + // Only the domain line is left. + expect(card.querySelectorAll("p")).toHaveLength(1); + expect(card).toHaveTextContent("example.com"); + }); + + it("cuts a long snippet to 200 characters", async () => { + const user = userEvent.setup(); + const words = Array.from({ length: 80 }, (_, i) => `word${i}`).join(" "); + renderCards({ sources: [source(1, { snippet: words })] }); + await user.click(toggle()); + const snippet = screen.getByText(/^word0 /); + expect(snippet.textContent!.length).toBeLessThanOrEqual(201); + expect(snippet.textContent!.endsWith("…")).toBe(true); + }); + + it("wraps a long unbroken snippet inside the card", async () => { + const user = userEvent.setup(); + renderCards({ sources: [source(1, { snippet: "x".repeat(150) })] }); + await user.click(toggle()); + expect(screen.getByText(/^x+$/)).toHaveClass("break-words"); + }); + + it("keeps a numbered card, without a link or favicon, for an unsafe URL", async () => { + const user = userEvent.setup(); + renderCards({ + sources: [ + source(1), + source(2, { url: "javascript:alert(1)", title: "Bad" }), + ], + }); + await user.click(toggle()); + const cards = screen.getAllByRole("listitem"); + expect(cards).toHaveLength(2); + expect(cards[1]).toHaveTextContent("Bad"); + expect(within(cards[1]).queryByRole("link")).toBeNull(); + expect(cards[1].querySelector("img")).toBeNull(); + expect(screen.getAllByRole("link")).toHaveLength(1); + }); + + it("requests the favicon from the source origin without a referrer, and falls back on error", async () => { + const user = userEvent.setup(); + renderCards({ sources: [source(1)] }); + await user.click(toggle()); + const card = screen.getByRole("listitem"); + const img = card.querySelector("img")!; + expect(img).toHaveAttribute("src", "https://example.com/favicon.ico"); + expect(img).toHaveAttribute("referrerpolicy", "no-referrer"); + expect(img).toHaveAttribute("alt", ""); + // The external-link icon is the only svg while the favicon shows. + expect(card.querySelectorAll("svg")).toHaveLength(1); + + fireEvent.error(img); + + expect(card.querySelector("img")).toBeNull(); + // Now the generic icon too. + expect(card.querySelectorAll("svg")).toHaveLength(2); + }); + + it("requests no favicon when favicons is off", async () => { + const user = userEvent.setup(); + renderCards({ sources: [source(1)], favicons: false }); + await user.click(toggle()); + const card = screen.getByRole("listitem"); + expect(card.querySelector("img")).toBeNull(); + expect(card.querySelectorAll("svg")).toHaveLength(2); + }); + + it("shows only maxSources cards until Show all, then all, then fewer again", async () => { + const user = userEvent.setup(); + renderCards({ sources: many(7), maxSources: 5 }); + await user.click(toggle()); + expect(screen.getAllByRole("listitem")).toHaveLength(5); + const more = screen.getByRole("button", { name: "Show all (7)" }); + expect(more).toHaveAttribute("aria-expanded", "false"); + await user.click(more); + expect(screen.getAllByRole("listitem")).toHaveLength(7); + const fewer = screen.getByRole("button", { name: "Show fewer" }); + expect(fewer).toHaveAttribute("aria-expanded", "true"); + await user.click(fewer); + expect(screen.getAllByRole("listitem")).toHaveLength(5); + }); + + it("offers no Show all when every card already fits", async () => { + const user = userEvent.setup(); + renderCards({ sources: many(5), maxSources: 5 }); + await user.click(toggle()); + expect(screen.getAllByRole("listitem")).toHaveLength(5); + expect(screen.queryByRole("button", { name: /Show all/ })).toBeNull(); + }); + + it("a reveal opens the footer, scrolls to the card, focuses and highlights it", () => { + const scroll = vi.spyOn(Element.prototype, "scrollIntoView"); + const { rerender } = renderCards({ sources: many(3), reveal: null }); + expect(screen.queryByRole("list")).toBeNull(); + + rerender({ reveal: { index: 1, key: 1 } }); + + const cards = screen.getAllByRole("listitem"); + expect(cards).toHaveLength(3); + expect(cards[1]).toHaveFocus(); + expect(cards[1]).toHaveAttribute("data-highlight", "true"); + expect(cards[0]).not.toHaveAttribute("data-highlight"); + expect(scroll).toHaveBeenCalledTimes(1); + expect(scroll.mock.contexts[0]).toBe(cards[1]); + expect(scroll).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth", + }); + }); + + it("a reveal past the cut shows all cards first", () => { + const { rerender } = renderCards({ + sources: many(7), + maxSources: 5, + reveal: null, + }); + rerender({ reveal: { index: 6, key: 1 } }); + const cards = screen.getAllByRole("listitem"); + expect(cards).toHaveLength(7); + expect(cards[6]).toHaveFocus(); + expect( + screen.getByRole("button", { name: "Show fewer" }), + ).toBeInTheDocument(); + }); + + it("does not replay the last reveal when maxSources changes", () => { + const scroll = vi.spyOn(Element.prototype, "scrollIntoView"); + const reveal = { index: 0, key: 1 }; + const { rerender } = renderCards({ sources: many(3), reveal }); + expect(scroll).toHaveBeenCalledTimes(1); + screen.getByRole("button", { name: /Sources/ }).focus(); + + rerender({ maxSources: 2, reveal }); + + expect(scroll).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole("listitem")[0]).not.toHaveFocus(); + }); + + it("scrolls instantly when the visitor prefers reduced motion", () => { + // Same override as the theme=auto test: the setup file defines + // matchMedia as a writable, non-configurable property, which spyOn + // cannot replace but defineProperty can. + const original = window.matchMedia; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: query.includes("reduce"), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); + try { + const scroll = vi.spyOn(Element.prototype, "scrollIntoView"); + const { rerender } = renderCards({ reveal: null }); + rerender({ reveal: { index: 0, key: 1 } }); + expect(scroll).toHaveBeenCalledWith({ + block: "nearest", + behavior: "auto", + }); + } finally { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: original, + }); + } + }); + + it("clears the highlight after 1.5 seconds and flashes again on a new reveal", () => { + vi.useFakeTimers(); + const { rerender } = renderCards({ reveal: null }); + rerender({ reveal: { index: 0, key: 1 } }); + const card = () => screen.getAllByRole("listitem")[0]; + expect(card()).toHaveAttribute("data-highlight", "true"); + + act(() => { + vi.advanceTimersByTime(1499); + }); + expect(card()).toHaveAttribute("data-highlight", "true"); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(card()).not.toHaveAttribute("data-highlight"); + + rerender({ reveal: { index: 0, key: 2 } }); + expect(card()).toHaveAttribute("data-highlight", "true"); + expect(card()).toHaveFocus(); + }); +}); diff --git a/widget/src/embed.tsx b/widget/src/embed.tsx index 5c0e463..d2638ba 100644 --- a/widget/src/embed.tsx +++ b/widget/src/embed.tsx @@ -6,6 +6,10 @@ import type { ClaudiusTranslations } from "./i18n"; import type { ClaudiusThemeInput } from "./theme/types"; import type { AttachmentsOptions } from "./utils/attachments"; import { voiceOptionsFromAttributes, type VoiceOptions } from "./utils/voice"; +import { + citationsOptionsFromAttributes, + type CitationsOptions, +} from "./utils/citations"; import "./styles.css"; // Injected at build time by vite.config.embed.ts; undefined under unit tests. @@ -32,6 +36,7 @@ interface ClaudiusConfig { attachments?: boolean | AttachmentsOptions; voice?: boolean | VoiceOptions; conversationExport?: boolean; + citations?: boolean | CitationsOptions; } declare global { @@ -76,6 +81,7 @@ function init() { attachments={config.attachments} voice={config.voice} conversationExport={config.conversationExport} + citations={config.citations} />, ); } @@ -100,6 +106,9 @@ class ClaudiusChat extends HTMLElement { "position", "attachments", "conversation-export", + "citations", + "citations-max-sources", + "citations-favicons", "voice", "voice-mode", "voice-auto-submit", @@ -188,6 +197,9 @@ class ClaudiusChat extends HTMLElement { attachments={attachments} conversationExport={conversationExport} voice={voiceOptionsFromAttributes((name) => this.getAttribute(name))} + citations={citationsOptionsFromAttributes((name) => + this.getAttribute(name), + )} />, ); } diff --git a/widget/src/hooks/__tests__/useChat.citations.test.ts b/widget/src/hooks/__tests__/useChat.citations.test.ts new file mode 100644 index 0000000..60a857c --- /dev/null +++ b/widget/src/hooks/__tests__/useChat.citations.test.ts @@ -0,0 +1,200 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useChat } from "../useChat"; +import type { Source } from "../../api/types"; + +const API_URL = "https://test.workers.dev"; +const pricing: Source = { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "Plans start at $10.", +}; +const faq: Source = { + url: "https://example.com/faq", + title: "FAQ", + type: "page", +}; + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +const encoder = new TextEncoder(); + +function sseStream() { + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(c) { + controller = c; + }, + }); + return { + response: { + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + body, + }, + emit(event: string, data: unknown) { + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), + ); + }, + close() { + controller.close(); + }, + }; +} + +/** Let the reader loop consume what was enqueued. */ +const settle = () => + act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + +describe("useChat citations", () => { + beforeEach(() => { + mockFetch.mockReset(); + sessionStorage.clear(); + }); + + it("asks the worker for citations only when the option is on", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + json: () => Promise.resolve({ reply: "Hi" }), + }); + + const on = renderHook(() => useChat({ apiUrl: API_URL, citations: true })); + await act(async () => { + await on.result.current.sendMessage("Prices?"); + }); + expect(JSON.parse(mockFetch.mock.calls[0][1].body).citations).toBe(true); + + const off = renderHook(() => useChat({ apiUrl: API_URL })); + await act(async () => { + await off.result.current.sendMessage("Prices?"); + }); + expect(JSON.parse(mockFetch.mock.calls[1][1].body)).not.toHaveProperty( + "citations", + ); + }); + + it("holds early sources until the first token, then attaches them", async () => { + const stream = sseStream(); + mockFetch.mockResolvedValueOnce(stream.response); + const { result } = renderHook(() => + useChat({ apiUrl: API_URL, citations: true }), + ); + + let sending!: Promise; + act(() => { + sending = result.current.sendMessage("Prices?"); + }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + + stream.emit("sources", { sources: [pricing] }); + await settle(); + // Only the visitor's message: an announcement alone creates no bubble. + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].role).toBe("user"); + + stream.emit("chunk", { text: "Plans start at $10 [1]." }); + await settle(); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[1]).toMatchObject({ + role: "assistant", + content: "Plans start at $10 [1].", + sources: [pricing], + }); + + stream.emit("done", { reply: "Plans start at $10 [1]." }); + stream.close(); + await act(async () => { + await sending; + }); + expect(result.current.messages[1].sources).toEqual([pricing]); + }); + + it("lets the done event's sources replace the early ones", async () => { + const stream = sseStream(); + mockFetch.mockResolvedValueOnce(stream.response); + const { result } = renderHook(() => + useChat({ apiUrl: API_URL, citations: true }), + ); + + let sending!: Promise; + act(() => { + sending = result.current.sendMessage("Prices?"); + }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + + stream.emit("chunk", { text: "Hi" }); + stream.emit("sources", { sources: [pricing] }); + await settle(); + expect(result.current.messages[1].sources).toEqual([pricing]); + + stream.emit("done", { reply: "Hi", sources: [pricing, faq] }); + stream.close(); + await act(async () => { + await sending; + }); + expect(result.current.messages[1].sources).toEqual([pricing, faq]); + }); + + it("waits for done before attaching sources when citations are off", async () => { + // Off means unchanged: the source icon must not appear mid-stream on a + // widget that never opted in, even against a worker that announces early. + const stream = sseStream(); + mockFetch.mockResolvedValueOnce(stream.response); + const { result } = renderHook(() => useChat({ apiUrl: API_URL })); + + let sending!: Promise; + act(() => { + sending = result.current.sendMessage("Prices?"); + }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + + stream.emit("sources", { sources: [pricing] }); + stream.emit("chunk", { text: "Plans start at $10." }); + await settle(); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[1].sources).toBeUndefined(); + + stream.emit("done", { reply: "Plans start at $10.", sources: [pricing] }); + stream.close(); + await act(async () => { + await sending; + }); + expect(result.current.messages[1].sources).toEqual([pricing]); + }); + + it("creates no message when the stream fails after the announcement", async () => { + const stream = sseStream(); + mockFetch.mockResolvedValueOnce(stream.response); + const { result } = renderHook(() => + useChat({ apiUrl: API_URL, citations: true }), + ); + + let sending!: Promise; + act(() => { + sending = result.current.sendMessage("Prices?"); + }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + + stream.emit("sources", { sources: [pricing] }); + stream.emit("error", { + error: "AI service temporarily unavailable.", + code: "STREAM_ERROR", + }); + stream.close(); + await act(async () => { + await sending; + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.error).toBe("AI service temporarily unavailable."); + expect(result.current.canRetry).toBe(true); + }); +}); diff --git a/widget/src/hooks/useChat.ts b/widget/src/hooks/useChat.ts index 3088b0b..f7783e5 100644 --- a/widget/src/hooks/useChat.ts +++ b/widget/src/hooks/useChat.ts @@ -3,6 +3,7 @@ import type { ClaudiusTranslations } from "../i18n"; import type { ChatAttachment, ChatMessage, + Source, StoredAttachment, } from "../api/types"; import { ChatApiClient } from "../api/client"; @@ -27,6 +28,11 @@ interface UseChatOptions { * @defaultValue `true` */ streaming?: boolean; + /** + * Ask the worker for numbered, citable sources with every request. + * @defaultValue `false` + */ + citations?: boolean; } interface UseChatReturn { @@ -94,10 +100,11 @@ export function useChat({ translations, plugins, streaming = true, + citations = false, }: UseChatOptions): UseChatReturn { const client = useMemo( - () => new ChatApiClient(apiUrl, { debounceMs: 0, timeoutMs }), - [apiUrl, timeoutMs], + () => new ChatApiClient(apiUrl, { debounceMs: 0, timeoutMs, citations }), + [apiUrl, timeoutMs, citations], ); const storageKey = getStorageKey(storageKeyPrefix, apiUrl); @@ -212,6 +219,13 @@ export function useChat({ // until then. let placeholderId: string | null = null; + // The worker may announce the sources before the first token. They wait + // here until the placeholder exists, so an announcement alone never + // creates an empty bubble. A widget without citations keeps today's + // timing and gets them on done, so nothing changes for it. + let earlySources: Source[] | undefined; + const attachEarly = () => (citations ? earlySources : undefined); + const upsertPlaceholder = (patch: Partial) => { if (placeholderId === null) { placeholderId = nextId(); @@ -223,6 +237,7 @@ export function useChat({ role: "assistant", content: "", createdAt: timestamp(), + ...(attachEarly() ? { sources: earlySources } : {}), ...patch, }, ]; @@ -263,9 +278,15 @@ export function useChat({ upsertPlaceholder({ content: fullText }), onToolUse: (_toolUse, allToolUses) => upsertPlaceholder({ toolUses: [...allToolUses] }), + onSources: (announced) => { + earlySources = announced; + if (citations && placeholderId !== null) { + upsertPlaceholder({ sources: announced }); + } + }, }); reply = result.reply; - sources = result.sources; + sources = result.sources ?? earlySources; toolUses = result.toolUses; storedAttachments = result.attachments; aborted = result.aborted ?? false; @@ -402,6 +423,7 @@ export function useChat({ }, [ apiUrl, + citations, client, getErrorMessage, isRetryableError, diff --git a/widget/src/i18n.ts b/widget/src/i18n.ts index 3e5aa93..c4983e5 100644 --- a/widget/src/i18n.ts +++ b/widget/src/i18n.ts @@ -98,6 +98,16 @@ export interface ClaudiusTranslations { transcriptAttachments: string; /** Transcript label above a message's citation list, including its colon. */ transcriptSources: string; + /** Label of the sources footer under a cited reply, and of its list. */ + sources: string; + /** Button that reveals the remaining source cards; supports `{count}`. */ + showAllSources: string; + /** Button that hides the extra source cards again. */ + showFewerSources: string; + /** Accessible name of a citation chip; supports `{n}` and `{title}`. */ + citation: string; + /** Visually hidden hint appended to links that open a new tab. */ + opensInNewTab: string; /** Generic fallback error message. */ errorGeneric: string; /** Error shown when the network request fails. */ diff --git a/widget/src/index.ts b/widget/src/index.ts index f7698a3..4899122 100644 --- a/widget/src/index.ts +++ b/widget/src/index.ts @@ -43,6 +43,11 @@ export type { VoiceInputMode, ResolvedVoiceConfig, } from "./utils/voice"; +export { DEFAULT_CITATIONS_OPTIONS } from "./utils/citations"; +export type { + CitationsOptions, + ResolvedCitationsConfig, +} from "./utils/citations"; // Plugin SDK: the ClaudiusPlugin interface, supporting types, and the three // reference plugins. diff --git a/widget/src/locales/de.ts b/widget/src/locales/de.ts index 25776d8..53df6d4 100644 --- a/widget/src/locales/de.ts +++ b/widget/src/locales/de.ts @@ -61,6 +61,13 @@ export const de: ClaudiusTranslations = { transcriptAttachments: "Anhänge:", transcriptSources: "Quellen:", + // Citations + sources: "Quellen", + showAllSources: "Alle anzeigen ({count})", + showFewerSources: "Weniger anzeigen", + citation: "Quelle {n}: {title}", + opensInNewTab: "(öffnet in einem neuen Tab)", + // Errors errorGeneric: "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.", errorConnection: "Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.", diff --git a/widget/src/locales/en.ts b/widget/src/locales/en.ts index 015d528..fbdc247 100644 --- a/widget/src/locales/en.ts +++ b/widget/src/locales/en.ts @@ -60,6 +60,13 @@ export const en: ClaudiusTranslations = { transcriptAttachments: "Attachments:", transcriptSources: "Sources:", + // Citations + sources: "Sources", + showAllSources: "Show all ({count})", + showFewerSources: "Show fewer", + citation: "Source {n}: {title}", + opensInNewTab: "(opens in a new tab)", + // Errors errorGeneric: "Something went wrong. Please try again.", errorConnection: "Failed to connect. Please try again.", diff --git a/widget/src/locales/es.ts b/widget/src/locales/es.ts index 8df5f25..24e8c39 100644 --- a/widget/src/locales/es.ts +++ b/widget/src/locales/es.ts @@ -60,6 +60,13 @@ export const es: ClaudiusTranslations = { transcriptAttachments: "Adjuntos:", transcriptSources: "Fuentes:", + // Citations + sources: "Fuentes", + showAllSources: "Mostrar todas ({count})", + showFewerSources: "Mostrar menos", + citation: "Fuente {n}: {title}", + opensInNewTab: "(se abre en una pestaña nueva)", + // Errors errorGeneric: "Algo salió mal. Inténtalo de nuevo.", errorConnection: "No se pudo conectar. Inténtalo de nuevo.", diff --git a/widget/src/locales/fr.ts b/widget/src/locales/fr.ts index c07045f..dbd176f 100644 --- a/widget/src/locales/fr.ts +++ b/widget/src/locales/fr.ts @@ -62,6 +62,13 @@ export const fr: ClaudiusTranslations = { transcriptAttachments: "Pièces jointes :", transcriptSources: "Sources :", + // Citations + sources: "Sources", + showAllSources: "Tout afficher ({count})", + showFewerSources: "Afficher moins", + citation: "Source {n} : {title}", + opensInNewTab: "(s'ouvre dans un nouvel onglet)", + // Errors errorGeneric: "Une erreur s'est produite. Veuillez réessayer.", errorConnection: "Échec de la connexion. Veuillez réessayer.", diff --git a/widget/src/main.tsx b/widget/src/main.tsx index 0295104..fdcf72f 100644 --- a/widget/src/main.tsx +++ b/widget/src/main.tsx @@ -10,6 +10,7 @@ createRoot(document.getElementById("root")!).render( attachments voice conversationExport + citations /> , ); diff --git a/widget/src/utils/__tests__/citations.test.ts b/widget/src/utils/__tests__/citations.test.ts new file mode 100644 index 0000000..b1fb012 --- /dev/null +++ b/widget/src/utils/__tests__/citations.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_CITATIONS_OPTIONS, + citationsOptionsFromAttributes, + faviconUrl, + hideTrailingCitationOpener, + parseCitations, + resolveCitationsConfig, + sourceDomain, + stripCitationMarkers, + truncateSnippet, +} from "../citations"; + +describe("resolveCitationsConfig", () => { + it("is off for anything but true or an options object", () => { + for (const value of [ + undefined, + null, + false, + "true", + "false", + 1, + 0, + [], + [true], + ]) { + expect(resolveCitationsConfig(value as never)).toBeNull(); + } + }); + + it("uses the defaults for true and an empty object", () => { + expect(resolveCitationsConfig(true)).toEqual({ + maxSources: 5, + favicons: true, + }); + expect(resolveCitationsConfig({})).toEqual(DEFAULT_CITATIONS_OPTIONS); + }); + + it("accepts a positive integer maxSources and falls back otherwise", () => { + expect(resolveCitationsConfig({ maxSources: 3 })!.maxSources).toBe(3); + for (const bad of [0, -1, 2.5, "3", NaN, Infinity]) { + expect( + resolveCitationsConfig({ maxSources: bad as never })!.maxSources, + ).toBe(5); + } + }); + + it("turns favicons off only for the literal false", () => { + expect(resolveCitationsConfig({ favicons: false })!.favicons).toBe(false); + expect(resolveCitationsConfig({ favicons: true })!.favicons).toBe(true); + expect( + resolveCitationsConfig({ favicons: "false" as never })!.favicons, + ).toBe(true); + }); +}); + +describe("citationsOptionsFromAttributes", () => { + const attrs = (map: Record) => (name: string) => + name in map ? map[name] : null; + + it("is undefined when the attribute is absent", () => { + expect(citationsOptionsFromAttributes(attrs({}))).toBeUndefined(); + }); + + it.each([[""], ["true"], ["TRUE"], [" true "]])( + "enables for citations=%j", + (value) => { + expect(citationsOptionsFromAttributes(attrs({ citations: value }))).toBe( + true, + ); + }, + ); + + it.each([["false"], ["False"], ["0"], ["no"], ["yes"], ["1"]])( + "stays off for citations=%j", + (value) => { + expect(citationsOptionsFromAttributes(attrs({ citations: value }))).toBe( + false, + ); + }, + ); + + it("reads a positive integer max and ignores junk", () => { + expect( + citationsOptionsFromAttributes( + attrs({ citations: "", "citations-max-sources": " 3 " }), + ), + ).toEqual({ maxSources: 3 }); + for (const bad of ["0", "-2", "2.5", "many", ""]) { + expect( + citationsOptionsFromAttributes( + attrs({ citations: "", "citations-max-sources": bad }), + ), + ).toBe(true); + } + }); + + it("turns favicons off for citations-favicons=false only", () => { + expect( + citationsOptionsFromAttributes( + attrs({ citations: "", "citations-favicons": "false" }), + ), + ).toEqual({ favicons: false }); + expect( + citationsOptionsFromAttributes( + attrs({ citations: "", "citations-favicons": " FALSE " }), + ), + ).toEqual({ favicons: false }); + expect( + citationsOptionsFromAttributes( + attrs({ citations: "", "citations-favicons": "0" }), + ), + ).toBe(true); + }); + + it("companion attributes do nothing while citations is off", () => { + expect( + citationsOptionsFromAttributes(attrs({ "citations-max-sources": "3" })), + ).toBeUndefined(); + expect( + citationsOptionsFromAttributes( + attrs({ citations: "false", "citations-favicons": "false" }), + ), + ).toBe(false); + }); +}); + +describe("parseCitations", () => { + it("turns an in-range marker into a citation and keeps the text around it", () => { + expect(parseCitations("Plans start at $10 [1].", 2)).toEqual([ + { type: "text", value: "Plans start at $10 " }, + { type: "cite", indexes: [0], raw: "[1]" }, + { type: "text", value: "." }, + ]); + }); + + it("leaves out-of-range, zero, and leading-zero markers as text", () => { + for (const text of ["See [3].", "See [0].", "See [01].", "See [1000]."]) { + expect(parseCitations(text, 2)).toEqual([{ type: "text", value: text }]); + } + }); + + it("treats every marker as text when there are no sources", () => { + expect(parseCitations("See [1].", 0)).toEqual([ + { type: "text", value: "See [1]." }, + ]); + }); + + it("returns nothing for empty text", () => { + expect(parseCitations("", 3)).toEqual([]); + }); + + it("splits a comma group into one citation with several indexes, deduplicated", () => { + expect(parseCitations("[1, 2,2]", 2)).toEqual([ + { type: "cite", indexes: [0, 1], raw: "[1, 2,2]" }, + ]); + }); + + it("rejects a comma group when any number is out of range", () => { + expect(parseCitations("[1, 7]", 3)).toEqual([ + { type: "text", value: "[1, 7]" }, + ]); + }); + + it("reads adjacent markers as separate citations", () => { + expect(parseCitations("Yes [1][3].", 3)).toEqual([ + { type: "text", value: "Yes " }, + { type: "cite", indexes: [0], raw: "[1]" }, + { type: "cite", indexes: [2], raw: "[3]" }, + { type: "text", value: "." }, + ]); + }); + + it("does not need a space before the marker", () => { + expect(parseCitations("word[2]", 2)).toEqual([ + { type: "text", value: "word" }, + { type: "cite", indexes: [1], raw: "[2]" }, + ]); + }); + + it("leaves the outer brackets of a doubled marker as text", () => { + expect(parseCitations("[[1]]", 1)).toEqual([ + { type: "text", value: "[" }, + { type: "cite", indexes: [0], raw: "[1]" }, + { type: "text", value: "]" }, + ]); + }); +}); + +describe("stripCitationMarkers", () => { + it("removes in-range markers and the space before them", () => { + expect( + stripCitationMarkers("Plans start at $10 [1]. More [2][3].", 3), + ).toBe("Plans start at $10. More."); + }); + + it("keeps out-of-range markers and text without markers", () => { + expect(stripCitationMarkers("See [7] and [1].", 2)).toBe("See [7] and."); + expect(stripCitationMarkers("No markers here.", 2)).toBe( + "No markers here.", + ); + }); + + it("does nothing when there are no sources", () => { + expect(stripCitationMarkers("See [1].", 0)).toBe("See [1]."); + }); +}); + +describe("hideTrailingCitationOpener", () => { + it.each([ + ["Plans start at $10 [", "Plans start at $10"], + ["Plans start at $10 [1", "Plans start at $10"], + ["Plans start at $10 [1, ", "Plans start at $10"], + ["Plans start at $10[1", "Plans start at $10"], + ])("hides %j as %j", (input, expected) => { + expect(hideTrailingCitationOpener(input)).toBe(expected); + }); + + it.each([ + "Plans start at $10 [1].", + "Plans start at $10 [1]", + "See [Pricing", + "Plain text", + ])("leaves %j alone", (text) => { + expect(hideTrailingCitationOpener(text)).toBe(text); + }); +}); + +describe("truncateSnippet", () => { + it("collapses whitespace and keeps text within the limit", () => { + expect(truncateSnippet(" Plans\nstart at $10. ")).toBe( + "Plans start at $10.", + ); + const exact = "a".repeat(200); + expect(truncateSnippet(exact)).toBe(exact); + }); + + it("cuts longer text at the last space and adds an ellipsis", () => { + const words = Array.from({ length: 60 }, (_, i) => `word${i}`).join(" "); + const cut = truncateSnippet(words); + expect(cut.length).toBeLessThanOrEqual(201); + expect(cut.endsWith("…")).toBe(true); + expect(words.startsWith(cut.slice(0, -1))).toBe(true); + expect(cut.slice(0, -1)).toMatch(/word\d+$/); + }); + + it("hard-cuts a single long token and drops trailing punctuation before the ellipsis", () => { + expect(truncateSnippet("x".repeat(250))).toBe(`${"x".repeat(200)}…`); + const text = `${"y".repeat(150)}, ${"z".repeat(100)}`; + expect(truncateSnippet(text)).toBe(`${"y".repeat(150)}…`); + }); +}); + +describe("faviconUrl and sourceDomain", () => { + it("points at /favicon.ico on the source origin", () => { + expect(faviconUrl("https://example.com/pricing?x=1#top")).toBe( + "https://example.com/favicon.ico", + ); + expect(faviconUrl("http://docs.example.com:8080/a/b")).toBe( + "http://docs.example.com:8080/favicon.ico", + ); + }); + + it("returns null for non-http schemes and garbage", () => { + for (const url of [ + "javascript:alert(1)", + "data:text/html,hi", + "ftp://x.com/a", + "nope", + "", + ]) { + expect(faviconUrl(url)).toBeNull(); + } + }); + + it("gives the hostname, or the input when it is not a URL", () => { + expect(sourceDomain("https://docs.example.com/a")).toBe("docs.example.com"); + expect(sourceDomain("not a url")).toBe("not a url"); + }); +}); diff --git a/widget/src/utils/citations.ts b/widget/src/utils/citations.ts new file mode 100644 index 0000000..cce2690 --- /dev/null +++ b/widget/src/utils/citations.ts @@ -0,0 +1,197 @@ +/** + * Inline citations. Pass to {@link ChatWidget} via the `citations` prop (or + * `true` for the defaults). `[n]` markers in a grounded reply become chips + * and its sources become a footer of cards. + */ +export interface CitationsOptions { + /** + * Source cards shown before the "Show all" control. Positive integer. + * @defaultValue `5` + */ + maxSources?: number; + /** + * Show each source's favicon, fetched from `/favicon.ico` on the source's + * own origin with no referrer. Set to `false` to make no such requests. + * @defaultValue `true` + */ + favicons?: boolean; +} + +/** {@link CitationsOptions} with every field filled in. */ +export type ResolvedCitationsConfig = Required; + +/** Defaults applied when citations are enabled with `true` or a partial config. */ +export const DEFAULT_CITATIONS_OPTIONS: ResolvedCitationsConfig = { + maxSources: 5, + favicons: true, +}; + +/** + * Turn the `citations` prop into a full config, or `null` when citations are + * off. Fails closed: only `true` or a plain object enables them, so a + * templated string such as `"false"` cannot. + */ +export function resolveCitationsConfig( + input: boolean | CitationsOptions | undefined | null, +): ResolvedCitationsConfig | null { + const isOptions = + typeof input === "object" && input !== null && !Array.isArray(input); + if (input !== true && !isOptions) return null; + const options: CitationsOptions = input === true ? {} : input; + const max = options.maxSources; + return { + maxSources: + typeof max === "number" && Number.isInteger(max) && max >= 1 + ? max + : DEFAULT_CITATIONS_OPTIONS.maxSources, + favicons: options.favicons !== false, + }; +} + +/** + * Build the `citations` option from `` attributes. `citations` + * switches it on only when present with no value or `"true"` (trimmed, any + * case), like `conversation-export`; `citations-max-sources` takes a positive + * integer and `citations-favicons="false"` turns favicons off. + */ +export function citationsOptionsFromAttributes( + getAttribute: (name: string) => string | null, +): boolean | CitationsOptions | undefined { + const raw = getAttribute("citations"); + if (raw === null) return undefined; + const value = raw.trim().toLowerCase(); + if (value !== "" && value !== "true") return false; + + const options: CitationsOptions = {}; + const max = getAttribute("citations-max-sources"); + if (max !== null && /^\s*\d+\s*$/.test(max)) { + const parsed = Number(max); + if (parsed >= 1) options.maxSources = parsed; + } + const favicons = getAttribute("citations-favicons"); + if (favicons !== null && favicons.trim().toLowerCase() === "false") { + options.favicons = false; + } + return Object.keys(options).length > 0 ? options : true; +} + +/** One piece of a text run: plain text, or a group of citation markers. */ +export type CitationSegment = + | { type: "text"; value: string } + | { + type: "cite"; + /** Zero-based source indexes, in the order written, without repeats. */ + indexes: number[]; + /** The marker as written, e.g. `"[1, 3]"`. */ + raw: string; + }; + +// `[1]`, `[12]`, `[1, 3]`: one to three digits, no leading zero. Models write +// both `[1][3]` and `[1, 3]`, so a comma list is one group. +const CITATION_GROUP = /\[([1-9]\d{0,2}(?:\s*,\s*[1-9]\d{0,2})*)\]/g; + +/** + * Split text into plain runs and citation groups. A group counts only when + * every number is between 1 and `sourceCount`; anything else stays text, so + * `[7]` with three sources, `[0]`, and every bracket in a reply without + * sources render as written. + */ +export function parseCitations( + text: string, + sourceCount: number, +): CitationSegment[] { + if (!text) return []; + if (sourceCount <= 0 || !text.includes("[")) { + return [{ type: "text", value: text }]; + } + const segments: CitationSegment[] = []; + let last = 0; + for (const match of text.matchAll(CITATION_GROUP)) { + const numbers = match[1].split(",").map((n) => Number(n.trim())); + if (numbers.some((n) => n > sourceCount)) continue; + const start = match.index ?? 0; + if (start > last) { + segments.push({ type: "text", value: text.slice(last, start) }); + } + segments.push({ + type: "cite", + indexes: [...new Set(numbers)].map((n) => n - 1), + raw: match[0], + }); + last = start + match[0].length; + } + if (last < text.length) { + segments.push({ type: "text", value: text.slice(last) }); + } + return segments; +} + +/** + * Text without its citation markers, for screen-reader announcements and + * read-aloud: the marker goes, and so does the space before it. + */ +export function stripCitationMarkers( + text: string, + sourceCount: number, +): string { + let out = ""; + for (const segment of parseCitations(text, sourceCount)) { + if (segment.type === "text") out += segment.value; + else out = out.replace(/[ \t]+$/, ""); + } + return out; +} + +/** + * Hide an unfinished marker at the very end of streaming text (`[`, `[1`, + * `[1, `), the way the bold stabilizer hides a bare `**`. The characters + * return with the next token. + */ +export function hideTrailingCitationOpener(text: string): string { + return text.replace(/[ \t]?\[[\d,\s]*$/, ""); +} + +/** Longest snippet a card shows; the worker applies the same cut. */ +export const SNIPPET_MAX_CHARS = 200; + +/** + * Collapse whitespace and cut at the last space before `maxChars` (hard when + * there is none), adding an ellipsis. A no-op for snippets the worker + * already cut; protects against long ones from plugins or custom workers. + */ +export function truncateSnippet( + text: string, + maxChars: number = SNIPPET_MAX_CHARS, +): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length <= maxChars) return collapsed; + const cut = collapsed.slice(0, maxChars); + const lastSpace = cut.lastIndexOf(" "); + const head = (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).replace( + /[\s,;:]+$/, + "", + ); + return `${head}…`; +} + +/** `/favicon.ico` on the source's origin, or null unless the URL is http(s). */ +export function faviconUrl(url: string): string | null { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + return `${parsed.origin}/favicon.ico`; + } catch { + return null; + } +} + +/** The hostname shown under a card's title, or the input when it is not a URL. */ +export function sourceDomain(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} diff --git a/worker/src/__tests__/chat-rag.test.ts b/worker/src/__tests__/chat-rag.test.ts index 9dcbdea..ddb2c47 100644 --- a/worker/src/__tests__/chat-rag.test.ts +++ b/worker/src/__tests__/chat-rag.test.ts @@ -82,7 +82,12 @@ describe("handleChat with RAG", () => { }); expect(result.response.sources).toEqual([ - { url: "https://example.com/pricing", title: "Pricing", type: "page" }, + { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "Plans start at $1,000/month.", + }, ]); }); @@ -105,6 +110,40 @@ describe("handleChat with RAG", () => { expect(createSpy.mock.calls[0][0].system).toBe(SYSTEM_PROMPT); }); + it("numbers the excerpts and asks for citations when the config opts in", async () => { + // The route copies the body's `citations: true` into the config. + await handleChat(request, "key", { + rag: ragWith([pricingDoc]), + citations: true, + }); + const system = createSpy.mock.calls[0][0].system as string; + expect(system).toContain("### [1] [Pricing](https://example.com/pricing)"); + expect(system).toContain("end it with the excerpt's number"); + }); + + it("leaves the prompt unnumbered when the config does not opt in", async () => { + await handleChat(request, "key", { rag: ragWith([pricingDoc]) }); + const system = createSpy.mock.calls[0][0].system as string; + expect(system).toContain("### [Pricing](https://example.com/pricing)"); + expect(system).not.toContain("[1]"); + expect(system).not.toContain("end it with the excerpt's number"); + }); + + it("returns sources only for the excerpts that fit the context budget", async () => { + const faqDoc: RagDocument = { + id: "faq.md#0", + content: "We are open 9 to 5.", + metadata: { url: "https://example.com/faq", title: "FAQ", type: "page" }, + score: 0.8, + }; + const result = await handleChat(request, "key", { + rag: { ...ragWith([pricingDoc, faqDoc]), maxContextChars: 60 }, + }); + expect(result.response.sources?.map((s) => s.url)).toEqual([ + "https://example.com/pricing", + ]); + }); + it("behaves exactly as before when RAG is not configured", async () => { const result = await handleChat(request, "key", {}); expect(createSpy.mock.calls[0][0].system).toBe(SYSTEM_PROMPT); @@ -134,10 +173,52 @@ describe("streamChat with RAG", () => { const done = events.at(-1) as { type: string; sources?: unknown }; expect(done.type).toBe("done"); expect(done.sources).toEqual([ - { url: "https://example.com/pricing", title: "Pricing", type: "page" }, + { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "Plans start at $1,000/month.", + }, ]); }); + it("announces the sources once the model call is made, before the first text", async () => { + const events: Array<{ type: string }> = []; + let createCallsAtSources = -1; + for await (const event of streamChat(request, "key", { + rag: ragWith([pricingDoc]), + })) { + if (event.type === "sources") { + createCallsAtSources = createSpy.mock.calls.length; + } + events.push(event); + } + expect(events.map((e) => e.type)).toEqual(["sources", "text", "done"]); + // Yielded after the connection opened, so a bad key still fails as JSON. + expect(createCallsAtSources).toBe(1); + expect(events[0]).toEqual({ + type: "sources", + sources: [ + { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "Plans start at $1,000/month.", + }, + ], + }); + }); + + it("announces nothing when retrieval found no sources", async () => { + const events: Array<{ type: string }> = []; + for await (const event of streamChat(request, "key", { + rag: ragWith([]), + })) { + events.push(event); + } + expect(events.map((e) => e.type)).toEqual(["text", "done"]); + }); + it("omits sources on the done event without RAG", async () => { const events = []; for await (const event of streamChat(request, "key", {})) { diff --git a/worker/src/__tests__/chat-route-citations.test.ts b/worker/src/__tests__/chat-route-citations.test.ts new file mode 100644 index 0000000..96144c0 --- /dev/null +++ b/worker/src/__tests__/chat-route-citations.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const createSpy = vi.hoisted(() => vi.fn()); +vi.mock("@anthropic-ai/sdk", () => ({ + default: class MockAnthropic { + messages = { create: createSpy }; + }, +})); + +import app from "../index"; + +function createMockKV(): KVNamespace { + const store = new Map(); + return { + get: async (key: string) => store.get(key) ?? null, + put: async (key: string, value: string) => void store.set(key, value), + } as unknown as KVNamespace; +} + +function createMockCtx(): ExecutionContext { + return { + waitUntil: () => {}, + passThroughOnException: () => {}, + } as unknown as ExecutionContext; +} + +/** Env with the RAG bindings, so retrieval runs and yields one source. */ +function ragEnv() { + return { + ANTHROPIC_API_KEY: "test-key", + ALLOWED_ORIGIN: "http://localhost:5173", + RATE_LIMIT: createMockKV(), + AI: { run: vi.fn().mockResolvedValue({ data: [[0.1, 0.2]] }) }, + VECTORIZE_INDEX: { + query: vi.fn().mockResolvedValue({ + matches: [ + { + id: "pricing.md#0", + score: 0.9, + metadata: { + content: "Plans start at $10.", + url: "https://example.com/pricing", + title: "Pricing", + }, + }, + ], + }), + }, + }; +} + +function request(path: string, body: unknown): Request { + return new Request(`http://localhost${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const messages = [{ role: "user", content: "What are your prices?" }]; + +async function* textStream() { + yield { type: "message_start", message: { usage: { input_tokens: 3 } } }; + yield { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Plans start at $10 [1]." }, + }; + yield { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 4 }, + }; + yield { type: "message_stop" }; +} + +describe("citations on the chat routes", () => { + beforeEach(() => { + createSpy.mockReset(); + }); + + it("numbers the prompt when the body carries citations: true", async () => { + createSpy.mockResolvedValue({ + stop_reason: "end_turn", + content: [{ type: "text", text: "Plans start at $10 [1]." }], + usage: { input_tokens: 3, output_tokens: 4 }, + }); + + const res = await app.fetch( + request("/api/chat", { messages, citations: true }), + ragEnv(), + createMockCtx() + ); + + expect(res.status).toBe(200); + const system = createSpy.mock.calls[0][0].system as string; + expect(system).toContain("### [1] [Pricing](https://example.com/pricing)"); + expect(await res.json()).toMatchObject({ + sources: [ + { url: "https://example.com/pricing", snippet: "Plans start at $10." }, + ], + }); + }); + + it.each([[undefined], ["true"], [1]])( + "leaves the prompt unnumbered for citations = %j", + async (citations) => { + createSpy.mockResolvedValue({ + stop_reason: "end_turn", + content: [{ type: "text", text: "Plans start at $10." }], + usage: { input_tokens: 3, output_tokens: 4 }, + }); + + const res = await app.fetch( + request("/api/chat", { messages, citations }), + ragEnv(), + createMockCtx() + ); + + expect(res.status).toBe(200); + const system = createSpy.mock.calls[0][0].system as string; + expect(system).toContain("### [Pricing](https://example.com/pricing)"); + expect(system).not.toContain("[1]"); + } + ); + + it("streams a sources event before the first chunk", async () => { + createSpy.mockImplementation(() => textStream()); + + const res = await app.fetch( + request("/api/chat/stream", { messages, citations: true }), + ragEnv(), + createMockCtx() + ); + + expect(res.status).toBe(200); + const text = await res.text(); + const sourcesAt = text.indexOf("event: sources"); + const chunkAt = text.indexOf("event: chunk"); + expect(sourcesAt).toBeGreaterThanOrEqual(0); + expect(chunkAt).toBeGreaterThanOrEqual(0); + expect(sourcesAt).toBeLessThan(chunkAt); + expect(text).toContain( + 'event: sources\ndata: {"sources":[{"url":"https://example.com/pricing","title":"Pricing","type":"page","snippet":"Plans start at $10."}]}' + ); + expect(text).toContain('"reply":"Plans start at $10 [1]."'); + }); + + it("still returns JSON 503 when the model stream fails on its first event with RAG on", async () => { + // Anthropic can deliver an overload as the first stream event. The route + // must still see that before any sources announcement opens the SSE. + createSpy.mockImplementation(() => + (async function* () { + await Promise.resolve(); + throw new Error("overloaded_error"); + })() + ); + + const res = await app.fetch( + request("/api/chat/stream", { messages, citations: true }), + ragEnv(), + createMockCtx() + ); + + expect(res.status).toBe(503); + expect(res.headers.get("Content-Type")).toContain("application/json"); + expect(await res.json()).toMatchObject({ code: "SERVICE_ERROR" }); + }); + + it("still returns JSON 500 when the model connection fails with RAG on", async () => { + createSpy.mockImplementation(() => { + throw new Error("authentication failed"); + }); + + const res = await app.fetch( + request("/api/chat/stream", { messages, citations: true }), + ragEnv(), + createMockCtx() + ); + + expect(res.status).toBe(500); + expect(res.headers.get("Content-Type")).toContain("application/json"); + expect(await res.json()).toMatchObject({ code: "CONFIG_ERROR" }); + }); +}); diff --git a/worker/src/__tests__/rag.test.ts b/worker/src/__tests__/rag.test.ts index 3970a2e..8922399 100644 --- a/worker/src/__tests__/rag.test.ts +++ b/worker/src/__tests__/rag.test.ts @@ -3,6 +3,9 @@ import { retrieveRagDocuments, formatRagContext, ragDocumentsToSources, + snippetFromContent, + buildRagContext, + CITATION_INSTRUCTIONS, DEFAULT_CONTEXT_TEMPLATE, VectorizeRetriever, } from "../rag"; @@ -127,16 +130,162 @@ describe("formatRagContext", () => { }); }); +describe("buildRagContext", () => { + const faq = doc({ + id: "faq.md#0", + content: "We are open 9 to 5.", + metadata: { url: "https://example.com/faq", title: "FAQ" }, + }); + + it("returns no context and no sources for no documents", () => { + expect(buildRagContext([])).toEqual({ sources: [] }); + }); + + it("matches formatRagContext and stays unnumbered when citations are off", () => { + const docs = [doc(), faq]; + const { context, sources } = buildRagContext(docs); + expect(context).toBe(formatRagContext(docs)); + expect(context).toContain("### [Pricing](https://example.com/pricing)"); + expect(context).not.toContain("[1]"); + expect(context).not.toContain(CITATION_INSTRUCTIONS); + expect(sources.map((s) => s.url)).toEqual([ + "https://example.com/pricing", + "https://example.com/faq", + ]); + }); + + it("numbers pages in order of first appearance and shares the number across chunks", () => { + const pricing1 = doc({ + id: "pricing.md#1", + content: "Enterprise plans are quoted.", + }); + const { context, sources } = buildRagContext([doc(), faq, pricing1], {}, { + citations: true, + }); + expect(context).toContain( + "### [1] [Pricing](https://example.com/pricing)\nPlans start at $75/hour." + ); + expect(context).toContain( + "### [2] [FAQ](https://example.com/faq)\nWe are open 9 to 5." + ); + expect(context).toContain( + "### [1] [Pricing](https://example.com/pricing)\nEnterprise plans are quoted." + ); + expect(sources.map((s) => s.url)).toEqual([ + "https://example.com/pricing", + "https://example.com/faq", + ]); + expect(context!.endsWith(CITATION_INSTRUCTIONS)).toBe(true); + }); + + it("leaves documents without a url unnumbered and uncited", () => { + const orphan = doc({ id: "notes.md#0", metadata: { title: "Notes" } }); + const { context, sources } = buildRagContext([orphan], {}, { citations: true }); + expect(context).toContain("### [Notes]\n"); + expect(context).not.toContain("[1]"); + expect(context).not.toContain(CITATION_INSTRUCTIONS); + expect(sources).toEqual([]); + }); + + it("drops a document past the budget from the sources too", () => { + const third = doc({ + id: "team.md#0", + content: "z".repeat(50), + metadata: { url: "https://example.com/team", title: "Team" }, + }); + const docs = [ + doc({ content: "x".repeat(50) }), + doc({ ...faq, content: "y".repeat(50) }), + third, + ]; + const { context, sources } = buildRagContext(docs, { maxContextChars: 200 }, { + citations: true, + }); + expect(context).toContain("[1] [Pricing]"); + expect(context).toContain("[2] [FAQ]"); + // "[3]" alone would match the instruction's own "[1][3]" example. + expect(context).not.toContain("[3] [Team]"); + expect(context).not.toContain("zzz"); + expect(sources.map((s) => s.title)).toEqual(["Pricing", "FAQ"]); + }); +}); + +describe("snippetFromContent", () => { + it("collapses whitespace and strips heading, bold, and backtick markers", () => { + expect( + snippetFromContent("## Pricing\n\nPlans start at **$75** per `hour`.") + ).toBe("Pricing Plans start at $75 per hour."); + }); + + it("strips list markers, blockquotes, links, images, and italics", () => { + expect( + snippetFromContent( + "- See [pricing](https://e.com/p) for *details*\n> _Quoted_ line\n1. ![alt](img.png) done" + ) + ).toBe("See pricing for details Quoted line alt done"); + // A lone asterisk or underscore in prose is not emphasis. + expect(snippetFromContent("5 * 3 = 15 and snake_case_names")).toBe( + "5 * 3 = 15 and snake_case_names" + ); + }); + + it("keeps text at the limit and cuts longer text at a word boundary", () => { + const exact = "a".repeat(200); + expect(snippetFromContent(exact)).toBe(exact); + + const words = Array.from({ length: 60 }, (_, i) => `word${i}`).join(" "); + const cut = snippetFromContent(words)!; + expect(cut.length).toBeLessThanOrEqual(201); + expect(cut.endsWith("\u2026")).toBe(true); + const body = cut.slice(0, -1); + expect(body).toMatch(/word\d+$/); + expect(words.startsWith(body)).toBe(true); + }); + + it("hard-cuts a single long token", () => { + expect(snippetFromContent("x".repeat(250))).toBe(`${"x".repeat(200)}\u2026`); + }); + + it("returns undefined for blank content", () => { + expect(snippetFromContent(" \n\t ")).toBeUndefined(); + }); +}); + describe("ragDocumentsToSources", () => { it("maps url/title/type metadata to widget sources", () => { const sources = ragDocumentsToSources([ doc({ metadata: { url: "https://e.com/blog/a", title: "A", type: "blog" } }), ]); expect(sources).toEqual([ - { url: "https://e.com/blog/a", title: "A", type: "blog" }, + { + url: "https://e.com/blog/a", + title: "A", + type: "blog", + snippet: "Plans start at $75/hour.", + }, ]); }); + it("adds a snippet from the first chunk of each page", () => { + const sources = ragDocumentsToSources([ + doc({ id: "p#0", content: "First chunk." }), + doc({ id: "p#1", content: "Second chunk." }), + ]); + expect(sources).toEqual([ + { + url: "https://example.com/pricing", + title: "Pricing", + type: "page", + snippet: "First chunk.", + }, + ]); + }); + + it("omits the snippet when the chunk has no text", () => { + const [source] = ragDocumentsToSources([doc({ content: " " })]); + expect(source).not.toHaveProperty("snippet"); + }); + it("skips documents without a url and dedupes chunks of one page", () => { const sources = ragDocumentsToSources([ doc({ id: "p#0" }), @@ -152,7 +301,12 @@ describe("ragDocumentsToSources", () => { doc({ metadata: { url: "https://e.com/x", type: "banana" } }), ]); expect(sources).toEqual([ - { url: "https://e.com/x", title: "https://e.com/x", type: "page" }, + { + url: "https://e.com/x", + title: "https://e.com/x", + type: "page", + snippet: "Plans start at $75/hour.", + }, ]); }); }); diff --git a/worker/src/chat.ts b/worker/src/chat.ts index c12e35b..3f329f5 100644 --- a/worker/src/chat.ts +++ b/worker/src/chat.ts @@ -4,11 +4,7 @@ import { attachmentToBlock, type AttachmentRef } from "./attachments"; import type { StoredAttachment } from "./attachment-storage"; import { toAnthropicTools, executeTool } from "./tools"; import type { ClaudiusTool, ToolContext, ToolUseSummary } from "./tools"; -import { - retrieveRagDocuments, - formatRagContext, - ragDocumentsToSources, -} from "./rag"; +import { retrieveRagDocuments, buildRagContext } from "./rag"; import type { RagConfig, ChatSource } from "./rag"; export interface ChatMessage { @@ -21,6 +17,11 @@ export interface ChatMessage { export interface ChatRequest { messages: ChatMessage[]; conversationId?: string; + /** + * Set by widgets that render `[n]` citations: numbers the retrieved + * excerpts to match `sources` and asks the model to cite them. + */ + citations?: boolean; } export interface ChatResponse { @@ -55,17 +56,21 @@ export interface ChatConfig { toolContext?: ToolContext; /** Retrieval-augmented generation settings; unset disables RAG. */ rag?: RagConfig; + /** Number RAG excerpts and ask the model to cite them as `[n]`. */ + citations?: boolean; } /** * A single event produced while streaming a chat completion. `text` events * carry one incremental text delta; `tool` events announce each executed - * tool call; the final `done` event carries the full assembled reply, - * accumulated tool-use summaries, and telemetry. + * tool call; a `sources` event announces the reply's sources once, before + * the first text delta; the final `done` event carries the full assembled + * reply, accumulated tool-use summaries, and telemetry. */ export type ChatStreamEvent = | { type: "text"; text: string } | { type: "tool"; toolUse: ToolUseSummary } + | { type: "sources"; sources: ChatSource[] } | { type: "done"; reply: string; @@ -129,11 +134,10 @@ async function prepareRag( if (!lastUser?.content) return { systemSuffix: "", sources: [] }; const documents = await retrieveRagDocuments(config.rag, lastUser.content); - const context = formatRagContext(documents, config.rag); - return { - systemSuffix: context ?? "", - sources: ragDocumentsToSources(documents), - }; + const built = buildRagContext(documents, config.rag, { + citations: config.citations === true, + }); + return { systemSuffix: built.context ?? "", sources: built.sources }; } /** Runs each requested tool call, returning result blocks and summaries. */ @@ -347,8 +351,18 @@ export async function* streamChat( // Separate this round's text from the previous round's with a blank // line, mirroring the non-streaming reply assembly. let firstTextOfRound = true; + // Announced together with the first upstream event, so the widget can + // render citation chips while the text streams. Not before it: the route + // pulls one event before opening the SSE response, and an upstream + // failure delivered as the first stream event (an overload, say) must + // still surface there as a JSON error the client retries. + let sourcesAnnounced = round > 0 || rag.sources.length === 0; for await (const event of stream) { + if (!sourcesAnnounced) { + sourcesAnnounced = true; + yield { type: "sources", sources: rag.sources }; + } switch (event.type) { case "message_start": inputTokens += event.message.usage?.input_tokens ?? 0; diff --git a/worker/src/index.ts b/worker/src/index.ts index d36c6bf..0c19dfe 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -206,6 +206,9 @@ function getChatConfig(env: Env, body?: ChatRequest) { }, // Retrieval config, active only when the Vectorize + AI bindings exist. rag: createRagFromEnv(env), + // Only the literal true: a hand-written client sending "true" gets the + // unnumbered prompt it has always had. + citations: body?.citations === true, }; } @@ -390,10 +393,11 @@ app.post("/api/chat", async (c) => { }); // Streaming variant of /api/chat. Emits SSE events: -// event: chunk data: {"text": "..."} one per model text delta -// event: tool data: {...ToolUseSummary} one per executed tool call -// event: done data: {"reply": "..."} full assembled reply, stream end -// event: error data: {"error": ..., "code"} failure after streaming began +// event: sources data: {"sources": [...]} once, before the first chunk, when RAG found any +// event: chunk data: {"text": "..."} one per model text delta +// event: tool data: {...ToolUseSummary} one per executed tool call +// event: done data: {"reply": "..."} full assembled reply, stream end +// event: error data: {"error": ..., "code"} failure after streaming began // Failures before the first byte (rate limit, validation, attachments, bad // API key, model errors) return plain JSON with the same status codes and // shapes as /api/chat, so clients can share error handling and fall back @@ -491,6 +495,11 @@ app.post("/api/chat/stream", async (c) => { event: "tool", data: JSON.stringify(event.toolUse), }); + } else if (event.type === "sources") { + await sse.writeSSE({ + event: "sources", + data: JSON.stringify({ sources: event.sources }), + }); } else { telemetry = event.telemetry; await sse.writeSSE({ diff --git a/worker/src/rag/index.ts b/worker/src/rag/index.ts index 027fe4a..60e4547 100644 --- a/worker/src/rag/index.ts +++ b/worker/src/rag/index.ts @@ -16,8 +16,14 @@ export { retrieveRagDocuments, formatRagContext, ragDocumentsToSources, + snippetFromContent, + buildRagContext, + CITATION_INSTRUCTIONS, DEFAULT_CONTEXT_TEMPLATE, + SOURCE_SNIPPET_MAX_CHARS, type ChatSource, + type RagContextOptions, + type RagContext, } from "./retrieval"; export { VectorizeRetriever, diff --git a/worker/src/rag/retrieval.ts b/worker/src/rag/retrieval.ts index fd2f73a..893d813 100644 --- a/worker/src/rag/retrieval.ts +++ b/worker/src/rag/retrieval.ts @@ -5,6 +5,44 @@ export interface ChatSource { url: string; title: string; type: "blog" | "page" | "external"; + /** Plain-text preview of the page, cut to about 200 characters. */ + snippet?: string; +} + +/** Longest snippet the worker sends; the widget applies the same cut. */ +export const SOURCE_SNIPPET_MAX_CHARS = 200; + +/** + * Plain-text preview of a chunk for the widget's source cards: Markdown + * block and inline markers removed (headings, blockquotes, list markers, + * links and images down to their text, bold, code, italics), whitespace + * collapsed, cut at the last space before `maxChars` (or hard when there is + * none) with a trailing ellipsis. Undefined when nothing is left. + */ +export function snippetFromContent( + content: string, + maxChars: number = SOURCE_SNIPPET_MAX_CHARS +): string | undefined { + const text = content + .replace(/^[ \t]*#{1,6}[ \t]+/gm, "") + .replace(/^[ \t]*>[ \t]?/gm, "") + .replace(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+/gm, "") + .replace(/!?\[([^\]\n]*)\]\([^)\n]*\)/g, "$1") + .replace(/\*\*|`/g, "") + // Emphasis only when the markers bound a word: "5 * 3" and snake_case + // stay as written. + .replace(/(^|[\s(])[*_]([^*_\n]+)[*_](?=[\s).,;:!?]|$)/gm, "$1$2") + .replace(/\s+/g, " ") + .trim(); + if (!text) return undefined; + if (text.length <= maxChars) return text; + const cut = text.slice(0, maxChars); + const lastSpace = cut.lastIndexOf(" "); + const head = (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).replace( + /[\s,;:]+$/, + "" + ); + return `${head}\u2026`; } const DEFAULT_TOP_K = 4; @@ -49,35 +87,90 @@ export async function retrieveRagDocuments( } } +/** Options for {@link buildRagContext}. */ +export interface RagContextOptions { + /** + * Number each excerpt to match its page's position in the returned + * `sources` and append {@link CITATION_INSTRUCTIONS}, so the model can cite + * with `[n]`. Set from the request's `citations` flag. + */ + citations?: boolean; +} + +/** What {@link buildRagContext} produces for one request. */ +export interface RagContext { + /** System-prompt suffix, or undefined when there was nothing to inject. */ + context?: string; + /** Source links for exactly the excerpts that made it into `context`. */ + sources: ChatSource[]; +} + +/** + * Appended after the context block when the request asked for citations. + * Kept outside the template so custom templates get it too. + */ +export const CITATION_INSTRUCTIONS = + "The excerpts above are numbered. When a sentence draws on one, end it with the excerpt's number in square brackets, like [1], or [1][3] for several. Cite only numbers that appear above and never invent one. Add no citation when the excerpts do not apply, and do not list the sources or their URLs yourself: the reader sees them beside your answer."; + /** * Renders retrieved documents into the context block that gets appended to - * the system prompt. Documents that would push the block past - * `maxContextChars` are dropped, lowest-ranked first. Returns undefined when - * there is nothing to inject. + * the system prompt, and returns the widget sources for the same excerpts. + * Documents that would push the block past `maxContextChars` are dropped, + * lowest-ranked first, from both. With `citations`, each excerpt with a URL + * gets its page's 1-based position in `sources` as a `[n]` prefix. */ -export function formatRagContext( +export function buildRagContext( documents: RagDocument[], - rag: Pick = {} -): string | undefined { - if (documents.length === 0) return undefined; + rag: Pick = {}, + options: RagContextOptions = {} +): RagContext { + if (documents.length === 0) return { sources: [] }; const maxChars = rag.maxContextChars ?? DEFAULT_MAX_CONTEXT_CHARS; const template = rag.contextTemplate ?? DEFAULT_CONTEXT_TEMPLATE; + const numbered = options.citations === true; const parts: string[] = []; + const included: RagDocument[] = []; + const numberByUrl = new Map(); let used = 0; for (const doc of documents) { const title = typeof doc.metadata?.title === "string" ? doc.metadata.title : doc.id; const url = typeof doc.metadata?.url === "string" ? doc.metadata.url : ""; - const header = url ? `[${title}](${url})` : `[${title}]`; + const link = url ? `[${title}](${url})` : `[${title}]`; + // Tentative: a new page gets its number only once its block fits. + const number = + numbered && url + ? (numberByUrl.get(url) ?? numberByUrl.size + 1) + : undefined; + const header = number === undefined ? link : `[${number}] ${link}`; const block = `### ${header}\n${doc.content.trim()}`; if (used + block.length > maxChars && parts.length > 0) break; parts.push(block); used += block.length; + included.push(doc); + if (number !== undefined && !numberByUrl.has(url)) { + numberByUrl.set(url, number); + } } - return template.replace("{context}", parts.join("\n\n")); + const sources = ragDocumentsToSources(included); + let context = template.replace("{context}", parts.join("\n\n")); + if (numbered && sources.length > 0) { + context += `\n\n${CITATION_INSTRUCTIONS}`; + } + return { context, sources }; +} + +/** + * The context block alone, unnumbered. See {@link buildRagContext}. + */ +export function formatRagContext( + documents: RagDocument[], + rag: Pick = {} +): string | undefined { + return buildRagContext(documents, rag).context; } const VALID_SOURCE_TYPES = new Set(["blog", "page", "external"]); @@ -105,7 +198,8 @@ export function ragDocumentsToSources(documents: RagDocument[]): ChatSource[] { : "page" ) as ChatSource["type"]; - sources.push({ url, title, type }); + const snippet = snippetFromContent(doc.content); + sources.push({ url, title, type, ...(snippet ? { snippet } : {}) }); } return sources;