diff --git a/.kiro/specs/a2ui-standard-renderers/.config.kiro b/.kiro/specs/a2ui-standard-renderers/.config.kiro new file mode 100644 index 00000000000..c1983e86d32 --- /dev/null +++ b/.kiro/specs/a2ui-standard-renderers/.config.kiro @@ -0,0 +1 @@ +{"specId": "9bd88caa-9d12-4a17-b2c0-e2f0117c1747", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/a2ui-standard-renderers/design.md b/.kiro/specs/a2ui-standard-renderers/design.md new file mode 100644 index 00000000000..9bff9eea7a1 --- /dev/null +++ b/.kiro/specs/a2ui-standard-renderers/design.md @@ -0,0 +1,361 @@ +# Design Document: A2UI Standard Renderers + +## Overview + +This design replaces the custom `a2ui-parser.ts` (~300 lines) and `SurfaceOutletComponent` dispatch logic in the generative-angular sample with the official `@a2ui/angular` renderer package. The migration introduces three new architectural pieces: + +1. **A2UI Adapter Service** — A thin bridge that extracts raw A2UI operations from Thermidor's `A2UISurface[]` (ActivitySnapshot envelopes) and feeds them to the standard `A2uiRendererService`. +2. **Commerce Catalog** — A custom catalog registration mapping our existing commerce component types to their Angular implementations. +3. **Component Host integration** — Replacing the `SurfaceOutletComponent` / `NgComponentOutlet` pattern with the standard `a2ui-v09-component-host` directive. + +The existing commerce components (ProductCarousel, ComparisonTable, ComparisonSummary, BundleDisplay, NextActionsBar) remain functionally unchanged. Their input interface shifts from typed `@Input surface` objects to the A2UI renderer's standard data-binding mechanism (component props + data model). + +```mermaid +graph TD + subgraph Thermidor + CC[ConverseController] -->|state subscription| CS[ConversationService] + end + + subgraph "New Bridge Layer" + CS -->|A2UISurface[]| ADA[A2uiAdapterService] + ADA -->|raw operations| RS[A2uiRendererService] + end + + subgraph "A2UI Angular Renderer" + RS -->|reactive surface state| CH[a2ui-v09-component-host] + CH -->|catalog lookup| CAT[Commerce Catalog] + CAT -->|instantiates| PC[ProductCarouselComponent] + CAT -->|instantiates| CT[ComparisonTableComponent] + CAT -->|instantiates| CSM[ComparisonSummaryComponent] + CAT -->|instantiates| BD[BundleDisplayComponent] + CAT -->|instantiates| NA[NextActionsBarComponent] + end + + NA -->|action handler| CS +``` + +## Architecture + +### High-Level Data Flow + +1. The `ConverseController` emits state updates containing `agentResponse.surfaces` — an array of opaque `A2UISurface` records (each being an ActivitySnapshot content with an `operations` array). +2. The new `A2uiAdapterService` subscribes to these updates, extracts the `operations` arrays, and calls `A2uiRendererService.processMessages()` with the raw A2UI v0.8 operations. +3. The `A2uiRendererService` manages its internal surface state reactively (via Angular Signals), resolving component types against registered catalogs. +4. The transcript panel template iterates over the renderer's surface list using `a2ui-v09-component-host`, which instantiates the correct commerce component for each surface. +5. When a user clicks a NextActionsBar action, the action handler (configured in `A2UI_RENDERER_CONFIG`) calls back into `ConversationService.submit()` to trigger a new turn. + +### Key Design Decisions + +| Decision | Rationale | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Introduce a dedicated `A2uiAdapterService` rather than inlining extraction in `ConversationService` | Separation of concerns: the adapter owns the A2UI protocol bridge; `ConversationService` stays focused on turn lifecycle. Also avoids circular dependencies. | +| Keep commerce components' internal template/styles unchanged | Minimizes risk. Only the input binding interface changes. | +| Register a single merged catalog (Commerce + Basic) | The Basic_Catalog provides fallback rendering for any future generic A2UI components while the Commerce_Catalog handles our domain-specific surfaces. | +| Use the action handler in `A2UI_RENDERER_CONFIG` for NextActionsBar interactions | This is the standard A2UI pattern for component-to-app communication — avoids custom event wiring. | +| Adapt component inputs to A2UI data-binding props | The A2UI Component_Host passes data via `componentProps` and the data model. Components read from these instead of a monolithic typed surface object. | + +## Components and Interfaces + +### 1. `A2uiAdapterService` (new) + +**Location:** `src/app/services/a2ui-adapter.service.ts` + +**Responsibility:** Bridge between Thermidor's `A2UISurface[]` and `A2uiRendererService`. + +```typescript +@Injectable({providedIn: 'root'}) +export class A2uiAdapterService { + private readonly renderer = inject(A2uiRendererService); + + /** + * Processes an array of A2UISurface records (ActivitySnapshot contents) + * by extracting operations and forwarding them to the renderer. + */ + processSurfaces(surfaces: A2UISurface[]): void; + + /** + * Resets the renderer state (used when a snapshot has replace: true, + * or when starting a new conversation). + */ + reset(): void; +} +``` + +**Extraction logic:** + +```typescript +for (const surface of surfaces) { + const content = surface as {operations?: unknown[]; replace?: boolean}; + if (content.replace) { + this.renderer.reset(); // or equivalent clear method + } + if (Array.isArray(content.operations)) { + this.renderer.processMessages(content.operations); + } +} +``` + +### 2. `Commerce Catalog` (new) + +**Location:** `src/app/a2ui/commerce-catalog.ts` + +**Responsibility:** Maps commerce component type strings to Angular component classes. + +```typescript +import type {A2uiCatalog} from '@a2ui/angular'; + +export const COMMERCE_CATALOG: A2uiCatalog = { + ProductCarousel: ProductCarouselComponent, + ComparisonTable: ComparisonTableComponent, + ComparisonSummary: ComparisonSummaryComponent, + BundleDisplay: BundleDisplayComponent, + NextActionsBar: NextActionsBarComponent, +}; +``` + +### 3. `A2UI_RENDERER_CONFIG` Provider (new) + +**Location:** `src/app/app.config.ts` + +**Responsibility:** Configures the A2UI renderer with catalogs and the action handler. + +```typescript +import {A2UI_RENDERER_CONFIG, BasicCatalog} from '@a2ui/angular'; +import {COMMERCE_CATALOG} from './a2ui/commerce-catalog'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + { + provide: A2UI_RENDERER_CONFIG, + useFactory: () => ({ + catalogs: [BasicCatalog, COMMERCE_CATALOG], + actionHandler: (action: {type: string; payload: unknown}) => { + // Injected at runtime via a wrapper — see ConversationService integration + return inject(ConversationService).submit(String(action.payload)); + }, + }), + }, + ], +}; +``` + +> **Note:** The exact action handler injection pattern will depend on `@a2ui/angular`'s DI support. If the config factory runs in an injection context, `inject()` works directly. Otherwise, a wrapper service or `APP_INITIALIZER` pattern may be needed. + +### 4. Modified `ConversationService` + +**Changes:** + +- Remove `parseSurfaces` import and usage. +- Remove the `surfaces` signal (no longer needed — surfaces come from `A2uiRendererService` state). +- Inject `A2uiAdapterService` and call `processSurfaces()` in `applyState()`. +- Expose a method for the action handler to trigger prompts. + +```typescript +@Injectable({providedIn: 'root'}) +export class ConversationService { + private readonly adapter = inject(A2uiAdapterService); + + // ... existing fields minus `surfaces` signal ... + + private applyState(state: ConverseControllerState): void { + // ... existing logic ... + this.adapter.processSurfaces(this.collectSurfaces(state.turns)); + } + + private collectSurfaces(turns: Turn[]): A2UISurface[] { + // Find latest turn with surfaces (same logic as current buildSurfaces) + for (let i = turns.length - 1; i >= 0; i--) { + if (turns[i].agentResponse?.surfaces?.length) { + return turns[i].agentResponse!.surfaces; + } + } + return []; + } +} +``` + +### 5. Modified `TranscriptPanelComponent` + +**Changes:** + +- Remove `SurfaceOutletComponent` import. +- Import and use `a2ui-v09-component-host` from `@a2ui/angular`. +- Inject `A2uiRendererService` (or receive surfaces signal as input) for the surface list. + +```html + +@if (rendererSurfaces().length > 0) { +
+
+

Assistant

+ Structured results +
+
+ @for (surface of rendererSurfaces(); track surface.id) { + + } +
+
+} +``` + +### 6. Modified Commerce Components + +Each commerce component shifts from a typed `surface` input to individual A2UI-bound props. The A2UI Component_Host passes data through its standard binding mechanism. + +**Example — ProductCarouselComponent (before):** + +```typescript +readonly surface = input.required(); +// Template accesses: surface().heading, surface().products, surface().isLoading +``` + +**Example — ProductCarouselComponent (after):** + +```typescript +readonly heading = input(''); +readonly products = input([]); +readonly isLoading = input(false); +// Template accesses: heading(), products(), isLoading() +``` + +The A2UI data-binding mechanism resolves `componentProps` (heading, isLoading) and `dataModel` entries (products from the `items` key) into the component's inputs automatically. + +### 7. Files to Delete + +| File | Reason | +| ------------------------------------------------ | ------------------------------------- | +| `src/app/a2ui-parser.ts` | Replaced by `A2uiRendererService` | +| `src/app/components/surface-outlet.component.ts` | Replaced by `a2ui-v09-component-host` | + +### 8. Types to Remove from `models.ts` + +- `A2UIOperation` +- `ActivitySnapshotContent` +- `BeginRenderingOperation` +- `SurfaceUpdateOperation` +- `DataModelUpdateOperation` +- `SurfaceComponentPayload` +- `CommerceSurfaceComponentType` +- `RenderableCommerceSurface` +- `ProductCarouselSurface` +- `ComparisonTableSurface` +- `ComparisonSummarySurface` +- `BundleDisplaySurface` +- `NextActionsBarSurface` +- `ValueMapEntry` +- `ValueMapItem` + +**Retained types:** `ProductRecord`, `NextAction`, `BundleDisplayTier`, `BundleDisplaySlot`, `BundleSlotConfig`, `BundleTierConfig`. + +## Data Models + +### A2UI Operation Flow (unchanged wire format) + +The A2UI v0.8 operations arrive as-is from the converse API. The adapter does not transform them — it only unwraps the ActivitySnapshot envelope: + +``` +ActivitySnapshot surface record: +{ + operations: [ + { beginRendering: { surfaceId, root, catalogId } }, + { surfaceUpdate: { surfaceId, components: [{ id, component: { ProductCarousel: {...} } }] } }, + { dataModelUpdate: { surfaceId, contents: [{ key: "items", valueMap: [...] }] } } + ], + replace?: boolean +} +``` + +After extraction, these raw operation objects are passed directly to `A2uiRendererService.processMessages()`. + +### Commerce Component Data Model (via A2UI binding) + +The A2UI renderer maps operations to component inputs as follows: + +| A2UI Source | Component Input | Example | +| ------------------------------------------------------------------------------- | --------------------------- | ------------------------ | +| `surfaceUpdate.components[].component.ProductCarousel.heading.literalString` | `heading: string` | `"Top Surfboards"` | +| `surfaceUpdate.components[].component.ProductCarousel.isLoading` | `isLoading: boolean` | `true` | +| `dataModelUpdate.contents[key="items"].valueMap` | `products: ProductRecord[]` | Array of product objects | +| `surfaceUpdate.components[].component.NextActionsBar.actions` (via dataBinding) | `actions: NextAction[]` | Array of action objects | + +## Correctness Properties + +_A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees._ + +### Property 1: Operations extraction preserves content + +_For any_ valid array of `A2UISurface` records (each containing an `operations` array), the `A2uiAdapterService.processSurfaces()` method SHALL extract and forward every operation object to `A2uiRendererService.processMessages()` without modification, in the same order they appear in the source. + +**Validates: Requirements 3.1** + +### Property 2: Action handler forwarding + +_For any_ non-empty action string dispatched through the A2UI action handler, the `ConversationService.submit()` method SHALL be called with that exact string as the prompt. + +**Validates: Requirements 1.3, 6.5** + +### Property 3: Surface render order preservation + +_For any_ ordered list of surfaces provided by the `A2uiRendererService` state, the transcript panel SHALL render `a2ui-v09-component-host` instances in the same sequential order. + +**Validates: Requirements 4.2** + +## Error Handling + +| Scenario | Handling | +| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| `A2UISurface` record has no `operations` field | Adapter skips silently (defensive guard: `if (!Array.isArray(content.operations)) continue`) | +| `operations` contains unknown operation types | Passed through to `A2uiRendererService` — the renderer ignores unrecognized operations per the A2UI spec | +| `A2uiRendererService` throws during `processMessages()` | Adapter catches and logs to console; does not propagate to `ConversationService` to avoid breaking the subscription loop | +| Commerce Catalog lookup fails (unknown component type) | `a2ui-v09-component-host` renders nothing (standard A2UI fallback behavior) | +| Action handler receives malformed payload | Guard with `String(action.payload ?? '')` — empty strings are ignored by `ConversationService.submit()` | +| Circular dependency between `ConversationService` ↔ action handler | Avoided by injecting `ConversationService` lazily in the action handler factory, or via an intermediate `ActionBridgeService` | + +## Testing Strategy + +### Unit Tests (Vitest) + +| Test Area | What to Verify | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `A2uiAdapterService.processSurfaces()` | Correctly extracts operations from various ActivitySnapshot shapes; handles missing/malformed data gracefully | +| `A2uiAdapterService.reset()` | Calls renderer reset | +| Commerce Catalog | Each of the 5 component types resolves to the correct Angular component class | +| Action handler | Forwards action payload to `ConversationService.submit()` | +| `ConversationService` (modified) | No longer calls `parseSurfaces`; calls adapter instead | +| Component input adaptation | Each commerce component receives correct data via individual inputs | + +### Property-Based Tests (fast-check) + +Property-based testing is applicable here for the adapter logic and action handler — these are pure data transformations where input variation reveals edge cases. + +**Configuration:** + +- Library: `fast-check` +- Minimum iterations: 100 per property +- Tag format: `Feature: a2ui-standard-renderers, Property {N}: {description}` + +| Property | Generator Strategy | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Property 1: Operations extraction | Generate random arrays of objects with varying `operations` arrays (including empty, nested, large) and verify pass-through integrity | +| Property 2: Action handler forwarding | Generate arbitrary non-empty strings and verify exact forwarding | +| Property 3: Surface render order | Generate random-length arrays of surface objects and verify DOM order matches | + +### Integration Tests + +| Test | Description | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| End-to-end surface rendering | Feed real A2UI operation sequences (from mock responses) through the full pipeline and verify commerce components render correctly | +| Turn lifecycle | Verify that streaming → complete transition clears loading states | +| Replace semantics | Verify that `replace: true` resets renderer before processing | +| NextActionsBar click | Verify clicking an action button triggers a new turn | + +### Smoke Tests + +| Test | Description | +| ---------------------- | ---------------------------------------------------------------------- | +| Build integrity | `ng build` succeeds with zero errors | +| No circular deps | Build output contains no circular dependency warnings | +| File cleanup | `a2ui-parser.ts` does not exist; removed types absent from `models.ts` | +| Dependency declaration | `package.json` includes `@a2ui/angular` and `@a2ui/web_core` | diff --git a/.kiro/specs/a2ui-standard-renderers/requirements.md b/.kiro/specs/a2ui-standard-renderers/requirements.md new file mode 100644 index 00000000000..7acc1bd6f16 --- /dev/null +++ b/.kiro/specs/a2ui-standard-renderers/requirements.md @@ -0,0 +1,98 @@ +# Requirements Document + +## Introduction + +This feature replaces the custom A2UI parsing logic (`a2ui-parser.ts`) in the generative-angular sample with the official `@a2ui/angular` renderer package (backed by `@a2ui/web_core`). The goal is to drastically reduce custom code, leverage the standard A2UI renderer's built-in state management and component resolution, and align with the A2UI ecosystem for easier future extensibility. + +## Glossary + +- **Renderer_Service**: The `A2uiRendererService` provided by `@a2ui/angular` — processes A2UI operation messages and manages reactive surface state via Angular Signals. +- **Component_Host**: The `a2ui-v09-component-host` / `SurfaceComponent` from `@a2ui/angular` that dynamically renders surfaces by resolving component types from registered catalogs. +- **Renderer_Config**: The `A2UI_RENDERER_CONFIG` injection token from `@a2ui/angular` used to supply catalogs and action handlers at the Angular module/provider level. +- **Basic_Catalog**: The standard catalog shipped with `@a2ui/angular` containing generic layout, content, and input components. +- **Commerce_Catalog**: A custom catalog defined in the sample app that maps commerce-specific A2UI component types (ProductCarousel, ComparisonTable, ComparisonSummary, BundleDisplay, NextActionsBar) to Angular components. +- **Activity_Snapshot**: An SSE event from the Coveo converse API with `type: "ACTIVITY_SNAPSHOT"` and `activityType: "a2ui-surface"` whose `content.operations` array contains A2UI v0.8 operations. +- **Operations_Array**: The `content.operations` field of an Activity_Snapshot containing `beginRendering`, `surfaceUpdate`, and `dataModelUpdate` operation objects. +- **Conversation_Service**: The Angular service (`ConversationService`) that manages conversation state, subscribes to `ConverseController`, and exposes reactive signals consumed by UI components. +- **Surface_Outlet**: The existing `SurfaceOutletComponent` that maps typed surfaces to Angular components via `NgComponentOutlet`. +- **Custom_Parser**: The existing `a2ui-parser.ts` module (~300 lines) that manually transforms raw A2UI surfaces into typed `RenderableCommerceSurface` objects. + +## Requirements + +### Requirement 1: Install and configure the A2UI Angular renderer packages + +**User Story:** As a developer maintaining the generative-angular sample, I want to install the official `@a2ui/angular` and `@a2ui/web_core` packages, so that I can leverage the standard renderer infrastructure instead of custom parsing logic. + +#### Acceptance Criteria + +1. THE generative-angular sample SHALL declare `@a2ui/angular` and `@a2ui/web_core` as dependencies in its `package.json`. +2. WHEN the application bootstraps, THE App SHALL provide `A2UI_RENDERER_CONFIG` via Angular's dependency injection with at least the Commerce_Catalog registered. +3. THE Renderer_Config SHALL include an action handler that triggers new prompts through the Conversation_Service when a NextActionsBar action is selected. + +### Requirement 2: Register a custom Commerce_Catalog + +**User Story:** As a developer, I want to register the existing commerce Angular components (ProductCarousel, ComparisonTable, ComparisonSummary, BundleDisplay, NextActionsBar) as a custom A2UI catalog, so that the standard renderer can resolve and instantiate them by component type name. + +#### Acceptance Criteria + +1. THE Commerce_Catalog SHALL map the component type `"ProductCarousel"` to the `ProductCarouselComponent`. +2. THE Commerce_Catalog SHALL map the component type `"ComparisonTable"` to the `ComparisonTableComponent`. +3. THE Commerce_Catalog SHALL map the component type `"ComparisonSummary"` to the `ComparisonSummaryComponent`. +4. THE Commerce_Catalog SHALL map the component type `"BundleDisplay"` to the `BundleDisplayComponent`. +5. THE Commerce_Catalog SHALL map the component type `"NextActionsBar"` to the `NextActionsBarComponent`. +6. WHEN the Renderer_Service encounters a component type present in the Commerce_Catalog, THE Component_Host SHALL instantiate the corresponding Angular component. + +### Requirement 3: Feed raw operations to the Renderer_Service + +**User Story:** As a developer, I want the Conversation_Service to pass raw A2UI operations directly to the Renderer_Service instead of the Custom_Parser, so that the standard renderer handles state management and component resolution. + +#### Acceptance Criteria + +1. WHEN the ConverseController emits a turn containing `agentResponse.surfaces`, THE Conversation_Service SHALL extract the Operations_Array from each Activity_Snapshot surface and pass it to `Renderer_Service.processMessages()`. +2. WHEN an Activity_Snapshot has `replace: true`, THE Conversation_Service SHALL reset the Renderer_Service state before processing the new operations. +3. WHILE a turn has status `"streaming"`, THE Renderer_Service SHALL reflect loading states (e.g., `isLoading: true`) as conveyed by the incoming component data. +4. WHEN a turn completes (status transitions from `"streaming"` to `"complete"`), THE Renderer_Service SHALL clear all loading indicators from rendered surfaces. + +### Requirement 4: Replace Surface_Outlet with Component_Host + +**User Story:** As a developer, I want the transcript panel to use the standard A2UI Component_Host for rendering surfaces instead of the custom Surface_Outlet and `NgComponentOutlet` dispatch logic, so that surface rendering is fully delegated to the A2UI renderer. + +#### Acceptance Criteria + +1. THE transcript panel template SHALL use `a2ui-v09-component-host` (or equivalent Component_Host directive) to render each active surface from the Renderer_Service state. +2. WHEN the Renderer_Service state contains multiple surfaces, THE transcript panel SHALL render them in the order provided by the Renderer_Service. +3. THE Component_Host SHALL pass resolved component data (products, headings, attributes, actions) to each commerce component through the standard A2UI data binding mechanism. + +### Requirement 5: Remove the Custom_Parser and associated custom types + +**User Story:** As a developer, I want to delete the `a2ui-parser.ts` file and the A2UI-specific type definitions from `models.ts`, so that the codebase no longer carries redundant custom parsing logic. + +#### Acceptance Criteria + +1. WHEN the migration is complete, THE generative-angular sample SHALL NOT contain the file `a2ui-parser.ts`. +2. WHEN the migration is complete, THE `models.ts` file SHALL NOT export the types `A2UIOperation`, `ActivitySnapshotContent`, `BeginRenderingOperation`, `SurfaceUpdateOperation`, `DataModelUpdateOperation`, `SurfaceComponentPayload`, `CommerceSurfaceComponentType`, or `RenderableCommerceSurface`. +3. WHEN the migration is complete, THE Conversation_Service SHALL NOT import from `a2ui-parser.ts`. +4. THE generative-angular sample SHALL continue to export shared domain types (`ProductRecord`, `NextAction`, `BundleDisplayTier`, `BundleSlotConfig`) that the commerce components require for their inputs. + +### Requirement 6: Preserve existing commerce component behavior + +**User Story:** As a user of the generative-angular sample, I want the rendered commerce surfaces (product carousels, comparison tables, comparison summaries, bundle displays, and next-actions bars) to behave identically after the migration, so that no user-facing functionality is lost. + +#### Acceptance Criteria + +1. WHEN a ProductCarousel surface is rendered, THE ProductCarouselComponent SHALL display the heading and product list as before the migration. +2. WHEN a ComparisonTable surface is rendered, THE ComparisonTableComponent SHALL display the heading, attribute columns, and product rows as before the migration. +3. WHEN a ComparisonSummary surface is rendered, THE ComparisonSummaryComponent SHALL display the summary text as before the migration. +4. WHEN a BundleDisplay surface is rendered, THE BundleDisplayComponent SHALL display the title, tier labels, and slot products as before the migration. +5. WHEN a NextActionsBar action button is clicked, THE NextActionsBarComponent SHALL invoke the action handler configured in the Renderer_Config, which triggers a new prompt submission through the Conversation_Service. +6. WHILE any commerce surface has `isLoading: true`, THE corresponding component SHALL display its loading/skeleton state. + +### Requirement 7: Maintain application build and serve integrity + +**User Story:** As a developer, I want the generative-angular sample to compile and serve without errors after the migration, so that the sample remains a functional reference implementation. + +#### Acceptance Criteria + +1. WHEN `ng build` is executed, THE generative-angular sample SHALL produce a successful build with zero compilation errors. +2. WHEN `ng serve` is executed, THE generative-angular sample SHALL serve without runtime errors related to the A2UI renderer integration. +3. THE generative-angular sample SHALL NOT introduce circular dependency warnings related to the Renderer_Service or Commerce_Catalog registration. diff --git a/.kiro/specs/a2ui-standard-renderers/tasks.md b/.kiro/specs/a2ui-standard-renderers/tasks.md new file mode 100644 index 00000000000..ff94ecba13b --- /dev/null +++ b/.kiro/specs/a2ui-standard-renderers/tasks.md @@ -0,0 +1,179 @@ +# Implementation Plan: A2UI Standard Renderers + +## Overview + +Replace the custom `a2ui-parser.ts` and `SurfaceOutletComponent` in the generative-angular sample with the official `@a2ui/angular` renderer package. This involves installing packages, creating a Commerce Catalog, building a thin adapter service, reconfiguring the transcript panel to use `a2ui-v09-component-host`, adapting commerce component inputs, and cleaning up obsolete code. + +## Tasks + +- [ ] 1. Install packages and create Commerce Catalog + - [ ] 1.1 Add `@a2ui/angular` and `@a2ui/web_core` dependencies to `samples/thermidor/generative-angular/package.json` + - Add both packages to the `dependencies` section + - Run `pnpm install` to update the lockfile + - _Requirements: 1.1_ + + - [ ] 1.2 Create the Commerce Catalog at `src/app/a2ui/commerce-catalog.ts` + - Define and export `COMMERCE_CATALOG` as an `A2uiCatalog` object + - Map `ProductCarousel` → `ProductCarouselComponent` + - Map `ComparisonTable` → `ComparisonTableComponent` + - Map `ComparisonSummary` → `ComparisonSummaryComponent` + - Map `BundleDisplay` → `BundleDisplayComponent` + - Map `NextActionsBar` → `NextActionsBarComponent` + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_ + +- [ ] 2. Create the A2uiAdapterService + - [ ] 2.1 Create `src/app/services/a2ui-adapter.service.ts` + - Inject `A2uiRendererService` from `@a2ui/angular` + - Implement `processSurfaces(surfaces: A2UISurface[])` method that iterates over surfaces, checks for `replace: true` (calling `renderer.reset()` if found), extracts `operations` arrays, and forwards them to `renderer.processMessages()` + - Implement `reset()` method that calls `renderer.reset()` + - Wrap `processMessages()` in a try/catch to log errors without propagating + - _Requirements: 3.1, 3.2_ + + - [ ]\* 2.2 Write property test for operations extraction (Property 1) + - **Property 1: Operations extraction preserves content** + - Use `fast-check` to generate random arrays of objects with varying `operations` arrays (empty, nested, large) + - Verify all operations are forwarded to `processMessages()` without modification, in order + - **Validates: Requirements 3.1** + + - [ ]\* 2.3 Write unit tests for `A2uiAdapterService` + - Test that surfaces without `operations` are skipped silently + - Test that `replace: true` triggers a reset before processing + - Test that errors in `processMessages()` are caught and logged + - _Requirements: 3.1, 3.2_ + +- [ ] 3. Configure A2UI_RENDERER_CONFIG provider + - [ ] 3.1 Modify `src/app/app.config.ts` to provide `A2UI_RENDERER_CONFIG` + - Import `A2UI_RENDERER_CONFIG` and `BasicCatalog` from `@a2ui/angular` + - Import `COMMERCE_CATALOG` from `./a2ui/commerce-catalog` + - Register both catalogs: `[BasicCatalog, COMMERCE_CATALOG]` + - Configure the `actionHandler` to forward action payloads to `ConversationService.submit()` (use lazy injection or an intermediate bridge to avoid circular dependencies) + - _Requirements: 1.2, 1.3, 6.5_ + + - [ ]\* 3.2 Write property test for action handler forwarding (Property 2) + - **Property 2: Action handler forwarding** + - Use `fast-check` to generate arbitrary non-empty strings + - Verify the action handler calls `ConversationService.submit()` with the exact string + - **Validates: Requirements 1.3, 6.5** + +- [ ] 4. Checkpoint - Ensure foundation compiles + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 5. Modify ConversationService to use the adapter + - [ ] 5.1 Update `src/app/services/conversation.service.ts` + - Remove `import {parseSurfaces} from '../a2ui-parser'` + - Remove the `surfaces` signal + - Inject `A2uiAdapterService` + - Replace the `buildSurfaces()` method with `collectSurfaces(turns: Turn[]): A2UISurface[]` that returns the latest turn's surfaces array + - Call `this.adapter.processSurfaces(this.collectSurfaces(state.turns))` inside `applyState()` + - _Requirements: 3.1, 3.2, 3.3, 3.4, 5.3_ + + - [ ]\* 5.2 Write unit tests for modified ConversationService + - Verify `parseSurfaces` is no longer called + - Verify `A2uiAdapterService.processSurfaces()` is called on state updates + - Verify streaming/complete transitions are handled correctly + - _Requirements: 3.1, 3.3, 3.4_ + +- [ ] 6. Modify TranscriptPanelComponent to use a2ui-v09-component-host + - [ ] 6.1 Update `src/app/components/transcript-panel.component.ts` + - Remove `SurfaceOutletComponent` import + - Import the `a2ui-v09-component-host` component/directive from `@a2ui/angular` + - Inject `A2uiRendererService` to access reactive surface state (or receive surfaces from the renderer as a signal input) + - Replace the `surfaces` input of type `RenderableCommerceSurface[]` with the renderer's surface signal + - Replace the `` loop with `` iterating over renderer surfaces + - Remove the `quickAction` output (action handling is now via `A2UI_RENDERER_CONFIG`) + - _Requirements: 4.1, 4.2, 4.3_ + + - [ ]\* 6.2 Write property test for surface render order (Property 3) + - **Property 3: Surface render order preservation** + - Use `fast-check` to generate random-length arrays of surface objects + - Verify DOM order of `a2ui-v09-component-host` instances matches the renderer's surface order + - **Validates: Requirements 4.2** + +- [ ] 7. Adapt commerce component inputs + - [ ] 7.1 Adapt `ProductCarouselComponent` inputs + - Replace `surface = input.required()` with individual inputs: `heading = input('')`, `products = input([])`, `isLoading = input(false)` + - Update template references from `surface().heading` to `heading()`, etc. + - _Requirements: 6.1, 6.6_ + + - [ ] 7.2 Adapt `ComparisonTableComponent` inputs + - Replace `surface` input with individual inputs: `heading`, `attributes`, `products`, `isLoading` + - Update template references accordingly + - _Requirements: 6.2, 6.6_ + + - [ ] 7.3 Adapt `ComparisonSummaryComponent` inputs + - Replace `surface` input with individual input: `text = input('')` + - Update template references accordingly + - _Requirements: 6.3_ + + - [ ] 7.4 Adapt `BundleDisplayComponent` inputs + - Replace `surface` input with individual inputs: `title`, `bundles`, `isLoading` + - Update template references accordingly + - _Requirements: 6.4, 6.6_ + + - [ ] 7.5 Adapt `NextActionsBarComponent` inputs + - Replace `surface` input with individual inputs: `actions`, `isLoading` + - Remove `onSelectAction` callback input (actions are now handled by the A2UI action handler config) + - Update template to dispatch actions through the A2UI mechanism + - _Requirements: 6.5, 6.6_ + + - [ ]\* 7.6 Write unit tests for commerce component input adaptation + - Verify each component renders correctly with individual inputs + - Verify loading states display skeleton UI + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6_ + +- [ ] 8. Checkpoint - Ensure adapted components compile and render + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 9. Clean up obsolete code + - [ ] 9.1 Remove obsolete types from `src/app/models.ts` + - Delete types: `A2UIOperation`, `ActivitySnapshotContent`, `BeginRenderingOperation`, `SurfaceUpdateOperation`, `DataModelUpdateOperation`, `SurfaceComponentPayload`, `CommerceSurfaceComponentType`, `RenderableCommerceSurface`, `ProductCarouselSurface`, `ComparisonTableSurface`, `ComparisonSummarySurface`, `BundleDisplaySurface`, `NextActionsBarSurface`, `ValueMapEntry`, `ValueMapItem` + - Retain: `ProductRecord`, `NextAction`, `BundleDisplayTier`, `BundleDisplaySlot`, `BundleSlotConfig`, `BundleTierConfig` + - _Requirements: 5.2, 5.4_ + + - [ ] 9.2 Delete `src/app/a2ui-parser.ts` + - Remove the file entirely + - _Requirements: 5.1, 5.3_ + + - [ ] 9.3 Delete `src/app/components/surface-outlet.component.ts` + - Remove the file entirely + - Verify no remaining imports reference this file + - _Requirements: 4.1_ + +- [ ] 10. Final verification + - [ ] 10.1 Verify build integrity + - Run `ng build` and confirm zero compilation errors + - Confirm no circular dependency warnings in build output + - Confirm `a2ui-parser.ts` and `surface-outlet.component.ts` no longer exist + - Confirm `package.json` includes `@a2ui/angular` and `@a2ui/web_core` + - _Requirements: 7.1, 7.2, 7.3_ + +- [ ] 11. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- Unit tests validate specific examples and edge cases +- The existing commerce component templates and styles remain unchanged — only the input binding interface shifts +- The `@a2ui/angular` package handles all surface state management internally via Angular Signals + +## Task Dependency Graph + +```json +{ + "waves": [ + {"id": 0, "tasks": ["1.1"]}, + {"id": 1, "tasks": ["1.2", "2.1"]}, + {"id": 2, "tasks": ["2.2", "2.3", "3.1"]}, + {"id": 3, "tasks": ["3.2", "5.1"]}, + {"id": 4, "tasks": ["5.2", "6.1"]}, + {"id": 5, "tasks": ["6.2", "7.1", "7.2", "7.3", "7.4", "7.5"]}, + {"id": 6, "tasks": ["7.6", "9.1"]}, + {"id": 7, "tasks": ["9.2", "9.3"]}, + {"id": 8, "tasks": ["10.1"]} + ] +} +``` diff --git a/.kiro/specs/thermidor-angular-commerce-agent-sample/.config.kiro b/.kiro/specs/thermidor-angular-commerce-agent-sample/.config.kiro new file mode 100644 index 00000000000..c1983e86d32 --- /dev/null +++ b/.kiro/specs/thermidor-angular-commerce-agent-sample/.config.kiro @@ -0,0 +1 @@ +{"specId": "9bd88caa-9d12-4a17-b2c0-e2f0117c1747", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/thermidor-angular-commerce-agent-sample/design.md b/.kiro/specs/thermidor-angular-commerce-agent-sample/design.md new file mode 100644 index 00000000000..3a5005be8f4 --- /dev/null +++ b/.kiro/specs/thermidor-angular-commerce-agent-sample/design.md @@ -0,0 +1,490 @@ +# Design Document: Thermidor Angular Commerce Agent Sample + +## Overview + +This design describes two workstreams that together deliver a fully integrated Angular commerce agent sample within the ui-kit monorepo: + +1. **Thermidor library enhancements** — Adding conversation persistence (`serialize()`/`initialState`), reasoning event support (`reasoningContent`), and state snapshot support (`stateSnapshot`) to the `ConverseController` and `GenerativeRuntime`. + +2. **Angular sample adaptation** — Replacing the Angular sample's custom transport, state management, and AG-UI event parsing with Thermidor's `Engine`, `GenerativeInterface`, and `ConverseController` APIs while preserving Angular-native presentation components. + +The resulting sample follows the same structural conventions as the existing `generative-react` sample: workspace dependency on `@coveo/thermidor`, catalog-managed Angular dependencies, Turbo pipeline integration, and a dev server proxy for the Coveo platform. + +## Architecture + +```mermaid +graph TD + subgraph Thermidor Library + E[Engine] --> GI[GenerativeInterface] + GI --> CC[ConverseController] + CC --> GR[GenerativeRuntime] + GR -->|dispatchEvent| SP[GenerativeStatePort] + SP -->|reasoning events| RS[reasoningContent on Turn] + SP -->|STATE_SNAPSHOT| SS[stateSnapshot on Turn] + CC -->|serialize/initialState| PERSIST[Persistence API] + end + + subgraph Angular Sample + ENV[environment.ts] --> SVC[ThermidorService] + SVC -->|creates| E + SVC -->|exposes| CC + CC -->|subscribe| APP[AppComponent] + APP --> TP[TranscriptPanel] + APP --> SO[SurfaceOutlet] + APP --> PC[PromptComposer] + SO --> PARSER[A2UI Parser] + PARSER --> CAROUSEL[ProductCarousel] + PARSER --> TABLE[ComparisonTable] + PARSER --> SUMMARY[ComparisonSummary] + PARSER --> BUNDLE[BundleDisplay] + PARSER --> ACTIONS[NextActionsBar] + APP -->|serialize/restore| LS[localStorage] + end + + GR -->|HTTP stream| API[Coveo Agentic API] + APP -->|proxy| API +``` + +### Layer Responsibilities + +| Layer | Responsibility | +| ----------------------- | -------------------------------------------------------------------------------------- | +| **Thermidor Engine** | Configuration, state store, navigator context | +| **GenerativeInterface** | Manages the generative state slice for one conversation | +| **ConverseController** | Public API (`submit`, `selectTurn`, `retry`, `serialize`) + subscribable state | +| **GenerativeRuntime** | Consumes the AG-UI event stream, dispatches to state port | +| **GenerativeStatePort** | Mutates turn state (messages, surfaces, toolCalls, reasoning, snapshots) | +| **ThermidorService** | Angular injectable that initializes Engine/Interface/Controller | +| **A2UI Parser** | Transforms opaque `A2UISurface` records into typed `RenderableCommerceSurface` objects | +| **Surface Components** | Angular standalone OnPush components rendering commerce UI | + +## Components and Interfaces + +### Workstream 1: Thermidor Library Enhancements + +#### 1.1 Conversation Persistence API + +```typescript +// Added to ConverseController +interface ConverseController { + // ... existing methods + serialize(): SerializedConverseState; +} + +interface SerializedConverseState { + turns: SerializedTurn[]; + activeTurnId: string | undefined; +} + +type SerializedTurn = Omit & { + routedInterface?: {useCase: string} | undefined; +}; + +// Added to ConverseControllerOptions +interface ConverseControllerOptions { + interface: GenerativeInterface; + initialState?: SerializedConverseState; +} +``` + +**Design decisions:** + +- `serialize()` returns a plain object that survives `JSON.stringify`/`JSON.parse` round-trips. The `routedInterface` field is serialized as a use-case identifier only (the live interface cannot be serialized). +- When `initialState` is provided, any turns with `status: 'streaming'` are transitioned to `status: 'error'` with a descriptive message, since in-flight streams cannot be resumed. +- The serialized format is a public contract within the same major version. + +#### 1.2 Reasoning Event Support + +```typescript +// Extended AgentResponse +interface AgentResponse { + messages: AgentMessage[]; + surfaces: A2UISurface[]; + toolCalls: ToolCall[]; + reasoningContent: string; // NEW — accumulated reasoning text +} + +// Extended GenerativeStatePort +interface GenerativeStatePort { + // ... existing methods + startReasoning(turnId: string): void; + appendReasoningDelta(turnId: string, delta: string): void; + endReasoning(turnId: string): void; +} +``` + +**Design decisions:** + +- `reasoningContent` is a single concatenated string (not an array of deltas) because consumers display it as flowing text. +- The runtime calls `startReasoning` / `appendReasoningDelta` / `endReasoning` in the `dispatchEvent` switch/case alongside the existing event handlers. +- When no reasoning events arrive, `reasoningContent` defaults to `''`. + +#### 1.3 State Snapshot Support + +```typescript +// Extended Turn +interface Turn { + // ... existing fields + stateSnapshot: Record | null; // NEW — transient execution status +} + +// Extended GenerativeStatePort +interface GenerativeStatePort { + // ... existing methods + setStateSnapshot(turnId: string, snapshot: Record): void; +} +``` + +**Design decisions:** + +- `stateSnapshot` is transient: it is set to `null` when the turn completes (status → `complete`). This prevents stale status labels from persisting after the agent finishes. +- The snapshot is opaque (`Record`) — interpretation (e.g., extracting a `label` field) is the consumer's responsibility. +- The `serialize()` method includes the `stateSnapshot` field as-is (it's already JSON-safe by contract from the server). + +### Workstream 2: Angular Sample Adaptation + +#### 2.1 ThermidorService (Injectable) + +```typescript +@Injectable({providedIn: 'root'}) +export class ThermidorService { + readonly converseController: ConverseController; + + constructor() { + const config = getEngineConfiguration(); // from environment.ts + const engine = new Engine({ + configuration: config, + navigatorContextProvider: getNavigatorContext, + }); + const generativeInterface = buildGenerativeInterface({engine}); + this.converseController = buildConverseController({ + interface: generativeInterface, + initialState: this.loadPersistedState(), + }); + } + + private loadPersistedState(): SerializedConverseState | undefined { + /* localStorage */ + } +} +``` + +#### 2.2 NavigatorContextProvider + +```typescript +function getNavigatorContext(): NavigatorContext { + return { + clientId: getClientId(), + location: window.location.href, + referrer: document.referrer || null, + userAgent: navigator.userAgent || null, + }; +} + +function getClientId(): string | undefined { + try { + const stored = sessionStorage.getItem('commerce-agent-client-id'); + if (stored) return stored; + const id = crypto.randomUUID(); + sessionStorage.setItem('commerce-agent-client-id', id); + return id; + } catch { + return undefined; + } +} +``` + +#### 2.3 Environment Configuration + +```typescript +// src/environments/environment.ts +export const environment = { + organizationId: '', + accessToken: '', + trackingId: '', + language: 'en', + country: 'AU', + currency: 'AUD', + endpoint: '', // empty = use proxy +}; +``` + +The Angular CLI proxy (`proxy.conf.json` or `angular.json` `proxyConfig`) routes: + +- `/rest/organizations/{orgId}/commerce/unstable/agentic` → admin endpoint +- `/rest/**` → platform endpoint + +When environment variables for the endpoints are not set, the proxy configuration is omitted and the dev server starts without error. + +#### 2.4 A2UI Parser (Retained) + +The existing `a2ui-parser.ts` is retained with a narrowed interface: it receives `A2UISurface[]` (opaque records from Thermidor state) and produces `RenderableCommerceSurface[]`. The `SurfaceState` internal type moves into the parser file as a private implementation detail. + +The `applyActivitySnapshot` function signature changes to accept a single `A2UISurface` record (since Thermidor delivers surfaces one at a time) and accumulates state internally. + +#### 2.5 Component Input Contracts + +All surface components use Angular `input()` signal inputs: + +```typescript +@Component({ changeDetection: ChangeDetectionStrategy.OnPush, ... }) +export class ProductCarouselComponent { + readonly surface = input.required(); +} +``` + +The `SurfaceOutlet` maps `componentType` → component class via the existing `SURFACE_COMPONENTS` registry. Unknown types render nothing (no error). + +#### 2.6 Files Removed + +| File/Directory | Reason | +| -------------------------------- | ------------------------------------------------ | +| `.git/` | Monorepo VCS | +| `package-lock.json` | pnpm workspace | +| `.prettierrc` | Root oxfmt handles formatting | +| `tsconfig.spec.json` | No unit tests ported | +| `docs/screenshots/` | Old standalone UX screenshots | +| `services/` (entire directory) | Custom transport replaced by Thermidor | +| `conversation.interfaces.ts` | Types superseded by Turn/ConverseControllerState | +| `formatting.ts` | Mock catalog utility no longer used | +| `mock-catalog.ts` | Local mock data removed | +| `demo-agent.config.ts` | Mock/live toggle removed | +| AG-UI event types in `models.ts` | Thermidor exports these | +| `ChatMessage`, `ChatRole` | Replaced by Turn, AgentMessage | + +#### 2.7 Package.json Target State + +```json +{ + "name": "@samples/thermidor-commerce-agent-angular", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "ng build", + "dev": "ng serve --configuration development" + }, + "dependencies": { + "@coveo/thermidor": "workspace:*", + "@angular/common": "catalog:", + "@angular/compiler": "catalog:", + "@angular/core": "catalog:", + "@angular/forms": "catalog:", + "@angular/platform-browser": "catalog:", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "catalog:", + "@angular/cli": "catalog:", + "@angular/compiler-cli": "catalog:", + "typescript": "catalog:" + } +} +``` + +#### 2.8 TypeScript Path Resolution + +The `tsconfig.app.json` adds a path mapping so imports of `@coveo/thermidor` resolve to the local source entry: + +```jsonc +{ + "compilerOptions": { + "paths": { + "@coveo/thermidor": ["../../packages/thermidor/src/index.ts"], + "@/*": ["../../packages/thermidor/*"], + }, + }, +} +``` + +This mirrors the React sample's Vite `resolve.alias` approach but uses TypeScript paths since Angular CLI respects them natively. + +## Data Models + +### Turn (Extended) + +```typescript +interface Turn { + id: string; + prompt: string; + status: TurnStatus; // 'streaming' | 'complete' | 'error' + routedInterface?: RoutedInterface; + agentResponse?: AgentResponse; + error?: string; + stateSnapshot: Record | null; // NEW +} + +interface AgentResponse { + messages: AgentMessage[]; + surfaces: A2UISurface[]; + toolCalls: ToolCall[]; + reasoningContent: string; // NEW +} +``` + +### SerializedConverseState + +```typescript +interface SerializedConverseState { + turns: SerializedTurn[]; + activeTurnId: string | undefined; +} + +// Turns are serialized as-is except routedInterface is reduced to a use-case tag +type SerializedTurn = { + id: string; + prompt: string; + status: TurnStatus; + agentResponse?: { + messages: AgentMessage[]; + surfaces: A2UISurface[]; + toolCalls: ToolCall[]; + reasoningContent: string; + }; + error?: string; + stateSnapshot: Record | null; + routedInterface?: {useCase: string}; +}; +``` + +### RenderableCommerceSurface (Retained from Angular sample) + +The discriminated union of `ProductCarouselSurface | ComparisonTableSurface | ComparisonSummarySurface | BundleDisplaySurface | NextActionsBarSurface` is kept in the Angular sample's `models.ts`. These types are sample-specific and not exported by Thermidor. + +## Correctness Properties + +_A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees._ + +### Property 1: Prompt validation determines submission + +_For any_ string `s`, calling the prompt submission logic SHALL invoke `converseController.submit({prompt: s.trim()})` if and only if `s.trim()` is non-empty. If `s.trim()` is empty, the controller state SHALL remain unchanged. + +**Validates: Requirements 3.4, 3.5** + +### Property 2: A2UI parser produces typed surfaces from opaque records + +_For any_ valid `A2UISurface` record containing a recognized `componentType` and well-formed operation payloads, the A2UI parser SHALL produce a `RenderableCommerceSurface` object whose `componentType` matches the input and whose data fields are populated from the operation payload. + +**Validates: Requirements 4.1** + +### Property 3: Surface component dispatch matches componentType + +_For any_ `RenderableCommerceSurface` with a `componentType` in the set `{ProductCarousel, ComparisonTable, ComparisonSummary, BundleDisplay, NextActionsBar}`, the `SurfaceOutlet` SHALL resolve to the corresponding Angular component class from the `SURFACE_COMPONENTS` registry. + +**Validates: Requirements 4.4, 8.2** + +### Property 4: Surface insertion order is preserved during streaming + +_For any_ sequence of surfaces arriving during a streaming turn, the rendered surface list SHALL maintain the first-appearance insertion order — a surface that appeared at index `i` (by first-seen time) SHALL never render at a position after a surface that appeared at index `j > i`. + +**Validates: Requirements 4.6** + +### Property 5: Assistant messages render in array order + +_For any_ turn with `agentResponse.messages` of length `n`, the rendered message list SHALL display messages at indices `0..n-1` in the same sequential order as the array. + +**Validates: Requirements 5.2** + +### Property 6: Tool call name truncation + +_For any_ tool call with a `name` of length `L`, the displayed tool name SHALL equal `name` when `L ≤ 60`, and SHALL equal `name.slice(0, 57) + '...'` when `L > 60`. + +**Validates: Requirements 5.5** + +### Property 7: Turn history display capacity + +_For any_ turn list of length `N`, the scrollable history SHALL display at least `min(N, 50)` entries. + +**Validates: Requirements 5.6** + +### Property 8: Serialization JSON round-trip + +_For any_ valid `ConverseControllerState`, calling `serialize()` SHALL return a plain object `s` such that `JSON.parse(JSON.stringify(s))` deep-equals `s`. + +**Validates: Requirements 13.1, 13.6** + +### Property 9: State restoration hydrates turns before first subscriber callback + +_For any_ valid `SerializedConverseState` passed as `initialState`, the first state emitted to a subscriber SHALL contain turns matching the serialized data (with streaming turns transitioned to error). + +**Validates: Requirements 13.3** + +### Property 10: Streaming turns become error on restore + +_For any_ `SerializedConverseState` containing turns with `status: 'streaming'`, after restoration those turns SHALL have `status: 'error'` and a non-empty `error` message indicating stream interruption. + +**Validates: Requirements 13.4** + +### Property 11: Reasoning delta accumulation + +_For any_ sequence of `REASONING_MESSAGE_CONTENT` events with deltas `[d₁, d₂, ..., dₙ]` received during a turn, the turn's `agentResponse.reasoningContent` SHALL equal the concatenation `d₁ + d₂ + ... + dₙ`. + +**Validates: Requirements 14.2, 14.3** + +### Property 12: State snapshot storage + +_For any_ `STATE_SNAPSHOT` event with payload `p` received during a streaming turn, the turn's `stateSnapshot` field SHALL equal `p` (replacing any previous value). + +**Validates: Requirements 15.2** + +### Property 13: State snapshot cleared on completion + +_For any_ turn that transitions to `status: 'complete'`, the turn's `stateSnapshot` field SHALL be `null` regardless of its prior value. + +**Validates: Requirements 15.4** + +## Error Handling + +| Scenario | Behavior | +| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sessionStorage` or `crypto.randomUUID()` unavailable | `getClientId()` returns `undefined`; Engine initializes without clientId | +| `localStorage` unavailable or stored state fails to parse | Controller initializes without `initialState`; no error thrown | +| `initialState` contains streaming turns | Those turns are marked `error` with message "Stream was interrupted" | +| AG-UI stream ends without terminal event | `GenerativeRuntime` fails the turn with "Stream ended without a terminal event" | +| Network error during stream | `GenerativeRuntime` catches the error and fails the turn with the error message | +| Unknown `componentType` in surface | `SurfaceOutlet` renders nothing; no error propagated | +| Empty/whitespace prompt submission | Submission is silently rejected; no state mutation | +| Dev proxy environment variables missing | Dev server starts without proxy; requests go directly to configured endpoint | +| `serialize()` called during streaming | Returns current state snapshot (streaming turn is included as-is); consumers may persist and restore — the restore logic handles the streaming→error transition | + +## Testing Strategy + +### Unit Tests (Example-Based) + +- **Engine initialization**: Verify `ThermidorService` creates Engine with expected configuration values from environment. +- **NavigatorContextProvider**: Verify `getClientId` persists to sessionStorage and reuses across calls. +- **Graceful fallbacks**: Verify missing `sessionStorage`/`crypto` doesn't throw; verify broken localStorage doesn't throw. +- **SurfaceOutlet unknown type**: Verify no error and no DOM output for unrecognized componentType. +- **UI indicators**: Verify streaming indicator shows when `isStreaming=true`, reasoning indicator shows when `reasoningContent` is non-empty. +- **Turn selection**: Verify `selectTurn` updates displayed content. +- **Retry**: Verify retry control calls `converseController.retry` with the error turn's id. + +### Property-Based Tests + +Property-based tests use `fast-check` with a minimum of **100 iterations** per property. + +Each test is tagged with a comment referencing the design property: + +``` +// Feature: thermidor-angular-commerce-agent-sample, Property {N}: {title} +``` + +Properties 1, 6, 7, 8, 9, 10, 11, 12, 13 are implemented as property-based tests in Thermidor's test suite (for library properties) and the Angular sample's test file (for sample-level properties like prompt validation and truncation). + +Properties 2, 3, 4, 5 are implemented as property-based tests in the Angular sample's test file since they exercise the A2UI parser and component dispatch logic. + +### Integration Tests + +- `pnpm install` resolves with exit code 0 from monorepo root. +- `pnpm run build` from the sample directory produces exit code 0. +- Dev server starts without error when proxy env vars are unset. +- `pnpm run lint:fix` from root formats the sample without errors. + +### Smoke Tests + +- `package.json` contains correct `name`, `private`, `workspace:*` dependency, `catalog:` versions, `build`/`dev` scripts. +- Removed files (`.git`, `package-lock.json`, `.prettierrc`, `tsconfig.spec.json`, `docs/screenshots/`, `services/`, `mock-catalog.ts`, `demo-agent.config.ts`, `conversation.interfaces.ts`, `formatting.ts`) do not exist. +- No source file imports reference removed modules. +- All surface components use `ChangeDetectionStrategy.OnPush`. +- `angular.json` uses `@angular/build:application` builder. +- `tsconfig.json` has `skipLibCheck: true`. diff --git a/.kiro/specs/thermidor-angular-commerce-agent-sample/requirements.md b/.kiro/specs/thermidor-angular-commerce-agent-sample/requirements.md new file mode 100644 index 00000000000..cf53468cc38 --- /dev/null +++ b/.kiro/specs/thermidor-angular-commerce-agent-sample/requirements.md @@ -0,0 +1,238 @@ +# Requirements Document + +## Introduction + +This document defines the requirements for adapting the externally-sourced Angular commerce agent sample (`samples/thermidor/commerce-agent-frontend-implementation-angular`) so that it integrates with the `@coveo/thermidor` headless library and follows the monorepo conventions established by the existing `generative-react` sample. + +The Angular sample currently implements its own AG-UI event parsing, A2UI surface rendering, custom transport layer, and manual state management. The adaptation replaces those custom layers with the framework-agnostic Thermidor `Engine`, `buildGenerativeInterface`, and `buildConverseController` APIs, while preserving the sample's Angular-native presentation components and commerce surface renderers. + +## Glossary + +- **Thermidor**: The `@coveo/thermidor` package — a framework-agnostic headless engine for search and conversational experiences in the monorepo. +- **Engine**: The root Thermidor object that manages configuration, state, and dispatches. Created via `new Engine({configuration, navigatorContextProvider})`. +- **GenerativeInterface**: A Thermidor interface created via `buildGenerativeInterface({engine})` that represents a conversational experience. +- **ConverseController**: A Thermidor controller created via `buildConverseController({interface})` that exposes `submit()`, `selectTurn()`, `retry()`, and a subscribable `state` containing turns, the active turn, and streaming status. +- **Turn**: A Thermidor state object representing one user prompt and its corresponding agent response, including messages, A2UI surfaces, and tool calls. +- **A2UISurface**: An opaque surface record emitted by the agent during a turn, passed through Thermidor state without interpretation. +- **Angular_Sample**: The Angular application at `samples/thermidor/commerce-agent-frontend-implementation-angular`. +- **React_Sample**: The reference Thermidor sample at `samples/thermidor/generative-react`. +- **Monorepo**: The `ui-kit` pnpm workspace that hosts packages, samples, and build tooling. +- **Commerce_Surface_Components**: The set of Angular standalone components rendering A2UI surfaces (ProductCarousel, ComparisonTable, ComparisonSummary, BundleDisplay, NextActionsBar). +- **pnpm_Workspace**: The workspace protocol (`workspace:*`) used to reference local packages in the monorepo. +- **Conversation_Persistence**: The capability to serialize a ConverseController's state to a JSON-safe format and restore it from a previously serialized snapshot. +- **Reasoning_Event**: An AG-UI stream event (`REASONING_MESSAGE_START`, `REASONING_MESSAGE_CONTENT`, `REASONING_MESSAGE_END`) that carries the model's internal reasoning/thinking text during a turn. +- **State_Snapshot**: An AG-UI stream event (`STATE_SNAPSHOT`) that carries a point-in-time status payload describing the agent's current execution progress (e.g., `{label: "Searching products"}`). + +## Requirements + +### Requirement 1: Monorepo Integration + +**User Story:** As a developer working in the ui-kit monorepo, I want the Angular sample to be a proper pnpm workspace member with correct dependency resolution, so that it builds alongside other packages and samples. + +#### Acceptance Criteria + +1. THE Angular_Sample SHALL declare `@coveo/thermidor` as a dependency using the `workspace:*` protocol. +2. THE Angular_Sample SHALL use pnpm `catalog:` references for all dependencies and devDependencies where a matching entry exists in `pnpm-workspace.yaml` catalog (including `@angular/common`, `@angular/compiler`, `@angular/core`, `@angular/forms`, `@angular/platform-browser`, `@angular/build`, `@angular/cli`, `@angular/compiler-cli`, `typescript`, and `playwright`). +3. THE Angular_Sample SHALL have a `package.json` `name` field following the pattern `@samples/thermidor-commerce-agent-angular`. +4. THE Angular_Sample SHALL set `"private": true` in its `package.json`. +5. THE Angular_Sample SHALL remove the nested `.git` directory so it is part of the parent monorepo's version control. +6. THE Angular_Sample SHALL remove the `package-lock.json` file and remove the `packageManager` field from `package.json` since the monorepo uses pnpm with a single lockfile and declares its own `packageManager` at the root. +7. THE Angular_Sample SHALL remove the `@ag-ui/client` direct dependency since Thermidor internalizes AG-UI protocol handling. +8. THE Angular_Sample SHALL include at minimum a `build` script and a `dev` script in its `package.json` so that the monorepo's Turbo pipeline (which defines `build` with `dependsOn: [^build]` and `dev` with `dependsOn: [build]`) can orchestrate the sample. +9. WHEN the monorepo runs `pnpm install` from the repository root, THE Angular_Sample SHALL have its dependencies resolved with a zero exit code and no unresolved peer dependency warnings related to workspace packages. + +### Requirement 2: Thermidor Engine Initialization + +**User Story:** As a developer, I want the Angular sample to initialize a Thermidor Engine and GenerativeInterface, so that the app uses the standard headless layer instead of a custom transport. + +#### Acceptance Criteria + +1. THE Angular_Sample SHALL create a Thermidor `Engine` instance with a configuration providing `organizationId`, `accessToken`, `trackingId`, `language`, `country`, `currency`, and `endpoint`. +2. THE Angular_Sample SHALL create a `GenerativeInterface` via `buildGenerativeInterface({engine})`. +3. THE Angular_Sample SHALL create a `ConverseController` via `buildConverseController({interface})`. +4. THE Angular_Sample SHALL expose the ConverseController through an Angular injectable service so that any component in the application can inject it. +5. THE Angular_Sample SHALL read configuration values from Angular environment files (`environment.ts` / `environment.development.ts`) rather than hard-coding them in application logic. +6. THE Angular_Sample SHALL provide a `NavigatorContextProvider` function that returns an object with `clientId` (string), `location` (string), `referrer` (string or null), and `userAgent` (string or null). +7. THE Angular_Sample SHALL generate a `clientId` using `crypto.randomUUID()` and persist it in `sessionStorage` so the same clientId is reused across page navigation events within a session. +8. IF `sessionStorage` is unavailable or `crypto.randomUUID()` is unavailable, THEN THE Angular_Sample SHALL continue without a clientId by passing `undefined` to the NavigatorContextProvider without throwing an error. + +### Requirement 3: Replace Custom Transport with ConverseController + +**User Story:** As a developer, I want the Angular sample to use Thermidor's ConverseController for conversation orchestration, so that it benefits from the standard state management and protocol handling. + +#### Acceptance Criteria + +1. THE Angular_Sample SHALL remove the `AgentDemoService` custom transport service such that no source file imports or references it. +2. THE Angular_Sample SHALL remove the `AgUiClientTransportService` custom AG-UI client wrapper such that no source file imports or references it. +3. THE Angular_Sample SHALL remove the `DemoConversationFacade` custom signal-based state layer such that no source file imports or references it. +4. WHEN the user submits a non-empty prompt, THE Angular_Sample SHALL call `converseController.submit({prompt})` with the trimmed prompt string. +5. IF the user submits an empty or whitespace-only prompt, THEN THE Angular_Sample SHALL not call `converseController.submit` and SHALL leave the conversation state unchanged. +6. THE Angular_Sample SHALL subscribe to `ConverseController` state changes and drive Angular UI updates from the `turns`, `activeTurnId`, `activeTurn`, and `isStreaming` state properties. +7. THE Angular_Sample SHALL use `converseController.selectTurn({id})` to navigate between turns. +8. IF a turn has an error status, THEN THE Angular_Sample SHALL use `converseController.retry({id})` to retry the failed turn when the user requests a retry. +9. WHEN the ConverseController state updates, THE Angular_Sample SHALL render the active turn's agent response messages, A2UI surfaces, and tool calls. +10. WHILE `isStreaming` is true, THE Angular_Sample SHALL indicate to the user that a response is in progress. + +### Requirement 4: A2UI Surface Rendering from Thermidor State + +**User Story:** As a developer, I want the Angular sample to render commerce surfaces from Thermidor's Turn state, so that the rendering layer works with the standard data shape. + +#### Acceptance Criteria + +1. THE Angular_Sample SHALL read A2UI surfaces from the active turn's `agentResponse.surfaces` array and transform each opaque `A2UISurface` record into a typed renderable commerce surface object using its A2UI parser logic. +2. IF the active turn's `agentResponse` property is undefined, THEN THE Angular_Sample SHALL render no commerce surfaces for that turn. +3. IF the active turn's `agentResponse.surfaces` array is empty, THEN THE Angular_Sample SHALL skip surface transformation logic and render no commerce surfaces. +4. THE Angular_Sample SHALL render ProductCarousel, ComparisonTable, ComparisonSummary, BundleDisplay, and NextActionsBar components from the parsed surface data. +5. IF a surface's `isLoading` property is true, THEN THE Angular_Sample SHALL render a visually distinct placeholder (skeleton or loading indicator) in place of the surface's final content. +6. WHILE a turn is streaming progressive updates, THE Angular_Sample SHALL preserve the first-appearance insertion order of surfaces, such that a surface that appeared earlier is never rendered after a surface that appeared later within the same turn. +7. IF a surface record contains a `componentType` value not matching any of the supported component types (ProductCarousel, ComparisonTable, ComparisonSummary, BundleDisplay, NextActionsBar), THEN THE Angular_Sample SHALL skip rendering that surface without affecting other surfaces. + +### Requirement 5: Conversation UI Driven by Turn State + +**User Story:** As a developer, I want the conversation UI to reflect the ConverseController's turn model, so that the user experience matches the Thermidor state lifecycle. + +#### Acceptance Criteria + +1. THE Angular_Sample SHALL render the user's prompt text from each `Turn.prompt` field as a visible message bubble attributed to the user role. +2. THE Angular_Sample SHALL render assistant messages from `Turn.agentResponse.messages` as visible message bubbles attributed to the assistant role, preserving message order. +3. WHILE a turn's status is `streaming`, THE Angular_Sample SHALL display a visible animated streaming indicator element that is removed when the turn status transitions away from `streaming`. +4. IF a turn's status is `error`, THEN THE Angular_Sample SHALL display the turn's error message text and a "Retry" control that, when activated, re-submits the same prompt for that turn. +5. THE Angular_Sample SHALL render tool call progress from `Turn.agentResponse.toolCalls`, showing the tool name (truncated to 60 characters with ellipsis if longer) and its current status (`running` or `completed`). +6. THE Angular_Sample SHALL display all turns in a scrollable turn history list showing at least the 50 most recent turns, each identifiable by its prompt text or sequence number. +7. WHEN the user selects a different turn in the history, THE Angular_Sample SHALL update all displayed content panels (messages, surfaces, and tool activity) to reflect the selected turn's data. + +### Requirement 6: Remove Custom AG-UI Event and Type Definitions + +**User Story:** As a developer, I want the Angular sample to rely on Thermidor's exported types instead of re-defining AG-UI protocol types locally, so that the codebase stays DRY and consistent. + +#### Acceptance Criteria + +1. THE Angular_Sample SHALL remove from `models.ts` all locally defined AG-UI event types (`RunStartedEvent`, `RunFinishedEvent`, `TextMessageStartEvent`, `TextMessageContentEvent`, `TextMessageEndEvent`, `ToolCallStartEvent`, `ToolCallArgsEvent`, `ToolCallResultEvent`, `ToolCallEndEvent`, `StateSnapshotEvent`, `ActivitySnapshotEvent`, `ReasoningStartEvent`, `ReasoningMessageStartEvent`, `ReasoningMessageContentEvent`, `ReasoningMessageEndEvent`, `ReasoningEndEvent`) and the `AgUiEvent` union type. +2. THE Angular_Sample SHALL import `Turn`, `TurnStatus`, `AgentResponse`, `AgentMessage`, `A2UISurface`, `ToolCall`, and `ToolCallStatus` from `@coveo/thermidor`. +3. THE Angular_Sample SHALL retain in `models.ts` all types that `@coveo/thermidor` does not export, including: `ProductRecord`, `RenderableCommerceSurface` and its constituent surface types, `CommerceSurfaceComponentType`, `NextAction`, `BundleSlotConfig`, `BundleTierConfig`, `BundleDisplaySlot`, `BundleDisplayTier`, `ValueMapEntry`, `ValueMapItem`, `A2UIOperation`, `ActivitySnapshotContent`, `StreamTurnInput`, and all surface operation and component payload types. +4. THE Angular_Sample SHALL remove the `ChatMessage` and `ChatRole` local types from `models.ts`, and all files that previously referenced `ChatMessage` SHALL be updated to use Thermidor's `Turn` and `AgentMessage` types for conversation history. +5. WHEN all type removals and import updates are applied, THE Angular_Sample SHALL compile without errors using `pnpm run build` from the sample directory. + +### Requirement 7: Remove Mock Transport Layer + +**User Story:** As a developer, I want the Angular sample to remove its local mock transport since Thermidor and the monorepo provide mock infrastructure, so that the sample focuses on demonstrating Thermidor integration. + +#### Acceptance Criteria + +1. THE Angular_Sample SHALL remove the `mock-catalog.ts` file and all import references to it, so that no source file depends on locally hard-coded mock scenarios. +2. THE Angular_Sample SHALL remove the `demo-agent.config.ts` file, the `DemoAgentMode` type, and all branching logic that switches between mock and live transport paths (including the `streamMockTurn` method and its helpers in `agent-demo.service.ts`). +3. THE Angular_Sample SHALL remove mock-mode related UI controls (live/mock toggle checkbox and associated `agentMode` input/output bindings in the conversation header component). +4. IF a mock mode is needed for local development, THEN THE Angular_Sample SHALL accept the Thermidor Engine endpoint URL through an Angular environment file or an environment variable override, defaulting to the monorepo's `packages/mock-converse-api` local address when no override is provided. +5. WHEN all mock transport artifacts are removed, THE Angular_Sample SHALL compile without errors and produce a runnable build using only the live transport path. + +### Requirement 8: Preserve Angular Presentation Components + +**User Story:** As a developer, I want the Angular surface-rendering components to remain as standalone Angular components, so that the sample demonstrates Angular-native A2UI rendering on top of Thermidor. + +#### Acceptance Criteria + +1. THE Angular_Sample SHALL retain the standalone Angular components for ProductCarousel, ComparisonTable, ComparisonSummary, BundleDisplay, and NextActionsBar, each declared with `standalone: true` (or using the standalone default in Angular 19+). +2. THE Angular_Sample SHALL retain the `SurfaceOutlet` component that maps a surface `componentType` to the matching renderer using `NgComponentOutlet`. +3. THE Angular_Sample SHALL update component inputs to accept data derived from Thermidor's `A2UISurface` type after parsing, using Angular `input()` signal inputs typed to the corresponding `RenderableCommerceSurface` variant. +4. THE Angular_Sample SHALL use `ChangeDetectionStrategy.OnPush` on all surface rendering components to efficiently update only when input references change. +5. IF a surface record contains a `componentType` not present in the `SURFACE_COMPONENTS` registry, THEN THE `SurfaceOutlet` component SHALL render nothing for that entry without throwing an error. + +### Requirement 9: Build and Development Tooling + +**User Story:** As a developer, I want the Angular sample to build correctly within the monorepo, so that CI and local development workflows function without manual steps. + +#### Acceptance Criteria + +1. THE Angular_Sample SHALL use the Angular CLI builder (`@angular/build:application`) for production builds. +2. THE Angular_Sample SHALL configure a dev server proxy that routes `/rest/organizations/{orgId}/commerce/unstable/agentic` requests to the Coveo admin endpoint and all other `/rest` requests to the Coveo platform endpoint, matching the same logical proxy routes used in the React_Sample's Vite config. +3. THE Angular_Sample SHALL resolve `@coveo/thermidor` to the local workspace source (via TypeScript path mapping or Angular CLI resolve alias) so that importing from `@coveo/thermidor` compiles against the local `packages/thermidor/src` entry point without requiring a prior publish or build of the Thermidor package. +4. THE Angular_Sample SHALL produce a successful build (exit code 0 with zero TypeScript compilation errors) when running `pnpm run build` from the sample directory, using `pnpm` as the package manager consistent with the monorepo tooling. +5. THE Angular_Sample SHALL set `skipLibCheck: true` in its `tsconfig.json` to align with the monorepo root TypeScript configuration, while retaining Angular-specific compiler options (`strict`, `isolatedModules`, `experimentalDecorators`) required by the Angular compiler. +6. IF the Coveo platform endpoint environment variables required by the dev server proxy are not set, THEN THE Angular_Sample SHALL start the dev server without the proxy enabled and without producing a startup error. + +### Requirement 10: Remove Unnecessary Configuration Files + +**User Story:** As a developer, I want the Angular sample to not carry configuration files that conflict with or duplicate the monorepo's root configuration, so that the project is clean and consistent. + +#### Acceptance Criteria + +1. THE Angular_Sample SHALL NOT contain a `.prettierrc` file, since the monorepo root provides formatting configuration via `.oxfmtrc.json`. +2. THE Angular_Sample SHALL retain its `.editorconfig` file, since the monorepo root does not provide a root-level `.editorconfig`. +3. IF the Angular_Sample's `.gitignore` contains patterns already covered by the monorepo root `.gitignore` (e.g., `node_modules`, `.DS_Store`), THEN THE Angular_Sample SHALL remove those redundant patterns from its local `.gitignore`. +4. IF the Angular_Sample requires ignore patterns specific to the Angular toolchain that are not covered by the monorepo root `.gitignore` (e.g., `/.angular/cache`), THEN THE Angular_Sample MAY retain a local `.gitignore` containing only those Angular-specific patterns; absence of a local `.gitignore` is also acceptable. +5. WHEN the `.prettierrc` file is removed, THE Angular_Sample SHALL still produce correctly formatted output when `pnpm run lint:fix` is executed from the monorepo root. + +### Requirement 11: Remove Dead Code and Obsolete Files + +**User Story:** As a developer, I want all source files, types, and directories that become unused after the Thermidor transition to be removed, so that the sample contains no dead code and remains easy to understand. + +#### Acceptance Criteria + +1. WHEN Requirements 1 through 7 are implemented, THE Angular_Sample SHALL remove the `conversation.interfaces.ts` file (containing `PersistedConversation`, `ConversationViewModel`, `ToolActivity`, and `SurfaceState` types), since these types are superseded by Thermidor's `Turn` and `ConverseController` state. +2. WHEN Requirements 1 through 7 are implemented, THE Angular_Sample SHALL remove the `formatting.ts` file (containing the `formatAudPrice` utility), since currency formatting tied to the old mock catalog is no longer referenced. +3. WHEN all service files (`demo-conversation.facade.ts`, `agent-demo.service.ts`, `ag-ui-client-transport.service.ts`) are removed per Requirement 3, THE Angular_Sample SHALL remove the `services/` directory entirely so that no empty directory remains. +4. THE Angular_Sample SHALL remove the `tsconfig.spec.json` file, since no unit test files are being ported and the Angular test runner configuration is not used in the monorepo sample. +5. THE Angular_Sample SHALL remove the `docs/screenshots/` directory and all its contents (`single-intent.png`, `comparison.png`, `bundles.png`, `.gitkeep`), since these screenshots reference the standalone project UX and are not applicable to the Thermidor-integrated sample. +6. IF the `docs/` directory is empty after the screenshots removal, THEN THE Angular_Sample SHALL remove the `docs/` directory entirely. +7. WHEN all dead code removals are applied, THE Angular_Sample SHALL compile without errors using `pnpm run build` from the sample directory. +8. WHEN all dead code removals are applied, no remaining source file in the Angular_Sample SHALL contain an import statement referencing any of the removed files. + +### Requirement 12: Update Sample Documentation + +**User Story:** As a developer exploring the monorepo, I want the Angular sample's README to accurately describe its purpose, setup, and architecture as a Thermidor-integrated sample, so that I can understand how to run and extend it without encountering outdated instructions. + +#### Acceptance Criteria + +1. THE Angular_Sample SHALL replace the existing `README.md` with documentation that describes the sample as a Thermidor-integrated Angular commerce agent experience within the ui-kit monorepo. +2. THE Angular_Sample README SHALL include a "Getting Started" section that documents setup using `pnpm install` from the monorepo root (not `npm install`) and running the sample with `pnpm run dev` from the sample directory. +3. THE Angular_Sample README SHALL include an "Environment Configuration" section that documents the Angular environment files (`environment.ts` / `environment.development.ts`) and the configuration values required by the Thermidor Engine (`organizationId`, `accessToken`, `trackingId`, `language`, `country`, `currency`, `endpoint`). +4. THE Angular_Sample README SHALL include an "Architecture" section that describes how the sample uses the Thermidor `Engine`, `GenerativeInterface`, and `ConverseController` APIs to drive the conversation experience, replacing the old five-layer architecture description. +5. THE Angular_Sample README SHALL include a "What This Sample Demonstrates" section listing: Thermidor Engine initialization, ConverseController-driven conversation flow, A2UI surface parsing and rendering with Angular standalone components, commerce surface components (ProductCarousel, ComparisonTable, ComparisonSummary, BundleDisplay, NextActionsBar), and the dev server proxy configuration. +6. THE Angular_Sample README SHALL remove all references to `npm install`, `npm start`, mock mode, `DemoAgentMode`, `@ag-ui/client` as an "alternative option", the hardcoded Freedom endpoint URL, and the old "Code Map" section describing removed services. +7. THE Angular_Sample README SHALL remove all references to the AG-UI and A2UI protocol concepts sections (including "What is AG-UI?", "What is A2UI?", "How They Work Together", and "AG-UI Event Contract"), since Thermidor abstracts these protocol details away from the sample consumer. +8. IF the `docs/` directory is retained for new documentation purposes, THEN THE Angular_Sample README SHALL reference it accurately; otherwise the README SHALL contain no references to `docs/screenshots/` or UX reference screenshots. +9. WHEN the README update is complete, THE Angular_Sample README SHALL contain no references to files or services that have been removed by Requirements 1 through 11. + +### Requirement 13: Thermidor Conversation Persistence API + +**User Story:** As a developer using Thermidor, I want to serialize and restore ConverseController state, so that conversation history survives page reloads and session resumption without the consuming application managing raw turn data structures. + +#### Acceptance Criteria + +1. THE ConverseController SHALL expose a `serialize()` method that returns a JSON-safe object representing the complete conversation state, including the turns array with all nested data (messages, surfaces, toolCalls, status, prompt, id, error, and routedInterface metadata). +2. THE `buildConverseController` function SHALL accept an optional `initialState` property in its options object that accepts a previously serialized state snapshot. +3. WHEN `initialState` is provided, THE ConverseController SHALL restore the serialized turns into its internal state before any subscriber callback fires, such that the first emitted state contains the hydrated turn history. +4. IF `initialState` contains turns with a `streaming` status, THEN THE ConverseController SHALL transition those turns to `error` status with a message indicating the stream was interrupted, since a previously streaming turn cannot be resumed. +5. THE serialized format returned by `serialize()` SHALL be treated as a public contract: its shape SHALL NOT change in a backward-incompatible way within the same major version of `@coveo/thermidor`. +6. WHEN `serialize()` is called, THE ConverseController SHALL return a plain object that can be passed to `JSON.stringify()` without throwing and without losing information upon a subsequent `JSON.parse()`. +7. THE Angular_Sample SHALL use `ConverseController.serialize()` to persist conversation state to `localStorage` and SHALL pass the parsed value as `initialState` when rebuilding the controller on page load. +8. IF `localStorage` is unavailable or the stored value fails to parse, THEN THE Angular_Sample SHALL initialize the ConverseController without `initialState` and SHALL not throw an error. + +### Requirement 14: Thermidor Reasoning Event Support + +**User Story:** As a developer using Thermidor, I want the runtime to process reasoning events and expose reasoning content on the turn state, so that consuming applications can display "thinking" indicators without manually parsing stream events. + +#### Acceptance Criteria + +1. THE AgentResponse type SHALL include a `reasoningContent` field of type `string` that accumulates the model's reasoning text for the turn. +2. WHEN a `REASONING_MESSAGE_START` event is received, THE GenerativeRuntime SHALL signal the start of a reasoning sequence for the active turn. +3. WHEN a `REASONING_MESSAGE_CONTENT` event is received, THE GenerativeRuntime SHALL append the event's delta text to the active turn's `agentResponse.reasoningContent` field. +4. WHEN a `REASONING_MESSAGE_END` event is received, THE GenerativeRuntime SHALL finalize the reasoning sequence for the active turn. +5. THE GenerativeStatePort SHALL expose `startReasoning(turnId: string)`, `appendReasoningDelta(turnId: string, delta: string)`, and `endReasoning(turnId: string)` methods that the GenerativeRuntime calls to manage reasoning state. +6. IF no reasoning events are received during a turn, THEN THE AgentResponse's `reasoningContent` field SHALL be an empty string. +7. THE Angular_Sample SHALL read reasoning content from `Turn.agentResponse.reasoningContent` and display it as a "thinking" indicator when the value is non-empty and the turn is streaming. +8. THE Angular_Sample SHALL remove its local reasoning text accumulation logic and rely on the ConverseController state as the single source of truth for reasoning content. + +### Requirement 15: Thermidor State Snapshot Support + +**User Story:** As a developer using Thermidor, I want the runtime to process STATE_SNAPSHOT events and expose the latest snapshot on the turn state, so that consuming applications can display agent execution status labels without manually parsing stream events. + +#### Acceptance Criteria + +1. THE Turn type SHALL include a `stateSnapshot` field of type `Record | null` that holds the most recent state snapshot payload for the turn. +2. WHEN a `STATE_SNAPSHOT` event is received, THE GenerativeRuntime SHALL store the event's snapshot payload on the active turn's `stateSnapshot` field, replacing any previous snapshot value. +3. THE GenerativeStatePort SHALL expose a `setStateSnapshot(turnId: string, snapshot: Record)` method that the GenerativeRuntime calls to update the turn's state snapshot. +4. WHEN a turn completes (status transitions to `complete`), THE Turn's `stateSnapshot` field SHALL be set to `null`, since the snapshot represents transient execution progress. +5. IF no `STATE_SNAPSHOT` events are received during a turn, THEN THE Turn's `stateSnapshot` field SHALL remain `null`. +6. THE Angular_Sample SHALL read status information from `Turn.stateSnapshot` and display it as an execution progress label (e.g., "Searching products", "Building comparison") when the value is non-null and the turn is streaming. +7. THE Angular_Sample SHALL remove its local state snapshot management logic and rely on the ConverseController state as the single source of truth for execution status. diff --git a/.kiro/specs/thermidor-angular-commerce-agent-sample/tasks.md b/.kiro/specs/thermidor-angular-commerce-agent-sample/tasks.md new file mode 100644 index 00000000000..0c2ce0715c8 --- /dev/null +++ b/.kiro/specs/thermidor-angular-commerce-agent-sample/tasks.md @@ -0,0 +1,301 @@ +# Implementation Plan: Thermidor Angular Commerce Agent Sample + +## Overview + +This plan delivers two workstreams: (1) Thermidor library enhancements (persistence, reasoning, state snapshot), and (2) Angular sample adaptation to use Thermidor APIs. Library enhancements come first since the Angular sample depends on them. Within the library work, reasoning and state snapshot support are independent and can proceed in parallel, while persistence builds on both (its serialized format includes the new fields). After the library is enhanced, the Angular sample is integrated into the monorepo, rewired to use Thermidor, and cleaned up. + +## Tasks + +- [x] 1. Thermidor Library: Reasoning Event Support + - [x] 1.1 Add `reasoningContent` field to `AgentResponse` and extend `GenerativeStatePort` + - Add `reasoningContent: string` to the `AgentResponse` interface in `generative-types.ts` + - Initialize `reasoningContent` to `''` in `initAgentResponse` action/reducer + - Add `startReasoning`, `appendReasoningDelta`, `endReasoning` methods to `GenerativeStatePort` interface in `generative-runtime.ts` + - Implement the corresponding action creators and reducer cases in the generative slice + - `appendReasoningDelta` concatenates the delta string onto `agentResponse.reasoningContent` + - _Requirements: 14.1, 14.2, 14.5, 14.6_ + + - [x] 1.2 Handle reasoning events in `GenerativeRuntime.dispatchEvent` + - Add cases for `REASONING_MESSAGE_START`, `REASONING_MESSAGE_CONTENT`, and `REASONING_MESSAGE_END` in the `dispatchEvent` switch + - `REASONING_MESSAGE_START` calls `this.statePort.startReasoning(turnId)` + - `REASONING_MESSAGE_CONTENT` calls `this.statePort.appendReasoningDelta(turnId, event.delta)` + - `REASONING_MESSAGE_END` calls `this.statePort.endReasoning(turnId)` + - _Requirements: 14.2, 14.3, 14.4_ + + - [x]\* 1.3 Write property test for reasoning delta accumulation + - **Property 11: Reasoning delta accumulation** + - **Validates: Requirements 14.2, 14.3** + +- [x] 2. Thermidor Library: State Snapshot Support + - [x] 2.1 Add `stateSnapshot` field to `Turn` and extend `GenerativeStatePort` + - Add `stateSnapshot: Record | null` to the `Turn` interface in `generative-types.ts` + - Initialize `stateSnapshot` to `null` when creating a turn + - Add `setStateSnapshot(turnId: string, snapshot: Record)` to `GenerativeStatePort` + - Implement the action creator and reducer case in the generative slice + - In the `completeTurn` reducer, set `stateSnapshot` to `null` + - _Requirements: 15.1, 15.3, 15.4, 15.5_ + + - [x] 2.2 Handle `STATE_SNAPSHOT` event in `GenerativeRuntime.dispatchEvent` + - Add a case for `STATE_SNAPSHOT` that calls `this.statePort.setStateSnapshot(turnId, event.snapshot)` + - _Requirements: 15.2_ + + - [x]\* 2.3 Write property test for state snapshot storage + - **Property 12: State snapshot storage** + - **Validates: Requirements 15.2** + + - [x]\* 2.4 Write property test for state snapshot cleared on completion + - **Property 13: State snapshot cleared on completion** + - **Validates: Requirements 15.4** + +- [x] 3. Thermidor Library: Conversation Persistence API + - [x] 3.1 Define `SerializedConverseState` and `SerializedTurn` types + - Create serialization types in a new file or alongside `converse-controller.ts` + - `SerializedTurn` includes all Turn fields (id, prompt, status, error, stateSnapshot, agentResponse with reasoningContent) plus `routedInterface?: {useCase: string}` + - Export the types from Thermidor's public API + - _Requirements: 13.1, 13.5, 13.6_ + + - [x] 3.2 Implement `serialize()` method on `ConverseController` + - Add `serialize(): SerializedConverseState` to the `ConverseController` interface + - In `ConverseControllerImpl`, implement `serialize()` to read current state, map turns to `SerializedTurn` (reducing `routedInterface` to `{useCase}` only), and return the plain object + - Ensure the returned object survives `JSON.stringify`/`JSON.parse` round-trips + - _Requirements: 13.1, 13.6_ + + - [x] 3.3 Implement `initialState` option in `buildConverseController` + - Add optional `initialState?: SerializedConverseState` to `ConverseControllerOptions` + - When provided, hydrate the generative slice with the serialized turns before creating the controller's state selector + - Transition any turns with `status: 'streaming'` to `status: 'error'` with message "Stream was interrupted" + - Set `activeTurnId` from the serialized state + - Ensure first subscriber callback receives the hydrated state + - _Requirements: 13.2, 13.3, 13.4_ + + - [x]\* 3.4 Write property test for serialization JSON round-trip + - **Property 8: Serialization JSON round-trip** + - **Validates: Requirements 13.1, 13.6** + + - [x]\* 3.5 Write property test for state restoration hydration + - **Property 9: State restoration hydrates turns before first subscriber callback** + - **Validates: Requirements 13.3** + + - [x]\* 3.6 Write property test for streaming turns becoming error on restore + - **Property 10: Streaming turns become error on restore** + - **Validates: Requirements 13.4** + +- [x] 4. Checkpoint - Thermidor library enhancements + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Angular Sample: Monorepo Integration + - [x] 5.1 Update `package.json` to monorepo conventions + - Set `name` to `@samples/thermidor-commerce-agent-angular` + - Set `"private": true` + - Remove `packageManager` field + - Add `@coveo/thermidor` as `workspace:*` dependency + - Replace Angular dependency versions with `catalog:` references for all packages present in `pnpm-workspace.yaml` catalog + - Remove `@ag-ui/client` dependency + - Ensure `build` and `dev` scripts are present (`"build": "ng build"`, `"dev": "ng serve --configuration development"`) + - Remove `watch` and `ng` scripts that are not needed + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.6, 1.7, 1.8_ + + - [x] 5.2 Remove standalone VCS and lockfile artifacts + - Delete nested `.git/` directory + - Delete `package-lock.json` if present + - Delete `.prettierrc` file + - _Requirements: 1.5, 1.6, 10.1_ + + - [x] 5.3 Configure TypeScript path resolution for local Thermidor + - Add `paths` mapping in `tsconfig.app.json`: `"@coveo/thermidor": ["../../packages/thermidor/src/index.ts"]` + - Set `skipLibCheck: true` in `tsconfig.json` + - Retain Angular-specific compiler options (`strict`, `isolatedModules`) + - _Requirements: 9.3, 9.5_ + + - [x] 5.4 Configure dev server proxy + - Create or update `proxy.conf.json` (or `angular.json` proxyConfig) to route `/rest/organizations/{orgId}/commerce/unstable/agentic` to admin endpoint and `/rest/**` to platform endpoint + - Ensure dev server starts without error when proxy env vars are unset + - _Requirements: 9.2, 9.6_ + + - [x] 5.5 Configure Angular CLI builder and build settings + - Ensure `angular.json` uses `@angular/build:application` builder + - Remove `tsconfig.spec.json` reference from angular.json if present + - _Requirements: 9.1, 11.4_ + +- [x] 6. Angular Sample: Engine Initialization and ThermidorService + - [x] 6.1 Create environment configuration files + - Create `src/environments/environment.ts` with `organizationId`, `accessToken`, `trackingId`, `language`, `country`, `currency`, `endpoint` fields + - Create `src/environments/environment.development.ts` with development overrides + - _Requirements: 2.1, 2.5_ + + - [x] 6.2 Implement `ThermidorService` injectable + - Create `src/app/services/thermidor.service.ts` as `@Injectable({providedIn: 'root'})` + - Initialize `Engine` with configuration from environment files + - Create `GenerativeInterface` via `buildGenerativeInterface({engine})` + - Create `ConverseController` via `buildConverseController({interface, initialState})` + - Implement `NavigatorContextProvider` returning `clientId`, `location`, `referrer`, `userAgent` + - Implement `getClientId()` using `crypto.randomUUID()` persisted to `sessionStorage` + - Handle `sessionStorage`/`crypto` unavailability gracefully (return `undefined`) + - Load persisted state from `localStorage`, pass as `initialState` + - Handle `localStorage` unavailability or parse failure gracefully + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 13.7, 13.8_ + +- [x] 7. Angular Sample: Replace Custom Transport with ConverseController + - [x] 7.1 Remove custom transport services + - Delete `services/agent-demo.service.ts` (AgentDemoService) + - Delete `services/ag-ui-client-transport.service.ts` (AgUiClientTransportService) + - Delete `services/demo-conversation.facade.ts` (DemoConversationFacade) + - Remove `services/` directory entirely + - _Requirements: 3.1, 3.2, 3.3, 11.3_ + + - [x] 7.2 Rewire `AppComponent` to use `ThermidorService` + - Replace `DemoConversationFacade` injection with `ThermidorService` + - Subscribe to `converseController.state` and drive UI updates from `turns`, `activeTurnId`, `activeTurn`, `isStreaming` + - Implement persistence via `converseController.serialize()` → `localStorage` + - Wire prompt submission to `converseController.submit({prompt})` + - Wire turn selection to `converseController.selectTurn({id})` + - Wire retry to `converseController.retry({id})` + - Show streaming indicator when `isStreaming` is true + - _Requirements: 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 13.7_ + + - [x]\* 7.3 Write property test for prompt validation + - **Property 1: Prompt validation determines submission** + - **Validates: Requirements 3.4, 3.5** + +- [x] 8. Angular Sample: A2UI Surface Rendering from Thermidor State + - [x] 8.1 Adapt A2UI parser to work with Thermidor's `A2UISurface` array + - Update `a2ui-parser.ts` to receive `A2UISurface[]` from `turn.agentResponse.surfaces` + - Keep `SurfaceState` as a private implementation detail within the parser + - Ensure insertion order preservation during streaming + - _Requirements: 4.1, 4.6_ + + - [x] 8.2 Update `SurfaceOutlet` and surface components + - Update component inputs to use Angular `input()` signal inputs typed to `RenderableCommerceSurface` variants + - Ensure `SurfaceOutlet` uses `SURFACE_COMPONENTS` registry to map `componentType` to component class + - Render nothing for unrecognized component types (no error) + - Ensure all surface components use `ChangeDetectionStrategy.OnPush` + - Wire surface rendering to active turn's `agentResponse.surfaces` + - Skip rendering when `agentResponse` is undefined or surfaces array is empty + - Render loading placeholders when `isLoading` is true + - _Requirements: 4.2, 4.3, 4.4, 4.5, 4.7, 8.1, 8.2, 8.3, 8.4, 8.5_ + + - [x]\* 8.3 Write property test for A2UI parser surface typing + - **Property 2: A2UI parser produces typed surfaces from opaque records** + - **Validates: Requirements 4.1** + + - [x]\* 8.4 Write property test for surface component dispatch + - **Property 3: Surface component dispatch matches componentType** + - **Validates: Requirements 4.4, 8.2** + + - [x]\* 8.5 Write property test for surface insertion order + - **Property 4: Surface insertion order is preserved during streaming** + - **Validates: Requirements 4.6** + +- [x] 9. Angular Sample: Conversation UI Driven by Turn State + - [x] 9.1 Update transcript panel and message rendering + - Render user prompt from `Turn.prompt` as user message bubble + - Render assistant messages from `Turn.agentResponse.messages` preserving array order + - Display streaming indicator when turn status is `streaming` + - Display error message and retry control when turn status is `error` + - Render tool call progress showing tool name (truncated to 60 chars with ellipsis) and status + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_ + + - [x] 9.2 Implement turn history and reasoning/snapshot display + - Display all turns in scrollable history showing at least 50 most recent turns + - Update content panels when user selects a different turn + - Display reasoning content from `Turn.agentResponse.reasoningContent` as thinking indicator when non-empty and streaming + - Display state snapshot from `Turn.stateSnapshot` as execution progress label when non-null and streaming + - Remove local reasoning text accumulation and state snapshot management logic + - _Requirements: 5.6, 5.7, 14.7, 14.8, 15.6, 15.7_ + + - [x]\* 9.3 Write property test for assistant message order + - **Property 5: Assistant messages render in array order** + - **Validates: Requirements 5.2** + + - [x]\* 9.4 Write property test for tool call name truncation + - **Property 6: Tool call name truncation** + - **Validates: Requirements 5.5** + + - [x]\* 9.5 Write property test for turn history display capacity + - **Property 7: Turn history display capacity** + - **Validates: Requirements 5.6** + +- [x] 10. Checkpoint - Core integration complete + - Ensure all tests pass, ask the user if questions arise. + +- [x] 11. Angular Sample: Remove AG-UI Types and Mock Transport + - [x] 11.1 Clean up `models.ts` type definitions + - Remove all locally defined AG-UI event types and `AgUiEvent` union from `models.ts` + - Remove `ChatMessage` and `ChatRole` types + - Import `Turn`, `TurnStatus`, `AgentResponse`, `AgentMessage`, `A2UISurface`, `ToolCall`, `ToolCallStatus` from `@coveo/thermidor` + - Retain sample-specific types: `ProductRecord`, `RenderableCommerceSurface`, `CommerceSurfaceComponentType`, `NextAction`, `BundleSlotConfig`, `BundleTierConfig`, `BundleDisplaySlot`, `BundleDisplayTier`, `ValueMapEntry`, `ValueMapItem`, `A2UIOperation`, `ActivitySnapshotContent` + - Update all files that referenced `ChatMessage`/`ChatRole` to use Thermidor types + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_ + + - [x] 11.2 Remove mock transport artifacts + - Delete `mock-catalog.ts` and all import references + - Delete `demo-agent.config.ts` and `DemoAgentMode` type + - Remove mock-mode UI controls (live/mock toggle in conversation header) + - Remove `agentMode` input/output bindings from conversation header component + - _Requirements: 7.1, 7.2, 7.3, 7.5_ + + - [x] 11.3 Remove conversation header mock toggle and update component + - Remove the `ConversationHeaderComponent` mock/live toggle UI + - Update the component to only show relevant controls (no mode switching) + - _Requirements: 7.3_ + +- [x] 12. Angular Sample: Dead Code Removal + - [x] 12.1 Remove obsolete files and directories + - Delete `conversation.interfaces.ts` + - Delete `formatting.ts` + - Delete `tsconfig.spec.json` + - Delete `docs/screenshots/` directory and contents + - Delete `docs/` directory if empty after removal + - Verify no remaining source file imports reference removed files + - _Requirements: 11.1, 11.2, 11.4, 11.5, 11.6, 11.7, 11.8_ + + - [x] 12.2 Clean up `.gitignore` and configuration files + - Remove patterns from local `.gitignore` already covered by monorepo root + - Retain Angular-specific patterns (e.g., `/.angular/cache`) if needed + - Retain `.editorconfig` + - _Requirements: 10.2, 10.3, 10.4, 10.5_ + +- [x] 13. Angular Sample: Update Documentation + - [x] 13.1 Rewrite `README.md` for Thermidor-integrated sample + - Describe the sample as a Thermidor-integrated Angular commerce agent experience + - Include "Getting Started" section with `pnpm install` and `pnpm run dev` instructions + - Include "Environment Configuration" section documenting environment files and required values + - Include "Architecture" section describing Thermidor Engine/Interface/Controller usage + - Include "What This Sample Demonstrates" section listing key capabilities + - Remove all references to npm, mock mode, `@ag-ui/client`, hardcoded endpoints, old Code Map + - Remove AG-UI/A2UI protocol concept sections + - Remove references to `docs/screenshots/` or removed files + - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7, 12.8, 12.9_ + +- [x] 14. Final Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- Unit tests validate specific examples and edge cases +- Thermidor library enhancements (tasks 1–3) must complete before Angular sample tasks (5+) that depend on the new APIs +- The Angular sample workstream uses TypeScript throughout, consistent with the existing codebase + +## Task Dependency Graph + +```json +{ + "waves": [ + {"id": 0, "tasks": ["1.1", "2.1", "5.1", "5.2"]}, + {"id": 1, "tasks": ["1.2", "2.2", "5.3", "5.4", "5.5"]}, + {"id": 2, "tasks": ["1.3", "2.3", "2.4", "3.1", "6.1"]}, + {"id": 3, "tasks": ["3.2", "3.3", "6.2"]}, + {"id": 4, "tasks": ["3.4", "3.5", "3.6", "7.1"]}, + {"id": 5, "tasks": ["7.2", "11.1", "11.2"]}, + {"id": 6, "tasks": ["7.3", "8.1", "11.3"]}, + {"id": 7, "tasks": ["8.2", "9.1"]}, + {"id": 8, "tasks": ["8.3", "8.4", "8.5", "9.2"]}, + {"id": 9, "tasks": ["9.3", "9.4", "9.5", "12.1"]}, + {"id": 10, "tasks": ["12.2", "13.1"]} + ] +} +``` diff --git a/packages/mock-converse-api/src/constants.ts b/packages/mock-converse-api/src/constants.ts index 169a58b8197..cc1176f11d7 100644 --- a/packages/mock-converse-api/src/constants.ts +++ b/packages/mock-converse-api/src/constants.ts @@ -33,6 +33,18 @@ export const PROMPT_TEMPLATE_MAP: ReadonlyArray<{ prompt: 'i like cold-water surfing. compare wetsuits for it', templateId: 'response8', }, + { + prompt: 'surfboards v09', + templateId: 'response9', + }, + { + prompt: 'compare wetsuits v09', + templateId: 'response10', + }, + { + prompt: 'surfing bundle v09', + templateId: 'response11', + }, ]; export const FALLBACK_TEMPLATE_ID: TemplateId = 'response5'; diff --git a/packages/mock-converse-api/src/sse-streamer.ts b/packages/mock-converse-api/src/sse-streamer.ts index a204ba12ee7..b30c6db6f17 100644 --- a/packages/mock-converse-api/src/sse-streamer.ts +++ b/packages/mock-converse-api/src/sse-streamer.ts @@ -1,7 +1,7 @@ import type http from 'node:http'; import {setCorsHeaders} from './cors.js'; -const DEFAULT_DELAY_MS = 25; +const DEFAULT_DELAY_MS = 60; export function streamSSEResponse( res: http.ServerResponse, diff --git a/packages/mock-converse-api/src/types.ts b/packages/mock-converse-api/src/types.ts index 56a96629f01..375c729d67d 100644 --- a/packages/mock-converse-api/src/types.ts +++ b/packages/mock-converse-api/src/types.ts @@ -6,7 +6,10 @@ export type TemplateId = | 'response5' | 'response6' | 'response7' - | 'response8'; + | 'response8' + | 'response9' + | 'response10' + | 'response11'; export interface ParsedRequest { message: string; diff --git a/packages/mock-converse-api/templates/response10.txt b/packages/mock-converse-api/templates/response10.txt new file mode 100644 index 00000000000..9201cea7f0a --- /dev/null +++ b/packages/mock-converse-api/templates/response10.txt @@ -0,0 +1,32 @@ +event:turn_started +data:{"conversationSessionId":"a2ui-v09-compare-session","conversationToken":"a2ui-v09-compare-token"} + +event:message +data:{"type": "RUN_STARTED", "threadId": "a2ui-v09-compare-session", "runId": "v09-run-002"} + +event:message +data:{"type": "STATE_SNAPSHOT", "snapshot": {}} + +event:message +data:{"type": "TEXT_MESSAGE_START", "messageId": "v09-msg-002", "role": "assistant"} + +event:message +data:{"type": "TEXT_MESSAGE_CONTENT", "messageId": "v09-msg-002", "delta": "Here's a comparison of popular wetsuits for cold-water surfing:"} + +event:message +data:{"type": "TEXT_MESSAGE_END", "messageId": "v09-msg-002"} + +event:message +data:{"type": "ACTIVITY_SNAPSHOT", "timestamp": 1781293060000, "messageId": "activity-v09-compare-001", "activityType": "a2ui-surface", "content": {"operations": [{"version": "v0.9", "createSurface": {"surfaceId": "comparison-table-surface", "catalogId": "commerce"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "comparison-table-surface", "components": [{"component": "ComparisonTable", "id": "root", "heading": "Cold-Water Wetsuits Comparison", "isLoading": false, "attributes": ["thickness", "material", "water_temp_range"], "products": {"path": "/products"}}]}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "comparison-table-surface", "path": "/products", "value": [{"ec_product_id": "ws-001", "ec_name": "Arctic Shield 5/4mm", "ec_brand": "O'Neill", "ec_price": 349.99, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/8f733582382a_bottom_right_c2b6748c-af4a-4c57-a032-8501dcd7873c.webp?v=1766163591", "clickUri": "#", "thickness": "5/4mm", "material": "Neoprene + Drylock", "water_temp_range": "40-50°F"}, {"ec_product_id": "ws-002", "ec_name": "Polar Flex 6/5mm", "ec_brand": "Rip Curl", "ec_price": 429.99, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/8a0762a91f5c_bottom_right_70ec9dff-c6cf-4307-9553-5e527911b52c.webp?v=1766163595", "clickUri": "#", "thickness": "6/5mm", "material": "E7 Flash Lining", "water_temp_range": "35-45°F"}, {"ec_product_id": "ws-003", "ec_name": "Hooded Storm 5/4mm", "ec_brand": "Xcel", "ec_price": 399.99, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/d22368dfb336_bottom_right.webp?v=1766163599", "clickUri": "#", "thickness": "5/4mm", "material": "TDC Thermo Dry", "water_temp_range": "38-48°F"}]}}]}} + +event:message +data:{"type": "ACTIVITY_SNAPSHOT", "timestamp": 1781293061000, "messageId": "activity-v09-compare-002", "activityType": "a2ui-surface", "content": {"operations": [{"version": "v0.9", "createSurface": {"surfaceId": "comparison-summary-surface", "catalogId": "commerce"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "comparison-summary-surface", "components": [{"component": "ComparisonSummary", "id": "root", "text": "For cold-water surfing below 45°F, the Rip Curl Polar Flex 6/5mm offers the best warmth with its E7 Flash Lining technology. If you prefer more flexibility with slightly less insulation, the O'Neill Arctic Shield 5/4mm strikes a great balance. The Xcel Hooded Storm is ideal if you want an integrated hood for extreme conditions."}]}}]}} + +event:message +data:{"type": "ACTIVITY_SNAPSHOT", "timestamp": 1781293062000, "messageId": "activity-v09-compare-003", "activityType": "a2ui-surface", "content": {"operations": [{"version": "v0.9", "createSurface": {"surfaceId": "compare-next-actions", "catalogId": "commerce"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "compare-next-actions", "components": [{"component": "NextActionsBar", "id": "root", "isLoading": false, "actions": {"path": "/actions"}}]}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "compare-next-actions", "path": "/actions", "value": [{"text": "Show me booties and gloves", "type": "followup"}, {"text": "Which is best for beginners?", "type": "followup"}]}}]}} + +event:message +data:{"type": "RUN_FINISHED", "threadId": "a2ui-v09-compare-session", "runId": "v09-run-002"} + +event:done +data:{} diff --git a/packages/mock-converse-api/templates/response11.txt b/packages/mock-converse-api/templates/response11.txt new file mode 100644 index 00000000000..6da7e0fd89d --- /dev/null +++ b/packages/mock-converse-api/templates/response11.txt @@ -0,0 +1,29 @@ +event:turn_started +data:{"conversationSessionId":"a2ui-v09-bundle-session","conversationToken":"a2ui-v09-bundle-token"} + +event:message +data:{"type": "RUN_STARTED", "threadId": "a2ui-v09-bundle-session", "runId": "v09-run-003"} + +event:message +data:{"type": "STATE_SNAPSHOT", "snapshot": {}} + +event:message +data:{"type": "TEXT_MESSAGE_START", "messageId": "v09-msg-003", "role": "assistant"} + +event:message +data:{"type": "TEXT_MESSAGE_CONTENT", "messageId": "v09-msg-003", "delta": "Here's a beginner surfing bundle with budget and premium tiers:"} + +event:message +data:{"type": "TEXT_MESSAGE_END", "messageId": "v09-msg-003"} + +event:message +data:{"type": "ACTIVITY_SNAPSHOT", "timestamp": 1781293070000, "messageId": "activity-v09-bundle-001", "activityType": "a2ui-surface", "content": {"operations": [{"version": "v0.9", "createSurface": {"surfaceId": "bundle-display-surface", "catalogId": "commerce"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "bundle-display-surface", "components": [{"component": "BundleDisplay", "id": "root", "title": "Beginner Surfing Kit", "isLoading": false, "bundles": {"path": "/bundles"}}]}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "bundle-display-surface", "path": "/bundles", "value": [{"bundleId": "budget", "label": "Budget", "description": "Everything you need to get started without breaking the bank.", "slots": [{"categoryLabel": "Surfboard", "surfaceRef": "budget-board", "product": {"ec_product_id": "sb-001", "ec_name": "Wave Rider Kids Soft Top", "ec_brand": "Storm Blade", "ec_price": 199.99, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/8f733582382a_bottom_right_c2b6748c-af4a-4c57-a032-8501dcd7873c.webp?v=1766163591"}}, {"categoryLabel": "Wetsuit", "surfaceRef": "budget-wetsuit", "product": {"ec_product_id": "ws-budget", "ec_name": "Basic Spring Suit", "ec_brand": "Wavestorm", "ec_price": 89.99, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/8a0762a91f5c_bottom_right_70ec9dff-c6cf-4307-9553-5e527911b52c.webp?v=1766163595"}}, {"categoryLabel": "Wax", "surfaceRef": "budget-wax", "product": {"ec_product_id": "wx-budget", "ec_name": "Wave Master Surf Wax", "ec_brand": "WaxIt", "ec_price": 35.0, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/d5ab761f07c3_bottom_right.webp?v=1766164031"}}]}, {"bundleId": "premium", "label": "Premium", "description": "Top-tier gear for the surfer who wants the best experience from day one.", "slots": [{"categoryLabel": "Surfboard", "surfaceRef": "premium-board", "product": {"ec_product_id": "sb-premium", "ec_name": "SurfPro Schooler", "ec_brand": "Odysea", "ec_price": 349.99, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/49eee557c7c5_top_right_f2f8e365-62ea-48d7-a73b-082e6b4ef99d.webp?v=1766163601"}}, {"categoryLabel": "Wetsuit", "surfaceRef": "premium-wetsuit", "product": {"ec_product_id": "ws-premium", "ec_name": "Arctic Shield 5/4mm", "ec_brand": "O'Neill", "ec_price": 349.99, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/8f733582382a_bottom_right_c2b6748c-af4a-4c57-a032-8501dcd7873c.webp?v=1766163591"}}, {"categoryLabel": "Wax", "surfaceRef": "premium-wax", "product": {"ec_product_id": "wx-premium", "ec_name": "Ultimate Glide Surf Wax", "ec_brand": "Wax and More", "ec_price": 35.0, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/60a0ffb77280_top_right_6f6ebc3b-5500-45c5-92aa-ffc2d7e0043f.webp?v=1766164032"}}]}]}}]}} + +event:message +data:{"type": "ACTIVITY_SNAPSHOT", "timestamp": 1781293071000, "messageId": "activity-v09-bundle-002", "activityType": "a2ui-surface", "content": {"operations": [{"version": "v0.9", "createSurface": {"surfaceId": "bundle-next-actions", "catalogId": "commerce"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "bundle-next-actions", "components": [{"component": "NextActionsBar", "id": "root", "isLoading": false, "actions": {"path": "/actions"}}]}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "bundle-next-actions", "path": "/actions", "value": [{"text": "Add a leash to each bundle", "type": "followup"}, {"text": "Show me mid-range options", "type": "followup"}]}}]}} + +event:message +data:{"type": "RUN_FINISHED", "threadId": "a2ui-v09-bundle-session", "runId": "v09-run-003"} + +event:done +data:{} diff --git a/packages/mock-converse-api/templates/response9.txt b/packages/mock-converse-api/templates/response9.txt new file mode 100644 index 00000000000..064121e6f56 --- /dev/null +++ b/packages/mock-converse-api/templates/response9.txt @@ -0,0 +1,29 @@ +event:turn_started +data:{"conversationSessionId":"a2ui-v09-demo-session","conversationToken":"a2ui-v09-demo-token"} + +event:message +data:{"type": "RUN_STARTED", "threadId": "a2ui-v09-demo-session", "runId": "v09-run-001"} + +event:message +data:{"type": "STATE_SNAPSHOT", "snapshot": {}} + +event:message +data:{"type": "TEXT_MESSAGE_START", "messageId": "v09-msg-001", "role": "assistant"} + +event:message +data:{"type": "TEXT_MESSAGE_CONTENT", "messageId": "v09-msg-001", "delta": "Here are some popular surfboards for you:"} + +event:message +data:{"type": "TEXT_MESSAGE_END", "messageId": "v09-msg-001"} + +event:message +data:{"type": "ACTIVITY_SNAPSHOT", "timestamp": 1781293057366, "messageId": "activity-v09-surface-001", "activityType": "a2ui-surface", "content": {"operations": [{"version": "v0.9", "createSurface": {"surfaceId": "product-carousel-surface", "catalogId": "commerce"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "product-carousel-surface", "components": [{"component": "ProductCarousel", "id": "root", "heading": "Top Surfboards", "isLoading": false, "products": {"path": "/products"}}]}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "product-carousel-surface", "path": "/products", "value": [{"ec_product_id": "sb-001", "ec_name": "Wave Rider Kids Soft Top", "ec_brand": "Storm Blade", "ec_price": 199.99, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/8f733582382a_bottom_right_c2b6748c-af4a-4c57-a032-8501dcd7873c.webp?v=1766163591", "clickUri": "https://barca-sports.myshopify.com/products/sbst_173a", "description": "Perfect beginner surfboard for young aspiring surfers"}, {"ec_product_id": "sb-002", "ec_name": "Surf Glide", "ec_brand": "South Bay Board Co.", "ec_price": 199.99, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/8a0762a91f5c_bottom_right_70ec9dff-c6cf-4307-9553-5e527911b52c.webp?v=1766163595", "clickUri": "https://barca-sports.myshopify.com/products/sbbst_4b2b", "description": "Ultimate beginner-friendly surfboard"}, {"ec_product_id": "sb-003", "ec_name": "Wave Cruiser Hybrid Board", "ec_brand": "Wavestorm", "ec_price": 299.99, "ec_image": "https://cdn.shopify.com/s/files/1/0910/6502/4786/files/d22368dfb336_bottom_right.webp?v=1766163599", "clickUri": "https://barca-sports.myshopify.com/products/wvst_e47d", "description": "Fun, easy, and safe surfing experience"}]}}]}} + +event:message +data:{"type": "ACTIVITY_SNAPSHOT", "timestamp": 1781293058000, "messageId": "activity-v09-surface-002", "activityType": "a2ui-surface", "content": {"operations": [{"version": "v0.9", "createSurface": {"surfaceId": "next-actions-surface", "catalogId": "commerce"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "next-actions-surface", "components": [{"component": "NextActionsBar", "id": "root", "isLoading": false, "actions": {"path": "/actions"}}]}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "next-actions-surface", "path": "/actions", "value": [{"text": "Compare these surfboards", "type": "followup"}, {"text": "Show me wetsuits", "type": "search"}, {"text": "Budget options only", "type": "followup"}]}}]}} + +event:message +data:{"type": "RUN_FINISHED", "threadId": "a2ui-v09-demo-session", "runId": "v09-run-001"} + +event:done +data:{} diff --git a/packages/thermidor/src/core/interface/api/generative-endpoint/generative-runtime.ts b/packages/thermidor/src/core/interface/api/generative-endpoint/generative-runtime.ts index dcb3be55493..5cf26d74c08 100644 --- a/packages/thermidor/src/core/interface/api/generative-endpoint/generative-runtime.ts +++ b/packages/thermidor/src/core/interface/api/generative-endpoint/generative-runtime.ts @@ -27,9 +27,13 @@ export interface GenerativeStatePort { startToolCall(turnId: string, toolCallId: string, toolName: string): void; appendToolCallArgs(turnId: string, toolCallId: string, delta: string): void; completeToolCall(turnId: string, toolCallId: string, result: string): void; + setStateSnapshot(turnId: string, snapshot: Record): void; completeTurn(turnId: string): void; failTurn(turnId: string, error: string): void; clearTurnResponse(turnId: string): void; + startReasoning(turnId: string): void; + appendReasoningDelta(turnId: string, delta: string): void; + endReasoning(turnId: string): void; } export type HydrateSubInterface = ( @@ -212,6 +216,21 @@ export class GenerativeRuntime { return {turnId, isTerminal: false}; } + case 'REASONING_MESSAGE_START': { + this.statePort.startReasoning(turnId); + return {turnId, isTerminal: false}; + } + + case 'REASONING_MESSAGE_CONTENT': { + this.statePort.appendReasoningDelta(turnId, event.delta); + return {turnId, isTerminal: false}; + } + + case 'REASONING_MESSAGE_END': { + this.statePort.endReasoning(turnId); + return {turnId, isTerminal: false}; + } + case 'TOOL_CALL_START': { this.ensureAgentResponse(turnId); this.statePort.startToolCall( @@ -244,6 +263,11 @@ export class GenerativeRuntime { return {turnId, isTerminal: false}; } + case 'STATE_SNAPSHOT': { + this.statePort.setStateSnapshot(turnId, event.snapshot); + return {turnId, isTerminal: false}; + } + case 'ACTIVITY_SNAPSHOT': { const routedInterface = this.hydrateSubInterface( event.activityType, diff --git a/packages/thermidor/src/core/interface/generative/generative-types.ts b/packages/thermidor/src/core/interface/generative/generative-types.ts index 949314dd497..42362361d43 100644 --- a/packages/thermidor/src/core/interface/generative/generative-types.ts +++ b/packages/thermidor/src/core/interface/generative/generative-types.ts @@ -38,6 +38,12 @@ export interface Turn { * A human-readable error message when the turn is in error status. */ error?: string; + + /** + * The most recent state snapshot payload for the turn, representing transient + * execution progress. Set to `null` when the turn completes. + */ + stateSnapshot: Record | null; } export type UseCaseInterfaceMap = { @@ -69,6 +75,11 @@ export interface AgentResponse { * Tool calls made by the agent during the turn, in order of invocation. */ toolCalls: ToolCall[]; + + /** + * Accumulated reasoning/thinking text received during the turn. + */ + reasoningContent: string; } export type ToolCallStatus = 'calling' | 'completed'; diff --git a/packages/thermidor/src/core/internal/generative/generative-actions.ts b/packages/thermidor/src/core/internal/generative/generative-actions.ts index aaa482e473c..45363b46f2b 100644 --- a/packages/thermidor/src/core/internal/generative/generative-actions.ts +++ b/packages/thermidor/src/core/internal/generative/generative-actions.ts @@ -7,6 +7,7 @@ import {getHandleInternals} from '@/src/core/interface/utils/get-handle-internal import type {InterfaceHandle} from '@/src/core/interface/utils/interface-types.js'; import type { A2UISurface, + GenerativeState, RoutedInterface, TurnStatus, } from '@/src/core/interface/generative/generative-types.js'; @@ -64,6 +65,16 @@ export function createGenerativeActions(interfaceId: string) { clearTurnResponse: createAction<{turnId: string}>( `${prefix}/clearTurnResponse` ), + setStateSnapshot: createAction<{ + turnId: string; + snapshot: Record; + }>(`${prefix}/setStateSnapshot`), + startReasoning: createAction<{turnId: string}>(`${prefix}/startReasoning`), + appendReasoningDelta: createAction<{turnId: string; delta: string}>( + `${prefix}/appendReasoningDelta` + ), + endReasoning: createAction<{turnId: string}>(`${prefix}/endReasoning`), + hydrateState: createAction(`${prefix}/hydrateState`), }; } diff --git a/packages/thermidor/src/core/internal/generative/generative-slice.ts b/packages/thermidor/src/core/internal/generative/generative-slice.ts index 031c6dd13eb..774c813a973 100644 --- a/packages/thermidor/src/core/internal/generative/generative-slice.ts +++ b/packages/thermidor/src/core/internal/generative/generative-slice.ts @@ -33,6 +33,7 @@ export function createGenerativeSlice( id: payload.id, prompt: payload.prompt, status: 'streaming', + stateSnapshot: null, }); }) .addCase(actions.setActiveTurnId, (state, {payload}) => { @@ -57,7 +58,12 @@ export function createGenerativeSlice( .addCase(actions.initAgentResponse, (state, {payload}) => { const turn = state.turns.find((t) => t.id === payload.turnId); if (turn) { - turn.agentResponse = {messages: [], surfaces: [], toolCalls: []}; + turn.agentResponse = { + messages: [], + surfaces: [], + toolCalls: [], + reasoningContent: '', + }; } }) .addCase(actions.startMessage, (state, {payload}) => { @@ -113,6 +119,7 @@ export function createGenerativeSlice( const turn = state.turns.find((t) => t.id === payload.turnId); if (turn) { turn.status = 'complete'; + turn.stateSnapshot = null; } }) .addCase(actions.failTurn, (state, {payload}) => { @@ -129,6 +136,27 @@ export function createGenerativeSlice( delete turn.agentResponse; delete turn.error; } + }) + .addCase(actions.startReasoning, (_state, _action) => { + // No-op: reasoning start is a lifecycle signal only. + }) + .addCase(actions.appendReasoningDelta, (state, {payload}) => { + const turn = state.turns.find((t) => t.id === payload.turnId); + if (turn?.agentResponse) { + turn.agentResponse.reasoningContent += payload.delta; + } + }) + .addCase(actions.endReasoning, (_state, _action) => { + // No-op: reasoning end is a lifecycle signal only. + }) + .addCase(actions.setStateSnapshot, (state, {payload}) => { + const turn = state.turns.find((t) => t.id === payload.turnId); + if (turn) { + turn.stateSnapshot = payload.snapshot; + } + }) + .addCase(actions.hydrateState, (_state, {payload}) => { + return payload; }); }, }); diff --git a/packages/thermidor/src/public/controllers/converse/converse-controller-serialization.ts b/packages/thermidor/src/public/controllers/converse/converse-controller-serialization.ts new file mode 100644 index 00000000000..32a124c3c79 --- /dev/null +++ b/packages/thermidor/src/public/controllers/converse/converse-controller-serialization.ts @@ -0,0 +1,10 @@ +import type {Turn} from '@/src/core/interface/generative/generative-types.js'; + +export interface SerializedConverseState { + turns: SerializedTurn[]; + activeTurnId: string | undefined; +} + +export type SerializedTurn = Omit & { + routedInterface?: {useCase: string} | undefined; +}; diff --git a/packages/thermidor/src/public/controllers/converse/converse-controller.test.ts b/packages/thermidor/src/public/controllers/converse/converse-controller.test.ts index 3d24ac9bb5d..8621dbb6c59 100644 --- a/packages/thermidor/src/public/controllers/converse/converse-controller.test.ts +++ b/packages/thermidor/src/public/controllers/converse/converse-controller.test.ts @@ -11,6 +11,7 @@ import { type GenerativeInterface, } from '@/src/public/interfaces/generative.js'; import {buildConverseController} from './converse-controller.js'; +import type {SerializedConverseState} from './converse-controller-serialization.js'; const TEST_ID = 'test-generative'; @@ -254,6 +255,106 @@ describe('buildConverseController', () => { }); }); + describe('serialize()', () => { + it('returns empty state when no turns exist', () => { + const controller = buildController(); + + const result = controller.serialize(); + + expect(result).toEqual({ + turns: [], + activeTurnId: undefined, + }); + }); + + it('serializes turns with their data', () => { + const controller = buildController(); + const actions = getOrCreateGenerativeActions(generativeInterface); + + fullEngine.mutate( + actions.createTurn({id: 'turn-1', prompt: 'hello', status: 'streaming'}) + ); + fullEngine.mutate(actions.completeTurn({turnId: 'turn-1'})); + fullEngine.mutate(actions.setActiveTurnId('turn-1')); + + const result = controller.serialize(); + + expect(result.turns).toHaveLength(1); + expect(result.turns[0]).toMatchObject({ + id: 'turn-1', + prompt: 'hello', + status: 'complete', + }); + expect(result.activeTurnId).toBe('turn-1'); + }); + + it('reduces routedInterface to {useCase} only', () => { + const controller = buildController(); + const actions = getOrCreateGenerativeActions(generativeInterface); + + fullEngine.mutate( + actions.createTurn({ + id: 'turn-1', + prompt: 'search', + status: 'streaming', + }) + ); + fullEngine.mutate( + actions.setRoutedInterface({ + turnId: 'turn-1', + routedInterface: { + useCase: 'commerceSearch', + interface: {} as never, + }, + }) + ); + fullEngine.mutate(actions.completeTurn({turnId: 'turn-1'})); + + const result = controller.serialize(); + + expect(result.turns[0].routedInterface).toEqual({ + useCase: 'commerceSearch', + }); + }); + + it('produces output that survives JSON round-trip', () => { + const controller = buildController(); + const actions = getOrCreateGenerativeActions(generativeInterface); + + fullEngine.mutate( + actions.createTurn({id: 'turn-1', prompt: 'hello', status: 'streaming'}) + ); + fullEngine.mutate(actions.initAgentResponse({turnId: 'turn-1'})); + fullEngine.mutate( + actions.startMessage({turnId: 'turn-1', role: 'assistant'}) + ); + fullEngine.mutate( + actions.appendMessageDelta({turnId: 'turn-1', delta: 'Hi there'}) + ); + fullEngine.mutate(actions.completeTurn({turnId: 'turn-1'})); + fullEngine.mutate(actions.setActiveTurnId('turn-1')); + + const serialized = controller.serialize(); + const roundTripped = JSON.parse(JSON.stringify(serialized)); + + expect(roundTripped).toEqual(serialized); + }); + + it('excludes routedInterface when not set', () => { + const controller = buildController(); + const actions = getOrCreateGenerativeActions(generativeInterface); + + fullEngine.mutate( + actions.createTurn({id: 'turn-1', prompt: 'hello', status: 'streaming'}) + ); + fullEngine.mutate(actions.completeTurn({turnId: 'turn-1'})); + + const result = controller.serialize(); + + expect(result.turns[0].routedInterface).toBeUndefined(); + }); + }); + describe('subscribe()', () => { it('invokes the callback when the generative state changes', () => { const controller = buildController(); @@ -293,4 +394,176 @@ describe('buildConverseController', () => { expect(callback).not.toHaveBeenCalled(); }); }); + + describe('initialState', () => { + it('hydrates turns from serialized state', () => { + const initialState: SerializedConverseState = { + turns: [ + { + id: 'turn-1', + prompt: 'hello', + status: 'complete', + stateSnapshot: null, + agentResponse: { + messages: [{content: 'Hi there', role: 'assistant'}], + surfaces: [], + toolCalls: [], + reasoningContent: '', + }, + }, + ], + activeTurnId: 'turn-1', + }; + + const controller = buildConverseController({ + interface: generativeInterface, + initialState, + }); + + expect(controller.state.turns).toHaveLength(1); + expect(controller.state.turns[0]).toMatchObject({ + id: 'turn-1', + prompt: 'hello', + status: 'complete', + }); + expect(controller.state.activeTurnId).toBe('turn-1'); + }); + + it('transitions streaming turns to error status', () => { + const initialState: SerializedConverseState = { + turns: [ + { + id: 'turn-1', + prompt: 'hello', + status: 'streaming', + stateSnapshot: null, + }, + ], + activeTurnId: 'turn-1', + }; + + const controller = buildConverseController({ + interface: generativeInterface, + initialState, + }); + + expect(controller.state.turns[0].status).toBe('error'); + expect(controller.state.turns[0].error).toBe('Stream was interrupted'); + }); + + it('does not modify complete or error turns', () => { + const initialState: SerializedConverseState = { + turns: [ + { + id: 'turn-1', + prompt: 'hello', + status: 'complete', + stateSnapshot: null, + }, + { + id: 'turn-2', + prompt: 'world', + status: 'error', + error: 'network failure', + stateSnapshot: null, + }, + ], + activeTurnId: 'turn-1', + }; + + const controller = buildConverseController({ + interface: generativeInterface, + initialState, + }); + + expect(controller.state.turns[0].status).toBe('complete'); + expect(controller.state.turns[1].status).toBe('error'); + expect(controller.state.turns[1].error).toBe('network failure'); + }); + + it('sets activeTurnId from serialized state', () => { + const initialState: SerializedConverseState = { + turns: [ + { + id: 'turn-1', + prompt: 'hello', + status: 'complete', + stateSnapshot: null, + }, + { + id: 'turn-2', + prompt: 'world', + status: 'complete', + stateSnapshot: null, + }, + ], + activeTurnId: 'turn-2', + }; + + const controller = buildConverseController({ + interface: generativeInterface, + initialState, + }); + + expect(controller.state.activeTurnId).toBe('turn-2'); + }); + + it('first state read contains hydrated turns', () => { + const initialState: SerializedConverseState = { + turns: [ + { + id: 'turn-1', + prompt: 'hello', + status: 'complete', + stateSnapshot: null, + }, + ], + activeTurnId: 'turn-1', + }; + + const controller = buildConverseController({ + interface: generativeInterface, + initialState, + }); + + expect(controller.state.turns).toHaveLength(1); + expect(controller.state.activeTurnId).toBe('turn-1'); + expect(controller.state.activeTurn).toMatchObject({ + id: 'turn-1', + prompt: 'hello', + }); + }); + + it('computes isStreaming as false after streaming turns are transitioned to error', () => { + const initialState: SerializedConverseState = { + turns: [ + { + id: 'turn-1', + prompt: 'hello', + status: 'streaming', + stateSnapshot: null, + }, + ], + activeTurnId: 'turn-1', + }; + + const controller = buildConverseController({ + interface: generativeInterface, + initialState, + }); + + expect(controller.state.isStreaming).toBe(false); + }); + + it('works without initialState (backwards compatible)', () => { + const controller = buildController(); + + expect(controller.state).toEqual({ + turns: [], + activeTurnId: undefined, + activeTurn: undefined, + isStreaming: false, + }); + }); + }); }); diff --git a/packages/thermidor/src/public/controllers/converse/converse-controller.ts b/packages/thermidor/src/public/controllers/converse/converse-controller.ts index 80b14077adc..f4cf3a81914 100644 --- a/packages/thermidor/src/public/controllers/converse/converse-controller.ts +++ b/packages/thermidor/src/public/controllers/converse/converse-controller.ts @@ -1,4 +1,5 @@ import type {Turn} from '@/src/core/interface/generative/generative-types.js'; +import type {GenerativeState} from '@/src/core/interface/generative/generative-types.js'; import {GenerativeRuntime} from '@/src/core/interface/api/generative-endpoint/generative-runtime.js'; import {createHydrateSubInterface} from '@/src/core/interface/generative/generative-hydration.js'; import {getOrCreateGenerativeSlice} from '@/src/core/internal/generative/generative-slice.js'; @@ -10,6 +11,10 @@ import {getOrCreateGenerativeActions} from '@/src/core/internal/generative/gener import {getOrCreateGenerativeSelectors} from '@/src/core/internal/generative/generative-selectors.js'; import type {GenerativeInterface} from '@/src/public/interfaces/generative.js'; import type {Controller} from '../controller-types.js'; +import type { + SerializedConverseState, + SerializedTurn, +} from './converse-controller-serialization.js'; class ConverseControllerImpl extends BaseController { #runtime: GenerativeRuntime; @@ -25,6 +30,11 @@ class ConverseControllerImpl extends BaseController { const actions = getOrCreateGenerativeActions(options.interface); const selectors = getOrCreateGenerativeSelectors(options.interface); + if (options.initialState) { + const hydratedState = hydrateFromSerializedState(options.initialState); + fullEngine.mutate(actions.hydrateState(hydratedState)); + } + const {stateId} = getInterfaceInternals(options.interface); const controllerState = createMemoizedStateSelector( @@ -73,6 +83,10 @@ class ConverseControllerImpl extends BaseController { }, appendSurface: (turnId, surface) => { this.engine.mutate(this.#actions.appendSurface({turnId, surface})); + const ops = (surface as {operations?: unknown[]}).operations; + if (Array.isArray(ops)) { + options.onSurfaceOperation?.(ops); + } }, startToolCall: (turnId, toolCallId, toolName) => { this.engine.mutate( @@ -89,6 +103,11 @@ class ConverseControllerImpl extends BaseController { this.#actions.completeToolCall({turnId, toolCallId, result}) ); }, + setStateSnapshot: (turnId, snapshot) => { + this.engine.mutate( + this.#actions.setStateSnapshot({turnId, snapshot}) + ); + }, completeTurn: (turnId) => { this.engine.mutate(this.#actions.completeTurn({turnId})); }, @@ -98,11 +117,40 @@ class ConverseControllerImpl extends BaseController { clearTurnResponse: (turnId) => { this.engine.mutate(this.#actions.clearTurnResponse({turnId})); }, + startReasoning: (turnId) => { + this.engine.mutate(this.#actions.startReasoning({turnId})); + }, + appendReasoningDelta: (turnId, delta) => { + this.engine.mutate( + this.#actions.appendReasoningDelta({turnId, delta}) + ); + }, + endReasoning: (turnId) => { + this.engine.mutate(this.#actions.endReasoning({turnId})); + }, }, hydrateSubInterface: createHydrateSubInterface(sourceEngine), }); } + serialize(): SerializedConverseState { + const {turns, activeTurnId} = this.state; + + const serializedTurns: SerializedTurn[] = turns.map((turn) => { + const {routedInterface, ...rest} = turn; + const serialized: SerializedTurn = {...rest}; + if (routedInterface) { + serialized.routedInterface = {useCase: routedInterface.useCase}; + } + return serialized; + }); + + return { + turns: serializedTurns, + activeTurnId, + }; + } + submit({prompt}: {prompt: string}): void { if (!prompt.trim()) { return; @@ -135,6 +183,7 @@ export const buildConverseController = ( ): ConverseController => new ConverseControllerImpl(options); export interface ConverseController extends Controller { + serialize(): SerializedConverseState; submit(options: {prompt: string}): void; selectTurn(options: {id: string}): void; retry(options: {id: string}): void; @@ -149,4 +198,27 @@ export interface ConverseControllerState { export interface ConverseControllerOptions { interface: GenerativeInterface; + initialState?: SerializedConverseState; + onSurfaceOperation?: (operations: unknown[]) => void; +} + +function hydrateFromSerializedState( + serialized: SerializedConverseState +): GenerativeState { + const turns: Turn[] = serialized.turns.map((serializedTurn) => { + const {routedInterface, ...rest} = serializedTurn; + const turn: Turn = {...rest}; + + if (turn.status === 'streaming') { + turn.status = 'error'; + turn.error = 'Stream was interrupted'; + } + + return turn; + }); + + return { + turns, + activeTurnId: serialized.activeTurnId, + }; } diff --git a/packages/thermidor/src/public/controllers/index.ts b/packages/thermidor/src/public/controllers/index.ts index 46191a12200..769c611f441 100644 --- a/packages/thermidor/src/public/controllers/index.ts +++ b/packages/thermidor/src/public/controllers/index.ts @@ -26,6 +26,10 @@ export type { ConverseControllerOptions, ConverseControllerState, } from './converse/converse-controller.js'; +export type { + SerializedConverseState, + SerializedTurn, +} from './converse/converse-controller-serialization.js'; export {buildProductListController} from './product-list/product-list-controller.js'; export type { ProductListController, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaa75c99ceb..80964f5514f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,7 +50,7 @@ catalogs: version: 19.22.0 '@msw/playwright': specifier: 0.6.7 - version: 0.6.7(msw@2.14.6(@types/node@26.0.1)(typescript@6.0.3)) + version: 0.6.7 '@playwright/mcp': specifier: 0.0.76 version: 0.0.76 @@ -1652,6 +1652,64 @@ importers: specifier: 'catalog:' version: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@20.0.3)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + samples/thermidor/generative-angular: + dependencies: + '@a2ui/angular': + specifier: ^0.10.2 + version: 0.10.2(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))) + '@a2ui/web_core': + specifier: ^0.10.3 + version: 0.10.3 + '@ag-ui/core': + specifier: 0.0.57 + version: 0.0.57 + '@angular/common': + specifier: 21.2.17 + version: 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/compiler': + specifier: 'catalog:' + version: 21.2.17 + '@angular/core': + specifier: 21.2.17 + version: 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/forms': + specifier: 'catalog:' + version: 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + '@angular/platform-browser': + specifier: 'catalog:' + version: 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + '@coveo/thermidor': + specifier: workspace:* + version: link:../../../packages/thermidor + '@reduxjs/toolkit': + specifier: 'catalog:' + version: 2.12.0(react@19.2.7) + idb: + specifier: 'catalog:' + version: 8.0.3 + marked: + specifier: ^15.0.0 + version: 15.0.12 + rxjs: + specifier: ~7.8.0 + version: 7.8.2 + tslib: + specifier: ^2.3.0 + version: 2.8.1 + devDependencies: + '@angular/build': + specifier: 'catalog:' + version: 21.2.17(72a8a0c0406f116b1fea61e321d4c85f) + '@angular/cli': + specifier: 'catalog:' + version: 21.2.17(@types/node@26.0.1)(chokidar@5.0.0) + '@angular/compiler-cli': + specifier: 'catalog:' + version: 21.2.17(@angular/compiler@21.2.17)(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + samples/thermidor/generative-react: dependencies: '@coveo/thermidor': @@ -1742,6 +1800,20 @@ packages: '@75lb/nature': optional: true + '@a2ui/angular@0.10.2': + resolution: {integrity: sha512-YnOcH3uxq7Uv3y4O9CPef9URAEPh1uU7P3qRa88S2oxTs0nHhv+i4fWRfL6il4SsEd2nbM+yMckS1BvIsQNrog==} + peerDependencies: + '@a2ui/markdown-it': '*' + '@angular/common': 21.2.17 + '@angular/core': 21.2.17 + '@angular/platform-browser': ^21.2.5 + peerDependenciesMeta: + '@a2ui/markdown-it': + optional: true + + '@a2ui/web_core@0.10.3': + resolution: {integrity: sha512-B0+zhC1gac4vweqrWxcW1tlmdspMuZl8YzNXSrza3ppayoFYcJblMuvu8g8+6l++aycNQK5D4WaBX85KVvVx+g==} + '@actions/github@9.1.1': resolution: {integrity: sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA==} @@ -5767,6 +5839,9 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@preact/signals-core@1.14.3': + resolution: {integrity: sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw==} + '@prettier-apex/apex-ast-serializer-darwin-arm64@2.2.6': resolution: {integrity: sha512-XzrGnEVQq/JH/rKPktpdL8/agocjDCnSrY4MuHxMs5V3OnV/4MJdMHp0frQhqOjbhnUIjewypX5XWcVi0xqRBQ==} cpu: [arm64] @@ -9005,6 +9080,9 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -15550,6 +15628,23 @@ snapshots: lodash: 4.18.1 typical: 7.3.0 + '@a2ui/angular@0.10.2(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))': + dependencies: + '@a2ui/web_core': 0.10.3 + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + '@preact/signals-core': 1.14.3 + tslib: 2.8.1 + zod: 3.25.76 + + '@a2ui/web_core@0.10.3': + dependencies: + '@preact/signals-core': 1.14.3 + date-fns: 4.4.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + '@actions/github@9.1.1': dependencies: '@actions/http-client': 3.0.2 @@ -20165,6 +20260,8 @@ snapshots: '@popperjs/core@2.11.8': {} + '@preact/signals-core@1.14.3': {} + '@prettier-apex/apex-ast-serializer-darwin-arm64@2.2.6': optional: true @@ -23803,6 +23900,8 @@ snapshots: dataloader@1.4.0: {} + date-fns@4.4.0: {} + dateformat@4.6.3: {} dayjs@1.11.21: {} @@ -32288,6 +32387,10 @@ snapshots: yoctocolors@2.1.2: {} + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/samples/thermidor/generative-angular/.env.example b/samples/thermidor/generative-angular/.env.example new file mode 100644 index 00000000000..92347a514fe --- /dev/null +++ b/samples/thermidor/generative-angular/.env.example @@ -0,0 +1,8 @@ +COVEO_ORGANIZATION_ID=barcasportsmcy01fvu +COVEO_ACCESS_TOKEN= +COVEO_TRACKING_ID=market_88728731922 +COVEO_LANGUAGE=en +COVEO_COUNTRY=US +COVEO_CURRENCY=USD +COVEO_PLATFORM_ENVIRONMENT=prod +COVEO_ENDPOINT= diff --git a/samples/thermidor/generative-angular/.gitignore b/samples/thermidor/generative-angular/.gitignore new file mode 100644 index 00000000000..4245839c53c --- /dev/null +++ b/samples/thermidor/generative-angular/.gitignore @@ -0,0 +1,4 @@ +# Angular-specific (not covered by monorepo root .gitignore) +/.angular/cache +.env +src/environments/environment.ts diff --git a/samples/thermidor/generative-angular/README.md b/samples/thermidor/generative-angular/README.md new file mode 100644 index 00000000000..14d7b1fbfdc --- /dev/null +++ b/samples/thermidor/generative-angular/README.md @@ -0,0 +1,119 @@ +# Thermidor Angular Commerce Agent Sample + +A Thermidor-integrated Angular commerce agent experience within the ui-kit monorepo. This sample demonstrates how to build a conversational commerce storefront using the `@coveo/thermidor` headless library with Angular standalone components. + +## Getting Started + +Install dependencies from the monorepo root: + +```bash +pnpm install +``` + +Run the sample from the sample directory: + +```bash +pnpm run dev +``` + +Then open `http://localhost:4200/`. + +### Running with the mock service + +To run against the local mock converse API (no Coveo credentials required): + +```bash +# Terminal 1 — start the mock server +cd packages/mock-converse-api +pnpm build && node dist/server.js + +# Terminal 2 — start the Angular sample with mock routing +cd samples/thermidor/generative-angular +pnpm dev:mock +``` + +Then open `http://localhost:4200/` and try one of the supported prompts: + +- `surfboards v09` — ProductCarousel + NextActionsBar +- `compare wetsuits v09` — ComparisonTable + ComparisonSummary + NextActionsBar +- `surfing bundle v09` — BundleDisplay + NextActionsBar + +## Environment Configuration + +The sample reads configuration from Angular environment files located at: + +- `src/environments/environment.ts` — production defaults +- `src/environments/environment.development.ts` — development overrides (used by `pnpm run dev`) + +### Required Values + +| Field | Description | +| ---------------- | -------------------------------------------------------- | +| `organizationId` | Your Coveo organization ID | +| `accessToken` | API access token for the organization | +| `trackingId` | Analytics tracking identifier | +| `language` | Language code (e.g., `en`) | +| `country` | Country code (e.g., `AU`) | +| `currency` | Currency code (e.g., `AUD`) | +| `endpoint` | Platform endpoint URL (leave empty to use the dev proxy) | + +## Architecture + +The sample follows the Thermidor headless pattern: + +``` +Engine → GenerativeInterface → ConverseController → Angular UI +``` + +### ThermidorService (Injectable) + +A root-provided Angular service that initializes the Thermidor stack: + +1. Creates an `Engine` with configuration from environment files and a `NavigatorContextProvider` supplying `clientId`, `location`, `referrer`, and `userAgent`. +2. Builds a `GenerativeInterface` via `buildGenerativeInterface({engine})`. +3. Builds a `ConverseController` via `buildConverseController({interface, initialState})`, restoring any previously persisted conversation from `localStorage`. + +### ConverseController + +The controller exposes the public conversation API consumed by Angular components: + +- `submit({prompt})` — send a user message +- `selectTurn({id})` — navigate between turns +- `retry({id})` — retry a failed turn +- `serialize()` — snapshot conversation state for persistence + +### A2UI Renderer Integration + +This sample uses the official `@a2ui/angular` v0.9 renderer to render surfaces. The integration consists of: + +- **`A2uiAdapterService`** — extracts v0.9 operations from Thermidor's opaque surface records and forwards them to the `A2uiRendererService`, managing surface lifecycle (deleting stale surfaces when new ones arrive). +- **`A2UI_RENDERER_CONFIG`** — provides the renderer with a custom catalog of commerce components and an action handler that triggers new prompts through the `ConversationService`. +- **Custom Catalog** (`custom-catalog.ts`) — registers commerce components (ProductCarousel, ComparisonTable, ComparisonSummary, BundleDisplay, NextActionsBar) with the A2UI renderer. +- **`SurfaceComponent`** (``) — the standard A2UI component host that dynamically instantiates the correct component for each surface. + +```mermaid +graph TD + Backend[Converse API] -->|SSE stream| Thermidor[ConverseController] + Thermidor -->|onSurfaceOperation| Adapter[A2uiAdapterService] + Adapter -->|processMessages| Renderer[A2uiRendererService] + Renderer -->|surfaceIds signal| Template[TranscriptPanel] + Template -->|a2ui-v09-surface| Host[ComponentHost] + Host -->|catalog lookup| Components[Commerce Components] +``` + +### Commerce Components + +Standalone Angular components (all using `OnPush` change detection) implement the A2UI `CatalogComponentInstance` interface. They receive a `props` signal input from the renderer and derive display values using the `prop()` utility from `a2ui/prop-reader.ts`. + +### Dev Server Proxy + +The Angular CLI dev server proxies `/rest/**` requests to the Coveo platform, removing the need to configure CORS or expose endpoint URLs in client code. + +## What This Sample Demonstrates + +- **Thermidor Engine initialization** with environment-driven configuration and navigator context +- **ConverseController-driven conversation** using `submit`, `selectTurn`, `retry`, and state subscription +- **Standard A2UI v0.9 renderer** (`@a2ui/angular`) with custom catalog registration and reactive surface rendering +- **Custom commerce components**: ProductCarousel, ComparisonTable, ComparisonSummary, BundleDisplay, NextActionsBar — all implementing `CatalogComponentInstance` +- **Dev server proxy** routing requests to the Coveo platform without hardcoded URLs +- **Conversation persistence** via `serialize()` / `initialState` backed by `localStorage` diff --git a/samples/thermidor/generative-angular/angular.json b/samples/thermidor/generative-angular/angular.json new file mode 100644 index 00000000000..448c201c516 --- /dev/null +++ b/samples/thermidor/generative-angular/angular.json @@ -0,0 +1,72 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "pnpm", + "analytics": false + }, + "newProjectRoot": "projects", + "projects": { + "barca-sports-commerce-agent": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": ["src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "8kB", + "maximumError": "12kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "options": { + "proxyConfig": "proxy.conf.js" + }, + "configurations": { + "production": { + "buildTarget": "barca-sports-commerce-agent:build:production" + }, + "development": { + "buildTarget": "barca-sports-commerce-agent:build:development" + } + }, + "defaultConfiguration": "development" + } + } + } + } +} diff --git a/samples/thermidor/generative-angular/package.json b/samples/thermidor/generative-angular/package.json new file mode 100644 index 00000000000..e785506ad2e --- /dev/null +++ b/samples/thermidor/generative-angular/package.json @@ -0,0 +1,34 @@ +{ + "name": "@samples/thermidor-commerce-agent-angular", + "version": "0.0.0", + "private": true, + "scripts": { + "prebuild": "node scripts/generate-env.js", + "build": "ng build", + "predev": "node scripts/generate-env.js", + "dev": "ng serve --configuration development", + "dev:mock": "COVEO_USE_MOCK=true ng serve --configuration development" + }, + "dependencies": { + "@a2ui/angular": "^0.10.2", + "@a2ui/web_core": "^0.10.3", + "@ag-ui/core": "0.0.57", + "@angular/common": "catalog:", + "@angular/compiler": "catalog:", + "@angular/core": "catalog:", + "@angular/forms": "catalog:", + "@angular/platform-browser": "catalog:", + "@coveo/thermidor": "workspace:*", + "@reduxjs/toolkit": "catalog:", + "idb": "catalog:", + "marked": "^15.0.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "catalog:", + "@angular/cli": "catalog:", + "@angular/compiler-cli": "catalog:", + "typescript": "catalog:" + } +} diff --git a/samples/thermidor/generative-angular/proxy.conf.js b/samples/thermidor/generative-angular/proxy.conf.js new file mode 100644 index 00000000000..2604d45ed5f --- /dev/null +++ b/samples/thermidor/generative-angular/proxy.conf.js @@ -0,0 +1,100 @@ +/** + * Angular CLI dev server proxy configuration. + * + * Routes: + * - /rest/organizations/{orgId}/commerce/unstable/agentic → admin endpoint + * - /rest/** → platform endpoint + * + * Reads configuration from .env file (same one used by the app build). + * OS environment variables take precedence over .env values. + * Set COVEO_USE_MOCK=true to route all requests to the local mock-converse-api. + */ +const {readFileSync} = require('node:fs'); +const {resolve} = require('node:path'); + +function loadEnvFile() { + try { + const content = readFileSync(resolve(__dirname, '.env'), 'utf-8'); + const vars = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); + } + return vars; + } catch { + return {}; + } +} + +const dotenv = loadEnvFile(); +const env = (key) => process.env[key]?.trim() || dotenv[key] || ''; + +function resolveEnvironment(value) { + if ( + value === 'prod' || + value === 'dev' || + value === 'stg' || + value === 'hipaa' + ) { + return value; + } + return 'prod'; +} + +function getOrganizationAdminEndpoint(organizationId, environment) { + const environmentSuffix = environment === 'prod' ? '' : environment; + return `https://${organizationId}.admin.org${environmentSuffix}.coveo.com`; +} + +function getOrganizationPlatformEndpoint(organizationId, environment) { + const environmentSuffix = environment === 'prod' ? '' : environment; + return `https://${organizationId}.org${environmentSuffix}.coveo.com`; +} + +const MOCK_CONVERSE_API_URL = 'http://localhost:3456'; + +function buildProxyConfig() { + const organizationId = env('COVEO_ORGANIZATION_ID') || 'barcasportsmcy01fvu'; + const useMock = env('COVEO_USE_MOCK').toLowerCase() === 'true'; + + if (useMock) { + return { + '/rest': { + target: MOCK_CONVERSE_API_URL, + secure: false, + changeOrigin: true, + }, + }; + } + + const environment = resolveEnvironment( + env('COVEO_PLATFORM_ENVIRONMENT') || 'prod' + ); + const endpointOverride = env('COVEO_ENDPOINT'); + + const adminTarget = endpointOverride + ? endpointOverride + : getOrganizationAdminEndpoint(organizationId, environment); + const platformTarget = getOrganizationPlatformEndpoint( + organizationId, + environment + ); + + return { + [`/rest/organizations/${organizationId}/commerce/unstable/agentic`]: { + target: adminTarget, + secure: true, + changeOrigin: true, + }, + '/rest': { + target: platformTarget, + secure: true, + changeOrigin: true, + }, + }; +} + +module.exports = buildProxyConfig(); diff --git a/samples/thermidor/generative-angular/public/favicon.ico b/samples/thermidor/generative-angular/public/favicon.ico new file mode 100644 index 00000000000..57614f9c967 Binary files /dev/null and b/samples/thermidor/generative-angular/public/favicon.ico differ diff --git a/samples/thermidor/generative-angular/scripts/generate-env.js b/samples/thermidor/generative-angular/scripts/generate-env.js new file mode 100644 index 00000000000..b3a764c17fe --- /dev/null +++ b/samples/thermidor/generative-angular/scripts/generate-env.js @@ -0,0 +1,48 @@ +/** + * Reads .env (or OS environment variables) and generates src/environments/environment.ts. + * Run before build: `node scripts/generate-env.js` + */ +const {readFileSync, writeFileSync} = require('node:fs'); +const {resolve} = require('node:path'); + +const ROOT = resolve(__dirname, '..'); +const ENV_PATH = resolve(ROOT, '.env'); +const OUTPUT_PATH = resolve(ROOT, 'src/environments/environment.ts'); + +function loadEnvFile() { + try { + const content = readFileSync(ENV_PATH, 'utf-8'); + const vars = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); + } + return vars; + } catch { + return {}; + } +} + +const env = loadEnvFile(); + +function get(key, fallback = '') { + return process.env[key] || env[key] || fallback; +} + +const output = `// Auto-generated from .env — do not edit manually. +export const environment = { + organizationId: '${get('COVEO_ORGANIZATION_ID')}', + accessToken: '${get('COVEO_ACCESS_TOKEN')}', + trackingId: '${get('COVEO_TRACKING_ID')}', + language: '${get('COVEO_LANGUAGE', 'en')}', + country: '${get('COVEO_COUNTRY', 'US')}', + currency: '${get('COVEO_CURRENCY', 'USD')}', + endpoint: '${get('COVEO_ENDPOINT')}', +}; +`; + +writeFileSync(OUTPUT_PATH, output); +console.log('Generated src/environments/environment.ts from .env'); diff --git a/samples/thermidor/generative-angular/src/app/a2ui/custom-catalog.ts b/samples/thermidor/generative-angular/src/app/a2ui/custom-catalog.ts new file mode 100644 index 00000000000..3a57b32ac2a --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/a2ui/custom-catalog.ts @@ -0,0 +1,29 @@ +import {Type} from '@angular/core'; +import { + AngularCatalog, + AngularComponentImplementation, +} from '@a2ui/angular/v0_9'; +import {BundleDisplayComponent} from '../components/bundle-display.component'; +import {ComparisonSummaryComponent} from '../components/comparison-summary.component'; +import {ComparisonTableComponent} from '../components/comparison-table.component'; +import {NextActionsBarComponent} from '../components/next-actions-bar.component'; +import {ProductCarouselComponent} from '../components/product-carousel.component'; + +function entry( + name: string, + component: Type +): AngularComponentImplementation { + return { + name, + schema: {} as AngularComponentImplementation['schema'], + component: component as AngularComponentImplementation['component'], + }; +} + +export const CUSTOM_CATALOG = new AngularCatalog('commerce', [ + entry('ProductCarousel', ProductCarouselComponent), + entry('ComparisonTable', ComparisonTableComponent), + entry('ComparisonSummary', ComparisonSummaryComponent), + entry('BundleDisplay', BundleDisplayComponent), + entry('NextActionsBar', NextActionsBarComponent), +]); diff --git a/samples/thermidor/generative-angular/src/app/a2ui/prop-reader.ts b/samples/thermidor/generative-angular/src/app/a2ui/prop-reader.ts new file mode 100644 index 00000000000..b4f47c7e37f --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/a2ui/prop-reader.ts @@ -0,0 +1,23 @@ +import {computed, Signal} from '@angular/core'; +import type {BoundProperty} from '@a2ui/angular/v0_9'; + +/** + * Creates a computed signal that reads a typed value from A2UI props. + * Use in catalog components to extract bound property values cleanly. + * + * @example + * readonly heading = prop(this.props, 'heading', ''); + * readonly products = prop(this.props, 'products', [] as ProductRecord[]); + */ +export function prop( + propsSignal: () => Record, + key: string, + fallback: T +): Signal { + return computed(() => { + const p = propsSignal()[key]; + if (!p) return fallback; + const val = (p.value as Signal)(); + return (val as T) ?? fallback; + }); +} diff --git a/samples/thermidor/generative-angular/src/app/app.config.ts b/samples/thermidor/generative-angular/src/app/app.config.ts new file mode 100644 index 00000000000..26b98bd3cb8 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/app.config.ts @@ -0,0 +1,34 @@ +import { + ApplicationConfig, + Injector, + inject, + provideBrowserGlobalErrorListeners, +} from '@angular/core'; +import { + A2UI_RENDERER_CONFIG, + A2uiRendererService, + BasicCatalog, +} from '@a2ui/angular/v0_9'; +import type {A2uiClientAction} from '@a2ui/web_core/v0_9'; +import {CUSTOM_CATALOG} from './a2ui/custom-catalog'; +import {ConversationService} from './services/conversation.service'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + { + provide: A2UI_RENDERER_CONFIG, + useFactory: () => { + const injector = inject(Injector); + return { + catalogs: [new BasicCatalog(), CUSTOM_CATALOG], + actionHandler: (action: A2uiClientAction) => { + const conversationService = injector.get(ConversationService); + conversationService.submit(String(action.context['payload'] ?? '')); + }, + }; + }, + }, + A2uiRendererService, + ], +}; diff --git a/samples/thermidor/generative-angular/src/app/app.css b/samples/thermidor/generative-angular/src/app/app.css new file mode 100644 index 00000000000..a61b40d7b55 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/app.css @@ -0,0 +1,125 @@ +:host { + display: block; +} + +.turn-history { + border-right: 1px solid #e2e2e2; + padding: 12px; + min-width: 180px; + max-width: 240px; + flex-shrink: 0; +} + +.turn-history-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #666; + margin-bottom: 8px; +} + +.turn-list { + list-style: none; + margin: 0; + padding: 0; +} + +.turn-entry { + display: block; + width: 100%; + text-align: left; + padding: 6px 8px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + font-size: 0.85rem; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turn-entry:hover { + background: #f0f0f0; +} + +.turn-entry.active { + background: #e8f0fe; + font-weight: 500; +} + +.streaming-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: #f8f9fa; + border-bottom: 1px solid #e2e2e2; + font-size: 0.85rem; + color: #555; + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} + +.snapshot-label { + font-style: italic; +} + +@media (max-width: 1024px) { + .hero { + grid-template-columns: 1fr; + } + + .workspace { + flex-direction: column; + max-height: none; + } + + .hero-copy h1 { + max-width: 14ch; + } + + .transcript-panel { + min-height: unset; + } + + .turn-history { + display: none; + } +} + +@media (max-width: 720px) { + .shell { + padding-inline: 16px; + } + + .hero-meta { + grid-template-columns: 1fr 1fr; + } + + .bubble { + max-width: 100%; + } + + .composer-actions, + .panel-header { + flex-direction: column; + align-items: stretch; + } + + .primary-button, + .ghost-button { + width: 100%; + } +} diff --git a/samples/thermidor/generative-angular/src/app/app.html b/samples/thermidor/generative-angular/src/app/app.html new file mode 100644 index 00000000000..a5cb32bdc6a --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/app.html @@ -0,0 +1,27 @@ +
+ + +
+ + + +
+
diff --git a/samples/thermidor/generative-angular/src/app/app.ts b/samples/thermidor/generative-angular/src/app/app.ts new file mode 100644 index 00000000000..7c52203c407 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/app.ts @@ -0,0 +1,43 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core'; +import {ConversationHeaderComponent} from './components/conversation-header.component'; +import {PromptComposerComponent} from './components/prompt-composer.component'; +import {TranscriptPanelComponent} from './components/transcript-panel.component'; +import {ConversationService} from './services/conversation.service'; + +@Component({ + selector: 'app-root', + imports: [ + ConversationHeaderComponent, + TranscriptPanelComponent, + PromptComposerComponent, + ], + templateUrl: './app.html', + styleUrl: './app.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class App { + protected readonly conversation = inject(ConversationService); + protected readonly draft = signal(''); + + protected readonly lastTurnId = computed(() => { + const turns = this.conversation.turns(); + return turns.length > 0 ? turns[turns.length - 1].id : ''; + }); + + protected submitPrompt(): void { + const prompt = this.draft().trim(); + if (!prompt) return; + this.conversation.submit(prompt); + this.draft.set(''); + } + + protected setDraft(value: string): void { + this.draft.set(value); + } +} diff --git a/samples/thermidor/generative-angular/src/app/components/bundle-display.component.ts b/samples/thermidor/generative-angular/src/app/components/bundle-display.component.ts new file mode 100644 index 00000000000..4fda345d6da --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/components/bundle-display.component.ts @@ -0,0 +1,271 @@ +import { + ChangeDetectionStrategy, + Component, + input, + signal, + computed, +} from '@angular/core'; +import type {BoundProperty} from '@a2ui/angular/v0_9'; +import {prop} from '../a2ui/prop-reader'; +import type {BundleDisplayTier} from '../models'; + +@Component({ + selector: 'app-bundle-display', + template: ` +
+
+

Bundle Display

+

{{ title() }}

+
+ + @if (isLoading() || bundles().length === 0) { +
+ @for (item of placeholders; track $index) { +
+ } +
+ } @else { + + + @if (activeTier(); as tier) { +

{{ tier.description }}

+ + + } + } +
+ `, + styles: [ + ` + .surface-header { + margin-bottom: 16px; + } + + .surface-kicker { + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.74rem; + color: #516661; + } + + h3 { + margin: 0; + } + + .loading-grid { + display: grid; + gap: 14px; + } + + .loading-card { + min-height: 180px; + border-radius: 22px; + background: linear-gradient( + 90deg, + rgba(231, 221, 209, 0.95), + rgba(247, 241, 232, 0.95), + rgba(231, 221, 209, 0.95) + ); + background-size: 200% 100%; + animation: shimmer 1.25s linear infinite; + } + + .tier-tabs { + display: flex; + gap: 8px; + margin-bottom: 16px; + overflow-x: auto; + } + + .tier-tab { + appearance: none; + border: 1px solid rgba(17, 35, 31, 0.12); + border-radius: 999px; + padding: 8px 16px; + background: rgba(255, 255, 255, 0.8); + color: #204f46; + cursor: pointer; + font: inherit; + font-size: 0.9rem; + white-space: nowrap; + transition: + background 150ms ease, + border-color 150ms ease; + } + + .tier-tab:hover { + background: rgba(215, 239, 231, 0.6); + } + + .tier-tab.active { + background: #204f46; + color: white; + border-color: #204f46; + } + + .tier-description { + margin: 0 0 16px; + color: #516661; + line-height: 1.5; + font-size: 0.95rem; + } + + .slot-carousel { + display: flex; + gap: 14px; + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-bottom: 8px; + } + + .slot-card { + flex: 0 0 200px; + scroll-snap-align: start; + padding: 14px; + border-radius: 18px; + background: rgba(246, 242, 232, 0.9); + border: 1px solid rgba(17, 35, 31, 0.08); + display: flex; + flex-direction: column; + gap: 8px; + } + + .slot-label { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.7rem; + color: #516661; + } + + .slot-image { + width: 100%; + height: 120px; + object-fit: cover; + border-radius: 12px; + background: #f0ece4; + } + + .slot-name { + font-size: 0.9rem; + } + + .slot-brand { + color: #516661; + font-size: 0.82rem; + } + + .slot-price { + font-weight: 600; + color: #204f46; + font-size: 0.9rem; + } + + .slot-empty { + height: 120px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: rgba(231, 221, 209, 0.6); + color: #516661; + font-size: 0.85rem; + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BundleDisplayComponent { + protected readonly placeholders = Array.from({length: 1}); + + readonly props = input>({}); + readonly surfaceId = input(''); + readonly componentId = input(''); + readonly dataContextPath = input(''); + + protected readonly title = prop(this.props, 'title', ''); + protected readonly bundles = prop( + this.props, + 'bundles', + [] as BundleDisplayTier[] + ); + protected readonly isLoading = prop(this.props, 'isLoading', false); + + protected readonly activeTierId = signal(''); + + protected readonly activeTier = computed(() => { + const bundles = this.bundles(); + if (bundles.length === 0) return null; + + const id = this.activeTierId(); + const found = bundles.find((b) => b.bundleId === id); + return found ?? bundles[0]; + }); + + protected isActive(bundleId: string): boolean { + const tier = this.activeTier(); + return tier?.bundleId === bundleId; + } + + protected selectTier(bundleId: string): void { + this.activeTierId.set(bundleId); + } + + protected formatPrice(value: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value); + } +} diff --git a/samples/thermidor/generative-angular/src/app/components/comparison-summary.component.ts b/samples/thermidor/generative-angular/src/app/components/comparison-summary.component.ts new file mode 100644 index 00000000000..11e344a87cd --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/components/comparison-summary.component.ts @@ -0,0 +1,62 @@ +import {ChangeDetectionStrategy, Component, input} from '@angular/core'; +import type {BoundProperty} from '@a2ui/angular/v0_9'; +import {prop} from '../a2ui/prop-reader'; + +@Component({ + selector: 'app-comparison-summary', + template: ` +
+
+

Comparison Summary

+ Assistant recommendation +
+

{{ text() }}

+
+ `, + styles: [ + ` + .summary { + border: 1px solid rgba(17, 35, 31, 0.12); + border-radius: 22px; + background: rgba(255, 255, 255, 0.72); + padding: 18px; + } + + .summary-lead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .surface-kicker { + margin: 0 0 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.74rem; + color: #516661; + } + + .summary-lead span { + font-size: 0.8rem; + color: #516661; + } + + .summary-text { + margin: 0; + line-height: 1.6; + color: #204f46; + font-size: 1rem; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComparisonSummaryComponent { + readonly props = input>({}); + readonly surfaceId = input(''); + readonly componentId = input(''); + readonly dataContextPath = input(''); + + protected readonly text = prop(this.props, 'text', ''); +} diff --git a/samples/thermidor/generative-angular/src/app/components/comparison-table.component.ts b/samples/thermidor/generative-angular/src/app/components/comparison-table.component.ts new file mode 100644 index 00000000000..3ac1ded1887 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/components/comparison-table.component.ts @@ -0,0 +1,229 @@ +import {ChangeDetectionStrategy, Component, input} from '@angular/core'; +import type {BoundProperty} from '@a2ui/angular/v0_9'; +import {prop} from '../a2ui/prop-reader'; +import type {ProductRecord} from '../models'; + +@Component({ + selector: 'app-comparison-table', + template: ` +
+
+

Comparison Table

+

{{ heading() }}

+
+ + @if (isLoading() || products().length === 0) { +
+ } @else { +
+ @for (product of products(); track product.ec_product_id) { +
+ @if (product.ec_image) { + + } @else { +
+ } +
+ {{ product.ec_brand }} + {{ product.ec_name }} + {{ + formatPrice(product.ec_promo_price ?? product.ec_price) + }} +
+
+ } +
+ +
+ + + + + @for (attribute of attributes(); track attribute) { + + } + + + + + @for (product of products(); track product.ec_product_id) { + + + @for (attribute of attributes(); track attribute) { + + } + + + } + +
Product{{ formatLabel(attribute) }}Price
+ {{ product.ec_name }} + {{ product.ec_brand }} + {{ product[attribute] || '—' }} + {{ + formatPrice(product.ec_promo_price ?? product.ec_price) + }} +
+
+ } +
+ `, + styles: [ + ` + .surface-header { + margin-bottom: 16px; + } + + .surface-kicker { + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.74rem; + color: #516661; + } + + h3 { + margin: 0; + } + + .table-wrap { + overflow-x: auto; + margin-top: 14px; + } + + table { + width: 100%; + border-collapse: collapse; + min-width: 560px; + } + + th, + td { + padding: 12px 10px; + border-bottom: 1px solid rgba(17, 35, 31, 0.12); + text-align: left; + vertical-align: top; + } + + td span { + display: block; + margin-top: 4px; + color: #516661; + font-size: 0.92rem; + } + + .product-strip { + display: flex; + gap: 14px; + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-bottom: 8px; + } + + .product-card { + flex: 0 0 200px; + scroll-snap-align: start; + border-radius: 18px; + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(17, 35, 31, 0.08); + overflow: hidden; + } + + .product-image { + width: 100%; + height: 130px; + object-fit: cover; + background: #f0ece4; + } + + .product-image-placeholder { + width: 100%; + height: 130px; + background: linear-gradient(135deg, #e7d8c8, #c6a889); + } + + .product-info { + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 4px; + } + + .product-brand { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.7rem; + color: #516661; + } + + .product-name { + font-size: 0.88rem; + } + + .product-price { + font-size: 0.85rem; + font-weight: 600; + color: #204f46; + } + + .loading-table { + height: 220px; + border-radius: 18px; + background: linear-gradient( + 90deg, + rgba(231, 221, 209, 0.95), + rgba(247, 241, 232, 0.95), + rgba(231, 221, 209, 0.95) + ); + background-size: 200% 100%; + animation: shimmer 1.25s linear infinite; + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComparisonTableComponent { + readonly props = input>({}); + readonly surfaceId = input(''); + readonly componentId = input(''); + readonly dataContextPath = input(''); + + protected readonly heading = prop(this.props, 'heading', ''); + protected readonly attributes = prop( + this.props, + 'attributes', + [] as string[] + ); + protected readonly products = prop( + this.props, + 'products', + [] as ProductRecord[] + ); + protected readonly isLoading = prop(this.props, 'isLoading', false); + + protected formatLabel(value: string): string { + return value.replace(/_/g, ' '); + } + + protected formatPrice(value: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value); + } +} diff --git a/samples/thermidor/generative-angular/src/app/components/conversation-header.component.ts b/samples/thermidor/generative-angular/src/app/components/conversation-header.component.ts new file mode 100644 index 00000000000..9dfbc0287fa --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/components/conversation-header.component.ts @@ -0,0 +1,34 @@ +import {ChangeDetectionStrategy, Component, input} from '@angular/core'; + +@Component({ + selector: 'app-conversation-header', + template: ` +
+
+

Barca Sports reference app

+

Structured commerce surfaces for a sports storefront.

+

+ This demo focuses on storefront-side responsibilities: stable + conversation identity, streamed assistant text, and renderable A2UI + surfaces for shopping flows. +

+
+ +
+
+ Status + {{ status() }} +
+
+ Turns + {{ historyCount() }} +
+
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConversationHeaderComponent { + readonly status = input.required(); + readonly historyCount = input.required(); +} diff --git a/samples/thermidor/generative-angular/src/app/components/next-actions-bar.component.ts b/samples/thermidor/generative-angular/src/app/components/next-actions-bar.component.ts new file mode 100644 index 00000000000..f5eff53e7a7 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/components/next-actions-bar.component.ts @@ -0,0 +1,115 @@ +import {ChangeDetectionStrategy, Component, inject, input} from '@angular/core'; +import type {BoundProperty} from '@a2ui/angular/v0_9'; +import {prop} from '../a2ui/prop-reader'; +import {ConversationService} from '../services/conversation.service'; +import type {NextAction} from '../models'; + +@Component({ + selector: 'app-next-actions-bar', + template: ` +
+
+

Next Actions

+

Suggested next steps

+
+ + @if (!isLoading()) { +
+ @for (action of actions(); track action.text + ':' + action.type) { + + } +
+ } @else { +
+ @for (item of placeholders; track $index) { + + } +
+ } +
+ `, + styles: [ + ` + .surface-header { + margin-bottom: 16px; + } + + .surface-kicker { + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.74rem; + color: #516661; + } + + h3 { + margin: 0; + } + + .actions, + .loading-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + button, + .loading-row span { + border-radius: 999px; + padding: 10px 14px; + } + + button { + appearance: none; + border: 1px solid rgba(17, 35, 31, 0.12); + background: rgba(215, 239, 231, 0.8); + color: #204f46; + cursor: pointer; + font: inherit; + } + + .loading-row span { + display: inline-block; + width: 160px; + height: 38px; + background: linear-gradient( + 90deg, + rgba(231, 221, 209, 0.95), + rgba(247, 241, 232, 0.95), + rgba(231, 221, 209, 0.95) + ); + background-size: 200% 100%; + animation: shimmer 1.25s linear infinite; + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NextActionsBarComponent { + protected readonly placeholders = Array.from({length: 3}); + + readonly props = input>({}); + readonly surfaceId = input(''); + readonly componentId = input(''); + readonly dataContextPath = input(''); + + protected readonly actions = prop(this.props, 'actions', [] as NextAction[]); + protected readonly isLoading = prop(this.props, 'isLoading', false); + + private readonly conversation = inject(ConversationService); + + protected handleAction(action: string): void { + this.conversation.submit(action); + } +} diff --git a/samples/thermidor/generative-angular/src/app/components/product-carousel.component.ts b/samples/thermidor/generative-angular/src/app/components/product-carousel.component.ts new file mode 100644 index 00000000000..7ad87211372 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/components/product-carousel.component.ts @@ -0,0 +1,186 @@ +import {ChangeDetectionStrategy, Component, input} from '@angular/core'; +import type {BoundProperty} from '@a2ui/angular/v0_9'; +import {prop} from '../a2ui/prop-reader'; +import type {ProductRecord} from '../models'; + +@Component({ + selector: 'app-product-carousel', + template: ` +
+
+

Product Carousel

+

{{ heading() }}

+
+ + @if (isLoading() || products().length === 0) { +
+ @for (item of placeholders; track $index) { +
+ } +
+ } @else { + + } +
+ `, + styles: [ + ` + .surface-header { + margin-bottom: 16px; + } + + .surface-kicker, + .brand, + .meta { + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.74rem; + color: #516661; + } + + h3, + h4 { + margin: 0; + } + + .carousel, + .loading-grid { + display: flex; + gap: 14px; + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-bottom: 8px; + } + + .card, + .loading-card { + flex: 0 0 240px; + scroll-snap-align: start; + border-radius: 22px; + padding: 16px; + border: 1px solid rgba(17, 35, 31, 0.12); + background: rgba(255, 255, 255, 0.8); + } + + .loading-card { + min-height: 220px; + background: linear-gradient( + 90deg, + rgba(231, 221, 209, 0.95), + rgba(247, 241, 232, 0.95), + rgba(231, 221, 209, 0.95) + ); + background-size: 200% 100%; + animation: shimmer 1.25s linear infinite; + } + + .product-image { + width: 100%; + height: 140px; + object-fit: cover; + border-radius: 16px; + margin-bottom: 12px; + background: #f0ece4; + } + + .swatch { + width: 100%; + height: 140px; + border-radius: 16px; + margin-bottom: 12px; + } + + .description { + margin: 12px 0; + color: #516661; + line-height: 1.5; + } + + .meta { + display: flex; + gap: 10px; + flex-wrap: wrap; + } + + .footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 12px; + } + + .footer span { + color: #204f46; + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProductCarouselComponent { + protected readonly placeholders = Array.from({length: 3}); + + readonly props = input>({}); + readonly surfaceId = input(''); + readonly componentId = input(''); + readonly dataContextPath = input(''); + + protected readonly heading = prop(this.props, 'heading', ''); + protected readonly products = prop( + this.props, + 'products', + [] as ProductRecord[] + ); + protected readonly isLoading = prop(this.props, 'isLoading', false); + + protected formatPrice(value: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value); + } +} diff --git a/samples/thermidor/generative-angular/src/app/components/prompt-composer.component.ts b/samples/thermidor/generative-angular/src/app/components/prompt-composer.component.ts new file mode 100644 index 00000000000..6d570d345ce --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/components/prompt-composer.component.ts @@ -0,0 +1,50 @@ +import {ChangeDetectionStrategy, Component, input, output} from '@angular/core'; + +@Component({ + selector: 'app-prompt-composer', + template: ` +
+ + + +
+ {{ status() }} + +
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PromptComposerComponent { + readonly draft = input(''); + readonly busy = input(false); + readonly status = input('Ready'); + readonly draftChange = output(); + readonly submitPrompt = output(); + + protected handleSubmit(event: Event): void { + event.preventDefault(); + + if (this.busy() || !this.draft().trim()) { + return; + } + + this.submitPrompt.emit(); + } +} diff --git a/samples/thermidor/generative-angular/src/app/components/transcript-panel.component.ts b/samples/thermidor/generative-angular/src/app/components/transcript-panel.component.ts new file mode 100644 index 00000000000..1fde1606d32 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/components/transcript-panel.component.ts @@ -0,0 +1,168 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, + Pipe, + PipeTransform, +} from '@angular/core'; +import {SurfaceComponent} from '@a2ui/angular/v0_9'; +import {marked} from 'marked'; +import type {ToolCall, Turn} from '../models'; +import {A2uiAdapterService} from '../services/a2ui-adapter.service'; + +marked.setOptions({breaks: true, gfm: true}); + +@Pipe({name: 'markdown', standalone: true}) +export class MarkdownPipe implements PipeTransform { + transform(value: string): string { + if (!value) return ''; + return marked.parse(value) as string; + } +} + +@Component({ + selector: 'app-transcript-panel', + imports: [SurfaceComponent, MarkdownPipe], + template: ` +
+
+

Conversation

+

Conversation with inline surfaces

+
+ +
+ +
+ @if (turns().length === 0 && !isStreaming()) { +
+

No messages yet.

+ Try "show me surfboards", "compare kayaks", or "build a surfing + bundle". +
+ } + + @for (turn of turns(); track turn.id) { +
+

You

+

{{ turn.prompt }}

+
+ + @for (msg of turn.agentResponse?.messages ?? []; track $index) { +
+

Assistant

+
+
+ } + } + + @if (isStreaming()) { +
+ + + +
+ } + + @if (errorMessage()) { + + } + + @if (hasProgress()) { +
+ + Progress + {{ progressLabel() }} + + +
+ @if (reasoningText()) { +

{{ reasoningText() }}

+ } + + @if (toolActivity().length > 0) { +
    + @for (tool of toolActivity(); track tool.id) { +
  • + {{ truncateToolName(tool.name) }} + {{ + tool.status === 'completed' ? 'Done' : 'Running' + }} +
  • + } +
+ } +
+
+ } + + @if (rendererSurfaces().length > 0) { +
+
+

Assistant

+ Structured results +
+ +
+ @for (surfaceId of rendererSurfaces(); track surfaceId) { + + } +
+
+ } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TranscriptPanelComponent { + readonly turns = input([]); + readonly reasoningText = input(''); + readonly toolActivity = input([]); + readonly isStreaming = input(false); + readonly errorMessage = input(''); + readonly turnId = input(''); + readonly resetConversation = output(); + readonly retryTurn = output(); + + private readonly adapter = inject(A2uiAdapterService); + protected readonly rendererSurfaces = this.adapter.surfaceIds; + + protected readonly hasProgress = computed( + () => this.toolActivity().length > 0 || this.reasoningText().length > 0 + ); + + protected readonly progressLabel = computed(() => { + const activity = this.toolActivity(); + return activity.length > 0 + ? activity[activity.length - 1].status === 'calling' + ? 'Working' + : 'Completed' + : 'Thinking'; + }); + + protected truncateToolName(name: string): string { + return name.length > 60 ? name.slice(0, 57) + '...' : name; + } +} diff --git a/samples/thermidor/generative-angular/src/app/constants.ts b/samples/thermidor/generative-angular/src/app/constants.ts new file mode 100644 index 00000000000..7b96f502909 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/constants.ts @@ -0,0 +1,2 @@ +export const CONVERSATION_STORAGE_KEY = 'commerce-agent-conversation'; +export const VISITOR_ID_COOKIE = 'coveo_visitorId'; diff --git a/samples/thermidor/generative-angular/src/app/models.ts b/samples/thermidor/generative-angular/src/app/models.ts new file mode 100644 index 00000000000..06e1280c1b6 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/models.ts @@ -0,0 +1,43 @@ +// Shared frontend contract types for the Angular sample. +// These types re-export Thermidor's canonical conversation types and define +// the sample-specific commerce surface shapes used across the app. +export type { + Turn, + TurnStatus, + AgentResponse, + AgentMessage, + A2UISurface, + ToolCall, + ToolCallStatus, +} from '@coveo/thermidor'; + +export type ProductRecord = { + ec_product_id: string; + ec_name: string; + ec_brand: string; + ec_price: number; + ec_promo_price?: number; + ec_image: string; + clickUri: string; + description?: string; + accent?: string; + [key: string]: string | number | undefined; +}; + +export type NextAction = { + text: string; + type: 'search' | 'followup'; +}; + +export type BundleDisplaySlot = { + categoryLabel: string; + surfaceRef: string; + product: ProductRecord | null; +}; + +export type BundleDisplayTier = { + bundleId: string; + label: string; + description: string; + slots: BundleDisplaySlot[]; +}; diff --git a/samples/thermidor/generative-angular/src/app/services/a2ui-adapter.service.ts b/samples/thermidor/generative-angular/src/app/services/a2ui-adapter.service.ts new file mode 100644 index 00000000000..36c46ebcbd3 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/services/a2ui-adapter.service.ts @@ -0,0 +1,26 @@ +import {Injectable, inject, signal} from '@angular/core'; +import {A2uiRendererService} from '@a2ui/angular/v0_9'; +import type {A2uiMessage} from '@a2ui/web_core/v0_9'; + +@Injectable({providedIn: 'root'}) +export class A2uiAdapterService { + private readonly renderer = inject(A2uiRendererService); + + readonly surfaceIds = signal([]); + + processOperations(operations: unknown[]): void { + this.renderer.processMessages(operations as A2uiMessage[]); + this.surfaceIds.set([...this.renderer.surfaceGroup.surfacesMap.keys()]); + } + + reset(): void { + const ids = [...this.renderer.surfaceGroup.surfacesMap.keys()]; + if (ids.length === 0) return; + const deletes: A2uiMessage[] = ids.map((surfaceId) => ({ + version: 'v0.9' as const, + deleteSurface: {surfaceId}, + })); + this.renderer.processMessages(deletes); + this.surfaceIds.set([]); + } +} diff --git a/samples/thermidor/generative-angular/src/app/services/conversation.service.ts b/samples/thermidor/generative-angular/src/app/services/conversation.service.ts new file mode 100644 index 00000000000..81d0f6c4697 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/services/conversation.service.ts @@ -0,0 +1,94 @@ +import {Injectable, inject, signal} from '@angular/core'; +import { + buildGenerativeInterface, + buildConverseController, + type ConverseController, + type ConverseControllerState, + type SerializedConverseState, +} from '@coveo/thermidor'; +import {EngineService} from './engine.service'; +import {A2uiAdapterService} from './a2ui-adapter.service'; +import {CONVERSATION_STORAGE_KEY} from '../constants'; +import type {ToolCall, Turn} from '../models'; + +@Injectable({providedIn: 'root'}) +export class ConversationService { + private readonly engineService = inject(EngineService); + private readonly adapter = inject(A2uiAdapterService); + private readonly controller: ConverseController; + + readonly busy = signal(false); + readonly turns = signal([]); + readonly reasoningText = signal(''); + readonly toolActivity = signal([]); + readonly activeTurnError = signal(''); + + constructor() { + const generativeInterface = buildGenerativeInterface({ + engine: this.engineService.engine, + }); + + this.controller = buildConverseController({ + interface: generativeInterface, + initialState: this.loadPersistedState(), + onSurfaceOperation: (operations) => { + this.adapter.processOperations(operations); + }, + }); + + this.applyState(this.controller.state); + this.controller.subscribe((state) => { + this.applyState(state); + this.persistState(); + }); + } + + submit(prompt: string): void { + if (prompt) { + this.adapter.reset(); + this.controller.submit({prompt}); + } + } + + retry(turnId: string): void { + this.controller.retry({id: turnId}); + } + + resetConversation(): void { + localStorage.removeItem(CONVERSATION_STORAGE_KEY); + window.location.reload(); + } + + private applyState(state: ConverseControllerState): void { + const {turns, activeTurn, isStreaming} = state; + + this.busy.set(isStreaming); + this.turns.set(turns); + + if (activeTurn) { + this.reasoningText.set( + isStreaming ? (activeTurn.agentResponse?.reasoningContent ?? '') : '' + ); + this.activeTurnError.set( + activeTurn.status === 'error' + ? (activeTurn.error ?? 'An error occurred') + : '' + ); + this.toolActivity.set(activeTurn.agentResponse?.toolCalls ?? []); + } else { + this.reasoningText.set(''); + this.activeTurnError.set(''); + this.toolActivity.set([]); + } + } + + private loadPersistedState(): SerializedConverseState | undefined { + const raw = localStorage.getItem(CONVERSATION_STORAGE_KEY); + return raw ? (JSON.parse(raw) as SerializedConverseState) : undefined; + } + + private persistState(): void { + const serialized = this.controller.serialize(); + localStorage.setItem(CONVERSATION_STORAGE_KEY, JSON.stringify(serialized)); + } +} diff --git a/samples/thermidor/generative-angular/src/app/services/engine.service.ts b/samples/thermidor/generative-angular/src/app/services/engine.service.ts new file mode 100644 index 00000000000..40069596cd3 --- /dev/null +++ b/samples/thermidor/generative-angular/src/app/services/engine.service.ts @@ -0,0 +1,61 @@ +import {Injectable} from '@angular/core'; +import {Engine} from '@coveo/thermidor'; +import {environment} from '../../environments/environment'; +import {VISITOR_ID_COOKIE} from '../constants'; + +@Injectable({providedIn: 'root'}) +export class EngineService { + readonly engine: Engine; + + constructor() { + this.engine = new Engine({ + configuration: this.buildConfiguration(), + navigatorContextProvider: () => ({ + clientId: this.getOrCreateVisitorId() ?? '', + location: window.location.href, + referrer: document.referrer || null, + userAgent: navigator.userAgent || null, + }), + }); + } + + private buildConfiguration() { + const endpoint = environment.endpoint || window.location.origin; + + return { + organizationId: environment.organizationId, + accessToken: environment.accessToken, + trackingId: environment.trackingId, + language: environment.language, + country: environment.country, + currency: environment.currency, + endpoint, + }; + } + + private getOrCreateVisitorId(): string | undefined { + try { + const existing = this.getCookie(VISITOR_ID_COOKIE); + if (existing) return existing; + + const id = crypto.randomUUID(); + this.setCookie(VISITOR_ID_COOKIE, id); + return id; + } catch { + return undefined; + } + } + + private getCookie(name: string): string | undefined { + const match = document.cookie + .split('; ') + .find((row) => row.startsWith(`${name}=`)); + return match?.split('=')[1]; + } + + private setCookie(name: string, value: string): void { + const expires = new Date(); + expires.setFullYear(expires.getFullYear() + 1); + document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`; + } +} diff --git a/samples/thermidor/generative-angular/src/index.html b/samples/thermidor/generative-angular/src/index.html new file mode 100644 index 00000000000..7eddc460bd7 --- /dev/null +++ b/samples/thermidor/generative-angular/src/index.html @@ -0,0 +1,13 @@ + + + + + Barca Sports Commerce Agent + + + + + + + + diff --git a/samples/thermidor/generative-angular/src/main.ts b/samples/thermidor/generative-angular/src/main.ts new file mode 100644 index 00000000000..a1a0a08cd81 --- /dev/null +++ b/samples/thermidor/generative-angular/src/main.ts @@ -0,0 +1,5 @@ +import {bootstrapApplication} from '@angular/platform-browser'; +import {appConfig} from './app/app.config'; +import {App} from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/samples/thermidor/generative-angular/src/styles.css b/samples/thermidor/generative-angular/src/styles.css new file mode 100644 index 00000000000..28bfb5718e8 --- /dev/null +++ b/samples/thermidor/generative-angular/src/styles.css @@ -0,0 +1,396 @@ +html, +body { + margin: 0; + min-height: 100%; +} + +body { + font-family: 'Satoshi', 'Avenir Next', 'Segoe UI', sans-serif; +} + +* { + box-sizing: border-box; +} + +button, +input, +textarea { + font: inherit; +} + +app-root { + --ink: #11231f; + --muted: #516661; + --line: rgba(17, 35, 31, 0.12); + --cream: #f6f2e8; + --sand: #efe4d2; + --pine: #204f46; + --foam: #d7efe7; + --accent: #da6f32; + --accent-soft: #f7dfcf; + --card: rgba(255, 252, 246, 0.86); + display: block; + min-height: 100dvh; + background: + radial-gradient( + circle at top left, + rgba(215, 239, 231, 0.95), + transparent 35% + ), + radial-gradient( + circle at bottom right, + rgba(239, 228, 210, 0.9), + transparent 40% + ), + linear-gradient(180deg, #f9f6ef 0%, #f0eadf 100%); + color: var(--ink); +} + +.shell { + max-width: 1320px; + margin: 0 auto; + padding: 40px 24px 48px; +} + +.hero { + display: grid; + gap: 24px; + grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); + align-items: start; + margin-bottom: 24px; +} + +.hero-copy h1 { + margin: 0; + font-size: clamp(2.6rem, 5vw, 4.8rem); + line-height: 0.95; + letter-spacing: -0.05em; + max-width: 11ch; +} + +.eyebrow, +.panel-kicker { + margin: 0 0 10px; + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.75rem; + color: var(--muted); +} + +.lede { + margin: 18px 0 0; + max-width: 52rem; + font-size: 1.05rem; + line-height: 1.6; + color: var(--muted); +} + +.hero-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.meta-card, +.panel { + border: 1px solid var(--line); + border-radius: 24px; + background: var(--card); + backdrop-filter: blur(18px); + box-shadow: 0 22px 50px rgba(32, 79, 70, 0.08); +} + +.meta-card { + padding: 18px; +} + +.meta-card span { + display: block; + margin-bottom: 8px; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); +} + +.meta-card strong { + font-size: 1.05rem; +} + +.workspace { + display: flex; + align-items: stretch; +} + +.panel { + padding: 22px; +} + +.panel-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.panel-header h2 { + margin: 0; + font-size: 1.45rem; +} + +.transcript-panel { + display: flex; + flex-direction: column; + min-height: 760px; + max-width: 940px; + margin: 0 auto; + flex: 1; +} + +.transcript { + flex: 1; + display: flex; + flex-direction: column; + gap: 14px; + min-height: 420px; +} + +.bubble { + max-width: 90%; + padding: 16px 18px; + border-radius: 20px; + border: 1px solid var(--line); +} + +.user-bubble { + align-self: end; + background: var(--pine); + color: white; +} + +.assistant-bubble { + background: rgba(255, 255, 255, 0.72); +} + +.inline-surfaces { + max-width: 92%; + border: 1px solid var(--line); + border-radius: 24px; + background: rgba(255, 255, 255, 0.78); + padding: 16px; +} + +.inline-surfaces-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.inline-surfaces-head span { + font-size: 0.82rem; + color: var(--muted); +} + +.progress-block { + width: fit-content; + max-width: 70%; + border: 1px solid rgba(17, 35, 31, 0.08); + border-radius: 18px; + background: rgba(255, 255, 255, 0.62); + padding: 0; +} + +.progress-block summary { + list-style: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + cursor: pointer; + padding: 12px 14px; + color: var(--muted); +} + +.progress-block summary::-webkit-details-marker { + display: none; +} + +.progress-block summary span { + font-size: 0.92rem; + font-weight: 600; + color: var(--ink); +} + +.progress-block summary small { + font-size: 0.78rem; + color: var(--muted); +} + +.progress-content { + padding: 0 14px 14px; +} + +.progress-reasoning { + margin: 0 0 10px; + color: var(--muted); + line-height: 1.5; + font-size: 0.92rem; +} + +.progress-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; +} + +.progress-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 0.9rem; + color: var(--ink); +} + +.progress-list small { + color: var(--muted); + font-size: 0.76rem; +} + +.bubble-role { + margin: 0 0 8px; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.12em; + opacity: 0.72; +} + +.bubble-text { + margin: 0; + line-height: 1.55; + white-space: pre-wrap; +} + +.composer { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid var(--line); +} + +.composer-label { + display: block; + margin-bottom: 10px; + font-size: 0.86rem; + color: var(--muted); +} + +.composer textarea { + width: 100%; + resize: vertical; + min-height: 96px; + border-radius: 18px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.92); + padding: 14px 16px; + font: inherit; + color: var(--ink); +} + +.composer textarea:focus { + outline: 2px solid rgba(32, 79, 70, 0.2); + border-color: rgba(32, 79, 70, 0.35); +} + +.composer-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 12px; +} + +.status-pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 10px 14px; + background: var(--sand); + color: var(--muted); + font-size: 0.9rem; +} + +.status-pill.active { + background: var(--foam); + color: var(--pine); +} + +.primary-button, +.ghost-button { + appearance: none; + border: 0; + cursor: pointer; + font: inherit; + transition: + transform 180ms ease, + opacity 180ms ease, + background 180ms ease; +} + +.primary-button { + border-radius: 999px; + background: var(--accent); + color: white; + padding: 12px 18px; + box-shadow: 0 16px 32px rgba(218, 111, 50, 0.22); +} + +.ghost-button { + border-radius: 999px; + background: transparent; + border: 1px solid var(--line); + padding: 10px 14px; + color: var(--ink); +} + +.primary-button:hover, +.ghost-button:hover { + transform: translateY(-1px); +} + +.primary-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.surface-stack { + display: flex; + flex-direction: column; + gap: 18px; +} + +.empty-state { + border: 1px dashed var(--line); + border-radius: 20px; + padding: 24px; + background: rgba(255, 255, 255, 0.42); +} + +.empty-state p { + margin: 0 0 8px; + font-weight: 600; +} + +.empty-state span { + color: var(--muted); + line-height: 1.45; +} + +.surface-empty { + min-height: 160px; +} diff --git a/samples/thermidor/generative-angular/tsconfig.app.json b/samples/thermidor/generative-angular/tsconfig.app.json new file mode 100644 index 00000000000..4ebc77cd3e1 --- /dev/null +++ b/samples/thermidor/generative-angular/tsconfig.app.json @@ -0,0 +1,17 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [], + "noPropertyAccessFromIndexSignature": false, + "importHelpers": false, + "paths": { + "@coveo/thermidor": ["../../../packages/thermidor/src/index.ts"], + "@/*": ["../../../packages/thermidor/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/samples/thermidor/generative-angular/tsconfig.json b/samples/thermidor/generative-angular/tsconfig.json new file mode 100644 index 00000000000..ad457fa2097 --- /dev/null +++ b/samples/thermidor/generative-angular/tsconfig.json @@ -0,0 +1,30 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +}