diff --git a/docs/superpowers/plans/2026-04-09-a2ui-phase1.md b/docs/superpowers/plans/2026-04-09-a2ui-phase1.md new file mode 100644 index 000000000..c59a42d19 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-a2ui-phase1.md @@ -0,0 +1,1674 @@ +# A2UI v0.9 Phase 1 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement A2UI v0.9 protocol support — JSONL message parsing, surface state management, a default component catalog (12 components), and an Angular renderer with data binding + template expansion. + +**Architecture:** New `@cacheplane/a2ui` library for framework-agnostic message types and JSONL parsing. Surface store, catalog, and renderer live in `@cacheplane/chat`. The `ContentClassifier` is updated to route A2UI content to the new JSONL parser instead of the single-JSON `PartialJsonParser`. The `ChatComponent` template renders A2UI surfaces via ``. + +**Tech Stack:** Angular 20+ signals, Vitest, TypeScript, `@cacheplane/chat`, `@cacheplane/render` + +--- + +## File Structure + +### New Library: `libs/a2ui/` + +| File | Responsibility | +|------|---------------| +| `src/index.ts` | Public API barrel | +| `src/lib/types.ts` | A2UI message types, component types, theme types | +| `src/lib/parser.ts` | JSONL message parser | +| `src/lib/parser.spec.ts` | Parser tests | +| `src/lib/pointer.ts` | JSON Pointer get/set utilities | +| `src/lib/pointer.spec.ts` | Pointer tests | +| `src/lib/resolve.ts` | DynamicString/Number/Boolean resolution | +| `src/lib/resolve.spec.ts` | Resolution tests | +| `project.json` | Nx project config | +| `package.json` | NPM metadata | +| `tsconfig.json` / `tsconfig.lib.json` | TS configs | +| `vite.config.mts` | Vitest config | + +### New in `libs/chat/` + +| File | Responsibility | +|------|---------------| +| `src/lib/a2ui/surface-store.ts` | Signal-based surface state management | +| `src/lib/a2ui/surface-store.spec.ts` | Store tests | +| `src/lib/a2ui/catalog/index.ts` | Default catalog factory | +| `src/lib/a2ui/catalog/text.component.ts` | A2UI Text component | +| `src/lib/a2ui/catalog/image.component.ts` | A2UI Image component | +| `src/lib/a2ui/catalog/icon.component.ts` | A2UI Icon component | +| `src/lib/a2ui/catalog/divider.component.ts` | A2UI Divider component | +| `src/lib/a2ui/catalog/row.component.ts` | A2UI Row layout | +| `src/lib/a2ui/catalog/column.component.ts` | A2UI Column layout | +| `src/lib/a2ui/catalog/card.component.ts` | A2UI Card container | +| `src/lib/a2ui/catalog/list.component.ts` | A2UI List container | +| `src/lib/a2ui/catalog/button.component.ts` | A2UI Button (read-only Phase 1) | +| `src/lib/a2ui/catalog/text-field.component.ts` | A2UI TextField (read-only Phase 1) | +| `src/lib/a2ui/catalog/check-box.component.ts` | A2UI CheckBox (read-only Phase 1) | +| `src/lib/a2ui/catalog/choice-picker.component.ts` | A2UI ChoicePicker (read-only Phase 1) | +| `src/lib/a2ui/surface.component.ts` | Surface renderer | +| `src/lib/a2ui/surface.component.spec.ts` | Renderer tests | +| `src/lib/a2ui/node.component.ts` | Recursive node renderer | + +### Modified in `libs/chat/` + +| File | Change | +|------|--------| +| `src/lib/streaming/content-classifier.ts` | Route A2UI to JSONL parser + surface store | +| `src/lib/streaming/content-classifier.spec.ts` | Update A2UI tests | +| `src/lib/compositions/chat/chat.component.ts` | Add A2UI surface rendering to template | +| `src/public-api.ts` | Export A2UI types and components | + +--- + +### Task 1: Scaffold `@cacheplane/a2ui` Library + +**Files:** +- Create: `libs/a2ui/project.json`, `libs/a2ui/package.json`, `libs/a2ui/tsconfig.json`, `libs/a2ui/tsconfig.lib.json`, `libs/a2ui/vite.config.mts`, `libs/a2ui/src/index.ts` +- Modify: `tsconfig.base.json` + +- [ ] **Step 1: Create all scaffold files** + +Follow the exact pattern from `libs/partial-json/` (simple TS lib, `@nx/js:tsc` builder, `node` test environment). + +`libs/a2ui/project.json`: +```json +{ + "name": "a2ui", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/a2ui/src", + "projectType": "library", + "tags": ["scope:shared", "type:lib"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/libs/a2ui"], + "options": { + "outputPath": "dist/libs/a2ui", + "main": "libs/a2ui/src/index.ts", + "tsConfig": "libs/a2ui/tsconfig.lib.json" + } + }, + "lint": { "executor": "@nx/eslint:lint" }, + "test": { + "executor": "@nx/vite:test", + "options": { "configFile": "libs/a2ui/vite.config.mts" } + } + } +} +``` + +`libs/a2ui/package.json`: +```json +{ + "name": "@cacheplane/a2ui", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} +``` + +`libs/a2ui/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [{ "path": "./tsconfig.lib.json" }] +} +``` + +`libs/a2ui/tsconfig.lib.json`: +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} +``` + +`libs/a2ui/vite.config.mts`: +```ts +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + environment: 'node', + globals: true, + include: ['src/**/*.spec.ts'], + passWithNoTests: true, + }, +}); +``` + +`libs/a2ui/src/index.ts`: +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Public API — populated as modules are added +``` + +Add to `tsconfig.base.json` paths: +```json +"@cacheplane/a2ui": ["libs/a2ui/src/index.ts"] +``` + +- [ ] **Step 2: Verify scaffold** + +Run: `npx nx test a2ui` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add libs/a2ui/ tsconfig.base.json +git commit -m "chore: scaffold @cacheplane/a2ui library" +``` + +--- + +### Task 2: A2UI Types + +**Files:** +- Create: `libs/a2ui/src/lib/types.ts` +- Modify: `libs/a2ui/src/index.ts` + +- [ ] **Step 1: Write types** + +Create `libs/a2ui/src/lib/types.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// --- Theme --- + +export interface A2uiTheme { + primaryColor?: string; + iconUrl?: string; + agentDisplayName?: string; +} + +// --- Dynamic value types --- + +export interface A2uiPathRef { + path: string; +} + +export interface A2uiFunctionCall { + call: string; + args: Record; + returnType?: string; +} + +/** A value that can be a literal, a path reference, or a function call. */ +export type DynamicValue = T | A2uiPathRef | A2uiFunctionCall; +export type DynamicString = DynamicValue; +export type DynamicNumber = DynamicValue; +export type DynamicBoolean = DynamicValue; +export type DynamicStringList = DynamicValue; + +// --- Children --- + +export interface A2uiChildTemplate { + path: string; + componentId: string; +} + +export type A2uiChildList = string[] | A2uiChildTemplate; + +// --- Actions (Phase 2 — type definitions only) --- + +export interface A2uiEventAction { + event: { name: string; context?: Record }; +} + +export interface A2uiLocalAction { + functionCall: A2uiFunctionCall; +} + +export type A2uiAction = A2uiEventAction | A2uiLocalAction; + +// --- Validation (Phase 2 — type definitions only) --- + +export interface A2uiCheck { + call: string; + args: Record; + message: string; +} + +// --- Components --- + +export interface A2uiComponent { + id: string; + component: string; + children?: A2uiChildList; + action?: A2uiAction; + checks?: A2uiCheck[]; + [key: string]: unknown; +} + +// --- Messages --- + +export interface A2uiCreateSurface { + type: 'createSurface'; + surfaceId: string; + catalogId: string; + theme?: A2uiTheme; + sendDataModel?: boolean; +} + +export interface A2uiUpdateComponents { + type: 'updateComponents'; + surfaceId: string; + components: A2uiComponent[]; +} + +export interface A2uiUpdateDataModel { + type: 'updateDataModel'; + surfaceId: string; + path?: string; + value?: unknown; +} + +export interface A2uiDeleteSurface { + type: 'deleteSurface'; + surfaceId: string; +} + +export type A2uiMessage = + | A2uiCreateSurface + | A2uiUpdateComponents + | A2uiUpdateDataModel + | A2uiDeleteSurface; + +// --- Surface --- + +export interface A2uiSurface { + surfaceId: string; + catalogId: string; + theme?: A2uiTheme; + components: Map; + dataModel: Record; +} +``` + +- [ ] **Step 2: Update barrel** + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export type { + A2uiTheme, A2uiPathRef, A2uiFunctionCall, + DynamicValue, DynamicString, DynamicNumber, DynamicBoolean, DynamicStringList, + A2uiChildTemplate, A2uiChildList, + A2uiEventAction, A2uiLocalAction, A2uiAction, A2uiCheck, + A2uiComponent, + A2uiCreateSurface, A2uiUpdateComponents, A2uiUpdateDataModel, A2uiDeleteSurface, + A2uiMessage, A2uiSurface, +} from './lib/types'; +``` + +- [ ] **Step 3: Verify and commit** + +Run: `npx nx test a2ui` +Commit: `git commit -m "feat(a2ui): add A2UI v0.9 message and component types"` + +--- + +### Task 3: JSON Pointer Utilities + +**Files:** +- Create: `libs/a2ui/src/lib/pointer.ts` +- Create: `libs/a2ui/src/lib/pointer.spec.ts` +- Modify: `libs/a2ui/src/index.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/a2ui/src/lib/pointer.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { getByPointer, setByPointer, deleteByPointer } from './pointer'; + +describe('getByPointer', () => { + it('returns root for empty string', () => { + const model = { a: 1 }; + expect(getByPointer(model, '')).toBe(model); + }); + + it('returns root for /', () => { + const model = { a: 1 }; + expect(getByPointer(model, '/')).toBe(model); + }); + + it('gets a top-level property', () => { + expect(getByPointer({ name: 'Alice' }, '/name')).toBe('Alice'); + }); + + it('gets a nested property', () => { + expect(getByPointer({ user: { profile: { name: 'Bob' } } }, '/user/profile/name')).toBe('Bob'); + }); + + it('gets an array element', () => { + expect(getByPointer({ items: ['a', 'b', 'c'] }, '/items/1')).toBe('b'); + }); + + it('returns undefined for non-existent path', () => { + expect(getByPointer({ a: 1 }, '/b')).toBeUndefined(); + }); +}); + +describe('setByPointer', () => { + it('sets a top-level property immutably', () => { + const original = { name: 'Alice' }; + const result = setByPointer(original, '/name', 'Bob'); + expect(result.name).toBe('Bob'); + expect(original.name).toBe('Alice'); // immutable + }); + + it('sets a nested property immutably', () => { + const original = { user: { name: 'Alice', age: 30 } }; + const result = setByPointer(original, '/user/name', 'Bob'); + expect(result.user.name).toBe('Bob'); + expect(result.user.age).toBe(30); + expect(original.user.name).toBe('Alice'); + }); + + it('replaces entire model for / path', () => { + const result = setByPointer({ old: true }, '/', { new: true }); + expect(result).toEqual({ new: true }); + }); + + it('creates intermediate objects', () => { + const result = setByPointer({}, '/a/b/c', 42); + expect(result.a.b.c).toBe(42); + }); + + it('sets array elements', () => { + const result = setByPointer({ items: ['a', 'b'] }, '/items/1', 'x'); + expect(result.items[1]).toBe('x'); + }); +}); + +describe('deleteByPointer', () => { + it('removes a top-level key', () => { + const result = deleteByPointer({ a: 1, b: 2 }, '/a'); + expect(result).toEqual({ b: 2 }); + }); + + it('removes a nested key', () => { + const result = deleteByPointer({ user: { name: 'Alice', age: 30 } }, '/user/age'); + expect(result.user).toEqual({ name: 'Alice' }); + }); +}); +``` + +- [ ] **Step 2: Implement** + +Create `libs/a2ui/src/lib/pointer.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +function parsePointer(pointer: string): string[] { + if (!pointer || pointer === '/') return []; + return pointer.split('/').filter(Boolean); +} + +export function getByPointer(model: Record, pointer: string): unknown { + const segments = parsePointer(pointer); + let current: unknown = model; + for (const seg of segments) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[seg]; + } + return current; +} + +export function setByPointer( + model: Record, + pointer: string, + value: unknown, +): Record { + const segments = parsePointer(pointer); + if (segments.length === 0) return value as Record; + + function clone(obj: unknown, segs: string[], val: unknown): unknown { + if (segs.length === 0) return val; + const [head, ...rest] = segs; + const base = (obj != null && typeof obj === 'object') ? obj : {}; + const isArray = Array.isArray(base); + const copy = isArray ? [...(base as unknown[])] : { ...(base as Record) }; + (copy as Record)[head] = clone( + (base as Record)[head], + rest, + val, + ); + return copy; + } + + return clone(model, segments, value) as Record; +} + +export function deleteByPointer( + model: Record, + pointer: string, +): Record { + const segments = parsePointer(pointer); + if (segments.length === 0) return {}; + + const parentPath = segments.slice(0, -1); + const key = segments[segments.length - 1]; + + if (parentPath.length === 0) { + const copy = { ...model }; + delete copy[key]; + return copy; + } + + const parent = getByPointer(model, '/' + parentPath.join('/')); + if (parent == null || typeof parent !== 'object') return model; + const parentCopy = { ...(parent as Record) }; + delete parentCopy[key]; + return setByPointer(model, '/' + parentPath.join('/'), parentCopy); +} +``` + +- [ ] **Step 3: Update barrel, verify, commit** + +Add to `libs/a2ui/src/index.ts`: +```ts +export { getByPointer, setByPointer, deleteByPointer } from './lib/pointer'; +``` + +Run: `npx nx test a2ui` +Commit: `git commit -m "feat(a2ui): add JSON Pointer get/set/delete utilities"` + +--- + +### Task 4: JSONL Message Parser + +**Files:** +- Create: `libs/a2ui/src/lib/parser.ts` +- Create: `libs/a2ui/src/lib/parser.spec.ts` +- Modify: `libs/a2ui/src/index.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/a2ui/src/lib/parser.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { createA2uiMessageParser } from './parser'; + +describe('createA2uiMessageParser', () => { + it('parses a createSurface message', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic"}}\n'); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toEqual({ + type: 'createSurface', + surfaceId: 's1', + catalogId: 'basic', + }); + }); + + it('parses an updateComponents message', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","updateComponents":{"surfaceId":"s1","components":[{"id":"root","component":"Column","children":["c1"]}]}}\n'); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe('updateComponents'); + expect((msgs[0] as any).components[0].id).toBe('root'); + }); + + it('parses an updateDataModel message', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","updateDataModel":{"surfaceId":"s1","path":"/user/name","value":"Alice"}}\n'); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toEqual({ + type: 'updateDataModel', + surfaceId: 's1', + path: '/user/name', + value: 'Alice', + }); + }); + + it('parses a deleteSurface message', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","deleteSurface":{"surfaceId":"s1"}}\n'); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toEqual({ type: 'deleteSurface', surfaceId: 's1' }); + }); + + it('parses multiple messages in one chunk', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push( + '{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic"}}\n' + + '{"version":"v0.9","updateComponents":{"surfaceId":"s1","components":[]}}\n' + ); + expect(msgs).toHaveLength(2); + expect(msgs[0].type).toBe('createSurface'); + expect(msgs[1].type).toBe('updateComponents'); + }); + + it('buffers incomplete lines across pushes', () => { + const parser = createA2uiMessageParser(); + const msgs1 = parser.push('{"version":"v0.9","createSurface":{"surfaceI'); + expect(msgs1).toHaveLength(0); + + const msgs2 = parser.push('d":"s1","catalogId":"basic"}}\n'); + expect(msgs2).toHaveLength(1); + expect(msgs2[0].type).toBe('createSurface'); + }); + + it('ignores empty lines', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('\n\n{"version":"v0.9","deleteSurface":{"surfaceId":"s1"}}\n\n'); + expect(msgs).toHaveLength(1); + }); + + it('skips unrecognized envelope keys', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","unknownType":{"foo":"bar"}}\n'); + expect(msgs).toHaveLength(0); + }); + + it('handles updateDataModel with no path (root replacement)', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","updateDataModel":{"surfaceId":"s1","value":{"key":"val"}}}\n'); + expect(msgs[0]).toEqual({ + type: 'updateDataModel', + surfaceId: 's1', + value: { key: 'val' }, + }); + }); + + it('handles updateDataModel with no value (delete)', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","updateDataModel":{"surfaceId":"s1","path":"/old"}}\n'); + expect(msgs[0]).toEqual({ + type: 'updateDataModel', + surfaceId: 's1', + path: '/old', + }); + }); + + it('preserves createSurface theme', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic","theme":{"primaryColor":"#00BFFF"}}}\n'); + expect((msgs[0] as any).theme).toEqual({ primaryColor: '#00BFFF' }); + }); +}); +``` + +- [ ] **Step 2: Implement** + +Create `libs/a2ui/src/lib/parser.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { A2uiMessage } from './types'; + +const ENVELOPE_KEYS = ['createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface'] as const; +type EnvelopeKey = typeof ENVELOPE_KEYS[number]; + +export interface A2uiMessageParser { + push(chunk: string): A2uiMessage[]; +} + +export function createA2uiMessageParser(): A2uiMessageParser { + let buffer = ''; + + function parseEnvelope(json: Record): A2uiMessage | null { + for (const key of ENVELOPE_KEYS) { + if (key in json && typeof json[key] === 'object' && json[key] !== null) { + const payload = json[key] as Record; + return { type: key, ...payload } as unknown as A2uiMessage; + } + } + return null; + } + + function push(chunk: string): A2uiMessage[] { + buffer += chunk; + const messages: A2uiMessage[] = []; + + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (!line) continue; + + try { + const json = JSON.parse(line); + const msg = parseEnvelope(json); + if (msg) messages.push(msg); + } catch { + // Skip malformed lines + } + } + + return messages; + } + + return { push }; +} +``` + +- [ ] **Step 3: Update barrel, verify, commit** + +Add to `libs/a2ui/src/index.ts`: +```ts +export { createA2uiMessageParser } from './lib/parser'; +export type { A2uiMessageParser } from './lib/parser'; +``` + +Run: `npx nx test a2ui` +Commit: `git commit -m "feat(a2ui): add JSONL message parser"` + +--- + +### Task 5: Dynamic Value Resolution + +**Files:** +- Create: `libs/a2ui/src/lib/resolve.ts` +- Create: `libs/a2ui/src/lib/resolve.spec.ts` +- Modify: `libs/a2ui/src/index.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/a2ui/src/lib/resolve.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { resolveDynamic } from './resolve'; + +const model = { user: { name: 'Alice', age: 30 }, items: ['a', 'b'] }; + +describe('resolveDynamic', () => { + it('passes through string literals', () => { + expect(resolveDynamic('hello', model)).toBe('hello'); + }); + + it('passes through number literals', () => { + expect(resolveDynamic(42, model)).toBe(42); + }); + + it('passes through boolean literals', () => { + expect(resolveDynamic(true, model)).toBe(true); + }); + + it('passes through null', () => { + expect(resolveDynamic(null, model)).toBeNull(); + }); + + it('resolves absolute path references', () => { + expect(resolveDynamic({ path: '/user/name' }, model)).toBe('Alice'); + }); + + it('resolves relative path in scope', () => { + const scope = { basePath: '/user', item: { name: 'Alice', age: 30 } }; + expect(resolveDynamic({ path: 'name' }, model, scope)).toBe('Alice'); + }); + + it('interpolates template strings with ${/path}', () => { + expect(resolveDynamic('Hello ${/user/name}!', model)).toBe('Hello Alice!'); + }); + + it('interpolates multiple references', () => { + expect(resolveDynamic('${/user/name} has ${/user/age} years', model)).toBe('Alice has 30 years'); + }); + + it('handles escaped \\${ as literal', () => { + expect(resolveDynamic('Price: \\${100}', model)).toBe('Price: ${100}'); + }); + + it('returns function calls as-is (Phase 2)', () => { + const fn = { call: 'formatDate', args: { value: '2026-01-01' } }; + expect(resolveDynamic(fn, model)).toBe('[formatDate]'); + }); + + it('resolves array elements', () => { + expect(resolveDynamic({ path: '/items/0' }, model)).toBe('a'); + }); + + it('returns undefined for non-existent paths', () => { + expect(resolveDynamic({ path: '/missing' }, model)).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Implement** + +Create `libs/a2ui/src/lib/resolve.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { A2uiPathRef, A2uiFunctionCall } from './types'; +import { getByPointer } from './pointer'; + +export interface A2uiScope { + basePath: string; + item: unknown; +} + +function isPathRef(value: unknown): value is A2uiPathRef { + return typeof value === 'object' && value !== null && 'path' in value && typeof (value as A2uiPathRef).path === 'string' && !('call' in value); +} + +function isFunctionCall(value: unknown): value is A2uiFunctionCall { + return typeof value === 'object' && value !== null && 'call' in value; +} + +function resolvePathRef(ref: A2uiPathRef, model: Record, scope?: A2uiScope): unknown { + const path = ref.path; + // Absolute path starts with / + if (path.startsWith('/')) { + return getByPointer(model, path); + } + // Relative path — resolve against scope + if (scope) { + return getByPointer(model, `${scope.basePath}/${path}`); + } + return getByPointer(model, '/' + path); +} + +function interpolateTemplate(template: string, model: Record, scope?: A2uiScope): string { + return template.replace(/\\(\$\{)|(? { + if (escaped) return '${'; + const value = resolvePathRef({ path }, model, scope); + if (value == null) return ''; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + }); +} + +export function resolveDynamic( + value: unknown, + model: Record, + scope?: A2uiScope, +): unknown { + if (value == null) return value; + + // Path reference + if (isPathRef(value)) { + return resolvePathRef(value, model, scope); + } + + // Function call — stub for Phase 2 + if (isFunctionCall(value)) { + return `[${(value as A2uiFunctionCall).call}]`; + } + + // Template string interpolation + if (typeof value === 'string' && value.includes('${')) { + return interpolateTemplate(value, model, scope); + } + + // Literal passthrough + return value; +} +``` + +- [ ] **Step 3: Update barrel, verify, commit** + +Add to `libs/a2ui/src/index.ts`: +```ts +export { resolveDynamic } from './lib/resolve'; +export type { A2uiScope } from './lib/resolve'; +``` + +Run: `npx nx test a2ui` +Commit: `git commit -m "feat(a2ui): add dynamic value resolution with template interpolation"` + +--- + +### Task 6: A2UI Surface Store + +**Files:** +- Create: `libs/chat/src/lib/a2ui/surface-store.ts` +- Create: `libs/chat/src/lib/a2ui/surface-store.spec.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/chat/src/lib/a2ui/surface-store.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { createA2uiSurfaceStore } from './surface-store'; +import type { A2uiMessage } from '@cacheplane/a2ui'; + +describe('createA2uiSurfaceStore', () => { + function setup() { + let store!: ReturnType; + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + store = createA2uiSurfaceStore(); + }); + return store; + } + + it('starts with no surfaces', () => { + const store = setup(); + expect(store.surfaces().size).toBe(0); + }); + + it('creates a surface', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + expect(store.surfaces().size).toBe(1); + const s = store.surfaces().get('s1')!; + expect(s.surfaceId).toBe('s1'); + expect(s.catalogId).toBe('basic'); + expect(s.components.size).toBe(0); + }); + + it('adds components to a surface', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ + type: 'updateComponents', + surfaceId: 's1', + components: [ + { id: 'root', component: 'Column', children: ['t1'] }, + { id: 't1', component: 'Text', text: 'Hello' }, + ], + }); + const s = store.surfaces().get('s1')!; + expect(s.components.size).toBe(2); + expect(s.components.get('root')!.component).toBe('Column'); + expect(s.components.get('t1')!.component).toBe('Text'); + }); + + it('replaces existing components by id', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ type: 'updateComponents', surfaceId: 's1', components: [{ id: 't1', component: 'Text', text: 'Old' }] }); + store.apply({ type: 'updateComponents', surfaceId: 's1', components: [{ id: 't1', component: 'Text', text: 'New' }] }); + expect((store.surfaces().get('s1')!.components.get('t1') as any).text).toBe('New'); + }); + + it('sets data model at path', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ type: 'updateDataModel', surfaceId: 's1', path: '/user/name', value: 'Alice' }); + expect((store.surfaces().get('s1')!.dataModel as any).user.name).toBe('Alice'); + }); + + it('replaces entire data model when path is omitted', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ type: 'updateDataModel', surfaceId: 's1', value: { fresh: true } }); + expect(store.surfaces().get('s1')!.dataModel).toEqual({ fresh: true }); + }); + + it('deletes data model key when value is omitted', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ type: 'updateDataModel', surfaceId: 's1', path: '/a', value: 1 }); + store.apply({ type: 'updateDataModel', surfaceId: 's1', path: '/a' }); + expect((store.surfaces().get('s1')!.dataModel as any).a).toBeUndefined(); + }); + + it('deletes a surface', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ type: 'deleteSurface', surfaceId: 's1' }); + expect(store.surfaces().size).toBe(0); + }); + + it('surface() returns a signal for a specific surface', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + const s = store.surface('s1'); + expect(s()).toBeDefined(); + expect(s()!.surfaceId).toBe('s1'); + }); + + it('ignores messages for non-existent surfaces', () => { + const store = setup(); + store.apply({ type: 'updateComponents', surfaceId: 'nope', components: [] }); + expect(store.surfaces().size).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Implement** + +Create `libs/chat/src/lib/a2ui/surface-store.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { computed, signal, type Signal } from '@angular/core'; +import type { A2uiMessage, A2uiSurface } from '@cacheplane/a2ui'; +import { setByPointer, deleteByPointer } from '@cacheplane/a2ui'; + +export interface A2uiSurfaceStore { + apply(message: A2uiMessage): void; + readonly surfaces: Signal>; + surface(surfaceId: string): Signal; +} + +export function createA2uiSurfaceStore(): A2uiSurfaceStore { + const surfacesSignal = signal>(new Map()); + + function apply(message: A2uiMessage): void { + const current = surfacesSignal(); + + switch (message.type) { + case 'createSurface': { + const next = new Map(current); + next.set(message.surfaceId, { + surfaceId: message.surfaceId, + catalogId: message.catalogId, + theme: message.theme, + components: new Map(), + dataModel: {}, + }); + surfacesSignal.set(next); + break; + } + case 'updateComponents': { + const surface = current.get(message.surfaceId); + if (!surface) return; + const components = new Map(surface.components); + for (const comp of message.components) { + components.set(comp.id, comp); + } + const next = new Map(current); + next.set(message.surfaceId, { ...surface, components }); + surfacesSignal.set(next); + break; + } + case 'updateDataModel': { + const surface = current.get(message.surfaceId); + if (!surface) return; + let dataModel: Record; + if (message.path === undefined || message.path === '/') { + dataModel = (message.value as Record) ?? {}; + } else if (message.value === undefined) { + dataModel = deleteByPointer(surface.dataModel, message.path); + } else { + dataModel = setByPointer(surface.dataModel, message.path, message.value); + } + const next = new Map(current); + next.set(message.surfaceId, { ...surface, dataModel }); + surfacesSignal.set(next); + break; + } + case 'deleteSurface': { + const next = new Map(current); + next.delete(message.surfaceId); + surfacesSignal.set(next); + break; + } + } + } + + function surface(surfaceId: string): Signal { + return computed(() => surfacesSignal().get(surfaceId)); + } + + return { + apply, + surfaces: surfacesSignal.asReadonly(), + surface, + }; +} +``` + +- [ ] **Step 3: Verify and commit** + +Run: `npx nx test chat -- --testPathPattern=surface-store` +Commit: `git commit -m "feat(chat): add A2UI surface store with signal-based state management"` + +--- + +### Task 7: Default Component Catalog (12 Components) + +**Files:** +- Create: 12 component files + catalog factory in `libs/chat/src/lib/a2ui/catalog/` + +- [ ] **Step 1: Create catalog factory** + +Create `libs/chat/src/lib/a2ui/catalog/index.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { views, type ViewRegistry } from '@cacheplane/render'; +import { A2uiTextComponent } from './text.component'; +import { A2uiImageComponent } from './image.component'; +import { A2uiIconComponent } from './icon.component'; +import { A2uiDividerComponent } from './divider.component'; +import { A2uiRowComponent } from './row.component'; +import { A2uiColumnComponent } from './column.component'; +import { A2uiCardComponent } from './card.component'; +import { A2uiListComponent } from './list.component'; +import { A2uiButtonComponent } from './button.component'; +import { A2uiTextFieldComponent } from './text-field.component'; +import { A2uiCheckBoxComponent } from './check-box.component'; +import { A2uiChoicePickerComponent } from './choice-picker.component'; + +export function a2uiBasicCatalog(): ViewRegistry { + return views({ + Text: A2uiTextComponent, + Image: A2uiImageComponent, + Icon: A2uiIconComponent, + Divider: A2uiDividerComponent, + Row: A2uiRowComponent, + Column: A2uiColumnComponent, + Card: A2uiCardComponent, + List: A2uiListComponent, + Button: A2uiButtonComponent, + TextField: A2uiTextFieldComponent, + CheckBox: A2uiCheckBoxComponent, + ChoicePicker: A2uiChoicePickerComponent, + }); +} +``` + +- [ ] **Step 2: Create display components** + +Create each component as a minimal standalone Angular component. Each receives its A2UI props as inputs. Container components receive `childKeys` and `spec` for recursive rendering (matching the render lib pattern). + +**`libs/chat/src/lib/a2ui/catalog/text.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-text', + standalone: true, + template: `{{ text() }}`, +}) +export class A2uiTextComponent { + readonly text = input(''); +} +``` + +**`libs/chat/src/lib/a2ui/catalog/image.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-image', + standalone: true, + template: ``, +}) +export class A2uiImageComponent { + readonly url = input(''); + readonly alt = input(''); +} +``` + +**`libs/chat/src/lib/a2ui/catalog/icon.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-icon', + standalone: true, + template: `{{ name() }}`, +}) +export class A2uiIconComponent { + readonly name = input(''); +} +``` + +**`libs/chat/src/lib/a2ui/catalog/divider.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component } from '@angular/core'; + +@Component({ + selector: 'a2ui-divider', + standalone: true, + template: `
`, +}) +export class A2uiDividerComponent {} +``` + +- [ ] **Step 3: Create container components** + +Container components use `RenderElementComponent` from `@cacheplane/render` for recursive child rendering. + +**`libs/chat/src/lib/a2ui/catalog/row.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; +import type { Spec } from '@json-render/core'; +import { RenderElementComponent } from '@cacheplane/render'; + +@Component({ + selector: 'a2ui-row', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @for (key of childKeys(); track key) { + + } +
+ `, +}) +export class A2uiRowComponent { + readonly childKeys = input([]); + readonly spec = input.required(); +} +``` + +**`libs/chat/src/lib/a2ui/catalog/column.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; +import type { Spec } from '@json-render/core'; +import { RenderElementComponent } from '@cacheplane/render'; + +@Component({ + selector: 'a2ui-column', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @for (key of childKeys(); track key) { + + } +
+ `, +}) +export class A2uiColumnComponent { + readonly childKeys = input([]); + readonly spec = input.required(); +} +``` + +**`libs/chat/src/lib/a2ui/catalog/card.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; +import type { Spec } from '@json-render/core'; +import { RenderElementComponent } from '@cacheplane/render'; + +@Component({ + selector: 'a2ui-card', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @if (title()) { +

{{ title() }}

+ } + @for (key of childKeys(); track key) { + + } +
+ `, +}) +export class A2uiCardComponent { + readonly title = input(''); + readonly childKeys = input([]); + readonly spec = input.required(); +} +``` + +**`libs/chat/src/lib/a2ui/catalog/list.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; +import type { Spec } from '@json-render/core'; +import { RenderElementComponent } from '@cacheplane/render'; + +@Component({ + selector: 'a2ui-list', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @for (key of childKeys(); track key) { + + } +
+ `, +}) +export class A2uiListComponent { + readonly childKeys = input([]); + readonly spec = input.required(); +} +``` + +- [ ] **Step 4: Create interactive components (read-only Phase 1)** + +**`libs/chat/src/lib/a2ui/catalog/button.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-button', + standalone: true, + template: ` + + `, +}) +export class A2uiButtonComponent { + readonly label = input(''); + readonly variant = input('primary'); + readonly disabled = input(false); +} +``` + +**`libs/chat/src/lib/a2ui/catalog/text-field.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-text-field', + standalone: true, + template: ` +
+ @if (label()) { } + +
+ `, +}) +export class A2uiTextFieldComponent { + readonly label = input(''); + readonly value = input(''); + readonly placeholder = input(''); +} +``` + +**`libs/chat/src/lib/a2ui/catalog/check-box.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-check-box', + standalone: true, + template: ` + + `, +}) +export class A2uiCheckBoxComponent { + readonly label = input(''); + readonly checked = input(false); +} +``` + +**`libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts`:** +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-choice-picker', + standalone: true, + template: ` +
+ @if (label()) { } + +
+ `, +}) +export class A2uiChoicePickerComponent { + readonly label = input(''); + readonly options = input([]); + readonly selected = input(''); +} +``` + +- [ ] **Step 5: Verify and commit** + +Run: `npx nx test chat` +Commit: `git commit -m "feat(chat): add A2UI v0.9 default component catalog (12 components)"` + +--- + +### Task 8: A2UI Surface Renderer + +**Files:** +- Create: `libs/chat/src/lib/a2ui/surface.component.ts` +- Create: `libs/chat/src/lib/a2ui/surface.component.spec.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/chat/src/lib/a2ui/surface.component.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import type { A2uiSurface, A2uiComponent } from '@cacheplane/a2ui'; + +describe('A2uiSurfaceComponent — data flow', () => { + function makeSurface(components: A2uiComponent[], dataModel: Record = {}): A2uiSurface { + const map = new Map(); + for (const c of components) map.set(c.id, c); + return { surfaceId: 's1', catalogId: 'basic', components: map, dataModel }; + } + + it('resolves root component from surface', () => { + const surface = makeSurface([ + { id: 'root', component: 'Column', children: ['t1'] }, + { id: 't1', component: 'Text', text: 'Hello' }, + ]); + expect(surface.components.get('root')!.component).toBe('Column'); + expect((surface.components.get('root')!.children as string[])).toEqual(['t1']); + }); + + it('resolves data bindings in component props', () => { + const surface = makeSurface( + [{ id: 'root', component: 'Text', text: { path: '/greeting' } as any }], + { greeting: 'Hello World' }, + ); + // The renderer will call resolveDynamic on each prop + expect(surface.dataModel).toEqual({ greeting: 'Hello World' }); + }); + + it('handles surfaces with no components', () => { + const surface = makeSurface([]); + expect(surface.components.size).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Implement surface component** + +Create `libs/chat/src/lib/a2ui/surface.component.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, computed, input, ChangeDetectionStrategy, +} from '@angular/core'; +import type { Spec } from '@json-render/core'; +import type { A2uiSurface } from '@cacheplane/a2ui'; +import { resolveDynamic } from '@cacheplane/a2ui'; +import { RenderSpecComponent, toRenderRegistry } from '@cacheplane/render'; +import type { ViewRegistry } from '@cacheplane/render'; + +/** + * Converts an A2UI surface to a json-render Spec by: + * 1. Walking the flat component map + * 2. Resolving DynamicValue props against the data model + * 3. Mapping A2UI children (string[] or template) to json-render children + * 4. Producing a Spec with root + elements + */ +function surfaceToSpec(surface: A2uiSurface): Spec | null { + if (!surface.components.has('root')) return null; + + const elements: Record = {}; + + for (const [id, comp] of surface.components) { + const props: Record = {}; + + // Resolve all props except reserved keys + const reserved = new Set(['id', 'component', 'children', 'action', 'checks']); + for (const [key, value] of Object.entries(comp)) { + if (reserved.has(key)) continue; + props[key] = resolveDynamic(value, surface.dataModel); + } + + // Map children + let children: string[] | undefined; + if (Array.isArray(comp.children)) { + children = comp.children as string[]; + } + // Template children (collection expansion) — Phase 2 for full implementation + // For now, skip template children + + elements[id] = { + type: comp.component, + props, + ...(children ? { children } : {}), + }; + } + + return { root: 'root', elements } as Spec; +} + +@Component({ + selector: 'a2ui-surface', + standalone: true, + imports: [RenderSpecComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (spec(); as s) { + + } + `, +}) +export class A2uiSurfaceComponent { + readonly surface = input.required(); + readonly catalog = input.required(); + + /** Convert the A2UI surface to a json-render Spec for rendering. */ + readonly spec = computed(() => surfaceToSpec(this.surface())); + + /** Convert ViewRegistry to AngularRegistry for RenderSpecComponent. */ + readonly registry = computed(() => toRenderRegistry(this.catalog())); +} +``` + +- [ ] **Step 3: Verify and commit** + +Run: `npx nx test chat -- --testPathPattern=surface.component` +Commit: `git commit -m "feat(chat): add A2UI surface renderer converting surfaces to render specs"` + +--- + +### Task 9: ContentClassifier A2UI Integration + +**Files:** +- Modify: `libs/chat/src/lib/streaming/content-classifier.ts` +- Modify: `libs/chat/src/lib/streaming/content-classifier.spec.ts` + +- [ ] **Step 1: Update classifier to use A2UI JSONL parser + surface store** + +In `libs/chat/src/lib/streaming/content-classifier.ts`: + +Add imports: +```ts +import { createA2uiMessageParser, type A2uiMessageParser } from '@cacheplane/a2ui'; +import type { A2uiSurface } from '@cacheplane/a2ui'; +import { createA2uiSurfaceStore, type A2uiSurfaceStore } from '../a2ui/surface-store'; +``` + +Add to `ContentClassifier` interface: +```ts +readonly a2uiSurfaces: Signal>; +``` + +Replace the A2UI detection block (lines 115-122) to use `A2uiMessageParser` + `A2uiSurfaceStore` instead of `initJsonStore`: + +```ts +// New fields +let a2uiParser: A2uiMessageParser | null = null; +let a2uiStore: A2uiSurfaceStore | null = null; +const a2uiSurfacesSignal = signal>(new Map()); +``` + +In detection: +```ts +} else if (detected === 'a2ui') { + streamingSignal.set(true); + a2uiParser = createA2uiMessageParser(); + a2uiStore = createA2uiSurfaceStore(); + jsonStartIndex = content.indexOf(A2UI_PREFIX) + A2UI_PREFIX.length; + const jsonContent = content.slice(jsonStartIndex); + if (jsonContent.length > 0) { + const msgs = a2uiParser.push(jsonContent); + for (const msg of msgs) a2uiStore.apply(msg); + a2uiSurfacesSignal.set(a2uiStore.surfaces()); + } + processedLength = content.length; +} +``` + +In delta processing for `'a2ui'`: +```ts +} else if (currentType === 'a2ui') { + if (a2uiParser && a2uiStore) { + const msgs = a2uiParser.push(delta); + for (const msg of msgs) a2uiStore.apply(msg); + a2uiSurfacesSignal.set(a2uiStore.surfaces()); + } +} +``` + +Add `a2uiSurfaces: a2uiSurfacesSignal.asReadonly()` to the returned object. + +- [ ] **Step 2: Add tests for A2UI JSONL parsing in classifier** + +Add to `content-classifier.spec.ts`: + +```ts +describe('a2ui JSONL parsing', () => { + it('parses A2UI messages and exposes surfaces', () => { + const c = setup(); + c.update( + '---a2ui_JSON---' + + '{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic"}}\n' + + '{"version":"v0.9","updateComponents":{"surfaceId":"s1","components":[{"id":"root","component":"Text","text":"Hi"}]}}\n' + ); + expect(c.type()).toBe('a2ui'); + expect(c.a2uiSurfaces().size).toBe(1); + expect(c.a2uiSurfaces().get('s1')!.components.get('root')!.component).toBe('Text'); + }); + + it('accumulates A2UI messages across updates', () => { + const c = setup(); + c.update('---a2ui_JSON---{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic"}}\n'); + expect(c.a2uiSurfaces().size).toBe(1); + + c.update( + '---a2ui_JSON---{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic"}}\n' + + '{"version":"v0.9","updateDataModel":{"surfaceId":"s1","path":"/name","value":"Alice"}}\n' + ); + expect((c.a2uiSurfaces().get('s1')!.dataModel as any).name).toBe('Alice'); + }); +}); +``` + +- [ ] **Step 3: Verify and commit** + +Run: `npx nx test chat` +Commit: `git commit -m "feat(chat): wire ContentClassifier A2UI detection to JSONL parser and surface store"` + +--- + +### Task 10: ChatComponent A2UI Template Integration + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` +- Modify: `libs/chat/src/public-api.ts` + +- [ ] **Step 1: Update ChatComponent template** + +Add imports: +```ts +import { A2uiSurfaceComponent } from '../../a2ui/surface.component'; +import { a2uiBasicCatalog } from '../../a2ui/catalog/index'; +``` + +Add to `imports` array: `A2uiSurfaceComponent` + +Add to component class: +```ts +protected readonly a2uiCatalog = a2uiBasicCatalog(); +``` + +In the AI message template, after the `@if (classified.spec(); as spec)` block, add: + +```html +@if (classified.type() === 'a2ui') { + @for (entry of classified.a2uiSurfaces() | keyvalue; track entry.key) { + + } +} +``` + +Note: Angular's `keyvalue` pipe needs to be imported. Add `KeyValuePipe` from `@angular/common` to imports. + +- [ ] **Step 2: Update public-api.ts** + +Add to `libs/chat/src/public-api.ts`: + +```ts +// A2UI +export { createA2uiSurfaceStore } from './lib/a2ui/surface-store'; +export type { A2uiSurfaceStore } from './lib/a2ui/surface-store'; +export { A2uiSurfaceComponent } from './lib/a2ui/surface.component'; +export { a2uiBasicCatalog } from './lib/a2ui/catalog/index'; +``` + +- [ ] **Step 3: Add @cacheplane/a2ui to chat peer dependencies** + +In `libs/chat/package.json`, add: +```json +"@cacheplane/a2ui": "^0.0.1" +``` + +- [ ] **Step 4: Verify all tests pass** + +Run: `npx nx run-many -t test -p a2ui chat render` +Expected: ALL PASS + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/ libs/a2ui/ +git commit -m "feat(chat): integrate A2UI surface rendering in ChatComponent" +``` + +--- + +### Task 11: Final Verification + +- [ ] **Step 1: Lint** + +Run: `npx nx run-many -t lint -p a2ui chat` +Fix any issues. + +- [ ] **Step 2: Full test suite** + +Run: `npx nx run-many -t test -p a2ui partial-json render chat` +Expected: ALL PASS + +- [ ] **Step 3: Commit any fixes** + +Only if needed. diff --git a/docs/superpowers/specs/2026-04-09-a2ui-phase1-design.md b/docs/superpowers/specs/2026-04-09-a2ui-phase1-design.md new file mode 100644 index 000000000..a6ea0fb04 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-a2ui-phase1-design.md @@ -0,0 +1,305 @@ +# A2UI v0.9 Phase 1 — Design Spec + +**Date:** 2026-04-09 +**Status:** Draft + +## Goal + +Implement A2UI v0.9 protocol support for rendering agent-driven UI surfaces in Angular. Phase 1 covers: JSONL message parsing, surface state management, a default component catalog matching the v0.9 basic catalog, and an Angular renderer that resolves data bindings and template iteration. No client-to-server actions or validation (Phase 2). + +## Architecture Overview + +``` +A2UI JSONL stream (one JSON object per line) + → A2uiMessageParser (parse + validate envelope messages) + → A2uiSurfaceStore (maintain surfaces, components, data model) + ├── Component adjacency list → tree resolution + ├── Data model with JSON Pointer get/set + └── Template expansion for collections + → A2uiSurfaceComponent (Angular renderer) + ├── Resolves DynamicString/Number/Boolean bindings + ├── Maps A2UI component names → Angular components via catalog + └── Recursively renders component tree +``` + +## Part 1: A2UI Message Parser + +### Purpose + +A standalone, framework-agnostic TypeScript library that parses A2UI v0.9 JSONL messages into typed envelope objects. Lives in a new `@cacheplane/a2ui` library at `libs/a2ui/`. + +### Message Types + +```ts +interface A2uiCreateSurface { + type: 'createSurface'; + surfaceId: string; + catalogId: string; + theme?: A2uiTheme; + sendDataModel?: boolean; +} + +interface A2uiUpdateComponents { + type: 'updateComponents'; + surfaceId: string; + components: A2uiComponent[]; +} + +interface A2uiUpdateDataModel { + type: 'updateDataModel'; + surfaceId: string; + path?: string; // JSON Pointer, defaults to '/' + value?: unknown; // omitting removes the key +} + +interface A2uiDeleteSurface { + type: 'deleteSurface'; + surfaceId: string; +} + +type A2uiMessage = + | A2uiCreateSurface + | A2uiUpdateComponents + | A2uiUpdateDataModel + | A2uiDeleteSurface; +``` + +### Component Type + +```ts +interface A2uiComponent { + id: string; + component: string; // 'Text', 'Button', 'Column', etc. + children?: A2uiChildList; // static array or template + action?: A2uiAction; // Phase 2 + checks?: A2uiCheck[]; // Phase 2 + [key: string]: unknown; // component-specific props (DynamicString, etc.) +} + +/** Static children or collection template. */ +type A2uiChildList = + | string[] // static list of component IDs + | { path: string; componentId: string }; // template over collection +``` + +### Parser API + +```ts +interface A2uiMessageParser { + /** Feed a chunk of JSONL text. Returns parsed messages. */ + push(chunk: string): A2uiMessage[]; +} + +function createA2uiMessageParser(): A2uiMessageParser; +``` + +The parser accumulates text, splits on newlines, parses each complete line as JSON, and extracts the envelope type from the top-level key (`createSurface`, `updateComponents`, etc.). + +### Library Boundary + +- Package: `@cacheplane/a2ui` +- Nx lib at `libs/a2ui/` +- Zero dependencies, pure TypeScript +- No Angular coupling — the Angular integration lives in `libs/chat/` + +--- + +## Part 2: A2UI Surface Store + +### Purpose + +An Angular signal-based store that maintains the state of all active A2UI surfaces. Processes parsed `A2uiMessage` objects and exposes reactive signals for rendering. + +Lives in `libs/chat/src/lib/streaming/` alongside the existing `ParseTreeStore` and `ContentClassifier`. + +### Interface + +```ts +interface A2uiSurface { + surfaceId: string; + catalogId: string; + theme?: A2uiTheme; + /** Flat component map: id → component */ + components: Map; + /** Data model (plain JS object, navigated via JSON Pointer) */ + dataModel: Record; +} + +interface A2uiSurfaceStore { + /** Process a parsed A2UI message. */ + apply(message: A2uiMessage): void; + + /** All active surfaces. */ + readonly surfaces: Signal>; + + /** Get a single surface by ID. */ + surface(surfaceId: string): Signal; +} + +function createA2uiSurfaceStore(): A2uiSurfaceStore; +``` + +### Message Handling + +- **createSurface** — Creates a new `A2uiSurface` entry with empty components and dataModel. +- **updateComponents** — Merges components into the surface's component map by ID. Existing components with the same ID are replaced. +- **updateDataModel** — Sets the value at the given JSON Pointer path in the surface's data model. If `path` is omitted or `/`, replaces the entire model. If `value` is omitted, deletes the key. +- **deleteSurface** — Removes the surface from the store. + +### Data Model Access + +```ts +function getByPointer(model: Record, pointer: string): unknown; +function setByPointer(model: Record, pointer: string, value: unknown): Record; +``` + +Uses RFC 6901 JSON Pointer syntax. `setByPointer` returns a new object (immutable update) for signal change detection. + +--- + +## Part 3: Default Component Catalog + +### Purpose + +Ships Angular components matching the A2UI v0.9 basic catalog. Uses the same `views()` / `ViewRegistry` pattern as json-render so consumers can override or extend. + +Lives in `libs/chat/src/lib/a2ui/catalog/`. + +### Components (Phase 1 — display + layout) + +| A2UI Type | Angular Component | Description | +|-----------|------------------|-------------| +| `Text` | `A2uiTextComponent` | Renders text with basic markdown support | +| `Image` | `A2uiImageComponent` | `` with src and alt bindings | +| `Icon` | `A2uiIconComponent` | Named icon from predefined set | +| `Divider` | `A2uiDividerComponent` | Horizontal rule | +| `Row` | `A2uiRowComponent` | Flex row, renders children | +| `Column` | `A2uiColumnComponent` | Flex column, renders children | +| `Card` | `A2uiCardComponent` | Card container with optional title | +| `List` | `A2uiListComponent` | Scrollable list, renders children | +| `Button` | `A2uiButtonComponent` | Clickable, primary/borderless variants (action wiring in Phase 2) | + +### Components (Phase 1 — interactive, read-only rendering) + +| A2UI Type | Angular Component | Description | +|-----------|------------------|-------------| +| `TextField` | `A2uiTextFieldComponent` | Text input (renders value, write-back in Phase 2) | +| `CheckBox` | `A2uiCheckBoxComponent` | Boolean toggle (renders value, write-back in Phase 2) | +| `ChoicePicker` | `A2uiChoicePickerComponent` | Option selector (renders value, write-back in Phase 2) | + +Components deferred to Phase 2: `Tabs`, `Modal`, `Video`, `AudioPlayer`, `DateTimeInput`, `Slider`. + +### Catalog Factory + +```ts +function a2uiBasicCatalog(): ViewRegistry; +``` + +Returns a `ViewRegistry` mapping all Phase 1 component names to their Angular implementations. + +--- + +## Part 4: A2UI Renderer + +### Purpose + +An Angular component that renders a single A2UI surface from the store. Recursively walks the component tree (from `root`), resolves data bindings, expands collection templates, and renders each component via the catalog. + +### Interface + +```ts +@Component({ + selector: 'a2ui-surface', + template: `...`, +}) +export class A2uiSurfaceComponent { + readonly surface = input.required(); + readonly catalog = input.required(); +} +``` + +### Data Binding Resolution + +A2UI uses `DynamicString` / `DynamicNumber` / `DynamicBoolean` types for property values. These can be: +- **Literal**: `"Hello"` / `42` / `true` — pass through as-is +- **Path reference**: `{ "path": "/user/name" }` — resolve from data model via JSON Pointer +- **Function call**: `{ "call": "formatDate", "args": {...} }` — execute registered function (Phase 2, pass through as string for now) +- **Template string**: `"Hello ${/user/name}"` — interpolate paths in `${...}` syntax + +```ts +function resolveDynamic( + value: unknown, + dataModel: Record, + scope?: { basePath: string; item: unknown }, +): unknown; +``` + +### Template Expansion (Collections) + +When `children` is `{ path: "/employees", componentId: "emp_card" }`: +1. Read the array at `/employees` from the data model +2. For each item, create a scope with `basePath = /employees/N` +3. Render the template component (`emp_card`) once per item, with relative path resolution scoped to that item + +### Recursive Rendering + +``` +A2uiSurfaceComponent + → finds root component (id: "root") + → renders A2uiNodeComponent for root + → resolves props via data bindings + → maps component type to Angular component via catalog + → renders children recursively (static IDs or template expansion) +``` + +--- + +## Part 5: ContentClassifier Integration + +### Current State + +The `ContentClassifier` already detects `---a2ui_JSON---` prefix and sets `type() === 'a2ui'`. Currently it routes to the same `PartialJsonParser` used for json-render. + +### Changes + +A2UI uses JSONL (one JSON object per line), not a single JSON object. The classifier needs to: +1. Detect the `---a2ui_JSON---` prefix +2. After stripping the prefix, route content to `A2uiMessageParser` (not `PartialJsonParser`) +3. Feed parsed messages to `A2uiSurfaceStore` +4. Expose new signals: `readonly a2uiSurfaces: Signal>` + +### ChatComponent Template + +```html +@if (classified.type() === 'a2ui') { + @for (surface of classified.a2uiSurfaces() | keyvalue; track surface.key) { + + } +} +``` + +--- + +## Deliverables + +| # | Deliverable | Package | Description | +|---|------------|---------|-------------| +| 1 | A2UI Message Parser | `@cacheplane/a2ui` (new lib) | JSONL parser + typed message envelopes | +| 2 | A2UI Surface Store | `@cacheplane/chat` | Signal-based surface state management | +| 3 | Default Component Catalog | `@cacheplane/chat` | 12 Angular components matching v0.9 basic catalog | +| 4 | A2UI Renderer | `@cacheplane/chat` | Surface rendering with data binding + template expansion | +| 5 | Classifier Integration | `@cacheplane/chat` | Wire A2UI detection to parser → store → renderer | + +## Out of Scope (Phase 2) + +- Client-to-server actions (event dispatch) +- Local function execution (functionCall) +- Validation system (checks array) +- Two-way data binding (input write-back) +- Tabs, Modal, Video, AudioPlayer, DateTimeInput, Slider components +- Multi-agent theme attribution +- `sendDataModel` metadata in action payloads +- Custom catalog definitions (`inlineCatalogs`) diff --git a/libs/a2ui/package.json b/libs/a2ui/package.json new file mode 100644 index 000000000..452a58550 --- /dev/null +++ b/libs/a2ui/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/a2ui", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/libs/a2ui/project.json b/libs/a2ui/project.json new file mode 100644 index 000000000..1efd89a15 --- /dev/null +++ b/libs/a2ui/project.json @@ -0,0 +1,23 @@ +{ + "name": "a2ui", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/a2ui/src", + "projectType": "library", + "tags": ["scope:shared", "type:lib"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/libs/a2ui"], + "options": { + "outputPath": "dist/libs/a2ui", + "main": "libs/a2ui/src/index.ts", + "tsConfig": "libs/a2ui/tsconfig.lib.json" + } + }, + "lint": { "executor": "@nx/eslint:lint" }, + "test": { + "executor": "@nx/vite:test", + "options": { "configFile": "libs/a2ui/vite.config.mts" } + } + } +} diff --git a/libs/a2ui/src/index.ts b/libs/a2ui/src/index.ts new file mode 100644 index 000000000..3a4bd1aac --- /dev/null +++ b/libs/a2ui/src/index.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export type { + A2uiTheme, A2uiPathRef, A2uiFunctionCall, + DynamicValue, DynamicString, DynamicNumber, DynamicBoolean, DynamicStringList, + A2uiChildTemplate, A2uiChildList, + A2uiEventAction, A2uiLocalAction, A2uiAction, A2uiCheck, + A2uiComponent, + A2uiCreateSurface, A2uiUpdateComponents, A2uiUpdateDataModel, A2uiDeleteSurface, + A2uiMessage, A2uiSurface, +} from './lib/types'; +export { getByPointer, setByPointer, deleteByPointer } from './lib/pointer'; +export { createA2uiMessageParser } from './lib/parser'; +export type { A2uiMessageParser } from './lib/parser'; +export { resolveDynamic } from './lib/resolve'; +export type { A2uiScope } from './lib/resolve'; diff --git a/libs/a2ui/src/lib/parser.spec.ts b/libs/a2ui/src/lib/parser.spec.ts new file mode 100644 index 000000000..9e1cd29f7 --- /dev/null +++ b/libs/a2ui/src/lib/parser.spec.ts @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { createA2uiMessageParser } from './parser'; + +describe('createA2uiMessageParser', () => { + it('parses a createSurface message', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic"}}\n'); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toEqual({ + type: 'createSurface', + surfaceId: 's1', + catalogId: 'basic', + }); + }); + + it('parses an updateComponents message', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","updateComponents":{"surfaceId":"s1","components":[{"id":"root","component":"Column","children":["c1"]}]}}\n'); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe('updateComponents'); + expect((msgs[0] as any).components[0].id).toBe('root'); + }); + + it('parses an updateDataModel message', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","updateDataModel":{"surfaceId":"s1","path":"/user/name","value":"Alice"}}\n'); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toEqual({ + type: 'updateDataModel', + surfaceId: 's1', + path: '/user/name', + value: 'Alice', + }); + }); + + it('parses a deleteSurface message', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","deleteSurface":{"surfaceId":"s1"}}\n'); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toEqual({ type: 'deleteSurface', surfaceId: 's1' }); + }); + + it('parses multiple messages in one chunk', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push( + '{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic"}}\n' + + '{"version":"v0.9","updateComponents":{"surfaceId":"s1","components":[]}}\n' + ); + expect(msgs).toHaveLength(2); + expect(msgs[0].type).toBe('createSurface'); + expect(msgs[1].type).toBe('updateComponents'); + }); + + it('buffers incomplete lines across pushes', () => { + const parser = createA2uiMessageParser(); + const msgs1 = parser.push('{"version":"v0.9","createSurface":{"surfaceI'); + expect(msgs1).toHaveLength(0); + + const msgs2 = parser.push('d":"s1","catalogId":"basic"}}\n'); + expect(msgs2).toHaveLength(1); + expect(msgs2[0].type).toBe('createSurface'); + }); + + it('ignores empty lines', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('\n\n{"version":"v0.9","deleteSurface":{"surfaceId":"s1"}}\n\n'); + expect(msgs).toHaveLength(1); + }); + + it('skips unrecognized envelope keys', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","unknownType":{"foo":"bar"}}\n'); + expect(msgs).toHaveLength(0); + }); + + it('handles updateDataModel with no path (root replacement)', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","updateDataModel":{"surfaceId":"s1","value":{"key":"val"}}}\n'); + expect(msgs[0]).toEqual({ + type: 'updateDataModel', + surfaceId: 's1', + value: { key: 'val' }, + }); + }); + + it('handles updateDataModel with no value (delete)', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","updateDataModel":{"surfaceId":"s1","path":"/old"}}\n'); + expect(msgs[0]).toEqual({ + type: 'updateDataModel', + surfaceId: 's1', + path: '/old', + }); + }); + + it('preserves createSurface theme', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push('{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic","theme":{"primaryColor":"#00BFFF"}}}\n'); + expect((msgs[0] as any).theme).toEqual({ primaryColor: '#00BFFF' }); + }); +}); diff --git a/libs/a2ui/src/lib/parser.ts b/libs/a2ui/src/lib/parser.ts new file mode 100644 index 000000000..2fc526c79 --- /dev/null +++ b/libs/a2ui/src/lib/parser.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { A2uiMessage } from './types'; + +const ENVELOPE_KEYS = ['createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface'] as const; +type EnvelopeKey = typeof ENVELOPE_KEYS[number]; + +export interface A2uiMessageParser { + push(chunk: string): A2uiMessage[]; +} + +export function createA2uiMessageParser(): A2uiMessageParser { + let buffer = ''; + + function parseEnvelope(json: Record): A2uiMessage | null { + for (const key of ENVELOPE_KEYS) { + if (key in json && typeof json[key] === 'object' && json[key] !== null) { + const payload = json[key] as Record; + return { type: key, ...payload } as unknown as A2uiMessage; + } + } + return null; + } + + function push(chunk: string): A2uiMessage[] { + buffer += chunk; + const messages: A2uiMessage[] = []; + + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (!line) continue; + + try { + const json = JSON.parse(line); + const msg = parseEnvelope(json); + if (msg) messages.push(msg); + } catch { + // Skip malformed lines + } + } + + return messages; + } + + return { push }; +} diff --git a/libs/a2ui/src/lib/pointer.spec.ts b/libs/a2ui/src/lib/pointer.spec.ts new file mode 100644 index 000000000..4bbec727c --- /dev/null +++ b/libs/a2ui/src/lib/pointer.spec.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { getByPointer, setByPointer, deleteByPointer } from './pointer'; + +describe('getByPointer', () => { + it('returns root for empty string', () => { + const model = { a: 1 }; + expect(getByPointer(model, '')).toBe(model); + }); + + it('returns root for /', () => { + const model = { a: 1 }; + expect(getByPointer(model, '/')).toBe(model); + }); + + it('gets a top-level property', () => { + expect(getByPointer({ name: 'Alice' }, '/name')).toBe('Alice'); + }); + + it('gets a nested property', () => { + expect(getByPointer({ user: { profile: { name: 'Bob' } } }, '/user/profile/name')).toBe('Bob'); + }); + + it('gets an array element', () => { + expect(getByPointer({ items: ['a', 'b', 'c'] }, '/items/1')).toBe('b'); + }); + + it('returns undefined for non-existent path', () => { + expect(getByPointer({ a: 1 }, '/b')).toBeUndefined(); + }); +}); + +describe('setByPointer', () => { + it('sets a top-level property immutably', () => { + const original = { name: 'Alice' }; + const result = setByPointer(original, '/name', 'Bob'); + expect(result.name).toBe('Bob'); + expect(original.name).toBe('Alice'); // immutable + }); + + it('sets a nested property immutably', () => { + const original = { user: { name: 'Alice', age: 30 } }; + const result = setByPointer(original, '/user/name', 'Bob'); + expect(result.user.name).toBe('Bob'); + expect(result.user.age).toBe(30); + expect(original.user.name).toBe('Alice'); + }); + + it('replaces entire model for / path', () => { + const result = setByPointer({ old: true }, '/', { new: true }); + expect(result).toEqual({ new: true }); + }); + + it('creates intermediate objects', () => { + const result = setByPointer({}, '/a/b/c', 42); + expect(result.a.b.c).toBe(42); + }); + + it('sets array elements', () => { + const result = setByPointer({ items: ['a', 'b'] }, '/items/1', 'x'); + expect(result.items[1]).toBe('x'); + }); +}); + +describe('deleteByPointer', () => { + it('removes a top-level key', () => { + const result = deleteByPointer({ a: 1, b: 2 }, '/a'); + expect(result).toEqual({ b: 2 }); + }); + + it('removes a nested key', () => { + const result = deleteByPointer({ user: { name: 'Alice', age: 30 } }, '/user/age'); + expect(result.user).toEqual({ name: 'Alice' }); + }); +}); diff --git a/libs/a2ui/src/lib/pointer.ts b/libs/a2ui/src/lib/pointer.ts new file mode 100644 index 000000000..e9b57e823 --- /dev/null +++ b/libs/a2ui/src/lib/pointer.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +function parsePointer(pointer: string): string[] { + if (!pointer || pointer === '/') return []; + return pointer.split('/').filter(Boolean); +} + +export function getByPointer(model: Record, pointer: string): unknown { + const segments = parsePointer(pointer); + let current: unknown = model; + for (const seg of segments) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[seg]; + } + return current; +} + +export function setByPointer( + model: Record, + pointer: string, + value: unknown, +): Record { + const segments = parsePointer(pointer); + if (segments.length === 0) return value as Record; + + function clone(obj: unknown, segs: string[], val: unknown): unknown { + if (segs.length === 0) return val; + const [head, ...rest] = segs; + const base = (obj != null && typeof obj === 'object') ? obj : {}; + const isArray = Array.isArray(base); + const copy = isArray ? [...(base as unknown[])] : { ...(base as Record) }; + (copy as Record)[head] = clone( + (base as Record)[head], + rest, + val, + ); + return copy; + } + + return clone(model, segments, value) as Record; +} + +export function deleteByPointer( + model: Record, + pointer: string, +): Record { + const segments = parsePointer(pointer); + if (segments.length === 0) return {}; + + const parentPath = segments.slice(0, -1); + const key = segments[segments.length - 1]; + + if (parentPath.length === 0) { + const copy = { ...model }; + delete copy[key]; + return copy; + } + + const parent = getByPointer(model, '/' + parentPath.join('/')); + if (parent == null || typeof parent !== 'object') return model; + const parentCopy = { ...(parent as Record) }; + delete parentCopy[key]; + return setByPointer(model, '/' + parentPath.join('/'), parentCopy); +} diff --git a/libs/a2ui/src/lib/resolve.spec.ts b/libs/a2ui/src/lib/resolve.spec.ts new file mode 100644 index 000000000..e6ab37b5e --- /dev/null +++ b/libs/a2ui/src/lib/resolve.spec.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { resolveDynamic } from './resolve'; + +const model = { user: { name: 'Alice', age: 30 }, items: ['a', 'b'] }; + +describe('resolveDynamic', () => { + it('passes through string literals', () => { + expect(resolveDynamic('hello', model)).toBe('hello'); + }); + + it('passes through number literals', () => { + expect(resolveDynamic(42, model)).toBe(42); + }); + + it('passes through boolean literals', () => { + expect(resolveDynamic(true, model)).toBe(true); + }); + + it('passes through null', () => { + expect(resolveDynamic(null, model)).toBeNull(); + }); + + it('resolves absolute path references', () => { + expect(resolveDynamic({ path: '/user/name' }, model)).toBe('Alice'); + }); + + it('resolves relative path in scope', () => { + const scope = { basePath: '/user', item: { name: 'Alice', age: 30 } }; + expect(resolveDynamic({ path: 'name' }, model, scope)).toBe('Alice'); + }); + + it('interpolates template strings with ${/path}', () => { + expect(resolveDynamic('Hello ${/user/name}!', model)).toBe('Hello Alice!'); + }); + + it('interpolates multiple references', () => { + expect(resolveDynamic('${/user/name} has ${/user/age} years', model)).toBe('Alice has 30 years'); + }); + + it('handles escaped \\${ as literal', () => { + expect(resolveDynamic('Price: \\${100}', model)).toBe('Price: ${100}'); + }); + + it('returns function calls as-is (Phase 2)', () => { + const fn = { call: 'formatDate', args: { value: '2026-01-01' } }; + expect(resolveDynamic(fn, model)).toBe('[formatDate]'); + }); + + it('resolves array elements', () => { + expect(resolveDynamic({ path: '/items/0' }, model)).toBe('a'); + }); + + it('returns undefined for non-existent paths', () => { + expect(resolveDynamic({ path: '/missing' }, model)).toBeUndefined(); + }); +}); diff --git a/libs/a2ui/src/lib/resolve.ts b/libs/a2ui/src/lib/resolve.ts new file mode 100644 index 000000000..de1f58a4a --- /dev/null +++ b/libs/a2ui/src/lib/resolve.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { A2uiPathRef, A2uiFunctionCall } from './types'; +import { getByPointer } from './pointer'; + +export interface A2uiScope { + basePath: string; + item: unknown; +} + +function isPathRef(value: unknown): value is A2uiPathRef { + return typeof value === 'object' && value !== null && 'path' in value && typeof (value as A2uiPathRef).path === 'string' && !('call' in value); +} + +function isFunctionCall(value: unknown): value is A2uiFunctionCall { + return typeof value === 'object' && value !== null && 'call' in value; +} + +function resolvePathRef(ref: A2uiPathRef, model: Record, scope?: A2uiScope): unknown { + const path = ref.path; + // Absolute path starts with / + if (path.startsWith('/')) { + return getByPointer(model, path); + } + // Relative path — resolve against scope + if (scope) { + return getByPointer(model, `${scope.basePath}/${path}`); + } + return getByPointer(model, '/' + path); +} + +function interpolateTemplate(template: string, model: Record, scope?: A2uiScope): string { + return template.replace(/\\(\$\{)|(? { + if (escaped) return '${'; + const value = resolvePathRef({ path }, model, scope); + if (value == null) return ''; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + }); +} + +export function resolveDynamic( + value: unknown, + model: Record, + scope?: A2uiScope, +): unknown { + if (value == null) return value; + + // Path reference + if (isPathRef(value)) { + return resolvePathRef(value, model, scope); + } + + // Function call — stub for Phase 2 + if (isFunctionCall(value)) { + return `[${(value as A2uiFunctionCall).call}]`; + } + + // Template string interpolation + if (typeof value === 'string' && value.includes('${')) { + return interpolateTemplate(value, model, scope); + } + + // Literal passthrough + return value; +} diff --git a/libs/a2ui/src/lib/types.ts b/libs/a2ui/src/lib/types.ts new file mode 100644 index 000000000..d8203274f --- /dev/null +++ b/libs/a2ui/src/lib/types.ts @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// --- Theme --- + +export interface A2uiTheme { + primaryColor?: string; + iconUrl?: string; + agentDisplayName?: string; +} + +// --- Dynamic value types --- + +export interface A2uiPathRef { + path: string; +} + +export interface A2uiFunctionCall { + call: string; + args: Record; + returnType?: string; +} + +/** A value that can be a literal, a path reference, or a function call. */ +export type DynamicValue = T | A2uiPathRef | A2uiFunctionCall; +export type DynamicString = DynamicValue; +export type DynamicNumber = DynamicValue; +export type DynamicBoolean = DynamicValue; +export type DynamicStringList = DynamicValue; + +// --- Children --- + +export interface A2uiChildTemplate { + path: string; + componentId: string; +} + +export type A2uiChildList = string[] | A2uiChildTemplate; + +// --- Actions (Phase 2 — type definitions only) --- + +export interface A2uiEventAction { + event: { name: string; context?: Record }; +} + +export interface A2uiLocalAction { + functionCall: A2uiFunctionCall; +} + +export type A2uiAction = A2uiEventAction | A2uiLocalAction; + +// --- Validation (Phase 2 — type definitions only) --- + +export interface A2uiCheck { + call: string; + args: Record; + message: string; +} + +// --- Components --- + +export interface A2uiComponent { + id: string; + component: string; + children?: A2uiChildList; + action?: A2uiAction; + checks?: A2uiCheck[]; + [key: string]: unknown; +} + +// --- Messages --- + +export interface A2uiCreateSurface { + type: 'createSurface'; + surfaceId: string; + catalogId: string; + theme?: A2uiTheme; + sendDataModel?: boolean; +} + +export interface A2uiUpdateComponents { + type: 'updateComponents'; + surfaceId: string; + components: A2uiComponent[]; +} + +export interface A2uiUpdateDataModel { + type: 'updateDataModel'; + surfaceId: string; + path?: string; + value?: unknown; +} + +export interface A2uiDeleteSurface { + type: 'deleteSurface'; + surfaceId: string; +} + +export type A2uiMessage = + | A2uiCreateSurface + | A2uiUpdateComponents + | A2uiUpdateDataModel + | A2uiDeleteSurface; + +// --- Surface --- + +export interface A2uiSurface { + surfaceId: string; + catalogId: string; + theme?: A2uiTheme; + components: Map; + dataModel: Record; +} diff --git a/libs/a2ui/tsconfig.json b/libs/a2ui/tsconfig.json new file mode 100644 index 000000000..cf0cba0d6 --- /dev/null +++ b/libs/a2ui/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/libs/a2ui/tsconfig.lib.json b/libs/a2ui/tsconfig.lib.json new file mode 100644 index 000000000..48fc200a0 --- /dev/null +++ b/libs/a2ui/tsconfig.lib.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/libs/a2ui/vite.config.mts b/libs/a2ui/vite.config.mts new file mode 100644 index 000000000..971c722be --- /dev/null +++ b/libs/a2ui/vite.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + environment: 'node', + globals: true, + include: ['src/**/*.spec.ts'], + passWithNoTests: true, + }, +}); diff --git a/libs/chat/eslint.config.mjs b/libs/chat/eslint.config.mjs index 99b5c066f..b46547840 100644 --- a/libs/chat/eslint.config.mjs +++ b/libs/chat/eslint.config.mjs @@ -27,7 +27,7 @@ export default [ 'error', { type: 'attribute', - prefix: 'chat', + prefix: ['chat', 'a2ui'], style: 'camelCase', }, ], @@ -35,7 +35,7 @@ export default [ 'error', { type: 'element', - prefix: 'chat', + prefix: ['chat', 'a2ui'], style: 'kebab-case', }, ], diff --git a/libs/chat/package.json b/libs/chat/package.json index df1ebd7fc..e464267fe 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -6,6 +6,7 @@ "@angular/common": "^20.0.0 || ^21.0.0", "@angular/forms": "^20.0.0 || ^21.0.0", "@cacheplane/render": "^0.0.1", + "@cacheplane/a2ui": "^0.0.1", "@cacheplane/partial-json": "^0.0.1", "@cacheplane/angular": "^0.0.1", "@json-render/core": "^0.16.0", diff --git a/libs/chat/src/lib/a2ui/catalog/button.component.ts b/libs/chat/src/lib/a2ui/catalog/button.component.ts new file mode 100644 index 000000000..98dc7dc18 --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/button.component.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-button', + standalone: true, + template: ` + + `, +}) +export class A2uiButtonComponent { + readonly label = input(''); + readonly variant = input('primary'); + readonly disabled = input(false); +} diff --git a/libs/chat/src/lib/a2ui/catalog/card.component.ts b/libs/chat/src/lib/a2ui/catalog/card.component.ts new file mode 100644 index 000000000..04bd85a66 --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/card.component.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; +import type { Spec } from '@json-render/core'; +import { RenderElementComponent } from '@cacheplane/render'; + +@Component({ + selector: 'a2ui-card', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @if (title()) { +

{{ title() }}

+ } + @for (key of childKeys(); track key) { + + } +
+ `, +}) +export class A2uiCardComponent { + readonly title = input(''); + readonly childKeys = input([]); + readonly spec = input.required(); +} diff --git a/libs/chat/src/lib/a2ui/catalog/check-box.component.ts b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts new file mode 100644 index 000000000..6d40ca62f --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-check-box', + standalone: true, + template: ` + + `, +}) +export class A2uiCheckBoxComponent { + readonly label = input(''); + readonly checked = input(false); +} diff --git a/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts b/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts new file mode 100644 index 000000000..92c205965 --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-choice-picker', + standalone: true, + template: ` +
+ @if (label()) { } + +
+ `, +}) +export class A2uiChoicePickerComponent { + readonly label = input(''); + readonly options = input([]); + readonly selected = input(''); +} diff --git a/libs/chat/src/lib/a2ui/catalog/column.component.ts b/libs/chat/src/lib/a2ui/catalog/column.component.ts new file mode 100644 index 000000000..230277434 --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/column.component.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; +import type { Spec } from '@json-render/core'; +import { RenderElementComponent } from '@cacheplane/render'; + +@Component({ + selector: 'a2ui-column', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @for (key of childKeys(); track key) { + + } +
+ `, +}) +export class A2uiColumnComponent { + readonly childKeys = input([]); + readonly spec = input.required(); +} diff --git a/libs/chat/src/lib/a2ui/catalog/divider.component.ts b/libs/chat/src/lib/a2ui/catalog/divider.component.ts new file mode 100644 index 000000000..5d6a97c48 --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/divider.component.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component } from '@angular/core'; + +@Component({ + selector: 'a2ui-divider', + standalone: true, + template: `
`, +}) +export class A2uiDividerComponent {} diff --git a/libs/chat/src/lib/a2ui/catalog/icon.component.ts b/libs/chat/src/lib/a2ui/catalog/icon.component.ts new file mode 100644 index 000000000..3f0ff378d --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/icon.component.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-icon', + standalone: true, + template: `{{ name() }}`, +}) +export class A2uiIconComponent { + readonly name = input(''); +} diff --git a/libs/chat/src/lib/a2ui/catalog/image.component.ts b/libs/chat/src/lib/a2ui/catalog/image.component.ts new file mode 100644 index 000000000..362174c99 --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/image.component.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-image', + standalone: true, + template: ``, +}) +export class A2uiImageComponent { + readonly url = input(''); + readonly alt = input(''); +} diff --git a/libs/chat/src/lib/a2ui/catalog/index.ts b/libs/chat/src/lib/a2ui/catalog/index.ts new file mode 100644 index 000000000..c226b075e --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/index.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { views, type ViewRegistry } from '@cacheplane/render'; +import { A2uiTextComponent } from './text.component'; +import { A2uiImageComponent } from './image.component'; +import { A2uiIconComponent } from './icon.component'; +import { A2uiDividerComponent } from './divider.component'; +import { A2uiRowComponent } from './row.component'; +import { A2uiColumnComponent } from './column.component'; +import { A2uiCardComponent } from './card.component'; +import { A2uiListComponent } from './list.component'; +import { A2uiButtonComponent } from './button.component'; +import { A2uiTextFieldComponent } from './text-field.component'; +import { A2uiCheckBoxComponent } from './check-box.component'; +import { A2uiChoicePickerComponent } from './choice-picker.component'; + +export function a2uiBasicCatalog(): ViewRegistry { + return views({ + Text: A2uiTextComponent, + Image: A2uiImageComponent, + Icon: A2uiIconComponent, + Divider: A2uiDividerComponent, + Row: A2uiRowComponent, + Column: A2uiColumnComponent, + Card: A2uiCardComponent, + List: A2uiListComponent, + Button: A2uiButtonComponent, + TextField: A2uiTextFieldComponent, + CheckBox: A2uiCheckBoxComponent, + ChoicePicker: A2uiChoicePickerComponent, + }); +} diff --git a/libs/chat/src/lib/a2ui/catalog/list.component.ts b/libs/chat/src/lib/a2ui/catalog/list.component.ts new file mode 100644 index 000000000..400b20530 --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/list.component.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; +import type { Spec } from '@json-render/core'; +import { RenderElementComponent } from '@cacheplane/render'; + +@Component({ + selector: 'a2ui-list', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @for (key of childKeys(); track key) { + + } +
+ `, +}) +export class A2uiListComponent { + readonly childKeys = input([]); + readonly spec = input.required(); +} diff --git a/libs/chat/src/lib/a2ui/catalog/row.component.ts b/libs/chat/src/lib/a2ui/catalog/row.component.ts new file mode 100644 index 000000000..fae088e69 --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/row.component.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; +import type { Spec } from '@json-render/core'; +import { RenderElementComponent } from '@cacheplane/render'; + +@Component({ + selector: 'a2ui-row', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @for (key of childKeys(); track key) { + + } +
+ `, +}) +export class A2uiRowComponent { + readonly childKeys = input([]); + readonly spec = input.required(); +} diff --git a/libs/chat/src/lib/a2ui/catalog/text-field.component.ts b/libs/chat/src/lib/a2ui/catalog/text-field.component.ts new file mode 100644 index 000000000..d9c15776f --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/text-field.component.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-text-field', + standalone: true, + template: ` +
+ @if (label()) { } + +
+ `, +}) +export class A2uiTextFieldComponent { + readonly label = input(''); + readonly value = input(''); + readonly placeholder = input(''); +} diff --git a/libs/chat/src/lib/a2ui/catalog/text.component.ts b/libs/chat/src/lib/a2ui/catalog/text.component.ts new file mode 100644 index 000000000..a730bab2c --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/text.component.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-text', + standalone: true, + template: `{{ text() }}`, +}) +export class A2uiTextComponent { + readonly text = input(''); +} diff --git a/libs/chat/src/lib/a2ui/surface-store.spec.ts b/libs/chat/src/lib/a2ui/surface-store.spec.ts new file mode 100644 index 000000000..eb21a8396 --- /dev/null +++ b/libs/chat/src/lib/a2ui/surface-store.spec.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { createA2uiSurfaceStore } from './surface-store'; +import type { A2uiMessage } from '@cacheplane/a2ui'; + +describe('createA2uiSurfaceStore', () => { + function setup() { + let store!: ReturnType; + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + store = createA2uiSurfaceStore(); + }); + return store; + } + + it('starts with no surfaces', () => { + const store = setup(); + expect(store.surfaces().size).toBe(0); + }); + + it('creates a surface', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + expect(store.surfaces().size).toBe(1); + const s = store.surfaces().get('s1')!; + expect(s.surfaceId).toBe('s1'); + expect(s.catalogId).toBe('basic'); + expect(s.components.size).toBe(0); + }); + + it('adds components to a surface', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ + type: 'updateComponents', + surfaceId: 's1', + components: [ + { id: 'root', component: 'Column', children: ['t1'] }, + { id: 't1', component: 'Text', text: 'Hello' }, + ], + }); + const s = store.surfaces().get('s1')!; + expect(s.components.size).toBe(2); + expect(s.components.get('root')!.component).toBe('Column'); + expect(s.components.get('t1')!.component).toBe('Text'); + }); + + it('replaces existing components by id', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ type: 'updateComponents', surfaceId: 's1', components: [{ id: 't1', component: 'Text', text: 'Old' }] }); + store.apply({ type: 'updateComponents', surfaceId: 's1', components: [{ id: 't1', component: 'Text', text: 'New' }] }); + expect((store.surfaces().get('s1')!.components.get('t1') as any).text).toBe('New'); + }); + + it('sets data model at path', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ type: 'updateDataModel', surfaceId: 's1', path: '/user/name', value: 'Alice' }); + expect((store.surfaces().get('s1')!.dataModel as any).user.name).toBe('Alice'); + }); + + it('replaces entire data model when path is omitted', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ type: 'updateDataModel', surfaceId: 's1', value: { fresh: true } }); + expect(store.surfaces().get('s1')!.dataModel).toEqual({ fresh: true }); + }); + + it('deletes data model key when value is omitted', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ type: 'updateDataModel', surfaceId: 's1', path: '/a', value: 1 }); + store.apply({ type: 'updateDataModel', surfaceId: 's1', path: '/a' }); + expect((store.surfaces().get('s1')!.dataModel as any).a).toBeUndefined(); + }); + + it('deletes a surface', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + store.apply({ type: 'deleteSurface', surfaceId: 's1' }); + expect(store.surfaces().size).toBe(0); + }); + + it('surface() returns a signal for a specific surface', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + const s = store.surface('s1'); + expect(s()).toBeDefined(); + expect(s()!.surfaceId).toBe('s1'); + }); + + it('ignores messages for non-existent surfaces', () => { + const store = setup(); + store.apply({ type: 'updateComponents', surfaceId: 'nope', components: [] }); + expect(store.surfaces().size).toBe(0); + }); +}); diff --git a/libs/chat/src/lib/a2ui/surface-store.ts b/libs/chat/src/lib/a2ui/surface-store.ts new file mode 100644 index 000000000..5492bc683 --- /dev/null +++ b/libs/chat/src/lib/a2ui/surface-store.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { computed, signal, type Signal } from '@angular/core'; +import type { A2uiMessage, A2uiSurface } from '@cacheplane/a2ui'; +import { setByPointer, deleteByPointer } from '@cacheplane/a2ui'; + +export interface A2uiSurfaceStore { + apply(message: A2uiMessage): void; + readonly surfaces: Signal>; + surface(surfaceId: string): Signal; +} + +export function createA2uiSurfaceStore(): A2uiSurfaceStore { + const surfacesSignal = signal>(new Map()); + + function apply(message: A2uiMessage): void { + const current = surfacesSignal(); + + switch (message.type) { + case 'createSurface': { + const next = new Map(current); + next.set(message.surfaceId, { + surfaceId: message.surfaceId, + catalogId: message.catalogId, + theme: message.theme, + components: new Map(), + dataModel: {}, + }); + surfacesSignal.set(next); + break; + } + case 'updateComponents': { + const surface = current.get(message.surfaceId); + if (!surface) return; + const components = new Map(surface.components); + for (const comp of message.components) { + components.set(comp.id, comp); + } + const next = new Map(current); + next.set(message.surfaceId, { ...surface, components }); + surfacesSignal.set(next); + break; + } + case 'updateDataModel': { + const surface = current.get(message.surfaceId); + if (!surface) return; + let dataModel: Record; + if (message.path === undefined || message.path === '/') { + dataModel = (message.value as Record) ?? {}; + } else if (message.value === undefined) { + dataModel = deleteByPointer(surface.dataModel, message.path); + } else { + dataModel = setByPointer(surface.dataModel, message.path, message.value); + } + const next = new Map(current); + next.set(message.surfaceId, { ...surface, dataModel }); + surfacesSignal.set(next); + break; + } + case 'deleteSurface': { + const next = new Map(current); + next.delete(message.surfaceId); + surfacesSignal.set(next); + break; + } + } + } + + function surface(surfaceId: string): Signal { + return computed(() => surfacesSignal().get(surfaceId)); + } + + return { + apply, + surfaces: surfacesSignal.asReadonly(), + surface, + }; +} diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts new file mode 100644 index 000000000..7f5511ade --- /dev/null +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import type { A2uiSurface, A2uiComponent } from '@cacheplane/a2ui'; + +describe('A2uiSurfaceComponent — data flow', () => { + function makeSurface(components: A2uiComponent[], dataModel: Record = {}): A2uiSurface { + const map = new Map(); + for (const c of components) map.set(c.id, c); + return { surfaceId: 's1', catalogId: 'basic', components: map, dataModel }; + } + + it('resolves root component from surface', () => { + const surface = makeSurface([ + { id: 'root', component: 'Column', children: ['t1'] }, + { id: 't1', component: 'Text', text: 'Hello' }, + ]); + expect(surface.components.get('root')!.component).toBe('Column'); + expect((surface.components.get('root')!.children as string[])).toEqual(['t1']); + }); + + it('resolves data bindings in component props', () => { + const surface = makeSurface( + [{ id: 'root', component: 'Text', text: { path: '/greeting' } as any }], + { greeting: 'Hello World' }, + ); + // The renderer will call resolveDynamic on each prop + expect(surface.dataModel).toEqual({ greeting: 'Hello World' }); + }); + + it('handles surfaces with no components', () => { + const surface = makeSurface([]); + expect(surface.components.size).toBe(0); + }); +}); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts new file mode 100644 index 000000000..89ed3d0eb --- /dev/null +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, computed, input, ChangeDetectionStrategy, +} from '@angular/core'; +import type { Spec } from '@json-render/core'; +import type { A2uiSurface } from '@cacheplane/a2ui'; +import { resolveDynamic } from '@cacheplane/a2ui'; +import { RenderSpecComponent, toRenderRegistry } from '@cacheplane/render'; +import type { ViewRegistry } from '@cacheplane/render'; + +/** + * Converts an A2UI surface to a json-render Spec by: + * 1. Walking the flat component map + * 2. Resolving DynamicValue props against the data model + * 3. Mapping A2UI children (string[] or template) to json-render children + * 4. Producing a Spec with root + elements + */ +function surfaceToSpec(surface: A2uiSurface): Spec | null { + if (!surface.components.has('root')) return null; + + const elements: Record = {}; + + for (const [id, comp] of surface.components) { + const props: Record = {}; + + // Resolve all props except reserved keys + const reserved = new Set(['id', 'component', 'children', 'action', 'checks']); + for (const [key, value] of Object.entries(comp)) { + if (reserved.has(key)) continue; + props[key] = resolveDynamic(value, surface.dataModel); + } + + // Map children + let children: string[] | undefined; + if (Array.isArray(comp.children)) { + children = comp.children as string[]; + } + // Template children (collection expansion) — Phase 2 for full implementation + // For now, skip template children + + elements[id] = { + type: comp.component, + props, + ...(children ? { children } : {}), + }; + } + + return { root: 'root', elements } as Spec; +} + +@Component({ + selector: 'a2ui-surface', + standalone: true, + imports: [RenderSpecComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (spec(); as s) { + + } + `, +}) +export class A2uiSurfaceComponent { + readonly surface = input.required(); + readonly catalog = input.required(); + + /** Convert the A2UI surface to a json-render Spec for rendering. */ + readonly spec = computed(() => surfaceToSpec(this.surface())); + + /** Convert ViewRegistry to AngularRegistry for RenderSpecComponent. */ + readonly registry = computed(() => toRenderRegistry(this.catalog())); +} diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index a5323835e..e5c2a8a50 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -29,6 +29,9 @@ import { createContentClassifier, type ContentClassifier } from '../../streaming import { messageContent } from '../shared/message-utils'; import { CHAT_THEME_STYLES } from '../../styles/chat-theme'; import { CHAT_MARKDOWN_STYLES, renderMarkdown } from '../../styles/chat-markdown'; +import { A2uiSurfaceComponent } from '../../a2ui/surface.component'; +import { a2uiBasicCatalog } from '../../a2ui/catalog/index'; +import { KeyValuePipe } from '@angular/common'; @Component({ selector: 'chat', @@ -42,6 +45,8 @@ import { CHAT_MARKDOWN_STYLES, renderMarkdown } from '../../styles/chat-markdown ChatInterruptComponent, ChatThreadListComponent, ChatGenerativeUiComponent, + A2uiSurfaceComponent, + KeyValuePipe, ], changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_THEME_STYLES, CHAT_MARKDOWN_STYLES], @@ -139,6 +144,15 @@ import { CHAT_MARKDOWN_STYLES, renderMarkdown } from '../../styles/chat-markdown [loading]="ref().isLoading()" /> } + + @if (classified.type() === 'a2ui') { + @for (entry of classified.a2uiSurfaces() | keyvalue; track entry.key) { + + } + } @@ -204,6 +218,8 @@ export class ChatComponent { readonly threadSelected = output(); readonly sidebarOpen = signal(false); + protected readonly a2uiCatalog = a2uiBasicCatalog(); + private readonly classifiers = new Map(); diff --git a/libs/chat/src/lib/streaming/content-classifier.spec.ts b/libs/chat/src/lib/streaming/content-classifier.spec.ts index 68f99f34b..3851db1e1 100644 --- a/libs/chat/src/lib/streaming/content-classifier.spec.ts +++ b/libs/chat/src/lib/streaming/content-classifier.spec.ts @@ -178,6 +178,32 @@ describe('ContentClassifier', () => { }); }); + describe('a2ui JSONL parsing', () => { + it('parses A2UI messages and exposes surfaces', () => { + const c = setup(); + c.update( + '---a2ui_JSON---' + + '{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic"}}\n' + + '{"version":"v0.9","updateComponents":{"surfaceId":"s1","components":[{"id":"root","component":"Text","text":"Hi"}]}}\n' + ); + expect(c.type()).toBe('a2ui'); + expect(c.a2uiSurfaces().size).toBe(1); + expect(c.a2uiSurfaces().get('s1')!.components.get('root')!.component).toBe('Text'); + }); + + it('accumulates A2UI messages across updates', () => { + const c = setup(); + c.update('---a2ui_JSON---{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic"}}\n'); + expect(c.a2uiSurfaces().size).toBe(1); + + c.update( + '---a2ui_JSON---{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic"}}\n' + + '{"version":"v0.9","updateDataModel":{"surfaceId":"s1","path":"/name","value":"Alice"}}\n' + ); + expect((c.a2uiSurfaces().get('s1')!.dataModel as any).name).toBe('Alice'); + }); + }); + describe('dispose', () => { it('can be called without errors', () => { const c = setup(); diff --git a/libs/chat/src/lib/streaming/content-classifier.ts b/libs/chat/src/lib/streaming/content-classifier.ts index 5feccf3bf..27dc3cfd8 100644 --- a/libs/chat/src/lib/streaming/content-classifier.ts +++ b/libs/chat/src/lib/streaming/content-classifier.ts @@ -3,6 +3,9 @@ import { signal, type Signal } from '@angular/core'; import type { Spec } from '@json-render/core'; import { createPartialJsonParser } from '@cacheplane/partial-json'; import { createParseTreeStore, type ElementAccumulationState, type ParseTreeStore } from './parse-tree-store'; +import { createA2uiMessageParser, type A2uiMessageParser } from '@cacheplane/a2ui'; +import type { A2uiSurface } from '@cacheplane/a2ui'; +import { createA2uiSurfaceStore, type A2uiSurfaceStore } from '../a2ui/surface-store'; export type ContentType = 'undetermined' | 'markdown' | 'json-render' | 'a2ui' | 'mixed'; @@ -14,6 +17,7 @@ export interface ContentClassifier { readonly markdown: Signal; readonly spec: Signal; readonly elementStates: Signal>; + readonly a2uiSurfaces: Signal>; readonly streaming: Signal; dispose(): void; } @@ -29,6 +33,10 @@ export function createContentClassifier(): ContentClassifier { let store: ParseTreeStore | null = null; let jsonStartIndex = 0; + let a2uiParser: A2uiMessageParser | null = null; + let a2uiStore: A2uiSurfaceStore | null = null; + const a2uiSurfacesSignal = signal>(new Map()); + function detectType(content: string): ContentType { // Find first non-whitespace character for (let i = 0; i < content.length; i++) { @@ -114,10 +122,14 @@ export function createContentClassifier(): ContentClassifier { processedLength = content.length; } else if (detected === 'a2ui') { streamingSignal.set(true); + a2uiParser = createA2uiMessageParser(); + a2uiStore = createA2uiSurfaceStore(); jsonStartIndex = content.indexOf(A2UI_PREFIX) + A2UI_PREFIX.length; - const jsonContent = content.slice(jsonStartIndex); - if (jsonContent.length > 0) { - initJsonStore(jsonContent); + const a2uiContent = content.slice(jsonStartIndex); + if (a2uiContent.length > 0) { + const msgs = a2uiParser.push(a2uiContent); + for (const msg of msgs) a2uiStore.apply(msg); + a2uiSurfacesSignal.set(a2uiStore.surfaces()); } processedLength = content.length; } @@ -132,16 +144,24 @@ export function createContentClassifier(): ContentClassifier { if (currentType === 'markdown' || currentType === 'mixed') { markdownSignal.set(content); - } else if (currentType === 'json-render' || currentType === 'a2ui') { + } else if (currentType === 'json-render') { if (store) { store.push(delta); syncJsonSignals(); } + } else if (currentType === 'a2ui') { + if (a2uiParser && a2uiStore) { + const msgs = a2uiParser.push(delta); + for (const msg of msgs) a2uiStore.apply(msg); + a2uiSurfacesSignal.set(a2uiStore.surfaces()); + } } } function dispose(): void { store = null; + a2uiParser = null; + a2uiStore = null; } return { @@ -150,6 +170,7 @@ export function createContentClassifier(): ContentClassifier { markdown: markdownSignal.asReadonly(), spec: specSignal.asReadonly(), elementStates: elementStatesSignal.asReadonly(), + a2uiSurfaces: a2uiSurfacesSignal.asReadonly(), streaming: streamingSignal.asReadonly(), dispose, }; diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 5dda923ea..5341f3160 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -61,5 +61,11 @@ export type { ContentClassifier, ContentType } from './lib/streaming/content-cla export { createParseTreeStore } from './lib/streaming/parse-tree-store'; export type { ParseTreeStore, ElementAccumulationState } from './lib/streaming/parse-tree-store'; +// A2UI +export { createA2uiSurfaceStore } from './lib/a2ui/surface-store'; +export type { A2uiSurfaceStore } from './lib/a2ui/surface-store'; +export { A2uiSurfaceComponent } from './lib/a2ui/surface.component'; +export { a2uiBasicCatalog } from './lib/a2ui/catalog/index'; + // Test utilities export { createMockAgentRef } from './lib/testing/mock-agent-ref'; diff --git a/tsconfig.base.json b/tsconfig.base.json index 22167ee00..d74df592d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -26,7 +26,8 @@ "@cacheplane/angular": ["libs/agent/src/public-api.ts"], "@cacheplane/render": ["libs/render/src/public-api.ts"], "@cacheplane/chat": ["libs/chat/src/public-api.ts"], - "@cacheplane/partial-json": ["libs/partial-json/src/index.ts"] + "@cacheplane/partial-json": ["libs/partial-json/src/index.ts"], + "@cacheplane/a2ui": ["libs/a2ui/src/index.ts"] }, "skipLibCheck": true, "strict": true,