From 0e04059293051cabd497a1b8b86214a0a9429540 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:28:58 -0700 Subject: [PATCH 01/16] docs(spec): @ngaf/chat 0.0.21 citations + sync all @ngaf to 0.0.21 Citations as Message.citations[] populated by adapters, rendered inline by markdown citation-reference view component and as a sources panel via . CitationsResolverService merges Message.citations and markdown-doc sidecar with message-first precedence. Synchronizes all 16 @ngaf libraries to 0.0.21 per project policy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-04-chat-citations-design.md | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-04-chat-citations-design.md diff --git a/docs/superpowers/specs/2026-05-04-chat-citations-design.md b/docs/superpowers/specs/2026-05-04-chat-citations-design.md new file mode 100644 index 000000000..57435ea5d --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-chat-citations-design.md @@ -0,0 +1,291 @@ +# `@ngaf/chat@0.0.21` — Citations + +**Status:** Approved +**Date:** 2026-05-04 +**Target version (synchronized across all @ngaf libs):** `0.0.21` + +## 1. Goals & scope + +Render citations inline (numbered markers like `[1]`, `[2]`) and as a sources panel under each assistant message. Provider-agnostic — both `@ngaf/langgraph` and `@ngaf/ag-ui` adapters populate the same `Message.citations` shape. + +**In scope:** +- `Message.citations?: Citation[]` field + `Citation` interface (in `@ngaf/chat`). +- `` primitive (sources panel under message). +- Markdown citation-reference view component (inline marker via `chat-md-citation-reference` selector). +- `CitationsResolverService` — DI-injected, signal-backed; merges `Message.citations` + markdown sidecar. +- LangGraph adapter populates from `additional_kwargs.citations` / `additional_kwargs.sources`. +- ag-ui adapter populates from STATE_DELTA at JSON Pointer `/citations/{messageId}`. +- Bump `@cacheplane/partial-markdown` peer in `@ngaf/chat` to `^0.2.0` (the published version with citation AST nodes). +- **Synchronize all 16 @ngaf libraries to `0.0.21`** (per project policy: all @ngaf packages share a single version). + +**Out of scope:** global sources sidebar (per-conversation aggregation), citation deduplication across messages, citation export/copy actions, custom citation grouping syntax (`[@a; @b]`), tables and task-list view components in the markdown registry (deferred — citation refs are the only new node type wired in this release; tables/task-lists ship in a later release). + +**Hard constraint:** no copilotkit / chatgpt / chatbot-kit / etc. references in code, comments, commits, PR bodies, or docs. + +--- + +## 2. Type surface + +### 2.1 New `Citation` interface + +```ts +// libs/chat/src/lib/agent/citation.ts +export interface Citation { + /** Stable id used to match `[^id]` markers in Pandoc-formatted content. */ + id: string; + /** 1-based display order. Stable per-message. */ + index: number; + title?: string; + url?: string; + snippet?: string; + /** Provider-specific extras (retrieval score, source type, etc.). */ + extra?: Record; +} +``` + +Exported from `@ngaf/chat` public surface. Lives in its own file under `libs/chat/src/lib/agent/`. + +### 2.2 `Message.citations` field + +```ts +// libs/chat/src/lib/agent/message.ts (modify) +export interface Message { + // ...existing fields (id, role, content, toolCallId, name, reasoning, reasoningDurationMs, extra)... + /** Provider-agnostic citation list. Populated by adapters. */ + citations?: Citation[]; +} +``` + +Mirrors the `reasoning` / `extra` optional-adapter-populated pattern. + +--- + +## 3. Components + +### 3.1 `MarkdownCitationReferenceComponent` + +- Path: `libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts` +- Selector: `chat-md-citation-reference` +- Registered in `cacheplaneMarkdownViews` for the `'citation-reference'` node type. +- Inputs: receives a `MarkdownCitationReferenceNode` via the render-spec `node` input (per existing markdown view pattern). +- Reads from `CitationsResolverService.lookup(refId)`. +- Rendering: + - **Resolved** (Citation found via lookup): `[index]`. `` for typographic conventions. + - **Unresolved** (no Citation): `[index]`. Greyed via styles, not interactive. +- Emits no events (markers are reads only). + +### 3.2 `ChatCitationsComponent` + +- Path: `libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts` +- Selector: `chat-citations` +- Inputs: `[message]: Message` +- Computed: `citations()` from `message().citations ?? []`, sorted by `index` ascending. +- Renders: + - Hidden (returns empty template) when `citations()` is empty. + - Otherwise: header element ("Sources" by default, configurable via `[heading]` input or i18n) + list of cards. +- Slots: ContentChild `` for custom card rendering. Default template uses `ChatCitationsCardComponent`. + +### 3.3 `ChatCitationsCardComponent` + +- Path: `libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts` +- Selector: `chat-citations-card` +- Inputs: `[citation]: Citation` +- Renders: index badge, title (or url if no title), url as href on the title, snippet as small muted text. + +### 3.4 Modifications to existing components + +- **`ChatMessageComponent`** — render `` after the message body slot, before message-actions. Only for assistant messages (`message.role === 'assistant'`). +- **`ChatStreamingMdComponent`** — provide `CitationsResolverService` and feed `markdownDefs.set(...)` from `doc.citations` on each render. Provide a `MESSAGE` injection token (or read `[message]` input — see resolver section) so the resolver can read `Message.citations` for lookups. +- **`cacheplaneMarkdownViews`** — register `MarkdownCitationReferenceComponent` under the `'citation-reference'` key. + +--- + +## 4. CitationsResolverService + +```ts +// libs/chat/src/lib/markdown/citations-resolver.service.ts +import { Injectable, computed, signal, type Signal } from '@angular/core'; +import type { CitationDefinition } from '@cacheplane/partial-markdown'; +import type { Message } from '../agent/message'; +import type { Citation } from '../agent/citation'; + +export interface ResolvedCitation { + source: 'message' | 'markdown'; + citation: Citation; +} + +@Injectable() // provided per-message in ChatStreamingMdComponent or ChatMessageComponent +export class CitationsResolverService { + /** Set by host primitive (chat-message) per-message. */ + readonly message = signal(null); + /** Set by chat-streaming-md from the partial-markdown doc.citations sidecar. */ + readonly markdownDefs = signal>(new Map()); + + /** Returns a signal that re-evaluates when message or markdownDefs change. */ + lookup(refId: string): Signal { + return computed(() => { + const fromMessage = this.message()?.citations?.find(c => c.id === refId); + if (fromMessage) return { source: 'message', citation: fromMessage }; + const fromMd = this.markdownDefs().get(refId); + if (fromMd) return { source: 'markdown', citation: mdDefToCitation(fromMd) }; + return null; + }); + } +} + +function mdDefToCitation(def: CitationDefinition): Citation { + // Walk inline children to extract title + url + snippet. + // First link/autolink → url; preceding text run → title; remaining → snippet. + let url: string | undefined; + const titleParts: string[] = []; + const remainingParts: string[] = []; + let phase: 'before-link' | 'after-link' = 'before-link'; + for (const child of def.children) { + if ((child.type === 'link' || child.type === 'autolink') && url === undefined) { + url = (child as any).url; + phase = 'after-link'; + continue; + } + const text = inlineToText(child); + (phase === 'before-link' ? titleParts : remainingParts).push(text); + } + const title = titleParts.join('').trim() || undefined; + const snippet = remainingParts.join('').trim() || undefined; + return { id: def.id, index: def.index, title, url, snippet }; +} + +function inlineToText(node: unknown): string { + // Collapse a markdown inline subtree to plain text. + // Implementation walks .text on leaf nodes and recurses through container children. + const n = node as { type: string; text?: string; children?: unknown[]; url?: string }; + if (typeof n.text === 'string') return n.text; + if (n.type === 'autolink' && typeof n.url === 'string') return n.url; + if (Array.isArray(n.children)) return n.children.map(inlineToText).join(''); + return ''; +} +``` + +The service is provided **per `chat-streaming-md` instance** (not at the root) — each rendered message has its own resolver. `ChatMessageComponent` provides it and feeds `message()`; `ChatStreamingMdComponent` reads it (via `inject`) and feeds `markdownDefs()`. + +--- + +## 5. Adapter bridges + +### 5.1 LangGraph adapter + +In `libs/langgraph/src/lib/internals/` (or wherever LangChain BaseMessage → Message conversion happens), add: + +```ts +import type { Citation } from '@ngaf/chat'; + +export function extractCitations(msg: { additional_kwargs?: Record }): Citation[] | undefined { + const raw = msg.additional_kwargs?.citations ?? msg.additional_kwargs?.sources; + if (!Array.isArray(raw) || raw.length === 0) return undefined; + return raw.map((entry, i) => normalizeCitation(entry, i + 1)); +} + +function normalizeCitation(entry: unknown, fallbackIndex: number): Citation { + if (typeof entry === 'string') { + return { id: `c${fallbackIndex}`, index: fallbackIndex, url: entry }; + } + const e = (entry ?? {}) as Record; + const get = (...keys: string[]): string | undefined => { + for (const k of keys) if (typeof e[k] === 'string') return e[k] as string; + return undefined; + }; + return { + id: typeof e.id === 'string' ? e.id : (typeof e.refId === 'string' ? e.refId : `c${fallbackIndex}`), + index: typeof e.index === 'number' ? e.index : fallbackIndex, + title: get('title', 'name'), + url: get('url', 'href', 'source'), + snippet: get('snippet', 'content', 'excerpt'), + extra: typeof e.extra === 'object' && e.extra !== null ? e.extra as Record : undefined, + }; +} +``` + +Wired into the existing LangChain → Message conversion: assigns `result.citations = extractCitations(msg) ?? undefined`. Idempotent across re-runs (same input → same output). + +### 5.2 ag-ui adapter + +In `libs/ag-ui/src/lib/reducer.ts`, after the existing thread-state JSON-patch application, scan `state.citations` (a `Record` at the top of thread state) and merge into matching messages: + +```ts +import type { Citation } from '@ngaf/chat'; + +export function bridgeCitationsState(thread: ThreadState, ngafMessages: Message[]): Message[] { + const citationsByMsg = (thread.state as { citations?: Record })?.citations; + if (!citationsByMsg || typeof citationsByMsg !== 'object') return ngafMessages; + return ngafMessages.map(msg => { + const raw = (citationsByMsg as Record)[msg.id]; + if (!Array.isArray(raw) || raw.length === 0) return msg; + return { ...msg, citations: raw.map((entry, i) => normalizeCitation(entry, i + 1)) }; + }); +} +``` + +`normalizeCitation` is duplicated in the ag-ui adapter — same shape and tests as the langgraph version. Per project memory ("shared adapter reducer deferred"), the duplication is intentional. + +--- + +## 6. Streaming semantics + +- LangGraph adapter re-emits `Message.citations` on every message update from the LangGraph thread. +- ag-ui adapter re-emits whenever STATE_DELTA touches `/citations/{messageId}`. +- `CitationsResolverService` is signal-based; updates flow to inline markers and the sources panel automatically. +- **Mid-stream:** if `[^id]` ref appears in content before the matching citation is in `Message.citations`, marker renders unresolved (greyed). When the citation arrives, marker re-renders as a linked active marker. Identity preserved by Angular's standard signal/OnPush flow. + +--- + +## 7. Synchronized version bump (project policy) + +All 16 @ngaf libs bump from their current versions to a unified `0.0.21`: + +| Lib | Current | New | +| --- | --- | --- | +| @ngaf/a2ui | 0.0.2 | 0.0.21 | +| @ngaf/ag-ui | 0.0.3 | 0.0.21 | +| @ngaf/chat | 0.0.20 | 0.0.21 | +| @ngaf/cockpit-docs | 0.0.1 | 0.0.21 | +| @ngaf/cockpit-registry | 0.0.1 | 0.0.21 | +| @ngaf/cockpit-shell | 0.0.1 | 0.0.21 | +| @ngaf/cockpit-testing | 0.0.1 | 0.0.21 | +| @ngaf/cockpit-ui | 0.0.1 | 0.0.21 | +| @ngaf/db | 0.0.1 | 0.0.21 | +| @ngaf/design-tokens | 0.0.1 | 0.0.21 | +| @ngaf/example-layouts | 0.0.1 | 0.0.21 | +| @ngaf/langgraph | 0.0.11 | 0.0.21 | +| @ngaf/licensing | 0.0.2 | 0.0.21 | +| @ngaf/partial-json | 0.0.2 | 0.0.21 | +| @ngaf/render | 0.0.2 | 0.0.21 | +| @ngaf/ui-react | 0.0.1 | 0.0.21 | + +Inter-package peer/dependency ranges (e.g. `@ngaf/render: "*"` in chat's peerDependencies) stay as `*`/wildcard or are explicitly updated to `^0.0.21` where there's a direct version pin. The `@cacheplane/partial-markdown` peer in chat is bumped to `^0.2.0`. + +Tag scheme: a single git tag `ngaf-v0.0.21` at the squash-merge commit. (Per-lib tags like `chat-v0.0.21` deprecated in favor of the unified tag.) + +--- + +## 8. Testing strategy + +**Per-feature unit tests:** +- `MarkdownCitationReferenceComponent` — resolved-from-message renders linked, resolved-from-markdown renders linked, unresolved renders greyed. Reactive: lookup result update re-renders. +- `ChatCitationsComponent` — empty/undefined citations hides; sorted by index; ContentChild template slot override; default card template renders title + url + snippet. +- `CitationsResolverService` — lookup precedence (message > markdown), reactivity, `mdDefToCitation` extracts title/url/snippet from inline children. + +**Adapter unit tests:** +- `langgraph/extractCitations` — string entry, full-object entry, key-spelling variations (url/href/source, title/name, snippet/content/excerpt), absent kwargs, sources-vs-citations fallback. +- `ag-ui/bridgeCitationsState` — citations Record keyed by messageId merges into matching messages, leaves unmatched messages untouched, idempotent re-run, empty Record passes through. + +**Integration tests:** +- End-to-end: assistant message with both Pandoc-formatted defs in content AND structured `Message.citations` — markers resolve from message first, fall back to markdown for unmatched ids. +- Streaming: Message.citations arrives mid-stream; markers flip greyed → active; identity preserved. + +--- + +## 9. Documentation + +- `libs/chat/README.md` (or section) — Citations: API, Citation interface, basic usage, custom card slot. +- `libs/langgraph/README.md` — citations sub-section noting `additional_kwargs.citations` / `additional_kwargs.sources` extraction. +- `libs/ag-ui/README.md` — citations sub-section noting the `state.citations[messageId]` STATE_DELTA path. +- Single CHANGELOG entry (root or per-lib, matching existing convention) with `0.0.21` covering citations + the synchronized version bump policy. From 52b4c8cea96e88a53a22cef4b37913d616763531 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:31:36 -0700 Subject: [PATCH 02/16] docs(plan): @ngaf 0.0.21 citations implementation plan --- .../plans/2026-05-04-chat-citations.md | 972 ++++++++++++++++++ 1 file changed, 972 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-04-chat-citations.md diff --git a/docs/superpowers/plans/2026-05-04-chat-citations.md b/docs/superpowers/plans/2026-05-04-chat-citations.md new file mode 100644 index 000000000..7b5012d46 --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-chat-citations.md @@ -0,0 +1,972 @@ +# `@ngaf/chat@0.0.21` — Citations Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. + +**Goal:** Render citations inline (numbered markers) and as a sources panel under each assistant message; populate via langgraph + ag-ui adapters. + +**Architecture:** `Message.citations?: Citation[]` populated by adapters. `` primitive renders the panel; `MarkdownCitationReferenceComponent` renders inline markers via the markdown view registry. `CitationsResolverService` merges Message + markdown sidecar with message-first precedence. + +**Tech Stack:** Angular 21 standalone components, signals, `*ngComponentOutlet`, `@cacheplane/partial-markdown@^0.2.0`, vitest + @analogjs/vite-plugin-angular. + +**Spec:** `docs/superpowers/specs/2026-05-04-chat-citations-design.md` + +**Working repo:** `/Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac` +**Implementation branch:** `claude/chat-citations-0.0.21` (already created from `origin/main`) + +--- + +## File Map + +**Create:** +- `libs/chat/src/lib/agent/citation.ts` — Citation interface +- `libs/chat/src/lib/markdown/citations-resolver.service.ts` — CitationsResolverService + helpers +- `libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts` + `.spec.ts` +- `libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts` + `.spec.ts` +- `libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts` +- `libs/chat/src/lib/primitives/chat-citations/index.ts` (barrel) +- `libs/langgraph/src/lib/internals/extract-citations.ts` + `.spec.ts` +- `libs/ag-ui/src/lib/bridge-citations-state.ts` + `.spec.ts` + +**Modify:** +- `libs/chat/src/lib/agent/message.ts` — add `citations?` field +- `libs/chat/src/lib/index.ts` — export Citation, CitationsResolverService, ChatCitationsComponent +- `libs/chat/src/lib/markdown/cacheplane-markdown-views.ts` — register `'citation-reference'` → `MarkdownCitationReferenceComponent` +- `libs/chat/src/lib/streaming/streaming-markdown.component.ts` — feed resolver markdownDefs from `doc.citations` +- `libs/chat/src/lib/primitives/chat-message/chat-message.component.ts` — render `` for assistant messages +- `libs/chat/package.json` — `@cacheplane/partial-markdown` peer to `^0.2.0`; version bump +- `libs/langgraph/src/lib/internals/` — call `extractCitations` +- `libs/ag-ui/src/lib/reducer.ts` — call `bridgeCitationsState` after JSON-patch application +- All 16 `libs/*/package.json` — version sync to `0.0.21` + +--- + +## Conventions + +- **TDD:** failing test → implement → green → commit, per task. +- **Commit style:** `feat(chat): ...`, `feat(langgraph): ...`, `feat(ag-ui): ...`, `chore(release): ...`. +- **Hard constraint:** no copilotkit / chatgpt / chatbot-kit / etc. references anywhere. +- **Test command:** `npx nx run :test` (e.g. `npx nx run chat:test`). +- **Lint command:** `npx nx run :lint`. +- **All chat selectors are prefixed `chat-` or `chat-md-`** to satisfy `@angular-eslint/component-selector` rule. + +--- + +## Phase 1 — Citation type + Message field + +### Task 1: Citation interface + +**Files:** Create `libs/chat/src/lib/agent/citation.ts`. + +- [ ] Step 1: Write file: + +```ts +// SPDX-License-Identifier: MIT + +/** + * Provider-agnostic citation entry. Populated by adapters from message + * metadata (LangGraph additional_kwargs.citations, ag-ui STATE_DELTA at + * /citations/{messageId}). Pandoc-formatted [^id]: ... defs in message + * content remain in the markdown AST sidecar and are merged via + * CitationsResolverService at render time. + */ +export interface Citation { + /** Stable id used to match `[^id]` markers in Pandoc-formatted content. */ + id: string; + /** 1-based display order. Stable per-message. */ + index: number; + title?: string; + url?: string; + snippet?: string; + /** Provider-specific extras (retrieval score, source type, etc.). */ + extra?: Record; +} +``` + +- [ ] Step 2: Commit `feat(chat): add Citation interface`. + +### Task 2: Add Message.citations field + export + +**Files:** Modify `libs/chat/src/lib/agent/message.ts`, `libs/chat/src/index.ts` (or wherever public exports live). + +- [ ] Step 1: In `message.ts`, add the import and the optional field: + +```ts +import type { Citation } from './citation'; +``` + +In the `Message` interface, after `extra?` add: + +```ts + /** Provider-agnostic citation list. Populated by adapters. */ + citations?: Citation[]; +``` + +- [ ] Step 2: Re-export `Citation` from the chat library's public surface. Find `libs/chat/src/index.ts` (or `libs/chat/src/lib/index.ts`) and add: + +```ts +export type { Citation } from './lib/agent/citation'; +``` + +- [ ] Step 3: Run `npx nx run chat:lint && npx nx run chat:test`. Expected: green. +- [ ] Step 4: Commit `feat(chat): add Message.citations field`. + +--- + +## Phase 2 — CitationsResolverService + +### Task 3: Create CitationsResolverService + mdDefToCitation + tests + +**Files:** Create `libs/chat/src/lib/markdown/citations-resolver.service.ts` + `.spec.ts`. + +- [ ] Step 1: Write the spec first (TDD): + +```ts +// libs/chat/src/lib/markdown/citations-resolver.service.spec.ts +// SPDX-License-Identifier: MIT +import { TestBed } from '@angular/core/testing'; +import { CitationsResolverService } from './citations-resolver.service'; +import type { Message } from '../agent/message'; +import type { CitationDefinition } from '@cacheplane/partial-markdown'; + +describe('CitationsResolverService', () => { + let svc: CitationsResolverService; + beforeEach(() => { + TestBed.configureTestingModule({ providers: [CitationsResolverService] }); + svc = TestBed.inject(CitationsResolverService); + }); + + it('returns null when no source matches', () => { + expect(svc.lookup('missing')()).toBeNull(); + }); + + it('resolves from Message.citations first', () => { + const msg: Message = { + id: 'm1', role: 'assistant', content: 'x', + citations: [{ id: 'src1', index: 1, title: 'From message', url: 'https://m.example' }], + }; + svc.message.set(msg); + const result = svc.lookup('src1')(); + expect(result?.source).toBe('message'); + expect(result?.citation.title).toBe('From message'); + }); + + it('falls back to markdown sidecar', () => { + const def: CitationDefinition = { + id: 'src1', index: 1, status: 'complete', + children: [ + { id: 1, type: 'text', status: 'complete', parent: null, index: 0, text: 'Title ' } as any, + { id: 2, type: 'autolink', status: 'complete', parent: null, index: 1, url: 'https://md.example', text: 'https://md.example' } as any, + { id: 3, type: 'text', status: 'complete', parent: null, index: 2, text: ' the rest' } as any, + ], + }; + svc.markdownDefs.set(new Map([['src1', def]])); + const result = svc.lookup('src1')(); + expect(result?.source).toBe('markdown'); + expect(result?.citation.title).toBe('Title'); + expect(result?.citation.url).toBe('https://md.example'); + expect(result?.citation.snippet).toBe('the rest'); + }); + + it('Message.citations precedence over markdown sidecar', () => { + const msg: Message = { + id: 'm1', role: 'assistant', content: 'x', + citations: [{ id: 'src1', index: 1, title: 'From message' }], + }; + const def: CitationDefinition = { + id: 'src1', index: 1, status: 'complete', + children: [{ id: 1, type: 'text', status: 'complete', parent: null, index: 0, text: 'From md' } as any], + }; + svc.message.set(msg); + svc.markdownDefs.set(new Map([['src1', def]])); + expect(svc.lookup('src1')()?.source).toBe('message'); + }); + + it('reactive — updates flow through signal', () => { + const lookup = svc.lookup('src1'); + expect(lookup()).toBeNull(); + svc.message.set({ + id: 'm1', role: 'assistant', content: 'x', + citations: [{ id: 'src1', index: 1, title: 'A' }], + }); + expect(lookup()?.citation.title).toBe('A'); + }); +}); +``` + +- [ ] Step 2: Run `npx nx run chat:test` — expect FAIL (service doesn't exist). + +- [ ] Step 3: Implement the service: + +```ts +// libs/chat/src/lib/markdown/citations-resolver.service.ts +// SPDX-License-Identifier: MIT +import { Injectable, computed, signal, type Signal } from '@angular/core'; +import type { CitationDefinition } from '@cacheplane/partial-markdown'; +import type { Message } from '../agent/message'; +import type { Citation } from '../agent/citation'; + +export interface ResolvedCitation { + source: 'message' | 'markdown'; + citation: Citation; +} + +@Injectable() +export class CitationsResolverService { + readonly message = signal(null); + readonly markdownDefs = signal>(new Map()); + + lookup(refId: string): Signal { + return computed(() => { + const fromMessage = this.message()?.citations?.find(c => c.id === refId); + if (fromMessage) return { source: 'message', citation: fromMessage }; + const fromMd = this.markdownDefs().get(refId); + if (fromMd) return { source: 'markdown', citation: mdDefToCitation(fromMd) }; + return null; + }); + } +} + +export function mdDefToCitation(def: CitationDefinition): Citation { + let url: string | undefined; + const titleParts: string[] = []; + const snippetParts: string[] = []; + let phase: 'before' | 'after' = 'before'; + for (const child of def.children) { + if ((child.type === 'link' || child.type === 'autolink') && url === undefined) { + url = (child as { url?: string }).url; + phase = 'after'; + continue; + } + const t = inlineToText(child); + (phase === 'before' ? titleParts : snippetParts).push(t); + } + const title = titleParts.join('').trim() || undefined; + const snippet = snippetParts.join('').trim() || undefined; + return { id: def.id, index: def.index, title, url, snippet }; +} + +function inlineToText(node: unknown): string { + const n = node as { type: string; text?: string; children?: unknown[]; url?: string }; + if (typeof n.text === 'string') return n.text; + if (n.type === 'autolink' && typeof n.url === 'string') return n.url; + if (Array.isArray(n.children)) return n.children.map(inlineToText).join(''); + return ''; +} +``` + +- [ ] Step 4: Run `npx nx run chat:test` — expect PASS. +- [ ] Step 5: Export `CitationsResolverService` and `ResolvedCitation` from chat's public surface. +- [ ] Step 6: Commit `feat(chat): add CitationsResolverService`. + +--- + +## Phase 3 — MarkdownCitationReferenceComponent + +### Task 4: Inline marker component + register in view registry + +**Files:** Create `libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts` + `.spec.ts`. Modify `libs/chat/src/lib/markdown/cacheplane-markdown-views.ts`. + +- [ ] Step 1: Write the spec: + +```ts +// libs/chat/src/lib/markdown/views/markdown-citation-reference.component.spec.ts +// SPDX-License-Identifier: MIT +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CitationsResolverService } from '../citations-resolver.service'; +import { MarkdownCitationReferenceComponent } from './markdown-citation-reference.component'; +import type { MarkdownCitationReferenceNode } from '@cacheplane/partial-markdown'; + +function makeNode(refId: string, index: number, resolved: boolean): MarkdownCitationReferenceNode { + return { + id: 1, type: 'citation-reference', status: 'complete', + parent: null, index, refId, resolved, + } as MarkdownCitationReferenceNode; +} + +@Component({ + standalone: true, + imports: [MarkdownCitationReferenceComponent], + providers: [CitationsResolverService], + template: ``, +}) +class HostComponent { + node = signal(makeNode('src1', 1, false)); +} + +describe('MarkdownCitationReferenceComponent', () => { + it('renders unresolved marker when no citation found', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const span = fixture.nativeElement.querySelector('span.chat-citation-marker'); + expect(span).toBeTruthy(); + expect(span.classList.contains('chat-citation-marker--unresolved')).toBe(true); + expect(span.textContent).toContain('1'); + }); + + it('renders linked marker when citation found via Message', () => { + const fixture = TestBed.createComponent(HostComponent); + const svc = fixture.debugElement.injector.get(CitationsResolverService); + svc.message.set({ + id: 'm1', role: 'assistant', content: 'x', + citations: [{ id: 'src1', index: 1, title: 'Source', url: 'https://example.com' }], + }); + fixture.componentInstance.node.set(makeNode('src1', 1, true)); + fixture.detectChanges(); + const a = fixture.nativeElement.querySelector('a.chat-citation-marker'); + expect(a).toBeTruthy(); + expect(a.getAttribute('href')).toBe('https://example.com'); + expect(a.textContent).toContain('1'); + }); +}); +``` + +- [ ] Step 2: Run test — expect FAIL. + +- [ ] Step 3: Implement: + +```ts +// libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import type { MarkdownCitationReferenceNode } from '@cacheplane/partial-markdown'; +import { CitationsResolverService } from '../citations-resolver.service'; + +@Component({ + selector: 'chat-md-citation-reference', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (resolved(); as r) { + + [{{ node().index }}] + + } @else { + + [{{ node().index }}] + + } + `, +}) +export class MarkdownCitationReferenceComponent { + readonly node = input.required(); + private readonly resolver = inject(CitationsResolverService); + protected readonly resolved = computed(() => { + const lookup = this.resolver.lookup(this.node().refId); + return lookup(); + }); +} +``` + +- [ ] Step 4: Register in `cacheplane-markdown-views.ts`: + +```ts +import { MarkdownCitationReferenceComponent } from './views/markdown-citation-reference.component'; +// ... +export const cacheplaneMarkdownViews: ViewRegistry = views({ + // ...existing entries... + 'citation-reference': MarkdownCitationReferenceComponent, +}); +``` + +- [ ] Step 5: Run `npx nx run chat:test`. Expect green. +- [ ] Step 6: Commit `feat(chat): add markdown citation-reference view component`. + +--- + +## Phase 4 — chat-citations primitive + +### Task 5: ChatCitationsCardComponent + ChatCitationsComponent + spec + +**Files:** Create `libs/chat/src/lib/primitives/chat-citations/`: +- `chat-citations-card.component.ts` +- `chat-citations.component.ts` +- `chat-citations.component.spec.ts` +- `index.ts` + +- [ ] Step 1: Write the card component: + +```ts +// libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import type { Citation } from '../../agent/citation'; + +@Component({ + selector: 'chat-citations-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
{{ citation().index }}
+
+ @if (citation().url; as url) { + + {{ citation().title ?? url }} + + } @else if (citation().title) { + {{ citation().title }} + } + @if (citation().snippet; as s) { +

{{ s }}

+ } +
+
+ `, +}) +export class ChatCitationsCardComponent { + readonly citation = input.required(); +} +``` + +- [ ] Step 2: Write the panel component: + +```ts +// libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts +// SPDX-License-Identifier: MIT +import { + ChangeDetectionStrategy, Component, ContentChild, Directive, TemplateRef, + computed, input, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Message } from '../../agent/message'; +import type { Citation } from '../../agent/citation'; +import { ChatCitationsCardComponent } from './chat-citations-card.component'; + +/** + * ContentChild template directive for custom citation card rendering. + * Usage: ... + */ +@Directive({ selector: 'ng-template[chatCitationCard]', standalone: true }) +export class ChatCitationCardTemplateDirective { + constructor(public readonly tpl: TemplateRef<{ $implicit: Citation }>) {} +} + +@Component({ + selector: 'chat-citations', + standalone: true, + imports: [NgTemplateOutlet, ChatCitationsCardComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (citations().length > 0) { +
+

{{ heading() }}

+
    + @for (c of citations(); track c.id) { +
  • + @if (cardTpl) { + + } @else { + + } +
  • + } +
+
+ } + `, +}) +export class ChatCitationsComponent { + readonly message = input.required(); + readonly heading = input('Sources'); + + @ContentChild(ChatCitationCardTemplateDirective) cardTpl: ChatCitationCardTemplateDirective | null = null; + + protected readonly citations = computed(() => { + const list = this.message().citations ?? []; + return [...list].sort((a, b) => a.index - b.index); + }); +} +``` + +- [ ] Step 3: Write the spec: + +```ts +// libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts +// SPDX-License-Identifier: MIT +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ChatCitationsComponent, ChatCitationCardTemplateDirective } from './chat-citations.component'; +import type { Message } from '../../agent/message'; + +function msg(citations: Message['citations']): Message { + return { id: 'm1', role: 'assistant', content: 'x', citations }; +} + +@Component({ + standalone: true, + imports: [ChatCitationsComponent], + template: ``, +}) +class HostComponent { + message = signal(msg(undefined)); +} + +describe('ChatCitationsComponent', () => { + it('renders nothing when citations is undefined', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-citations')).toBeNull(); + }); + + it('renders nothing when citations is empty', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.message.set(msg([])); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-citations')).toBeNull(); + }); + + it('renders citations sorted by index', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.message.set(msg([ + { id: 'b', index: 2, title: 'B' }, + { id: 'a', index: 1, title: 'A' }, + ])); + fixture.detectChanges(); + const titles = Array.from(fixture.nativeElement.querySelectorAll('.chat-citations-card__title')) + .map((el: any) => el.textContent.trim()); + expect(titles).toEqual(['A', 'B']); + }); + + it('uses ContentChild template slot when provided', () => { + @Component({ + standalone: true, + imports: [ChatCitationsComponent, ChatCitationCardTemplateDirective], + template: ` + + + {{ c.title }} + + + `, + }) + class CustomHost { + message: Message = msg([{ id: 'a', index: 1, title: 'Custom' }]); + } + const fixture = TestBed.createComponent(CustomHost); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.custom-card')?.textContent.trim()).toBe('Custom'); + expect(fixture.nativeElement.querySelector('.chat-citations-card')).toBeNull(); + }); +}); +``` + +- [ ] Step 4: Write the barrel `libs/chat/src/lib/primitives/chat-citations/index.ts`: + +```ts +export { ChatCitationsComponent, ChatCitationCardTemplateDirective } from './chat-citations.component'; +export { ChatCitationsCardComponent } from './chat-citations-card.component'; +``` + +- [ ] Step 5: Re-export from chat's public surface. Find the existing primitives barrel pattern (look at how `chat-reasoning` is exported) and mirror it. + +- [ ] Step 6: Run `npx nx run chat:test`. Expect green. +- [ ] Step 7: Commit `feat(chat): add chat-citations primitive (sources panel)`. + +--- + +## Phase 5 — Wire into ChatMessage and ChatStreamingMd + +### Task 6: Render `` in `` (assistant only) + +**Files:** Modify `libs/chat/src/lib/primitives/chat-message/chat-message.component.ts`. + +- [ ] Step 1: Locate the template position immediately after the message body slot and before message-actions. +- [ ] Step 2: Add `` for assistant messages: + +```html +@if (message().role === 'assistant') { + +} +``` + +- [ ] Step 3: Add `ChatCitationsComponent` to the component's `imports` array. +- [ ] Step 4: Run `npx nx run chat:test`. Expect green (existing chat-message tests still pass). +- [ ] Step 5: Commit `feat(chat): render chat-citations under assistant messages`. + +### Task 7: Provide CitationsResolverService at chat-message level + feed markdown sidecar + +**Files:** Modify `libs/chat/src/lib/primitives/chat-message/chat-message.component.ts`, `libs/chat/src/lib/streaming/streaming-markdown.component.ts`. + +- [ ] Step 1: In `ChatMessageComponent`, add to `providers`: + +```ts +providers: [CitationsResolverService], +``` + +In an `effect()` (created in constructor), set the resolver's message: + +```ts +constructor() { + effect(() => { + this.resolver.message.set(this.message()); + }); +} +private readonly resolver = inject(CitationsResolverService); +``` + +- [ ] Step 2: In `ChatStreamingMdComponent`, inject the resolver and feed `markdownDefs` from `doc.citations` whenever the root computes: + +```ts +private readonly resolver = inject(CitationsResolverService, { optional: true }); + +constructor() { + effect(() => { + const r = this.root(); + if (this.resolver && r) { + this.resolver.markdownDefs.set(r.citations ?? new Map()); + } + }); +} +``` + +(`{ optional: true }` because chat-streaming-md may be used outside of chat-message in tests.) + +- [ ] Step 3: Run `npx nx run chat:test`. Existing tests still pass; markdown citation refs in assistant messages now resolve via the chained injector. +- [ ] Step 4: Commit `feat(chat): wire CitationsResolverService through chat-message + chat-streaming-md`. + +--- + +## Phase 6 — LangGraph adapter + +### Task 8: extractCitations + tests + integration + +**Files:** +- Create: `libs/langgraph/src/lib/internals/extract-citations.ts` + `.spec.ts` +- Modify: the existing LangChain message → ngaf Message conversion file (likely `libs/langgraph/src/lib/internals/messages.ts` or similar — locate by grep for "additional_kwargs" or "BaseMessage"). + +- [ ] Step 1: Write the spec: + +```ts +// libs/langgraph/src/lib/internals/extract-citations.spec.ts +// SPDX-License-Identifier: MIT +import { extractCitations } from './extract-citations'; + +describe('extractCitations', () => { + it('returns undefined when no citations or sources', () => { + expect(extractCitations({ additional_kwargs: {} })).toBeUndefined(); + expect(extractCitations({})).toBeUndefined(); + }); + + it('reads additional_kwargs.citations', () => { + const result = extractCitations({ + additional_kwargs: { citations: [{ id: 'a', title: 'Title A', url: 'https://a' }] }, + }); + expect(result).toEqual([{ id: 'a', index: 1, title: 'Title A', url: 'https://a' }]); + }); + + it('falls back to additional_kwargs.sources', () => { + const result = extractCitations({ + additional_kwargs: { sources: [{ id: 'b', title: 'B', url: 'https://b' }] }, + }); + expect(result).toEqual([{ id: 'b', index: 1, title: 'B', url: 'https://b' }]); + }); + + it('handles string entries (URL only)', () => { + expect(extractCitations({ additional_kwargs: { citations: ['https://x'] } })) + .toEqual([{ id: 'c1', index: 1, url: 'https://x' }]); + }); + + it('coerces key spellings (href/source, name, content/excerpt)', () => { + expect(extractCitations({ + additional_kwargs: { + citations: [ + { name: 'N', href: 'https://h', content: 'C' }, + { name: 'O', source: 'https://s', excerpt: 'E' }, + ], + }, + })).toEqual([ + { id: 'c1', index: 1, title: 'N', url: 'https://h', snippet: 'C' }, + { id: 'c2', index: 2, title: 'O', url: 'https://s', snippet: 'E' }, + ]); + }); + + it('preserves explicit index when provided', () => { + expect(extractCitations({ + additional_kwargs: { citations: [{ id: 'a', index: 5, title: 'A' }] }, + })).toEqual([{ id: 'a', index: 5, title: 'A' }]); + }); + + it('returns undefined for empty array', () => { + expect(extractCitations({ additional_kwargs: { citations: [] } })).toBeUndefined(); + }); +}); +``` + +- [ ] Step 2: Run `npx nx run langgraph:test` — expect FAIL. + +- [ ] Step 3: Implement: + +```ts +// libs/langgraph/src/lib/internals/extract-citations.ts +// SPDX-License-Identifier: MIT +import type { Citation } from '@ngaf/chat'; + +interface KwargsLike { + additional_kwargs?: Record | undefined; +} + +export function extractCitations(msg: KwargsLike): Citation[] | undefined { + const raw = msg.additional_kwargs?.['citations'] ?? msg.additional_kwargs?.['sources']; + if (!Array.isArray(raw) || raw.length === 0) return undefined; + return raw.map((entry, i) => normalizeCitation(entry, i + 1)); +} + +function normalizeCitation(entry: unknown, fallbackIndex: number): Citation { + if (typeof entry === 'string') { + return { id: `c${fallbackIndex}`, index: fallbackIndex, url: entry }; + } + const e = (entry ?? {}) as Record; + const str = (key: string): string | undefined => + typeof e[key] === 'string' ? (e[key] as string) : undefined; + const firstStr = (...keys: string[]): string | undefined => { + for (const k of keys) { + const v = str(k); + if (v !== undefined) return v; + } + return undefined; + }; + return { + id: str('id') ?? str('refId') ?? `c${fallbackIndex}`, + index: typeof e['index'] === 'number' ? (e['index'] as number) : fallbackIndex, + title: firstStr('title', 'name'), + url: firstStr('url', 'href', 'source'), + snippet: firstStr('snippet', 'content', 'excerpt'), + extra: + typeof e['extra'] === 'object' && e['extra'] !== null + ? (e['extra'] as Record) + : undefined, + }; +} +``` + +- [ ] Step 4: Wire into the existing message conversion. Find the file that maps LangChain BaseMessage → `Message` (search for `additional_kwargs`, `role: 'assistant'`, or `reasoning?:`). After the existing message construction, set: + +```ts +const citations = extractCitations(rawLcMessage); +if (citations) (result as Message).citations = citations; +``` + +- [ ] Step 5: Run `npx nx run langgraph:test`. Expect green. +- [ ] Step 6: Commit `feat(langgraph): extract citations from additional_kwargs`. + +--- + +## Phase 7 — ag-ui adapter + +### Task 9: bridgeCitationsState + tests + integration + +**Files:** +- Create: `libs/ag-ui/src/lib/bridge-citations-state.ts` + `.spec.ts` +- Modify: `libs/ag-ui/src/lib/reducer.ts` + +- [ ] Step 1: Write the spec: + +```ts +// libs/ag-ui/src/lib/bridge-citations-state.spec.ts +// SPDX-License-Identifier: MIT +import { bridgeCitationsState } from './bridge-citations-state'; +import type { Message } from '@ngaf/chat'; + +describe('bridgeCitationsState', () => { + const baseMsg = (id: string): Message => ({ id, role: 'assistant', content: 'x' }); + + it('returns messages unchanged when state has no citations', () => { + const msgs = [baseMsg('m1'), baseMsg('m2')]; + const result = bridgeCitationsState({ state: {} }, msgs); + expect(result).toEqual(msgs); + }); + + it('merges citations into matching messages by id', () => { + const result = bridgeCitationsState( + { state: { citations: { m1: [{ id: 'a', title: 'A', url: 'https://a' }] } } }, + [baseMsg('m1'), baseMsg('m2')], + ); + expect(result[0].citations).toEqual([{ id: 'a', index: 1, title: 'A', url: 'https://a' }]); + expect(result[1].citations).toBeUndefined(); + }); + + it('idempotent — same input produces same output', () => { + const state = { state: { citations: { m1: [{ id: 'a', title: 'A' }] } } }; + const msgs = [baseMsg('m1')]; + const a = bridgeCitationsState(state, msgs); + const b = bridgeCitationsState(state, a); + expect(b[0].citations).toEqual(a[0].citations); + }); + + it('coerces key spellings (href/source, name, excerpt)', () => { + const result = bridgeCitationsState( + { state: { citations: { m1: [{ name: 'N', href: 'https://h', excerpt: 'E' }] } } }, + [baseMsg('m1')], + ); + expect(result[0].citations).toEqual([ + { id: 'c1', index: 1, title: 'N', url: 'https://h', snippet: 'E' }, + ]); + }); + + it('handles string entries', () => { + const result = bridgeCitationsState( + { state: { citations: { m1: ['https://x'] } } }, + [baseMsg('m1')], + ); + expect(result[0].citations).toEqual([{ id: 'c1', index: 1, url: 'https://x' }]); + }); +}); +``` + +- [ ] Step 2: Run test — expect FAIL. + +- [ ] Step 3: Implement: + +```ts +// libs/ag-ui/src/lib/bridge-citations-state.ts +// SPDX-License-Identifier: MIT +import type { Citation, Message } from '@ngaf/chat'; + +interface ThreadStateLike { + state?: Record; +} + +export function bridgeCitationsState(thread: ThreadStateLike, messages: Message[]): Message[] { + const citationsByMsg = (thread.state as { citations?: unknown })?.citations; + if (!citationsByMsg || typeof citationsByMsg !== 'object') return messages; + const map = citationsByMsg as Record; + return messages.map(msg => { + const raw = map[msg.id]; + if (!Array.isArray(raw) || raw.length === 0) return msg; + return { ...msg, citations: raw.map((entry, i) => normalizeCitation(entry, i + 1)) }; + }); +} + +function normalizeCitation(entry: unknown, fallbackIndex: number): Citation { + if (typeof entry === 'string') { + return { id: `c${fallbackIndex}`, index: fallbackIndex, url: entry }; + } + const e = (entry ?? {}) as Record; + const str = (key: string): string | undefined => + typeof e[key] === 'string' ? (e[key] as string) : undefined; + const firstStr = (...keys: string[]): string | undefined => { + for (const k of keys) { + const v = str(k); + if (v !== undefined) return v; + } + return undefined; + }; + return { + id: str('id') ?? str('refId') ?? `c${fallbackIndex}`, + index: typeof e['index'] === 'number' ? (e['index'] as number) : fallbackIndex, + title: firstStr('title', 'name'), + url: firstStr('url', 'href', 'source'), + snippet: firstStr('snippet', 'content', 'excerpt'), + extra: + typeof e['extra'] === 'object' && e['extra'] !== null + ? (e['extra'] as Record) + : undefined, + }; +} +``` + +- [ ] Step 4: Wire into `reducer.ts` — call `bridgeCitationsState(thread, messages)` after the existing JSON-patch application step. The exact integration depends on the reducer shape; the implementer should locate where `messages` are produced and pipe them through this bridge. + +- [ ] Step 5: Run `npx nx run ag-ui:test`. Expect green. +- [ ] Step 6: Commit `feat(ag-ui): bridge state.citations into Message.citations`. + +--- + +## Phase 8 — Synchronize all @ngaf libs to 0.0.21 + +### Task 10: Bump all 16 @ngaf lib versions to 0.0.21 + +**Files:** All `libs/*/package.json`. + +- [ ] Step 1: For each of: + - `libs/a2ui/package.json` + - `libs/ag-ui/package.json` + - `libs/chat/package.json` + - `libs/cockpit-docs/package.json` + - `libs/cockpit-registry/package.json` + - `libs/cockpit-shell/package.json` + - `libs/cockpit-testing/package.json` + - `libs/cockpit-ui/package.json` + - `libs/db/package.json` + - `libs/design-tokens/package.json` + - `libs/example-layouts/package.json` + - `libs/langgraph/package.json` + - `libs/licensing/package.json` + - `libs/partial-json/package.json` + - `libs/render/package.json` + - `libs/ui-react/package.json` + + Set `"version": "0.0.21"`. + +- [ ] Step 2: In `libs/chat/package.json`, also update the `@cacheplane/partial-markdown` peer: + +```json +"@cacheplane/partial-markdown": "^0.2.0", +``` + +(Currently `^0.1.0`.) + +- [ ] Step 3: Verify no peer/dep ranges that pin specific @ngaf versions need updating. (Most use `*` per existing repo convention.) +- [ ] Step 4: Run `npx nx run-many --target=lint --projects=chat,langgraph,ag-ui,a2ui,render,licensing` and `npx nx run-many --target=test --projects=chat,langgraph,ag-ui,a2ui,render,licensing`. Expect green. +- [ ] Step 5: Commit `chore(release): synchronize all @ngaf libs to 0.0.21`. + +--- + +## Phase 9 — Documentation + +### Task 11: README + CHANGELOG updates + +**Files:** `libs/chat/README.md`, `libs/langgraph/README.md`, `libs/ag-ui/README.md`, root or per-lib CHANGELOG. + +- [ ] Step 1: Add a "Citations" section to `libs/chat/README.md` covering: Citation interface, `Message.citations` field, `` primitive usage, custom card slot, inline marker rendering via the markdown view registry. + +- [ ] Step 2: Add a citations sub-section to `libs/langgraph/README.md` documenting `additional_kwargs.citations` / `additional_kwargs.sources` extraction with shape examples. + +- [ ] Step 3: Add a citations sub-section to `libs/ag-ui/README.md` documenting the `state.citations[messageId]` STATE_DELTA path with shape examples. + +- [ ] Step 4: Add `0.0.21` entry to the project's CHANGELOG.md (or per-lib CHANGELOGs if that's the convention) covering: citations primitive + adapter bridges + synchronized version bump policy. + +- [ ] Step 5: Commit `docs: chat citations + adapter bridge documentation`. + +--- + +## Phase 10 — Release + +### Task 12: Push, PR, merge on green, tag + +- [ ] Step 1: Push branch: + +```bash +git push -u origin claude/chat-citations-0.0.21 +``` + +- [ ] Step 2: Open PR: + +```bash +gh pr create --title "feat: citations + sync all @ngaf to 0.0.21" --body "..." +``` + +PR body covers: citations summary, type surface, adapter bridges, synchronized version bump rationale, test plan checklist. + +- [ ] Step 3: Wait for CI green. If failing, fix root cause; do NOT skip checks. + +- [ ] Step 4: Squash-merge: + +```bash +gh pr merge --squash --delete-branch +``` + +- [ ] Step 5: Tag at squash-merge commit: + +```bash +gh api -X POST repos/cacheplane/angular-agent-framework/git/refs -f ref=refs/tags/ngaf-v0.0.21 -f sha= +``` + +(Single unified tag, not per-lib tags.) + +- [ ] Step 6: Done. From 97599c3a2f020ad46b25c4778cffaba54d0badef Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:33:16 -0700 Subject: [PATCH 03/16] feat(chat): add Citation interface --- libs/chat/src/lib/agent/citation.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 libs/chat/src/lib/agent/citation.ts diff --git a/libs/chat/src/lib/agent/citation.ts b/libs/chat/src/lib/agent/citation.ts new file mode 100644 index 000000000..65ff3ce09 --- /dev/null +++ b/libs/chat/src/lib/agent/citation.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT + +/** + * Provider-agnostic citation entry. Populated by adapters from message + * metadata (LangGraph additional_kwargs.citations, ag-ui STATE_DELTA at + * /citations/{messageId}). Pandoc-formatted [^id]: ... defs in message + * content remain in the markdown AST sidecar and are merged via + * CitationsResolverService at render time. + */ +export interface Citation { + /** Stable id used to match `[^id]` markers in Pandoc-formatted content. */ + id: string; + /** 1-based display order. Stable per-message. */ + index: number; + title?: string; + url?: string; + snippet?: string; + /** Provider-specific extras (retrieval score, source type, etc.). */ + extra?: Record; +} From c4ffcc8670053ebd80a7da3e44a67dcdc69dc127 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:33:58 -0700 Subject: [PATCH 04/16] feat(chat): add Message.citations field --- libs/chat/package.json | 2 +- libs/chat/src/lib/agent/index.ts | 1 + libs/chat/src/lib/agent/message.ts | 3 +++ libs/chat/src/public-api.ts | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/libs/chat/package.json b/libs/chat/package.json index 77f58e75a..87b58a959 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@cacheplane/partial-json": "^0.1.1", - "@cacheplane/partial-markdown": "^0.1.0" + "@cacheplane/partial-markdown": "^0.2.0" }, "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", diff --git a/libs/chat/src/lib/agent/index.ts b/libs/chat/src/lib/agent/index.ts index eb129d31d..e9b08cb5f 100644 --- a/libs/chat/src/lib/agent/index.ts +++ b/libs/chat/src/lib/agent/index.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT export type { Agent } from './agent'; +export type { Citation } from './citation'; export type { Message, Role } from './message'; export { isUserMessage, isAssistantMessage, isToolMessage, isSystemMessage } from './message'; export type { ContentBlock } from './content-block'; diff --git a/libs/chat/src/lib/agent/message.ts b/libs/chat/src/lib/agent/message.ts index 210b06d7a..5f2766a06 100644 --- a/libs/chat/src/lib/agent/message.ts +++ b/libs/chat/src/lib/agent/message.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT import type { ContentBlock } from './content-block'; +import type { Citation } from './citation'; export type Role = 'user' | 'assistant' | 'system' | 'tool'; @@ -30,6 +31,8 @@ export interface Message { reasoningDurationMs?: number; /** Runtime-specific extras; do not rely on shape in portable code. */ extra?: Record; + /** Provider-agnostic citation list. Populated by adapters. */ + citations?: Citation[]; } export function isUserMessage(m: Message): m is Message & { role: 'user' } { diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 3ccac1ebe..4207fabf6 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -8,6 +8,7 @@ export type { MessageTemplateType } from './lib/chat.types'; export type { Agent, AgentWithHistory, + Citation, Message, Role, ContentBlock, From 632be3e19fff05d6f0d41379b4b8896c28835569 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:35:46 -0700 Subject: [PATCH 05/16] feat(chat): add CitationsResolverService --- .../citations-resolver.service.spec.ts | 70 +++++++++++++++++++ .../markdown/citations-resolver.service.ts | 54 ++++++++++++++ libs/chat/src/public-api.ts | 4 ++ 3 files changed, 128 insertions(+) create mode 100644 libs/chat/src/lib/markdown/citations-resolver.service.spec.ts create mode 100644 libs/chat/src/lib/markdown/citations-resolver.service.ts diff --git a/libs/chat/src/lib/markdown/citations-resolver.service.spec.ts b/libs/chat/src/lib/markdown/citations-resolver.service.spec.ts new file mode 100644 index 000000000..1e69117dc --- /dev/null +++ b/libs/chat/src/lib/markdown/citations-resolver.service.spec.ts @@ -0,0 +1,70 @@ +// libs/chat/src/lib/markdown/citations-resolver.service.spec.ts +// SPDX-License-Identifier: MIT +import { TestBed } from '@angular/core/testing'; +import { CitationsResolverService } from './citations-resolver.service'; +import type { Message } from '../agent/message'; +import type { CitationDefinition } from '@cacheplane/partial-markdown'; + +describe('CitationsResolverService', () => { + let svc: CitationsResolverService; + beforeEach(() => { + TestBed.configureTestingModule({ providers: [CitationsResolverService] }); + svc = TestBed.inject(CitationsResolverService); + }); + + it('returns null when no source matches', () => { + expect(svc.lookup('missing')()).toBeNull(); + }); + + it('resolves from Message.citations first', () => { + const msg: Message = { + id: 'm1', role: 'assistant', content: 'x', + citations: [{ id: 'src1', index: 1, title: 'From message', url: 'https://m.example' }], + }; + svc.message.set(msg); + const result = svc.lookup('src1')(); + expect(result?.source).toBe('message'); + expect(result?.citation.title).toBe('From message'); + }); + + it('falls back to markdown sidecar', () => { + const def: CitationDefinition = { + id: 'src1', index: 1, status: 'complete', + children: [ + { id: 1, type: 'text', status: 'complete', parent: null, index: 0, text: 'Title ' } as any, + { id: 2, type: 'autolink', status: 'complete', parent: null, index: 1, url: 'https://md.example', text: 'https://md.example' } as any, + { id: 3, type: 'text', status: 'complete', parent: null, index: 2, text: ' the rest' } as any, + ], + }; + svc.markdownDefs.set(new Map([['src1', def]])); + const result = svc.lookup('src1')(); + expect(result?.source).toBe('markdown'); + expect(result?.citation.title).toBe('Title'); + expect(result?.citation.url).toBe('https://md.example'); + expect(result?.citation.snippet).toBe('the rest'); + }); + + it('Message.citations precedence over markdown sidecar', () => { + const msg: Message = { + id: 'm1', role: 'assistant', content: 'x', + citations: [{ id: 'src1', index: 1, title: 'From message' }], + }; + const def: CitationDefinition = { + id: 'src1', index: 1, status: 'complete', + children: [{ id: 1, type: 'text', status: 'complete', parent: null, index: 0, text: 'From md' } as any], + }; + svc.message.set(msg); + svc.markdownDefs.set(new Map([['src1', def]])); + expect(svc.lookup('src1')()?.source).toBe('message'); + }); + + it('reactive — updates flow through signal', () => { + const lookup = svc.lookup('src1'); + expect(lookup()).toBeNull(); + svc.message.set({ + id: 'm1', role: 'assistant', content: 'x', + citations: [{ id: 'src1', index: 1, title: 'A' }], + }); + expect(lookup()?.citation.title).toBe('A'); + }); +}); diff --git a/libs/chat/src/lib/markdown/citations-resolver.service.ts b/libs/chat/src/lib/markdown/citations-resolver.service.ts new file mode 100644 index 000000000..6b929ed63 --- /dev/null +++ b/libs/chat/src/lib/markdown/citations-resolver.service.ts @@ -0,0 +1,54 @@ +// libs/chat/src/lib/markdown/citations-resolver.service.ts +// SPDX-License-Identifier: MIT +import { Injectable, computed, signal, type Signal } from '@angular/core'; +import type { CitationDefinition } from '@cacheplane/partial-markdown'; +import type { Message } from '../agent/message'; +import type { Citation } from '../agent/citation'; + +export interface ResolvedCitation { + source: 'message' | 'markdown'; + citation: Citation; +} + +@Injectable() +export class CitationsResolverService { + readonly message = signal(null); + readonly markdownDefs = signal>(new Map()); + + lookup(refId: string): Signal { + return computed(() => { + const fromMessage = this.message()?.citations?.find(c => c.id === refId); + if (fromMessage) return { source: 'message', citation: fromMessage }; + const fromMd = this.markdownDefs().get(refId); + if (fromMd) return { source: 'markdown', citation: mdDefToCitation(fromMd) }; + return null; + }); + } +} + +export function mdDefToCitation(def: CitationDefinition): Citation { + let url: string | undefined; + const titleParts: string[] = []; + const snippetParts: string[] = []; + let phase: 'before' | 'after' = 'before'; + for (const child of def.children) { + if ((child.type === 'link' || child.type === 'autolink') && url === undefined) { + url = (child as { url?: string }).url; + phase = 'after'; + continue; + } + const t = inlineToText(child); + (phase === 'before' ? titleParts : snippetParts).push(t); + } + const title = titleParts.join('').trim() || undefined; + const snippet = snippetParts.join('').trim() || undefined; + return { id: def.id, index: def.index, title, url, snippet }; +} + +function inlineToText(node: unknown): string { + const n = node as { type: string; text?: string; children?: unknown[]; url?: string }; + if (typeof n.text === 'string') return n.text; + if (n.type === 'autolink' && typeof n.url === 'string') return n.url; + if (Array.isArray(n.children)) return n.children.map(inlineToText).join(''); + return ''; +} diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 4207fabf6..5ec3bb26c 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -77,6 +77,10 @@ export { ChatToolCallCardComponent } from './lib/compositions/chat-tool-call-car export type { ToolCallInfo } from './lib/compositions/chat-tool-call-card/chat-tool-call-card.component'; export { ChatSubagentCardComponent, statusColor } from './lib/compositions/chat-subagent-card/chat-subagent-card.component'; +// Citations resolver +export { CitationsResolverService } from './lib/markdown/citations-resolver.service'; +export type { ResolvedCitation } from './lib/markdown/citations-resolver.service'; + // Streaming export { ChatStreamingMdComponent } from './lib/streaming/streaming-markdown.component'; From f3cdbbf651a18553fce930a53360556018969137 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:45:22 -0700 Subject: [PATCH 06/16] feat(chat): add markdown citation-reference view component Implements MarkdownCitationReferenceComponent (selector chat-md-citation-reference) that renders inline citation markers resolved via CitationsResolverService. Registers the component under the 'citation-reference' key in cacheplaneMarkdownViews. Updates the view registry spec to cover 19 node types (v0.2 adds citation-reference). Co-Authored-By: Claude Sonnet 4.6 --- .../cacheplane-markdown-views.spec.ts | 3 +- .../lib/markdown/cacheplane-markdown-views.ts | 2 + ...kdown-citation-reference.component.spec.ts | 50 +++++++++++++++++++ .../markdown-citation-reference.component.ts | 34 +++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 libs/chat/src/lib/markdown/views/markdown-citation-reference.component.spec.ts create mode 100644 libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts diff --git a/libs/chat/src/lib/markdown/cacheplane-markdown-views.spec.ts b/libs/chat/src/lib/markdown/cacheplane-markdown-views.spec.ts index 7245067c2..a17883971 100644 --- a/libs/chat/src/lib/markdown/cacheplane-markdown-views.spec.ts +++ b/libs/chat/src/lib/markdown/cacheplane-markdown-views.spec.ts @@ -4,10 +4,11 @@ import { describe, it, expect } from 'vitest'; import { cacheplaneMarkdownViews } from './cacheplane-markdown-views'; describe('cacheplaneMarkdownViews', () => { - it('registers all 18 v0.1 markdown node types', () => { + it('registers all 19 markdown node types (v0.2 adds citation-reference)', () => { expect(Object.keys(cacheplaneMarkdownViews).sort()).toEqual([ 'autolink', 'blockquote', + 'citation-reference', 'code-block', 'document', 'emphasis', diff --git a/libs/chat/src/lib/markdown/cacheplane-markdown-views.ts b/libs/chat/src/lib/markdown/cacheplane-markdown-views.ts index db45f2b88..b0d468797 100644 --- a/libs/chat/src/lib/markdown/cacheplane-markdown-views.ts +++ b/libs/chat/src/lib/markdown/cacheplane-markdown-views.ts @@ -19,6 +19,7 @@ import { MarkdownAutolinkComponent } from './views/markdown-autolink.component'; import { MarkdownImageComponent } from './views/markdown-image.component'; import { MarkdownSoftBreakComponent } from './views/markdown-soft-break.component'; import { MarkdownHardBreakComponent } from './views/markdown-hard-break.component'; +import { MarkdownCitationReferenceComponent } from './views/markdown-citation-reference.component'; /** * Default view registry consumed by . Maps every @@ -46,4 +47,5 @@ export const cacheplaneMarkdownViews: ViewRegistry = views({ 'image': MarkdownImageComponent, 'soft-break': MarkdownSoftBreakComponent, 'hard-break': MarkdownHardBreakComponent, + 'citation-reference': MarkdownCitationReferenceComponent, }); diff --git a/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.spec.ts b/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.spec.ts new file mode 100644 index 000000000..c1da58ac3 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.spec.ts @@ -0,0 +1,50 @@ +// libs/chat/src/lib/markdown/views/markdown-citation-reference.component.spec.ts +// SPDX-License-Identifier: MIT +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CitationsResolverService } from '../citations-resolver.service'; +import { MarkdownCitationReferenceComponent } from './markdown-citation-reference.component'; +import type { MarkdownCitationReferenceNode } from '@cacheplane/partial-markdown'; + +function makeNode(refId: string, index: number, resolved: boolean): MarkdownCitationReferenceNode { + return { + id: 1, type: 'citation-reference', status: 'complete', + parent: null, index, refId, resolved, + } as MarkdownCitationReferenceNode; +} + +@Component({ + standalone: true, + imports: [MarkdownCitationReferenceComponent], + providers: [CitationsResolverService], + template: ``, +}) +class HostComponent { + node = signal(makeNode('src1', 1, false)); +} + +describe('MarkdownCitationReferenceComponent', () => { + it('renders unresolved marker when no citation found', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const span = fixture.nativeElement.querySelector('span.chat-citation-marker'); + expect(span).toBeTruthy(); + expect(span.classList.contains('chat-citation-marker--unresolved')).toBe(true); + expect(span.textContent).toContain('1'); + }); + + it('renders linked marker when citation found via Message', () => { + const fixture = TestBed.createComponent(HostComponent); + const svc = fixture.debugElement.injector.get(CitationsResolverService); + svc.message.set({ + id: 'm1', role: 'assistant', content: 'x', + citations: [{ id: 'src1', index: 1, title: 'Source', url: 'https://example.com' }], + }); + fixture.componentInstance.node.set(makeNode('src1', 1, true)); + fixture.detectChanges(); + const a = fixture.nativeElement.querySelector('a.chat-citation-marker'); + expect(a).toBeTruthy(); + expect(a.getAttribute('href')).toBe('https://example.com'); + expect(a.textContent).toContain('1'); + }); +}); diff --git a/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts b/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts new file mode 100644 index 000000000..8e11969cd --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts @@ -0,0 +1,34 @@ +// libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import type { MarkdownCitationReferenceNode } from '@cacheplane/partial-markdown'; +import { CitationsResolverService } from '../citations-resolver.service'; + +@Component({ + selector: 'chat-md-citation-reference', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (resolved(); as r) { + + [{{ node().index }}] + + } @else { + + [{{ node().index }}] + + } + `, +}) +export class MarkdownCitationReferenceComponent { + readonly node = input.required(); + private readonly resolver = inject(CitationsResolverService); + protected readonly resolved = computed(() => { + const lookup = this.resolver.lookup(this.node().refId); + return lookup(); + }); +} From 435581658617c6d2c9536b0693d96a99bdf37bc9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:48:00 -0700 Subject: [PATCH 07/16] feat(chat): add chat-citations primitive (sources panel) Creates ChatCitationsComponent (chat-citations selector) with a ChatCitationCardTemplateDirective ContentChild slot for custom card rendering, and ChatCitationsCardComponent (chat-citations-card) for default citation display. Exports all three from the chat public surface. Co-Authored-By: Claude Sonnet 4.6 --- .../chat-citations-card.component.ts | 30 +++++++++ .../chat-citations.component.spec.ts | 67 +++++++++++++++++++ .../chat-citations.component.ts | 55 +++++++++++++++ .../lib/primitives/chat-citations/index.ts | 2 + libs/chat/src/public-api.ts | 2 + 5 files changed, 156 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts create mode 100644 libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts create mode 100644 libs/chat/src/lib/primitives/chat-citations/index.ts diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts new file mode 100644 index 000000000..55243f04b --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts @@ -0,0 +1,30 @@ +// libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import type { Citation } from '../../agent/citation'; + +@Component({ + selector: 'chat-citations-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
{{ citation().index }}
+
+ @if (citation().url; as url) { + + {{ citation().title ?? url }} + + } @else if (citation().title) { + {{ citation().title }} + } + @if (citation().snippet; as s) { +

{{ s }}

+ } +
+
+ `, +}) +export class ChatCitationsCardComponent { + readonly citation = input.required(); +} diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts new file mode 100644 index 000000000..90c53a361 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts @@ -0,0 +1,67 @@ +// libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts +// SPDX-License-Identifier: MIT +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ChatCitationsComponent, ChatCitationCardTemplateDirective } from './chat-citations.component'; +import type { Message } from '../../agent/message'; + +function msg(citations: Message['citations']): Message { + return { id: 'm1', role: 'assistant', content: 'x', citations }; +} + +@Component({ + standalone: true, + imports: [ChatCitationsComponent], + template: ``, +}) +class HostComponent { + message = signal(msg(undefined)); +} + +describe('ChatCitationsComponent', () => { + it('renders nothing when citations is undefined', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-citations')).toBeNull(); + }); + + it('renders nothing when citations is empty', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.message.set(msg([])); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-citations')).toBeNull(); + }); + + it('renders citations sorted by index', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.message.set(msg([ + { id: 'b', index: 2, title: 'B' }, + { id: 'a', index: 1, title: 'A' }, + ])); + fixture.detectChanges(); + const titles = Array.from(fixture.nativeElement.querySelectorAll('.chat-citations-card__title')) + .map((el: any) => el.textContent.trim()); + expect(titles).toEqual(['A', 'B']); + }); + + it('uses ContentChild template slot when provided', () => { + @Component({ + standalone: true, + imports: [ChatCitationsComponent, ChatCitationCardTemplateDirective], + template: ` + + + {{ c.title }} + + + `, + }) + class CustomHost { + message: Message = msg([{ id: 'a', index: 1, title: 'Custom' }]); + } + const fixture = TestBed.createComponent(CustomHost); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.custom-card')?.textContent.trim()).toBe('Custom'); + expect(fixture.nativeElement.querySelector('.chat-citations-card')).toBeNull(); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts new file mode 100644 index 000000000..23ba480ef --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts @@ -0,0 +1,55 @@ +// libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts +// SPDX-License-Identifier: MIT +import { + ChangeDetectionStrategy, Component, ContentChild, Directive, TemplateRef, + computed, input, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Message } from '../../agent/message'; +import type { Citation } from '../../agent/citation'; +import { ChatCitationsCardComponent } from './chat-citations-card.component'; + +/** + * ContentChild template directive for custom citation card rendering. + * Usage: ... + */ +@Directive({ selector: 'ng-template[chatCitationCard]', standalone: true }) +export class ChatCitationCardTemplateDirective { + constructor(public readonly tpl: TemplateRef<{ $implicit: Citation }>) {} +} + +@Component({ + selector: 'chat-citations', + standalone: true, + imports: [NgTemplateOutlet, ChatCitationsCardComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (citations().length > 0) { +
+

{{ heading() }}

+
    + @for (c of citations(); track c.id) { +
  • + @if (cardTpl) { + + } @else { + + } +
  • + } +
+
+ } + `, +}) +export class ChatCitationsComponent { + readonly message = input.required(); + readonly heading = input('Sources'); + + @ContentChild(ChatCitationCardTemplateDirective) cardTpl: ChatCitationCardTemplateDirective | null = null; + + protected readonly citations = computed(() => { + const list = this.message().citations ?? []; + return [...list].sort((a, b) => a.index - b.index); + }); +} diff --git a/libs/chat/src/lib/primitives/chat-citations/index.ts b/libs/chat/src/lib/primitives/chat-citations/index.ts new file mode 100644 index 000000000..215a0f2e4 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-citations/index.ts @@ -0,0 +1,2 @@ +export { ChatCitationsComponent, ChatCitationCardTemplateDirective } from './chat-citations.component'; +export { ChatCitationsCardComponent } from './chat-citations-card.component'; diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 5ec3bb26c..86fb66a76 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -60,6 +60,8 @@ export { ChatWelcomeComponent } from './lib/primitives/chat-welcome/chat-welcome export { ChatWelcomeSuggestionComponent } from './lib/primitives/chat-welcome/chat-welcome-suggestion.component'; export { ChatSelectComponent } from './lib/primitives/chat-select/chat-select.component'; export type { ChatSelectOption } from './lib/primitives/chat-select/chat-select.component'; +export { ChatCitationsComponent, ChatCitationCardTemplateDirective } from './lib/primitives/chat-citations/chat-citations.component'; +export { ChatCitationsCardComponent } from './lib/primitives/chat-citations/chat-citations-card.component'; // DI provider export { provideChat, CHAT_CONFIG } from './lib/provide-chat'; From 233269c9e8bc753c030b8940d1a36fe8a7fdd122 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:50:19 -0700 Subject: [PATCH 08/16] feat(chat): render chat-citations under assistant messages Adds optional [message] input to ChatMessageComponent and renders after the body slot for assistant role. Provides CitationsResolverService at the chat-message component level and uses an effect to sync message() into the resolver's signal. Updates the component spec to provide CitationsResolverService in the test injector. Co-Authored-By: Claude Sonnet 4.6 --- .../chat-message.component.spec.ts | 3 ++- .../chat-message/chat-message.component.ts | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts b/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts index 231f50c7f..0b6eb2198 100644 --- a/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts @@ -3,10 +3,11 @@ import { describe, it, expect } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { ChatMessageComponent } from './chat-message.component'; +import { CitationsResolverService } from '../../markdown/citations-resolver.service'; describe('ChatMessageComponent', () => { it('instantiates without error', () => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ providers: [CitationsResolverService] }); let component!: ChatMessageComponent; TestBed.runInInjectionContext(() => { component = new ChatMessageComponent(); diff --git a/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts index f6192f6c6..d2c83b614 100644 --- a/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts +++ b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts @@ -1,16 +1,21 @@ // libs/chat/src/lib/primitives/chat-message/chat-message.component.ts // SPDX-License-Identifier: MIT -import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, computed, effect, inject } from '@angular/core'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_MESSAGE_STYLES } from '../../styles/chat-message.styles'; +import { ChatCitationsComponent } from '../chat-citations/chat-citations.component'; +import { CitationsResolverService } from '../../markdown/citations-resolver.service'; +import type { Message } from '../../agent/message'; export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool'; @Component({ selector: 'chat-message', standalone: true, + imports: [ChatCitationsComponent], changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_STYLES], + providers: [CitationsResolverService], host: { '[attr.data-role]': 'role()', '[attr.data-current]': 'currentStr()', @@ -22,6 +27,9 @@ export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool'; + @if (message()?.role === 'assistant' && message(); as msg) { + + }
@@ -32,6 +40,15 @@ export class ChatMessageComponent { readonly current = input(false); readonly streaming = input(false); readonly prevRole = input(undefined); + readonly message = input(undefined); + + private readonly resolver = inject(CitationsResolverService); + + constructor() { + effect(() => { + this.resolver.message.set(this.message() ?? null); + }); + } readonly currentStr = computed(() => String(this.current())); readonly streamingStr = computed(() => String(this.streaming())); From ed5d3e08651d1d0154f963665d1d99234737b0c2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:50:24 -0700 Subject: [PATCH 09/16] feat(chat): wire CitationsResolverService through chat-message + chat-streaming-md Injects CitationsResolverService optionally in ChatStreamingMdComponent and feeds markdownDefs from doc.citations via an effect on the root() signal. This chains the markdown AST citation sidecar into the resolver so inline citation-reference markers can resolve to Citation objects at render time. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/streaming/streaming-markdown.component.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.ts index e7f98efed..08c091595 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.ts @@ -5,6 +5,8 @@ import { ChangeDetectionStrategy, ViewEncapsulation, computed, + effect, + inject, input, } from '@angular/core'; import { @@ -18,6 +20,7 @@ import { CHAT_MARKDOWN_STYLES } from '../styles/chat-markdown.styles'; import { MARKDOWN_VIEW_REGISTRY } from '../markdown/markdown-view-registry'; import { MarkdownChildrenComponent } from '../markdown/markdown-children.component'; import { cacheplaneMarkdownViews } from '../markdown/cacheplane-markdown-views'; +import { CitationsResolverService } from '../markdown/citations-resolver.service'; /** * Renders streaming markdown by walking a @cacheplane/partial-markdown AST @@ -65,6 +68,17 @@ export class ChatStreamingMdComponent { () => this.viewRegistry() ?? cacheplaneMarkdownViews, ); + private readonly resolver = inject(CitationsResolverService, { optional: true }); + + constructor() { + effect(() => { + const r = this.root(); + if (this.resolver && r) { + this.resolver.markdownDefs.set(r.citations ?? new Map()); + } + }); + } + // Parser instance is rebuilt only when content diverges from the prior // prefix (rare). For the common streaming case where content extends the // prior content, we push the delta and reuse the existing parser tree. From 618f2c00bed52570cb026d4f262b7974a8a35ccd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:51:34 -0700 Subject: [PATCH 10/16] feat(chat): export MarkdownCitationReferenceComponent from public surface Adds MarkdownCitationReferenceComponent to the @ngaf/chat public API so consumers can override or inspect the inline citation marker renderer alongside the other per-node markdown view components. Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/src/public-api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 86fb66a76..da8584c2b 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -111,6 +111,7 @@ export { MarkdownAutolinkComponent } from './lib/markdown/views/markdown-a export { MarkdownImageComponent } from './lib/markdown/views/markdown-image.component'; export { MarkdownSoftBreakComponent } from './lib/markdown/views/markdown-soft-break.component'; export { MarkdownHardBreakComponent } from './lib/markdown/views/markdown-hard-break.component'; +export { MarkdownCitationReferenceComponent } from './lib/markdown/views/markdown-citation-reference.component'; // Shared styles & utilities export { CHAT_MARKDOWN_STYLES } from './lib/styles/chat-markdown.styles'; From d3c69f3338c1810c2f898b0f0006514918343e81 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:53:30 -0700 Subject: [PATCH 11/16] feat(langgraph): extract citations from additional_kwargs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create extractCitations() normalizer in internals/ and wire it into the toMessage() BaseMessage → Message conversion so that additional_kwargs.citations (or .sources fallback) flows through as Message.citations on every assistant turn. Co-Authored-By: Claude Sonnet 4.6 --- libs/langgraph/src/lib/agent.fn.ts | 6 ++- .../lib/internals/extract-citations.spec.ts | 53 +++++++++++++++++++ .../src/lib/internals/extract-citations.ts | 40 ++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 libs/langgraph/src/lib/internals/extract-citations.spec.ts create mode 100644 libs/langgraph/src/lib/internals/extract-citations.ts diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index e07038301..b6c099902 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -43,6 +43,7 @@ import type { ThreadState, ToolProgress } from '@langchain/langgraph-sdk'; import type { MessageMetadata } from '@langchain/langgraph-sdk/ui'; import { createStreamManagerBridge } from './internals/stream-manager.bridge'; import { buildBranchTree } from './internals/branch-tree'; +import { extractCitations } from './internals/extract-citations'; /** * Creates a streaming resource connected to a LangGraph agent. @@ -358,7 +359,7 @@ function toMessage( const reasoningDurationMs = reasoning && getReasoningDurationMs ? getReasoningDurationMs(id) : undefined; - return { + const result: Message = { id, role, content: extractTextContent(m.content), @@ -368,6 +369,9 @@ function toMessage( reasoningDurationMs, extra: raw, }; + const citations = extractCitations(raw as { additional_kwargs?: Record }); + if (citations) result.citations = citations; + return result; } /** diff --git a/libs/langgraph/src/lib/internals/extract-citations.spec.ts b/libs/langgraph/src/lib/internals/extract-citations.spec.ts new file mode 100644 index 000000000..05aac0b6d --- /dev/null +++ b/libs/langgraph/src/lib/internals/extract-citations.spec.ts @@ -0,0 +1,53 @@ +// libs/langgraph/src/lib/internals/extract-citations.spec.ts +// SPDX-License-Identifier: MIT +import { extractCitations } from './extract-citations'; + +describe('extractCitations', () => { + it('returns undefined when no citations or sources', () => { + expect(extractCitations({ additional_kwargs: {} })).toBeUndefined(); + expect(extractCitations({})).toBeUndefined(); + }); + + it('reads additional_kwargs.citations', () => { + const result = extractCitations({ + additional_kwargs: { citations: [{ id: 'a', title: 'Title A', url: 'https://a' }] }, + }); + expect(result).toEqual([{ id: 'a', index: 1, title: 'Title A', url: 'https://a' }]); + }); + + it('falls back to additional_kwargs.sources', () => { + const result = extractCitations({ + additional_kwargs: { sources: [{ id: 'b', title: 'B', url: 'https://b' }] }, + }); + expect(result).toEqual([{ id: 'b', index: 1, title: 'B', url: 'https://b' }]); + }); + + it('handles string entries (URL only)', () => { + expect(extractCitations({ additional_kwargs: { citations: ['https://x'] } })) + .toEqual([{ id: 'c1', index: 1, url: 'https://x' }]); + }); + + it('coerces key spellings (href/source, name, content/excerpt)', () => { + expect(extractCitations({ + additional_kwargs: { + citations: [ + { name: 'N', href: 'https://h', content: 'C' }, + { name: 'O', source: 'https://s', excerpt: 'E' }, + ], + }, + })).toEqual([ + { id: 'c1', index: 1, title: 'N', url: 'https://h', snippet: 'C' }, + { id: 'c2', index: 2, title: 'O', url: 'https://s', snippet: 'E' }, + ]); + }); + + it('preserves explicit index when provided', () => { + expect(extractCitations({ + additional_kwargs: { citations: [{ id: 'a', index: 5, title: 'A' }] }, + })).toEqual([{ id: 'a', index: 5, title: 'A' }]); + }); + + it('returns undefined for empty array', () => { + expect(extractCitations({ additional_kwargs: { citations: [] } })).toBeUndefined(); + }); +}); diff --git a/libs/langgraph/src/lib/internals/extract-citations.ts b/libs/langgraph/src/lib/internals/extract-citations.ts new file mode 100644 index 000000000..23e8356c5 --- /dev/null +++ b/libs/langgraph/src/lib/internals/extract-citations.ts @@ -0,0 +1,40 @@ +// libs/langgraph/src/lib/internals/extract-citations.ts +// SPDX-License-Identifier: MIT +import type { Citation } from '@ngaf/chat'; + +interface KwargsLike { + additional_kwargs?: Record | undefined; +} + +export function extractCitations(msg: KwargsLike): Citation[] | undefined { + const raw = msg.additional_kwargs?.['citations'] ?? msg.additional_kwargs?.['sources']; + if (!Array.isArray(raw) || raw.length === 0) return undefined; + return raw.map((entry, i) => normalizeCitation(entry, i + 1)); +} + +function normalizeCitation(entry: unknown, fallbackIndex: number): Citation { + if (typeof entry === 'string') { + return { id: `c${fallbackIndex}`, index: fallbackIndex, url: entry }; + } + const e = (entry ?? {}) as Record; + const str = (key: string): string | undefined => + typeof e[key] === 'string' ? (e[key] as string) : undefined; + const firstStr = (...keys: string[]): string | undefined => { + for (const k of keys) { + const v = str(k); + if (v !== undefined) return v; + } + return undefined; + }; + return { + id: str('id') ?? str('refId') ?? `c${fallbackIndex}`, + index: typeof e['index'] === 'number' ? (e['index'] as number) : fallbackIndex, + title: firstStr('title', 'name'), + url: firstStr('url', 'href', 'source'), + snippet: firstStr('snippet', 'content', 'excerpt'), + extra: + typeof e['extra'] === 'object' && e['extra'] !== null + ? (e['extra'] as Record) + : undefined, + }; +} From 3f588c92b05d58d8a70f879c3f8d994b9f0b59ec Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:54:14 -0700 Subject: [PATCH 12/16] feat(ag-ui): bridge state.citations into Message.citations Create bridgeCitationsState() normalizer and wire it into reduceEvent() after STATE_SNAPSHOT and STATE_DELTA apply their patches, so that state.citations[messageId] arrays flow through as Message.citations on every affected assistant message. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/bridge-citations-state.spec.ts | 49 +++++++++++++++++++ libs/ag-ui/src/lib/bridge-citations-state.ts | 45 +++++++++++++++++ libs/ag-ui/src/lib/reducer.ts | 6 ++- 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 libs/ag-ui/src/lib/bridge-citations-state.spec.ts create mode 100644 libs/ag-ui/src/lib/bridge-citations-state.ts diff --git a/libs/ag-ui/src/lib/bridge-citations-state.spec.ts b/libs/ag-ui/src/lib/bridge-citations-state.spec.ts new file mode 100644 index 000000000..4dde0ec01 --- /dev/null +++ b/libs/ag-ui/src/lib/bridge-citations-state.spec.ts @@ -0,0 +1,49 @@ +// libs/ag-ui/src/lib/bridge-citations-state.spec.ts +// SPDX-License-Identifier: MIT +import { bridgeCitationsState } from './bridge-citations-state'; +import type { Message } from '@ngaf/chat'; + +describe('bridgeCitationsState', () => { + const baseMsg = (id: string): Message => ({ id, role: 'assistant', content: 'x' }); + + it('returns messages unchanged when state has no citations', () => { + const msgs = [baseMsg('m1'), baseMsg('m2')]; + const result = bridgeCitationsState({ state: {} }, msgs); + expect(result).toEqual(msgs); + }); + + it('merges citations into matching messages by id', () => { + const result = bridgeCitationsState( + { state: { citations: { m1: [{ id: 'a', title: 'A', url: 'https://a' }] } } }, + [baseMsg('m1'), baseMsg('m2')], + ); + expect(result[0].citations).toEqual([{ id: 'a', index: 1, title: 'A', url: 'https://a' }]); + expect(result[1].citations).toBeUndefined(); + }); + + it('idempotent — same input produces same output', () => { + const state = { state: { citations: { m1: [{ id: 'a', title: 'A' }] } } }; + const msgs = [baseMsg('m1')]; + const a = bridgeCitationsState(state, msgs); + const b = bridgeCitationsState(state, a); + expect(b[0].citations).toEqual(a[0].citations); + }); + + it('coerces key spellings (href/source, name, excerpt)', () => { + const result = bridgeCitationsState( + { state: { citations: { m1: [{ name: 'N', href: 'https://h', excerpt: 'E' }] } } }, + [baseMsg('m1')], + ); + expect(result[0].citations).toEqual([ + { id: 'c1', index: 1, title: 'N', url: 'https://h', snippet: 'E' }, + ]); + }); + + it('handles string entries', () => { + const result = bridgeCitationsState( + { state: { citations: { m1: ['https://x'] } } }, + [baseMsg('m1')], + ); + expect(result[0].citations).toEqual([{ id: 'c1', index: 1, url: 'https://x' }]); + }); +}); diff --git a/libs/ag-ui/src/lib/bridge-citations-state.ts b/libs/ag-ui/src/lib/bridge-citations-state.ts new file mode 100644 index 000000000..8d8f5bd32 --- /dev/null +++ b/libs/ag-ui/src/lib/bridge-citations-state.ts @@ -0,0 +1,45 @@ +// libs/ag-ui/src/lib/bridge-citations-state.ts +// SPDX-License-Identifier: MIT +import type { Citation, Message } from '@ngaf/chat'; + +interface ThreadStateLike { + state?: Record; +} + +export function bridgeCitationsState(thread: ThreadStateLike, messages: Message[]): Message[] { + const citationsByMsg = (thread.state as { citations?: unknown })?.citations; + if (!citationsByMsg || typeof citationsByMsg !== 'object') return messages; + const map = citationsByMsg as Record; + return messages.map(msg => { + const raw = map[msg.id]; + if (!Array.isArray(raw) || raw.length === 0) return msg; + return { ...msg, citations: raw.map((entry, i) => normalizeCitation(entry, i + 1)) }; + }); +} + +function normalizeCitation(entry: unknown, fallbackIndex: number): Citation { + if (typeof entry === 'string') { + return { id: `c${fallbackIndex}`, index: fallbackIndex, url: entry }; + } + const e = (entry ?? {}) as Record; + const str = (key: string): string | undefined => + typeof e[key] === 'string' ? (e[key] as string) : undefined; + const firstStr = (...keys: string[]): string | undefined => { + for (const k of keys) { + const v = str(k); + if (v !== undefined) return v; + } + return undefined; + }; + return { + id: str('id') ?? str('refId') ?? `c${fallbackIndex}`, + index: typeof e['index'] === 'number' ? (e['index'] as number) : fallbackIndex, + title: firstStr('title', 'name'), + url: firstStr('url', 'href', 'source'), + snippet: firstStr('snippet', 'content', 'excerpt'), + extra: + typeof e['extra'] === 'object' && e['extra'] !== null + ? (e['extra'] as Record) + : undefined, + }; +} diff --git a/libs/ag-ui/src/lib/reducer.ts b/libs/ag-ui/src/lib/reducer.ts index 6cb4386db..cd7cc27c3 100644 --- a/libs/ag-ui/src/lib/reducer.ts +++ b/libs/ag-ui/src/lib/reducer.ts @@ -10,6 +10,7 @@ import type { } from '@ngaf/chat'; import type { BaseEvent } from '@ag-ui/client'; import { applyPatch, type Operation } from 'fast-json-patch'; +import { bridgeCitationsState } from './bridge-citations-state'; export interface ReducerStore { messages: WritableSignal; @@ -157,13 +158,16 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { } case 'STATE_SNAPSHOT': { const e = event as unknown as { snapshot: Record }; - store.state.set(e.snapshot ?? {}); + const snapshot = e.snapshot ?? {}; + store.state.set(snapshot); + store.messages.update(msgs => bridgeCitationsState({ state: snapshot }, msgs)); return; } case 'STATE_DELTA': { const e = event as unknown as { delta: Operation[] }; const next = applyPatch(deepClone(store.state()), e.delta).newDocument; store.state.set(next); + store.messages.update(msgs => bridgeCitationsState({ state: next }, msgs)); return; } case 'MESSAGES_SNAPSHOT': { From d040cee97c7e98742d9c2f09f24dba75f6e537fc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:55:01 -0700 Subject: [PATCH 13/16] chore(release): synchronize all @ngaf libs to 0.0.21 Per project policy: all @ngaf packages share a single version. This bumps all 16 libraries (a2ui, ag-ui, chat, cockpit-{docs,registry,shell,testing,ui}, db, design-tokens, example-layouts, langgraph, licensing, partial-json, render, ui-react) to 0.0.21 alongside the citations release. --- libs/a2ui/package.json | 2 +- libs/ag-ui/package.json | 2 +- libs/chat/package.json | 2 +- libs/cockpit-docs/package.json | 2 +- libs/cockpit-registry/package.json | 2 +- libs/cockpit-shell/package.json | 2 +- libs/cockpit-testing/package.json | 2 +- libs/cockpit-ui/package.json | 2 +- libs/db/package.json | 2 +- libs/design-tokens/package.json | 2 +- libs/example-layouts/package.json | 2 +- libs/langgraph/package.json | 2 +- libs/licensing/package.json | 2 +- libs/partial-json/package.json | 2 +- libs/render/package.json | 2 +- libs/ui-react/package.json | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/libs/a2ui/package.json b/libs/a2ui/package.json index 54d8570de..aeccdaf61 100644 --- a/libs/a2ui/package.json +++ b/libs/a2ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/a2ui", - "version": "0.0.2", + "version": "0.0.21", "license": "MIT", "repository": { "type": "git", diff --git a/libs/ag-ui/package.json b/libs/ag-ui/package.json index 84def1fd0..62d345df2 100644 --- a/libs/ag-ui/package.json +++ b/libs/ag-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/ag-ui", - "version": "0.0.3", + "version": "0.0.21", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/chat/package.json b/libs/chat/package.json index 87b58a959..383a8991c 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.20", + "version": "0.0.21", "exports": { ".": { "types": "./index.d.ts", diff --git a/libs/cockpit-docs/package.json b/libs/cockpit-docs/package.json index 0f554e006..4d4886bad 100644 --- a/libs/cockpit-docs/package.json +++ b/libs/cockpit-docs/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-docs", - "version": "0.0.1", + "version": "0.0.21", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-registry/package.json b/libs/cockpit-registry/package.json index 7cbb95ed9..bde74b544 100644 --- a/libs/cockpit-registry/package.json +++ b/libs/cockpit-registry/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-registry", - "version": "0.0.1", + "version": "0.0.21", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-shell/package.json b/libs/cockpit-shell/package.json index 66245188b..ac39a3531 100644 --- a/libs/cockpit-shell/package.json +++ b/libs/cockpit-shell/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-shell", - "version": "0.0.1", + "version": "0.0.21", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-testing/package.json b/libs/cockpit-testing/package.json index cc0d1c5ef..898e6c261 100644 --- a/libs/cockpit-testing/package.json +++ b/libs/cockpit-testing/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-testing", - "version": "0.0.1", + "version": "0.0.21", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-ui/package.json b/libs/cockpit-ui/package.json index 93cfa6830..163461ea8 100644 --- a/libs/cockpit-ui/package.json +++ b/libs/cockpit-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-ui", - "version": "0.0.1", + "version": "0.0.21", "license": "MIT", "repository": { "type": "git", diff --git a/libs/db/package.json b/libs/db/package.json index 7a7441001..946f87d05 100644 --- a/libs/db/package.json +++ b/libs/db/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/db", - "version": "0.0.1", + "version": "0.0.21", "license": "MIT", "repository": { "type": "git", diff --git a/libs/design-tokens/package.json b/libs/design-tokens/package.json index c52e4c1ad..5a1fe550a 100644 --- a/libs/design-tokens/package.json +++ b/libs/design-tokens/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/design-tokens", - "version": "0.0.1", + "version": "0.0.21", "license": "MIT", "repository": { "type": "git", diff --git a/libs/example-layouts/package.json b/libs/example-layouts/package.json index 9884e6134..3dcd55f5f 100644 --- a/libs/example-layouts/package.json +++ b/libs/example-layouts/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/example-layouts", - "version": "0.0.1", + "version": "0.0.21", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0" diff --git a/libs/langgraph/package.json b/libs/langgraph/package.json index fba5e3c7e..f75e357c3 100644 --- a/libs/langgraph/package.json +++ b/libs/langgraph/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/langgraph", - "version": "0.0.11", + "version": "0.0.21", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/licensing/package.json b/libs/licensing/package.json index fdb9049f4..ad96c85b5 100644 --- a/libs/licensing/package.json +++ b/libs/licensing/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/licensing", - "version": "0.0.2", + "version": "0.0.21", "license": "MIT", "repository": { "type": "git", diff --git a/libs/partial-json/package.json b/libs/partial-json/package.json index d17fc00ef..ff6f4d202 100644 --- a/libs/partial-json/package.json +++ b/libs/partial-json/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/partial-json", - "version": "0.0.2", + "version": "0.0.21", "deprecated": "Replaced by @cacheplane/partial-json. No further versions will be published from this package.", "license": "MIT", "repository": { diff --git a/libs/render/package.json b/libs/render/package.json index c5349d9b4..85291c860 100644 --- a/libs/render/package.json +++ b/libs/render/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/render", - "version": "0.0.2", + "version": "0.0.21", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", diff --git a/libs/ui-react/package.json b/libs/ui-react/package.json index cd8a036ec..bfc1182c6 100644 --- a/libs/ui-react/package.json +++ b/libs/ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/ui-react", - "version": "0.0.1", + "version": "0.0.21", "license": "MIT", "repository": { "type": "git", From 76ac4d2fe2dfd35311d9ad8b8c5760aace8f9d08 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:56:58 -0700 Subject: [PATCH 14/16] docs: chat citations + adapter bridge documentation Add minimal documentation for the @ngaf 0.0.21 citations release across all adapter libraries and changelog. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 13 +++++++++++ libs/ag-ui/README.md | 26 +++++++++++++++++++++ libs/chat/README.md | 49 ++++++++++++++++++++++++++++++++++++++++ libs/langgraph/README.md | 35 ++++++++++++++++++++++++++-- 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa536361..bb7437a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.0.21 — 2026-05-04 + +### Added + +- **`@ngaf/chat`** — `Citation` interface, `Message.citations` field, `` primitive (sources panel), `MarkdownCitationReferenceComponent` registered in the markdown view registry, `CitationsResolverService` for message-first / markdown-fallback citation lookup. +- **`@ngaf/langgraph`** — `extractCitations()` populates `Message.citations` from `additional_kwargs.citations` or `additional_kwargs.sources`. +- **`@ngaf/ag-ui`** — `bridgeCitationsState()` populates `Message.citations` from STATE_DELTA at JSON Pointer `/citations/{messageId}`. +- **`@cacheplane/partial-markdown`** peer in `@ngaf/chat` bumped to `^0.2.0`. + +### Changed + +- **All @ngaf libraries synchronized to `0.0.21`** per project policy (single version across the suite). + ## 0.0.2 (2026-05-01) ### 🩹 Fixes diff --git a/libs/ag-ui/README.md b/libs/ag-ui/README.md index 4df99e4b6..a3f204319 100644 --- a/libs/ag-ui/README.md +++ b/libs/ag-ui/README.md @@ -20,3 +20,29 @@ export class App { protected readonly agent = inject(AG_UI_AGENT); } ``` + +## Citations + +The `bridgeCitationsState()` function populates `Message.citations` from AG-UI STATE_DELTA events. Citations are located at JSON Pointer `/citations/{messageId}`. + +### Example: AG-UI citations state shape + +```json +{ + "state": { + "citations": { + "msg-123": [ + { + "id": "src1", + "index": 1, + "title": "Example Source", + "url": "https://example.com", + "snippet": "Relevant excerpt from the source..." + } + ] + } + } +} +``` + +Each citation object in the array supports `id`, `index`, `title`, `url`, `snippet`, and custom `extra` fields. The messageId key matches the corresponding message in the chat history. diff --git a/libs/chat/README.md b/libs/chat/README.md index fa560fdc7..e71f5f140 100644 --- a/libs/chat/README.md +++ b/libs/chat/README.md @@ -12,3 +12,52 @@ Chat primitives consume a runtime-neutral `Agent` contract. Two adapters ship to Custom backends can implement `Agent` directly with no library dependency. See the capability matrix in the docs site for which primitives require which runtime capabilities. + +## Citations + +Chat messages can include citations to sources. The `Citation` interface provides structured metadata for each source: + +```ts +interface Citation { + id: string; // Unique identifier for the citation + index?: number; // Display index (1-based) for inline markers + title?: string; // Source title + url?: string; // Source URL + snippet?: string; // Quoted excerpt + extra?: unknown; // Custom fields per adapter +} +``` + +### Message citations + +Adapters populate `Message.citations?: Citation[]` from their respective backends. Messages are rendered with the `` primitive, which displays a collapsible sources panel under assistant messages. + +### Rendering sources + +Use the `` component to render a sources panel. Customize the card layout with the optional `chatCitationCard` ng-template: + +```html + + + + +
+ {{ citation.title }} +

{{ citation.snippet }}

+
+
+
+``` + +### Inline markers + +Markdown rendering registers `chat-md-citation-reference` in the markdown view registry. Citation indices are rendered as superscript markers inline with the message text. The markers link to the corresponding citation in the sources panel. + +### Adapter integration + +Each runtime adapter extracts citations into the `Message.citations` array: + +- **LangGraph** — reads from `message.additional_kwargs.citations` (preferred) or `message.additional_kwargs.sources` (fallback) +- **AG-UI** — reads from STATE_DELTA at JSON Pointer `/citations/{messageId}` + +The `CitationsResolverService` is provided to query citations in message-first or markdown-fallback order. diff --git a/libs/langgraph/README.md b/libs/langgraph/README.md index 8abcc7f61..a2c859a31 100644 --- a/libs/langgraph/README.md +++ b/libs/langgraph/README.md @@ -1,3 +1,34 @@ -# angular +# @ngaf/langgraph -This library was generated with [Nx](https://nx.dev). +Adapter that wraps a LangGraph agent into the runtime-neutral `Agent` contract from `@ngaf/chat`. + +## Citations + +The `extractCitations()` function populates `Message.citations` from LangGraph message metadata. It reads from `additional_kwargs.citations` (preferred) or `additional_kwargs.sources` (fallback). + +### Example: RAG chain with citations + +```ts +import { additional_kwargs } from '@langchain/core/messages'; + +// In your LangGraph node: +const response = await llm.invoke([...]); + +// Attach citations metadata: +const messageWithCitations = new AIMessage({ + content: response.content, + additional_kwargs: { + citations: [ + { + id: 'doc-1', + index: 1, + title: 'Example Article', + url: 'https://example.com/article', + snippet: 'Relevant excerpt...' + } + ] + } +}); + +// Message.citations auto-populates in @ngaf/chat via extractCitations() +``` From 16ac8416832e0c3c3a3a084623cee0bd27cfa1e9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 14:57:45 -0700 Subject: [PATCH 15/16] chore: bump @cacheplane/partial-markdown to ^0.2.0 in workspace root --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc4dd25a3..0eb211dd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@angular/platform-browser": "~21.1.0", "@angular/router": "~21.1.0", "@cacheplane/partial-json": "^0.1.1", - "@cacheplane/partial-markdown": "^0.1.0", + "@cacheplane/partial-markdown": "^0.2.0", "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", "@modelcontextprotocol/sdk": "^1.27.1", @@ -6936,9 +6936,9 @@ "license": "MIT" }, "node_modules/@cacheplane/partial-markdown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@cacheplane/partial-markdown/-/partial-markdown-0.1.0.tgz", - "integrity": "sha512-aAKyaf3NU8SDJ9WD+Pu0CEDm1whfb61CipHbDOM+gNkNDnTVT9n8pNGUQEKUqfexoV6KEPc2tG2+OrG7k1pCbA==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@cacheplane/partial-markdown/-/partial-markdown-0.2.0.tgz", + "integrity": "sha512-qEGwU6EpurdFeVEYpiFEANLGjXQrNvByUhGnXDpcTjsO0RlAlrdGtjrZdlO2347trEQlhAo+ReR4cDcsz+YBvA==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 36e727583..90ed4cd6d 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "@angular/platform-browser": "~21.1.0", "@angular/router": "~21.1.0", "@cacheplane/partial-json": "^0.1.1", - "@cacheplane/partial-markdown": "^0.1.0", + "@cacheplane/partial-markdown": "^0.2.0", "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", "@modelcontextprotocol/sdk": "^1.27.1", From 50893fd051e57e69b3668785d23f02d2a8a0649c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 4 May 2026 15:03:06 -0700 Subject: [PATCH 16/16] fix(chat): use inject() for ChatCitationCardTemplateDirective per lint rule --- .../lib/primitives/chat-citations/chat-citations.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts index 23ba480ef..dd00753f8 100644 --- a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, ContentChild, Directive, TemplateRef, - computed, input, + computed, inject, input, } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import type { Message } from '../../agent/message'; @@ -15,7 +15,7 @@ import { ChatCitationsCardComponent } from './chat-citations-card.component'; */ @Directive({ selector: 'ng-template[chatCitationCard]', standalone: true }) export class ChatCitationCardTemplateDirective { - constructor(public readonly tpl: TemplateRef<{ $implicit: Citation }>) {} + readonly tpl = inject>(TemplateRef); } @Component({