diff --git a/package.json b/package.json index a670f438..e4c3b76c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ }, "resolutions": { "@a2ui/web_core": "0.10.5", - "@google/genai": "2.8.0" + "@google/genai": "2.8.0", + "rxjs": "7.8.2" }, "packageManager": "yarn@4.5.0", "engines": { diff --git a/shell/package.json b/shell/package.json index a46a6527..096dd062 100644 --- a/shell/package.json +++ b/shell/package.json @@ -27,6 +27,7 @@ "@angular/material": "22.0.5", "@angular/platform-browser": "22.0.7", "@angular/router": "22.0.7", + "@copilotkit/angular": "^0.1.2", "@google/genai": "2.11.0", "@monaco-editor/loader": "1.7.0", "a2ui-bridge": "0.1.0", diff --git a/shell/src/app/app.config.ts b/shell/src/app/app.config.ts index f92ec37c..a339cfac 100644 --- a/shell/src/app/app.config.ts +++ b/shell/src/app/app.config.ts @@ -22,7 +22,9 @@ import { } from '@angular/core'; import {provideRouter} from '@angular/router'; import {provideAnimations} from '@angular/platform-browser/animations'; +import {COPILOT_KIT_CONFIG} from '@copilotkit/angular'; import {routes} from './app.routes'; +import {GeminiA2uiAgent} from './copilotkit/gemini-a2ui-agent/gemini-a2ui-agent'; import {StartupResolution} from './shell/startup-resolution/startup-resolution'; import {AppConfigProvider} from './settings/app-config-provider/app-config-provider'; import {LocalStorageAppConfigProvider} from './settings/local-storage-config-provider/local-storage-config.provider'; @@ -38,6 +40,14 @@ export const appConfig: ApplicationConfig = { provideZonelessChangeDetection(), provideRouter(routes), provideAnimations(), + // Register the in-browser AG-UI agent as the default CopilotKit agent. + // provideCopilotKit only binds COPILOT_KIT_CONFIG as a value, so we bind it + // via a factory to inject the DI-constructed GeminiA2uiAgent (no runtimeUrl — + // the agent runs entirely client-side). + { + provide: COPILOT_KIT_CONFIG, + useFactory: () => ({agents: {default: inject(GeminiA2uiAgent)}}), + }, provideAppInitializer(() => { const startupResolution = inject(StartupResolution); const configProvider = inject(AppConfigProvider); diff --git a/shell/src/app/app.routes.spec.ts b/shell/src/app/app.routes.spec.ts index da02746e..cb14834d 100644 --- a/shell/src/app/app.routes.spec.ts +++ b/shell/src/app/app.routes.spec.ts @@ -18,9 +18,11 @@ import {TestBed} from '@angular/core/testing'; import {provideRouter, Router} from '@angular/router'; import {RouterTestingHarness} from '@angular/router/testing'; import {provideNoopAnimations} from '@angular/platform-browser/animations'; -import {signal} from '@angular/core'; +import {Component, signal} from '@angular/core'; import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; import {routes} from './app.routes'; +import {ComposerWorkspace} from './shell/composer-workspace/composer-workspace'; +import {CopilotSidebar} from './copilotkit/copilot-sidebar/copilot-sidebar'; import {StartupResolution, ProfileConfig} from './shell/startup-resolution/startup-resolution'; import {ChatState} from './chat/chat-state/chat-state'; import {ChatCoordinator} from './chat/chat-service/chat-coordinator'; @@ -37,6 +39,17 @@ import {IndexedDbStorage} from './storage/indexed-db-storage/indexed-db-storage' import {LocalStorageInteractions} from './storage/local-storage-interactions/local-storage-interactions'; import {PipelineStatus} from './chat/pipeline-status/pipeline-status'; +/** + * Stubs the docked sidebar so routing into the workspace does not require the + * CopilotKit runtime (COPILOT_KIT_CONFIG) — this suite verifies routing only. + */ +@Component({ + selector: 'a2ui-composer-copilot-sidebar', + standalone: true, + template: '', +}) +class CopilotSidebarStub {} + class MockStartupResolution { readonly resolvedUrl = signal('http://localhost:4200'); readonly isLockedContext = signal(false); @@ -131,7 +144,12 @@ describe('App Routes Active Verification', () => { useValue: {removeItem: vi.fn(), getItem: vi.fn().mockReturnValue(null)}, }, ], - }).compileComponents(); + }) + .overrideComponent(ComposerWorkspace, { + remove: {imports: [CopilotSidebar]}, + add: {imports: [CopilotSidebarStub]}, + }) + .compileComponents(); harness = await RouterTestingHarness.create(); router = TestBed.inject(Router); diff --git a/shell/src/app/chat/chat-service/chat-coordinator.ts b/shell/src/app/chat/chat-service/chat-coordinator.ts index aab0330e..117844f6 100644 --- a/shell/src/app/chat/chat-service/chat-coordinator.ts +++ b/shell/src/app/chat/chat-service/chat-coordinator.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import {Injectable, inject, computed, effect, untracked} from '@angular/core'; +import {Injectable, inject, effect, untracked} from '@angular/core'; import {formatJson} from '../../utils/json'; -import {CatalogManagement} from '../../storage/catalog-management/catalog-management'; import { LlmMessage, LlmClient, @@ -30,20 +29,22 @@ import {AppConfigProvider} from '../../settings/app-config-provider/app-config-p import {StateSync} from '../state-sync/state-sync'; import {ChatState, LlmLogType} from '../chat-state/chat-state'; import {CrossFrameValidator} from '../../shell/cross-frame-validator/cross-frame-validator'; -import {PreviewBridgeMessageType, RenderA2uiItem, A2uiComponentInstance} from 'a2ui-bridge'; +import {PreviewBridgeMessageType, A2uiComponentInstance} from 'a2ui-bridge'; import {cleanErrorMessage, redactApiKey} from './error-utils'; +import {A2uiGenerationService} from '../../copilotkit/a2ui-generation/a2ui-generation.service'; @Injectable({ providedIn: 'root', }) /** - * Dynamic chat panel coordinator managing system prompt generation using - * dynamic component configurations. Manages LLM completions transport - * streams, self-healing parsers, schemas typo corrections, and gateway - * error fallbacks. + * Dynamic chat panel coordinator managing the conversational chat-bubble UX + * around A2UI generation. Owns the streamed chat history turns, the loading + * pulse indicator, and gateway error bubbles, while delegating the headless + * generation pipeline (system prompt, JSON healing, schema validation, catalog + * healing, and error mapping) to the shared {@link A2uiGenerationService}. */ export class ChatCoordinator { - private readonly catalogManagement = inject(CatalogManagement); + private readonly generationService = inject(A2uiGenerationService); private readonly configProvider = inject(AppConfigProvider); private readonly stateSync = inject(StateSync); private readonly chatState = inject(ChatState); @@ -58,6 +59,13 @@ export class ChatCoordinator { */ readonly isProgrammaticStreamActive = this.chatState.isProgrammaticStreamActive; + /** + * A dynamic, reactive, computed signal property constructing conformed JSON + * catalog schema specifications system instructions. Delegated to the shared + * generation service so both chat and headless agent drivers share prompts. + */ + readonly systemPrompt = this.generationService.systemPrompt; + private lastSeenRendererUrl = ''; private isFirstUrlEffectRun = true; @@ -242,12 +250,15 @@ export class ChatCoordinator { /** * Post-processes, extracts, syntax heals, and validates raw JSON lines. + * The parsing, healing, and catalog schema checks are delegated to the + * shared {@link A2uiGenerationService}; this method retains the chat-panel + * pipeline status transitions and layout commit. */ private async processRawLlmPayload(rawText: string): Promise { // Stage 1: Parse and Syntax Healing let parsedBlocks: unknown[] = []; try { - parsedBlocks = this.parseAndHealJsonLines(rawText); + parsedBlocks = this.generationService.parseAndHealJsonLines(rawText); } catch (err: unknown) { this.chatState.setPipelineStatus(PipelineStatus.FAILED); this.chatState.setProgrammaticStreamActive(false); @@ -286,7 +297,7 @@ export class ChatCoordinator { } // Catalog Component Schema Check & Name Typos Healing - this.runCatalogComponentSchemaCheck(parsedBlocks); + this.generationService.runCatalogComponentSchemaCheck(parsedBlocks); // Stage 3: Ready & Commit Layout Wipes this.chatState.setPipelineStatus(PipelineStatus.READY); @@ -309,394 +320,11 @@ export class ChatCoordinator { } } - /** - * Robust parser extracting JSON objects from blocks, performing syntax - * repairs. - */ - private parseAndHealJsonLines(text: string): unknown[] { - let content = text.trim(); - - // Markdown Extraction: If output has Markdown wrappers, extract content - const mdJsonRegex = /```json\s*([\s\S]*?)\s*```/; - const match = content.match(mdJsonRegex); - if (match && match[1]) { - this.chatState.setPipelineStatus(PipelineStatus.HEALING); - content = match[1].trim(); - } - - const lines = content - .split('\n') - .map(l => l.trim()) - .filter(l => l.length > 0); - const parsedBlocks: unknown[] = []; - - for (const line of lines) { - // Skip Markdown code tags if they leaked, or general prompt filler - // text lines - if (line.startsWith('```') || (!line.startsWith('{') && !line.startsWith('['))) { - continue; - } - - try { - parsedBlocks.push(JSON.parse(line)); - } catch (err) { - // Syntax Healing Loop - this.chatState.setPipelineStatus(PipelineStatus.HEALING); - const healedObj = this.attemptSyntaxHealing(line); - if (healedObj !== null) { - parsedBlocks.push(healedObj); - } else { - // If it looks like A2UI JSON but couldn't be repaired, throw - // validation error - if (line.includes('"version"') || line.includes('"createSurface"')) { - throw new Error(`Syntax recovery failed for corrupted JSON Line:\n"${line}"`); - } - } - } - } - - if (parsedBlocks.length === 0) { - throw new Error('No valid A2UI JSON layout command block could be parsed or recovered.'); - } - - return parsedBlocks; - } - - /** - * Attempts structural syntax patching on broken JSON strings. - */ - private attemptSyntaxHealing(line: string): unknown | null { - let patched = line.trim(); - - // Repair 1: Strip trailing commas inside properties arrays - patched = patched.replace(/,\s*([\]}])/g, '$1'); - - // Repair 2: Auto-close braces - try { - return JSON.parse(patched); - } catch (e) { - // Loop to try appending up to 5 missing closing curly braces - for (let i = 1; i <= 5; i++) { - try { - return JSON.parse(patched + '}'.repeat(i)); - } catch (_) {} - } - - // Loop to try appending matching square brackets then curly braces - for (let i = 1; i <= 3; i++) { - for (let j = 1; j <= 3; j++) { - try { - return JSON.parse(patched + ']'.repeat(i) + '}'.repeat(j)); - } catch (_) {} - } - } - } - - return null; - } - - /** - * Validates parsed components against custom catalog schemas, healing name - * typos, mapping legacy names, and recursively stripping out custom mock - * rules configurations. - */ - private runCatalogComponentSchemaCheck(parsedBlocks: unknown[]): void { - const catalog = this.catalogManagement.activeCatalog(); - const componentsObj = catalog?.components; - const componentHealMap: Record = {}; - - if (componentsObj) { - for (const key of Object.keys(componentsObj)) { - const normalizedKey = key.toLowerCase().replace(/[^a-z]/g, ''); - componentHealMap[normalizedKey] = key; - } - } - - const SYNONYM_MAP: Record = { - textbox: 'textfield', - textinput: 'textfield', - rowlayout: 'row', - columnlayout: 'column', - choice: 'choicepicker', - datepicker: 'datetimeinput', - datetimepicker: 'datetimeinput', - }; - - for (const block of parsedBlocks) { - if (!block || typeof block !== 'object') { - continue; - } - const bObj = block as RenderA2uiItem; - const updateComponents = bObj.updateComponents; - if ( - !updateComponents || - typeof updateComponents !== 'object' || - !Array.isArray(updateComponents.components) - ) { - continue; - } - - const cleanedComponents: unknown[] = []; - for (const comp of updateComponents.components) { - if (!comp || typeof comp !== 'object' || Array.isArray(comp)) { - cleanedComponents.push(comp); - continue; - } - - const compObj = comp as A2uiComponentInstance; - let compType = compObj.component; - - // legacy property "name" fallback: heal to "component" key mapping - if (compObj['name'] && !compObj.component) { - this.chatState.setPipelineStatus(PipelineStatus.HEALING); - compType = compObj['name'] as string; - compObj.component = compType; - delete compObj['name']; - } - - if (typeof compType !== 'string') { - throw new Error('Component declaration is missing component type name string.'); - } - - let targetType = compType; - - // Schema validation (only if catalog is actively loaded with components) - if (componentsObj) { - if (!componentsObj[compType]) { - // Unrecognized component type - check case-insensitive lookup! - const normalized = compType.toLowerCase().replace(/[^a-z]/g, ''); - let healedType = componentHealMap[normalized]; - - // If not found directly, check synonym translation dictionary - if (!healedType) { - const synonymTarget = SYNONYM_MAP[normalized]; - if (synonymTarget) { - healedType = componentHealMap[synonymTarget]; - } - } - - if (healedType && componentsObj[healedType]) { - this.chatState.setPipelineStatus(PipelineStatus.HEALING); - targetType = healedType; - } else { - // Fuzzy search matches - const fuzzyMatch = normalized - ? Object.keys(componentsObj).find( - key => - key.toLowerCase().includes(normalized) || - normalized.includes(key.toLowerCase()), - ) - : undefined; - - if (fuzzyMatch) { - this.chatState.setPipelineStatus(PipelineStatus.HEALING); - targetType = fuzzyMatch; - } else { - throw new Error( - `Validation failure: Component type "${compType}" is ` + - 'not registered in the active custom catalog.', - ); - } - } - } - } - - // Recursively strip out dynamic mock setups configuration fields - const cleanedComp = this.sanitizeComponentObject(compObj); - // Restore corrected element name - cleanedComp.component = targetType; - cleanedComponents.push(cleanedComp); - } - - // Commit sanitized array back in-place - updateComponents.components = cleanedComponents; - } - } - - /** - * Unifies recursive sanitization traversal and strips out dynamic mock setups - * configurations recursively. - */ - private sanitizeValue(val: unknown): unknown { - if (val === null || typeof val !== 'object') { - return val; - } - - if (Array.isArray(val)) { - return val.map(item => this.sanitizeValue(item)); - } - - const obj = val as Record; - const cleaned: Record = {}; - - for (const [key, propVal] of Object.entries(obj)) { - if (key === 'rules' || /^mock/i.test(key)) { - continue; - } - cleaned[key] = this.sanitizeValue(propVal); - } - - return cleaned; - } - - /** - * Recursively sanitizes component declarations maps. - * Strips out dynamic rules configs matching /rules/ or prefix /^mock/i. - */ - private sanitizeComponentObject(obj: A2uiComponentInstance): A2uiComponentInstance { - return this.sanitizeValue(obj) as A2uiComponentInstance; - } - readonly TEST_ONLY = { - sanitizeComponentObject: (obj: A2uiComponentInstance) => this.sanitizeComponentObject(obj), + sanitizeComponentObject: (obj: A2uiComponentInstance) => + this.generationService.sanitizeComponentObject(obj), }; - /** - * Connectivity Exception Handling: bubbles diagnostics stack details. - * Instantly dismisses overlays locks on network, proxy, or auth failures - * to restore workspace editing controls immediately. - */ - private isConnectivityError(lowerMsg: string): boolean { - return ( - lowerMsg.includes('failed to fetch') || - lowerMsg.includes('fetch') || - lowerMsg.includes('timeout') || - lowerMsg.includes('504') || - lowerMsg.includes('proxy') || - lowerMsg.includes('networkerror') || - lowerMsg.includes('connection') || - lowerMsg.includes('401') || - lowerMsg.includes('403') || - lowerMsg.includes('credential') || - lowerMsg.includes('quota') || - lowerMsg.includes('blocked') || - lowerMsg.includes('503') || - lowerMsg.includes('unavailable') || - lowerMsg.includes('api key') || - lowerMsg.includes('apikey') - ); - } - - private parseError( - lowerMsg: string, - cleanMsg: string, - originalPrompt?: string, - ): { - errorTitle: string; - errorMessage: string; - errorTip: string; - isRetryable: boolean; - showDetails: boolean; - errorDetails?: string; - } { - // Default values (Connectivity Failure) - const errorTitle = 'Connectivity Failure'; - const isJson = cleanMsg.trim().startsWith('{'); - const errorMessage = isJson ? 'A connectivity error occurred.' : cleanMsg; - const errorDetails = isJson ? 'Details: ' + cleanMsg : undefined; - const errorTip = - 'Tip: Please check your network proxy configurations or verify your settings to restore connections.'; - const isRetryable = !!originalPrompt; - const showDetails = true; - - const isValidationError = - lowerMsg.includes('validation') || - lowerMsg.includes('syntax recovery') || - lowerMsg.includes('validation failure'); - - if (isValidationError) { - return { - errorTitle: 'Validation Failure', - errorMessage: 'The generated layout contains invalid components or structure.', - errorTip: - 'Tip: Try rephrasing your prompt to guide the model to generate valid components.', - isRetryable: !!originalPrompt, - showDetails: true, - errorDetails: 'Details: ' + cleanMsg, - }; - } - - if (lowerMsg.includes('503') || lowerMsg.includes('unavailable')) { - return { - errorTitle: 'Service Unavailable', - errorMessage: 'The generative service is temporarily unavailable. Please try again later.', - errorTip: '', - isRetryable: true, - showDetails: false, - }; - } - - if (lowerMsg.includes('high demand')) { - return { - errorTitle: 'Model High Demand', - errorMessage: - 'This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.', - errorTip: '', - isRetryable: true, - showDetails: false, - }; - } - - if (lowerMsg.includes('timeout') || lowerMsg.includes('504')) { - return { - errorTitle: 'REST Gateway Timeout', - errorMessage: 'Remote generation service did not respond.', - errorDetails: 'Details: ' + cleanMsg, - errorTip, - isRetryable, - showDetails: true, - }; - } - - if (lowerMsg.includes('api key') || lowerMsg.includes('apikey')) { - return { - errorTitle: 'Invalid API Key', - errorMessage: 'The provided Gemini API key is invalid or missing.', - errorDetails: 'Details: ' + cleanMsg, - errorTip: - 'Tip: Please update your third-party Gemini developer API key on the settings page to restore connections.', - isRetryable, - showDetails: true, - }; - } - - if ( - lowerMsg.includes('auth') || - lowerMsg.includes('401') || - lowerMsg.includes('403') || - lowerMsg.includes('credential') - ) { - return { - errorTitle: 'Authentication Refused', - errorMessage: 'Authentication failed. Please verify your credentials in Settings.', - errorDetails: 'Details: ' + cleanMsg, - errorTip, - isRetryable, - showDetails: true, - }; - } - - if (lowerMsg.includes('quota') || lowerMsg.includes('blocked') || lowerMsg.includes('429')) { - return { - errorTitle: 'GenAI Service Blocked', - errorMessage: 'Resource quota depleted or content safety limits triggered.', - errorDetails: 'Details: ' + cleanMsg, - errorTip, - isRetryable, - showDetails: true, - }; - } - - return { - errorTitle, - errorMessage, - errorTip, - isRetryable, - showDetails, - errorDetails, - }; - } - private handleConnectivityError( err: unknown, originalPrompt?: string, @@ -706,14 +334,14 @@ export class ChatCoordinator { const lowerMsg = rawError.toLowerCase(); const cleanMsg = cleanErrorMessage(rawError); - if (this.isConnectivityError(lowerMsg)) { + if (this.generationService.isConnectivityError(lowerMsg)) { this.chatState.setPipelineStatus(PipelineStatus.IDLE); } else { this.chatState.setPipelineStatus(PipelineStatus.FAILED); } this.chatState.setProgrammaticStreamActive(false); - const parsed = this.parseError(lowerMsg, cleanMsg, originalPrompt); + const parsed = this.generationService.parseError(lowerMsg, cleanMsg, originalPrompt); let exceptionDetails = ''; if (err instanceof Error) { @@ -755,124 +383,4 @@ export class ChatCoordinator { return updated; }); } - - /** - * A dynamic, reactive, computed signal property constructing conformed JSON - * catalog schema specifications system instructions. - */ - readonly systemPrompt = computed(() => { - const catalog = this.catalogManagement.activeCatalog(); - if (!catalog) { - return ( - 'You are an AI assistant designed to help model mock screens ' + - 'inside A2UI Composer shell.\n' + - 'Status: Awaiting renderer dynamic handshake settlement...' - ); - } - - return this.generateSystemPrompt(formatJson(catalog)); - }); - - private generateSystemPrompt(catalog: string): string { - return ` - # A2UI Generation Expert - - ## Role - You are an A2UI expert. Your job is to translate the user's request into valid - A2UI messages. - - # Overview - You MUST ensure all payloads strictly adhere to the **JSON Lines (JSONL)** - format. Each JSON object MUST be flattened to a single line without unescaped - newline characters. - - The generated A2UI MUST conform to this A2UI JSON: - \`\`\`json - ${catalog}. - \`\`\` - - ## Protocol - When building the \`createSurface\` message, you MUST set the \`catalogId\` to - reference the appropriate catalog schema URL. - - You MUST follow the strict message sequence (\`createSurface\` -> - \`updateComponents\` -> \`updateDataModel\`) and use JSON Pointers for data - binding. - - ## Validation - - A complete A2UI payload consists of one or more message objects sent as - continuous JSON objects (or JSON Lines). Every message object MUST include a - top-level \`"version": "v0.9"\` field. - - The four primary messages you must use to manage a UI surface are: - - 1. **\`createSurface\`**: Sent **FIRST** to signal the client to create a new - surface. It defines the \`catalogId\` and optional \`theme\` parameters. - 2. **\`updateComponents\`**: Used to define or update the UI component tree. You - must provide a flat list of components. One component MUST have an \`id\` of - \`"root"\`. - 3. **\`updateDataModel\`**: Used to define or update data values that the - components bind to. - 4. **\`deleteSurface\`**: Signals the client to destroy the surface. - - ## Lifecycle and Ordering - - Typical sequence: \`createSurface\` -> \`updateComponents\` -> \`updateDataModel\` (or - combined/interleaved after creation). - - ## Examples - - * **Simple Example**: A basic column with text: - \`\`\`jsonl - {"version": "v0.9", "createSurface": {"surfaceId": "main", "catalogId": "https://a2ui.org/specification/v0_9/material_catalog.json"}} - {"version": "v0.9", "updateComponents": {"surfaceId": "main", "components": [{"id": "root", "component": "MaterialColumn", "children": ["header", "content"]}, {"id": "header", "component": "MaterialText", "text": "Welcome"}, {"id": "content", "component": "MaterialText", "text": {"path": "/message"}}]}} - {"version": "v0.9", "updateDataModel": {"surfaceId": "main", "path": "/message", "value": "Hello, world!"}} - \`\`\` - - * **Complex Form Example**: A vacation booking form demonstrating advanced - Material form controls (\`MaterialDatepicker\`, \`MaterialSelect\`, - \`MaterialSlideToggle\`) and buttons using the modernized Material catalog: - \`\`\`jsonl - {"version": "v0.9", "createSurface": {"surfaceId": "vacation_booking", "catalogId": "https://a2ui.org/specification/v0_9/material_catalog.json"}} - {"version": "v0.9", "updateComponents": {"surfaceId": "vacation_booking", "components": [{"id": "root", "component": "MaterialColumn", "children": ["title", "destination_input", "checkin_datepicker", "checkout_datepicker", "room_type_select", "passenger_select", "flexible_dates_toggle", "search_button"]}, {"id": "title", "component": "MaterialText", "text": {"path": "/title_label"}, "usageHint": "h1"}, {"id": "destination_input", "component": "MaterialInput", "label": {"path": "/destination_label"}, "value": {"path": "/destination_value"}}, {"id": "checkin_datepicker", "component": "MaterialDatepicker", "label": {"path": "/checkin_label"}, "value": {"path": "/checkin_value"}}, {"id": "checkout_datepicker", "component": "MaterialDatepicker", "label": {"path": "/checkout_label"}, "value": {"path": "/checkout_value"}}, {"id": "room_type_select", "component": "MaterialSelect", "label": {"path": "/room_type_label"}, "value": {"path": "/room_type_value"}, "options": [{"label": "Standard Room", "value": "standard"}, {"label": "Deluxe Suite", "value": "deluxe"}]}, {"id": "passenger_select", "component": "MaterialSelect", "label": {"path": "/passenger_label"}, "value": {"path": "/passenger_value"}, "options": [{"label": "1 Passenger", "value": "1"}, {"label": "2 Passengers", "value": "2"}, {"label": "3+ Passengers", "value": "3"}]}, {"id": "flexible_dates_toggle", "component": "MaterialSlideToggle", "label": {"path": "/flexible_dates_label"}, "checked": {"path": "/flexible_dates_checked"}, "color": "primary"}, {"id": "search_button", "component": "MaterialButton", "label": {"path": "/search_label"}, "action": {"event": {"name": "searchVacation"}}}]}} - {"version": "v0.9", "updateDataModel": {"surfaceId": "vacation_booking", "value": {"title_label": "Book Your Dream Vacation", "destination_label": "Destination", "destination_value": "Hawaii", "checkin_label": "Check-in Date", "checkin_value": "2026-07-01", "checkout_label": "Check-out Date", "checkout_value": "2026-07-14", "room_type_label": "Room Type", "room_type_value": "standard", "passenger_label": "Passengers", "passenger_value": "2", "flexible_dates_label": "Flexible Dates (+/- 3 days)", "flexible_dates_checked": true, "search_label": "Search Flights & Hotels"}}} - \`\`\` - - * **Dynamic List Example**: An example using templates to render a list of - items. - \`\`\`jsonl - {"version": "v0.9", "createSurface": {"surfaceId": "dynamic_list_demo", "catalogId": "https://a2ui.org/specification/v0_9/material_catalog.json"}} - {"version": "v0.9", "updateComponents": {"surfaceId": "dynamic_list_demo", "components": [{"id": "root", "component": "MaterialColumn", "children": ["title", "list_container"]}, {"id": "title", "component": "MaterialText", "text": "Dynamic List Demo"}, {"id": "list_container", "component": "MaterialColumn", "children": {"componentId": "item_template", "path": "/items"}}, {"id": "item_template", "component": "MaterialText", "text": {"path": "text"}}]}} - {"version": "v0.9", "updateDataModel": {"surfaceId": "dynamic_list_demo", "value": {"items": [{"text": "Item One"}, {"text": "Item Two"}]}}} - \`\`\` - - ## Data Binding - Every component property value MUST come from the data model (with minor - exceptions for static primitives). - - When referencing data in the data model, you MUST use valid JSON Pointer syntax - starting with \`/\`. - - ## Actions and Context - - When defining actions (e.g., on buttons), the \`context\` payload is a standard - JSON object, rather than an array of key-value pairs. - - Example action definition: - - \`\`\`jsonl - "action": { - "event": { - "name": "selectItem", - "context": { - "itemId": "12345", - "itemName": {"path": "/selected/name"} - } - } - } - \`\`\` - - `; - } } diff --git a/shell/src/app/copilotkit/a2ui-generation/a2ui-generation.service.spec.ts b/shell/src/app/copilotkit/a2ui-generation/a2ui-generation.service.spec.ts new file mode 100644 index 00000000..d92fa8d8 --- /dev/null +++ b/shell/src/app/copilotkit/a2ui-generation/a2ui-generation.service.spec.ts @@ -0,0 +1,309 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {TestBed} from '@angular/core/testing'; +import {signal} from '@angular/core'; +import {describe, it, expect, beforeEach, vi} from 'vitest'; +import {A2uiGenerationService} from './a2ui-generation.service'; +import {CatalogManagement} from '../../storage/catalog-management/catalog-management'; +import {Catalog} from '../../storage/models/catalog-storage.model'; +import {ChatState, LlmLogEntry, LlmLogType} from '../../chat/chat-state/chat-state'; +import {StateSync} from '../../chat/state-sync/state-sync'; +import { + LlmClient, + LlmMessage, + LlmResponse, + LlmStreamResponse, + MessageRole, + CANCEL_ERROR_NAME, +} from '../../chat/llm-client/llm-client'; +import {PipelineStatus} from '../../chat/pipeline-status/pipeline-status'; + +class MockCatalogManagement { + readonly activeCatalog = signal(null); +} + +class MockChatState { + readonly chatHistory = signal([]); + readonly pipelineStatus = signal(PipelineStatus.IDLE); + readonly isProgrammaticStreamActive = signal(false); + readonly latestLlmLog = signal(null); + readonly llmHistory = signal([]); + + setChatHistory(history: LlmMessage[]) { + this.chatHistory.set(history); + } + updateChatHistory(updater: (history: LlmMessage[]) => LlmMessage[]) { + this.chatHistory.update(updater); + } + setPipelineStatus(status: PipelineStatus) { + this.pipelineStatus.set(status); + } + setProgrammaticStreamActive(active: boolean) { + this.isProgrammaticStreamActive.set(active); + } + addRawLlmLog(type: LlmLogType, payload: unknown): void { + const entry: LlmLogEntry = {type, timestamp: Date.now(), payload}; + this.latestLlmLog.set(entry); + this.llmHistory.update(history => [...history, entry].slice(-50)); + } + clearRawLlmHistory(): void { + this.latestLlmLog.set(null); + this.llmHistory.set([]); + } +} + +class MockStateSync { + readonly activeDraftSignal = signal('Initial draft text'); + readonly activeDraft = this.activeDraftSignal.asReadonly(); + commitLayoutFromLlm = vi.fn((val: string) => { + this.activeDraftSignal.set(val); + }); + flushDraft = vi.fn(() => { + this.activeDraftSignal.set('Initial draft text'); + }); + hydrateActiveDraft = vi.fn(() => this.activeDraftSignal()); +} + +async function* createMockStream(chunks: string[]): AsyncIterable { + for (const content of chunks) { + yield {content}; + } +} + +/** Builds a resolved streaming response from a single final payload string. */ +function streamOf(payload: string): LlmStreamResponse { + return { + contentStream: createMockStream([payload]), + complete: Promise.resolve(payload), + }; +} + +/** A stream whose chunks may carry `thinking` deltas alongside content. */ +async function* createThinkingStream( + chunks: Array<{content?: string; thinking?: string}>, +): AsyncIterable { + for (const chunk of chunks) { + yield {content: chunk.content ?? '', thinking: chunk.thinking}; + } +} + +class MockLlmClient { + chat = vi.fn(); + chatStream = vi.fn(async (): Promise => streamOf('')); +} + +const VALID_THREE_LINE_PAYLOAD = + '{"version": "v0.9", "createSurface": {"surfaceId": "main", "catalogId": "https://a2ui.org/specification/v0_9/material_catalog.json"}}\n' + + '{"version": "v0.9", "updateComponents": {"surfaceId": "main", "components": [{"id": "root", "component": "MaterialText", "text": {"path": "/message"}}]}}\n' + + '{"version": "v0.9", "updateDataModel": {"surfaceId": "main", "value": {"message": "Hello, world!"}}}'; + +const USER_MESSAGES: LlmMessage[] = [ + {role: MessageRole.SYSTEM, content: 'system prompt'}, + {role: MessageRole.USER, content: 'Create a greeting screen'}, +]; + +describe('A2uiGenerationService', () => { + let service: A2uiGenerationService; + let chatStateMock: MockChatState; + let catalogManagementMock: MockCatalogManagement; + let stateSyncMock: MockStateSync; + let llmClientMock: MockLlmClient; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + A2uiGenerationService, + {provide: ChatState, useClass: MockChatState}, + {provide: CatalogManagement, useClass: MockCatalogManagement}, + {provide: StateSync, useClass: MockStateSync}, + {provide: LlmClient, useClass: MockLlmClient}, + ], + }); + + service = TestBed.inject(A2uiGenerationService); + chatStateMock = TestBed.inject(ChatState) as unknown as MockChatState; + catalogManagementMock = TestBed.inject(CatalogManagement) as unknown as MockCatalogManagement; + stateSyncMock = TestBed.inject(StateSync) as unknown as MockStateSync; + llmClientMock = TestBed.inject(LlmClient) as unknown as MockLlmClient; + }); + + it('generates and commits a valid three-line A2UI payload with no heals', async () => { + llmClientMock.chatStream = vi.fn(async () => streamOf(VALID_THREE_LINE_PAYLOAD)); + + const result = await service.generate(USER_MESSAGES); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok result'); + + expect(result.heals).toEqual([]); + expect(result.blocks.length).toBe(3); + + // Layout committed once, with the formatted parsed blocks. + expect(stateSyncMock.commitLayoutFromLlm).toHaveBeenCalledTimes(1); + const committed = stateSyncMock.commitLayoutFromLlm.mock.calls[0][0]; + expect(committed).toBe(result.layoutText); + + const parsed = JSON.parse(committed); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].createSurface.surfaceId).toBe('main'); + expect(parsed[1].updateComponents.components[0].component).toBe('MaterialText'); + expect(parsed[2].updateDataModel.value.message).toBe('Hello, world!'); + + // Pipeline reaches READY and releases the lock. + expect(chatStateMock.pipelineStatus()).toBe(PipelineStatus.READY); + expect(chatStateMock.isProgrammaticStreamActive()).toBe(false); + }); + + it('heals a synonym/mis-cased component name and reports the correction', async () => { + const payload = + '{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "test"}}\n' + + '{"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "c1", "component": "textbox"}]}}'; + llmClientMock.chatStream = vi.fn(async () => streamOf(payload)); + + catalogManagementMock.activeCatalog.set({ + catalogId: 'test', + components: { + TextField: {}, + }, + }); + + const result = await service.generate(USER_MESSAGES); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok result'); + + // The synonym "textbox" is corrected to the catalog's "TextField". + expect(result.heals).toEqual([{from: 'textbox', to: 'TextField'}]); + + const parsed = JSON.parse(stateSyncMock.commitLayoutFromLlm.mock.calls[0][0]); + expect(parsed[1].updateComponents.components[0].component).toBe('TextField'); + }); + + it('returns a validation failure result and does not commit for an invalid payload', async () => { + // Missing the mandatory top-level "version" field fails envelope validation. + const invalidPayload = '{"createSurface": {"surfaceId": "s1", "catalogId": "basic"}}'; + llmClientMock.chatStream = vi.fn(async () => streamOf(invalidPayload)); + + const result = await service.generate(USER_MESSAGES); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected failure result'); + + expect(result.title).toBe('Validation Failure'); + expect(result.message).toBe('The generated layout contains invalid components or structure.'); + // Retryable because the message context carried a user prompt. + expect(result.retryable).toBe(true); + + // Nothing committed on a failed generation; pipeline marked FAILED. + expect(stateSyncMock.commitLayoutFromLlm).not.toHaveBeenCalled(); + expect(chatStateMock.pipelineStatus()).toBe(PipelineStatus.FAILED); + expect(chatStateMock.isProgrammaticStreamActive()).toBe(false); + }); + + it('forwards the model thinking deltas to the onThinking callback', async () => { + llmClientMock.chatStream = vi.fn(async () => ({ + contentStream: createThinkingStream([ + {thinking: 'First I '}, + {thinking: 'plan the layout.'}, + {content: 'streamed-content-with-no-thinking'}, + ]), + complete: Promise.resolve(VALID_THREE_LINE_PAYLOAD), + })); + + const deltas: string[] = []; + let lastAccumulated = ''; + const result = await service.generate(USER_MESSAGES, { + onThinking: (delta, accumulated) => { + deltas.push(delta); + lastAccumulated = accumulated; + }, + }); + + // Only chunks carrying `thinking` fire the callback; content-only chunks don't. + expect(deltas).toEqual(['First I ', 'plan the layout.']); + expect(lastAccumulated).toBe('First I plan the layout.'); + // The final layout still comes from `complete`, not the streamed chunks. + expect(result.ok).toBe(true); + expect(stateSyncMock.commitLayoutFromLlm).toHaveBeenCalledTimes(1); + }); + + it('cancels an active stream and resolves without committing', async () => { + let cancelCalled = false; + let rejectCompletePromise!: (err: unknown) => void; + const completePromise = new Promise((_, reject) => { + rejectCompletePromise = reject; + }); + completePromise.catch(() => {}); + + const mockCancel = vi.fn(() => { + cancelCalled = true; + const err = new Error('Cancelled'); + err.name = CANCEL_ERROR_NAME; + rejectCompletePromise(err); + }); + + const contentStream: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + if (cancelCalled) { + const err = new Error('Cancelled'); + err.name = CANCEL_ERROR_NAME; + throw err; + } + await new Promise((_, reject) => { + const check = setInterval(() => { + if (cancelCalled) { + clearInterval(check); + const err = new Error('Cancelled'); + err.name = CANCEL_ERROR_NAME; + reject(err); + } + }, 10); + }); + return {value: undefined, done: true}; + }, + }; + }, + }; + + llmClientMock.chatStream = vi.fn(async () => ({ + contentStream, + complete: completePromise, + cancel: mockCancel, + })); + + const generatePromise = service.generate(USER_MESSAGES); + + // Let the stream setup run. + await new Promise(resolve => setTimeout(resolve, 10)); + expect(chatStateMock.pipelineStatus()).toBe(PipelineStatus.RECEIVING_STREAM); + expect(chatStateMock.isProgrammaticStreamActive()).toBe(true); + + service.cancel(); + + const result = await generatePromise; + + expect(mockCancel).toHaveBeenCalled(); + expect(result.ok).toBe(false); + expect(stateSyncMock.commitLayoutFromLlm).not.toHaveBeenCalled(); + expect(chatStateMock.pipelineStatus()).toBe(PipelineStatus.IDLE); + expect(chatStateMock.isProgrammaticStreamActive()).toBe(false); + }); +}); diff --git a/shell/src/app/copilotkit/a2ui-generation/a2ui-generation.service.ts b/shell/src/app/copilotkit/a2ui-generation/a2ui-generation.service.ts new file mode 100644 index 00000000..b8a1843f --- /dev/null +++ b/shell/src/app/copilotkit/a2ui-generation/a2ui-generation.service.ts @@ -0,0 +1,870 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Injectable, inject, computed, Signal} from '@angular/core'; +import {formatJson} from '../../utils/json'; +import {CatalogManagement} from '../../storage/catalog-management/catalog-management'; +import { + LlmMessage, + LlmClient, + MessageRole, + LlmStreamResponse, + CANCEL_ERROR_NAME, +} from '../../chat/llm-client/llm-client'; +import {PipelineStatus} from '../../chat/pipeline-status/pipeline-status'; +import {StateSync} from '../../chat/state-sync/state-sync'; +import {ChatState, LlmLogType} from '../../chat/chat-state/chat-state'; +import {CrossFrameValidator} from '../../shell/cross-frame-validator/cross-frame-validator'; +import { + PreviewBridgeMessageType, + RenderA2uiItem, + A2uiComponentInstance, + UpdateComponentsDetails, + UpdateDataModelDetails, +} from 'a2ui-bridge'; +import {cleanErrorMessage, redactApiKey} from '../../chat/chat-service/error-utils'; + +/** + * Describes a single component-name correction applied during catalog schema + * healing, mapping the raw name emitted by the model to the healed catalog name. + */ +export interface Heal { + readonly from: string; + readonly to: string; +} + +/** + * Discriminated result of an A2UI generation attempt. Successful generations + * carry the parsed blocks, the committed layout text, and any component-name + * heals applied. Failed generations carry a user-facing diagnostic built from + * the shared error parser, never throwing for expected generation, validation, + * or connectivity failures. + */ +export type GenerationResult = + | { + ok: true; + blocks: unknown[]; + layoutText: string; + heals: Heal[]; + surfaceTitle?: string; + } + | { + ok: false; + title: string; + message: string; + details?: string; + tip?: string; + retryable: boolean; + }; + +/** + * Structured representation returned by {@link A2uiGenerationService.parseError}, + * describing a user-facing diagnostic for a generation, validation, or + * connectivity failure. + */ +export interface ParsedError { + errorTitle: string; + errorMessage: string; + errorTip: string; + isRetryable: boolean; + showDetails: boolean; + errorDetails?: string; +} + +@Injectable({ + providedIn: 'root', +}) +/** + * Headless A2UI generation pipeline extracted from the chat coordinator. Owns + * the system prompt, the streamed LLM accumulation loop, the self-healing JSON + * Lines parser, catalog component schema healing, and gateway error mapping, + * without producing any chat-bubble side effects. This lets an external driver + * (e.g. a CopilotKit AG-UI agent) run generation while the chat coordinator + * continues to own conversational history. + */ +export class A2uiGenerationService { + private readonly catalogManagement = inject(CatalogManagement); + private readonly stateSync = inject(StateSync); + private readonly chatState = inject(ChatState); + private readonly llmClient = inject(LlmClient); + + private activeStreamResponse?: LlmStreamResponse; + private isCancelRequested = false; + + /** + * A dynamic, reactive, computed signal property constructing conformed JSON + * catalog schema specifications system instructions. + */ + readonly systemPrompt: Signal = computed(() => { + const catalog = this.catalogManagement.activeCatalog(); + if (!catalog) { + return ( + 'You are an AI assistant designed to help model mock screens ' + + 'inside A2UI Composer shell.\n' + + 'Status: Awaiting renderer dynamic handshake settlement...' + ); + } + + return this.generateSystemPrompt(formatJson(catalog)); + }); + + /** + * Runs the full generation pipeline for the supplied message context: + * streams the LLM completion, accumulates it, heals and validates the JSON + * Lines payload, applies catalog component healing, and on success commits + * the formatted layout to the editor draft. Never touches chat-history + * bubbles. Expected generation, validation, and connectivity failures resolve + * to an `{ok: false}` result rather than throwing. + */ + async generate( + messages: LlmMessage[], + opts?: {onThinking?: (delta: string, accumulated: string) => void}, + ): Promise { + // Lock UI controls and transition state indicators to receiving stream + this.chatState.setProgrammaticStreamActive(true); + this.chatState.setPipelineStatus(PipelineStatus.RECEIVING_STREAM); + + // Log the raw LLM request telemetry + this.chatState.addRawLlmLog(LlmLogType.REQUEST, messages); + + let finalRawText: string; + try { + this.isCancelRequested = false; + // Trigger streaming GenAI completions call using client facade + const responseStream = await this.llmClient.chatStream(messages); + + // If a cancel was requested while the stream connection was establishing + if (this.isCancelRequested) { + if (responseStream.cancel) responseStream.cancel(); + throw this.makeCancelError(); + } + + this.activeStreamResponse = responseStream; + + // Drive the stream to completion so an in-flight cancel can interrupt + // it. Chunk content is surfaced to chat bubbles only by ChatCoordinator; + // here we forward only the model's thinking deltas, letting a driver + // stream the reasoning live. The final layout still comes from `complete`. + let accumulatedThinking = ''; + for await (const chunk of responseStream.contentStream) { + if (chunk.thinking) { + accumulatedThinking += chunk.thinking; + opts?.onThinking?.(chunk.thinking, accumulatedThinking); + } + } + + // Stream exhausted, resolve final complete text + finalRawText = await responseStream.complete; + + // Log the raw LLM response telemetry + this.chatState.addRawLlmLog(LlmLogType.RESPONSE, finalRawText); + this.chatState.setPipelineStatus(PipelineStatus.RECEIVED_RAW); + } catch (err: unknown) { + if (this.isCancelError(err)) { + this.chatState.setPipelineStatus(PipelineStatus.IDLE); + this.chatState.setProgrammaticStreamActive(false); + return { + ok: false, + title: 'Generation Cancelled', + message: 'Generation was cancelled before completion.', + retryable: true, + }; + } + return this.buildErrorResult(err, messages); + } finally { + this.activeStreamResponse = undefined; + } + + return this.processPayload(finalRawText, messages); + } + + /** + * Cancels the currently active streaming request if there is one, reusing the + * shared {@link CANCEL_ERROR_NAME} mechanism. + */ + cancel(): void { + this.isCancelRequested = true; + if (this.activeStreamResponse && this.activeStreamResponse.cancel) { + this.activeStreamResponse.cancel(); + } + } + + /** + * Post-processes an accumulated raw payload: parses and syntax-heals the JSON + * Lines, validates the outgoing envelope, applies catalog component healing, + * and commits the formatted layout on success. + */ + private processPayload(rawText: string, messages: LlmMessage[]): GenerationResult { + // Stage 1: Parse and Syntax Healing + let parsedBlocks: unknown[]; + try { + parsedBlocks = this.parseAndHealJsonLines(rawText); + } catch (err: unknown) { + return this.buildErrorResult(err, messages); + } + + // Stage 2: Schema Validation + this.chatState.setPipelineStatus(PipelineStatus.VALIDATING); + try { + this.validateEnvelope(parsedBlocks); + + // Catalog Component Schema Check & Name Typos Healing + const heals = this.runCatalogComponentSchemaCheck(parsedBlocks); + + // Stage 3: Ready & Commit Layout Wipes + this.chatState.setPipelineStatus(PipelineStatus.READY); + + // Turn list of updates back into raw formatted JSON text to write to + // editor draft + const layoutText = formatJson(parsedBlocks); + + // Commit layout synchronously to editor viewport before releasing lockout + this.stateSync.commitLayoutFromLlm(layoutText); + + // Release panel textareas lockout synchronously to avoid race condition + this.chatState.setProgrammaticStreamActive(false); + + const surfaceTitle = this.extractSurfaceTitle(parsedBlocks); + return {ok: true, blocks: parsedBlocks, layoutText, heals, surfaceTitle}; + } catch (err: unknown) { + return this.buildErrorResult(err, messages); + } + } + + /** + * Verifies basic schema envelopes using the pre-existing CrossFrameValidator, + * temporarily capturing console.error output to surface validation failures. + */ + private validateEnvelope(parsedBlocks: unknown[]): void { + const mockEnvMsg = { + type: PreviewBridgeMessageType.RENDER_A2UI, + payload: parsedBlocks, + }; + + // Temporary override console.error to capture validation failures + const originalConsoleError = console.error; + const validationErrors: string[] = []; + console.error = (...args: unknown[]) => { + validationErrors.push( + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '), + ); + }; + + let isValidEnvelope = false; + try { + isValidEnvelope = CrossFrameValidator.validateOutgoingMessage(mockEnvMsg); + } finally { + console.error = originalConsoleError; + } + + if (!isValidEnvelope) { + throw new Error( + `Outgoing message envelope validation failed:\n${validationErrors.join('\n')}`, + ); + } + } + + /** + * Robust parser extracting JSON objects from blocks, performing syntax + * repairs. + */ + parseAndHealJsonLines(text: string): unknown[] { + let content = text.trim(); + + // Markdown Extraction: If output has Markdown wrappers, extract content + const mdJsonRegex = /```json\s*([\s\S]*?)\s*```/; + const match = content.match(mdJsonRegex); + if (match && match[1]) { + this.chatState.setPipelineStatus(PipelineStatus.HEALING); + content = match[1].trim(); + } + + const lines = content + .split('\n') + .map(l => l.trim()) + .filter(l => l.length > 0); + const parsedBlocks: unknown[] = []; + + for (const line of lines) { + // Skip Markdown code tags if they leaked, or general prompt filler + // text lines + if (line.startsWith('```') || (!line.startsWith('{') && !line.startsWith('['))) { + continue; + } + + try { + parsedBlocks.push(JSON.parse(line)); + } catch (err) { + // Syntax Healing Loop + this.chatState.setPipelineStatus(PipelineStatus.HEALING); + const healedObj = this.attemptSyntaxHealing(line); + if (healedObj !== null) { + parsedBlocks.push(healedObj); + } else { + // If it looks like A2UI JSON but couldn't be repaired, throw + // validation error + if (line.includes('"version"') || line.includes('"createSurface"')) { + throw new Error(`Syntax recovery failed for corrupted JSON Line:\n"${line}"`); + } + } + } + } + + if (parsedBlocks.length === 0) { + throw new Error('No valid A2UI JSON layout command block could be parsed or recovered.'); + } + + return parsedBlocks; + } + + /** + * Attempts structural syntax patching on broken JSON strings. + */ + private attemptSyntaxHealing(line: string): unknown | null { + let patched = line.trim(); + + // Repair 1: Strip trailing commas inside properties arrays + patched = patched.replace(/,\s*([\]}])/g, '$1'); + + // Repair 2: Auto-close braces + try { + return JSON.parse(patched); + } catch (e) { + // Loop to try appending up to 5 missing closing curly braces + for (let i = 1; i <= 5; i++) { + try { + return JSON.parse(patched + '}'.repeat(i)); + } catch (_) {} + } + + // Loop to try appending matching square brackets then curly braces + for (let i = 1; i <= 3; i++) { + for (let j = 1; j <= 3; j++) { + try { + return JSON.parse(patched + ']'.repeat(i) + '}'.repeat(j)); + } catch (_) {} + } + } + } + + return null; + } + + /** + * Validates parsed components against custom catalog schemas, healing name + * typos, mapping legacy names, and recursively stripping out custom mock + * rules configurations. Returns the list of component-name corrections that + * were applied. + */ + runCatalogComponentSchemaCheck(parsedBlocks: unknown[]): Heal[] { + const heals: Heal[] = []; + const catalog = this.catalogManagement.activeCatalog(); + const componentsObj = catalog?.components; + const componentHealMap: Record = {}; + + if (componentsObj) { + for (const key of Object.keys(componentsObj)) { + const normalizedKey = key.toLowerCase().replace(/[^a-z]/g, ''); + componentHealMap[normalizedKey] = key; + } + } + + const SYNONYM_MAP: Record = { + textbox: 'textfield', + textinput: 'textfield', + rowlayout: 'row', + columnlayout: 'column', + choice: 'choicepicker', + datepicker: 'datetimeinput', + datetimepicker: 'datetimeinput', + }; + + for (const block of parsedBlocks) { + if (!block || typeof block !== 'object') { + continue; + } + const bObj = block as RenderA2uiItem; + const updateComponents = bObj.updateComponents; + if ( + !updateComponents || + typeof updateComponents !== 'object' || + !Array.isArray(updateComponents.components) + ) { + continue; + } + + const cleanedComponents: unknown[] = []; + for (const comp of updateComponents.components) { + if (!comp || typeof comp !== 'object' || Array.isArray(comp)) { + cleanedComponents.push(comp); + continue; + } + + const compObj = comp as A2uiComponentInstance; + let compType = compObj.component; + + // legacy property "name" fallback: heal to "component" key mapping + if (compObj['name'] && !compObj.component) { + this.chatState.setPipelineStatus(PipelineStatus.HEALING); + compType = compObj['name'] as string; + compObj.component = compType; + delete compObj['name']; + } + + if (typeof compType !== 'string') { + throw new Error('Component declaration is missing component type name string.'); + } + + let targetType = compType; + + // Schema validation (only if catalog is actively loaded with components) + if (componentsObj) { + if (!componentsObj[compType]) { + // Unrecognized component type - check case-insensitive lookup! + const normalized = compType.toLowerCase().replace(/[^a-z]/g, ''); + let healedType = componentHealMap[normalized]; + + // If not found directly, check synonym translation dictionary + if (!healedType) { + const synonymTarget = SYNONYM_MAP[normalized]; + if (synonymTarget) { + healedType = componentHealMap[synonymTarget]; + } + } + + if (healedType && componentsObj[healedType]) { + this.chatState.setPipelineStatus(PipelineStatus.HEALING); + targetType = healedType; + } else { + // Fuzzy search matches + const fuzzyMatch = normalized + ? Object.keys(componentsObj).find( + key => + key.toLowerCase().includes(normalized) || + normalized.includes(key.toLowerCase()), + ) + : undefined; + + if (fuzzyMatch) { + this.chatState.setPipelineStatus(PipelineStatus.HEALING); + targetType = fuzzyMatch; + } else { + throw new Error( + `Validation failure: Component type "${compType}" is ` + + 'not registered in the active custom catalog.', + ); + } + } + } + } + + // Record a component-name correction when healing changed the type. + if (targetType !== compType) { + heals.push({from: compType, to: targetType}); + } + + // Recursively strip out dynamic mock setups configuration fields + const cleanedComp = this.sanitizeComponentObject(compObj); + // Restore corrected element name + cleanedComp.component = targetType; + cleanedComponents.push(cleanedComp); + } + + // Commit sanitized array back in-place + updateComponents.components = cleanedComponents; + } + + return heals; + } + + /** + * Unifies recursive sanitization traversal and strips out dynamic mock setups + * configurations recursively. + */ + private sanitizeValue(val: unknown): unknown { + if (val === null || typeof val !== 'object') { + return val; + } + + if (Array.isArray(val)) { + return val.map(item => this.sanitizeValue(item)); + } + + const obj = val as Record; + const cleaned: Record = {}; + + for (const [key, propVal] of Object.entries(obj)) { + if (key === 'rules' || /^mock/i.test(key)) { + continue; + } + cleaned[key] = this.sanitizeValue(propVal); + } + + return cleaned; + } + + /** + * Recursively sanitizes component declarations maps. + * Strips out dynamic rules configs matching /rules/ or prefix /^mock/i. + */ + sanitizeComponentObject(obj: A2uiComponentInstance): A2uiComponentInstance { + return this.sanitizeValue(obj) as A2uiComponentInstance; + } + + /** + * Best-effort extraction of a human-readable surface title from parsed + * blocks: first a `title`-like key in an `updateDataModel` value, then the + * static text of the first Text-like component, else undefined. + */ + private extractSurfaceTitle(parsedBlocks: unknown[]): string | undefined { + for (const block of parsedBlocks) { + if (!block || typeof block !== 'object') { + continue; + } + const updateDataModel = (block as RenderA2uiItem)['updateDataModel'] as + UpdateDataModelDetails | undefined; + const value = updateDataModel?.['value']; + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const [key, propVal] of Object.entries(value as Record)) { + if (/title/i.test(key) && typeof propVal === 'string' && propVal.trim()) { + return propVal; + } + } + } + } + + for (const block of parsedBlocks) { + if (!block || typeof block !== 'object') { + continue; + } + const updateComponents = (block as RenderA2uiItem)['updateComponents'] as + UpdateComponentsDetails | undefined; + if (!updateComponents || !Array.isArray(updateComponents['components'])) { + continue; + } + for (const comp of updateComponents['components']) { + if (!comp || typeof comp !== 'object' || Array.isArray(comp)) { + continue; + } + const compObj = comp as A2uiComponentInstance; + const componentName = typeof compObj['component'] === 'string' ? compObj['component'] : ''; + if (/text/i.test(componentName) && typeof compObj['text'] === 'string') { + const text = (compObj['text'] as string).trim(); + if (text) { + return text; + } + } + } + } + + return undefined; + } + + /** + * Builds an `{ok: false}` generation result from the shared error parser, + * mirroring the connectivity/failed pipeline-status transitions and API-key + * redaction of the chat coordinator's error handling. + */ + private buildErrorResult(err: unknown, messages: LlmMessage[]): GenerationResult { + const rawError = err instanceof Error ? err.message : String(err); + const lowerMsg = rawError.toLowerCase(); + const cleanMsg = cleanErrorMessage(rawError); + + if (this.isConnectivityError(lowerMsg)) { + this.chatState.setPipelineStatus(PipelineStatus.IDLE); + } else { + this.chatState.setPipelineStatus(PipelineStatus.FAILED); + } + this.chatState.setProgrammaticStreamActive(false); + + const parsed = this.parseError(lowerMsg, cleanMsg, this.lastUserPrompt(messages)); + + return { + ok: false, + title: parsed.errorTitle, + message: redactApiKey(parsed.errorMessage), + details: + parsed.showDetails && parsed.errorDetails ? redactApiKey(parsed.errorDetails) : undefined, + tip: parsed.showDetails && parsed.errorTip ? redactApiKey(parsed.errorTip) : undefined, + retryable: parsed.isRetryable, + }; + } + + /** + * Connectivity Exception classification: network, proxy, or auth failures. + */ + isConnectivityError(lowerMsg: string): boolean { + return ( + lowerMsg.includes('failed to fetch') || + lowerMsg.includes('fetch') || + lowerMsg.includes('timeout') || + lowerMsg.includes('504') || + lowerMsg.includes('proxy') || + lowerMsg.includes('networkerror') || + lowerMsg.includes('connection') || + lowerMsg.includes('401') || + lowerMsg.includes('403') || + lowerMsg.includes('credential') || + lowerMsg.includes('quota') || + lowerMsg.includes('blocked') || + lowerMsg.includes('503') || + lowerMsg.includes('unavailable') || + lowerMsg.includes('api key') || + lowerMsg.includes('apikey') + ); + } + + /** + * Maps a raw error into a structured, user-facing diagnostic describing the + * generation, validation, or connectivity failure. + */ + parseError(lowerMsg: string, cleanMsg: string, originalPrompt?: string): ParsedError { + // Default values (Connectivity Failure) + const errorTitle = 'Connectivity Failure'; + const isJson = cleanMsg.trim().startsWith('{'); + const errorMessage = isJson ? 'A connectivity error occurred.' : cleanMsg; + const errorDetails = isJson ? 'Details: ' + cleanMsg : undefined; + const errorTip = + 'Tip: Please check your network proxy configurations or verify your settings to restore connections.'; + const isRetryable = !!originalPrompt; + const showDetails = true; + + const isValidationError = + lowerMsg.includes('validation') || + lowerMsg.includes('syntax recovery') || + lowerMsg.includes('validation failure'); + + if (isValidationError) { + return { + errorTitle: 'Validation Failure', + errorMessage: 'The generated layout contains invalid components or structure.', + errorTip: + 'Tip: Try rephrasing your prompt to guide the model to generate valid components.', + isRetryable: !!originalPrompt, + showDetails: true, + errorDetails: 'Details: ' + cleanMsg, + }; + } + + if (lowerMsg.includes('503') || lowerMsg.includes('unavailable')) { + return { + errorTitle: 'Service Unavailable', + errorMessage: 'The generative service is temporarily unavailable. Please try again later.', + errorTip: '', + isRetryable: true, + showDetails: false, + }; + } + + if (lowerMsg.includes('high demand')) { + return { + errorTitle: 'Model High Demand', + errorMessage: + 'This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.', + errorTip: '', + isRetryable: true, + showDetails: false, + }; + } + + if (lowerMsg.includes('timeout') || lowerMsg.includes('504')) { + return { + errorTitle: 'REST Gateway Timeout', + errorMessage: 'Remote generation service did not respond.', + errorDetails: 'Details: ' + cleanMsg, + errorTip, + isRetryable, + showDetails: true, + }; + } + + if (lowerMsg.includes('api key') || lowerMsg.includes('apikey')) { + return { + errorTitle: 'Invalid API Key', + errorMessage: 'The provided Gemini API key is invalid or missing.', + errorDetails: 'Details: ' + cleanMsg, + errorTip: + 'Tip: Please update your third-party Gemini developer API key on the settings page to restore connections.', + isRetryable, + showDetails: true, + }; + } + + if ( + lowerMsg.includes('auth') || + lowerMsg.includes('401') || + lowerMsg.includes('403') || + lowerMsg.includes('credential') + ) { + return { + errorTitle: 'Authentication Refused', + errorMessage: 'Authentication failed. Please verify your credentials in Settings.', + errorDetails: 'Details: ' + cleanMsg, + errorTip, + isRetryable, + showDetails: true, + }; + } + + if (lowerMsg.includes('quota') || lowerMsg.includes('blocked') || lowerMsg.includes('429')) { + return { + errorTitle: 'GenAI Service Blocked', + errorMessage: 'Resource quota depleted or content safety limits triggered.', + errorDetails: 'Details: ' + cleanMsg, + errorTip, + isRetryable, + showDetails: true, + }; + } + + return { + errorTitle, + errorMessage, + errorTip, + isRetryable, + showDetails, + errorDetails, + }; + } + + /** + * Extracts the most recent user prompt text from a message context, used to + * determine retryability of a failed generation. + */ + private lastUserPrompt(messages: LlmMessage[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === MessageRole.USER) { + return messages[i].content; + } + } + return undefined; + } + + private makeCancelError(): Error { + const err = new Error('Cancelled'); + err.name = CANCEL_ERROR_NAME; + return err; + } + + private isCancelError(err: unknown): boolean { + return ( + !!err && typeof err === 'object' && 'name' in err && (err as Error).name === CANCEL_ERROR_NAME + ); + } + + private generateSystemPrompt(catalog: string): string { + return ` + # A2UI Generation Expert + + ## Role + You are an A2UI expert. Your job is to translate the user's request into valid + A2UI messages. + + # Overview + You MUST ensure all payloads strictly adhere to the **JSON Lines (JSONL)** + format. Each JSON object MUST be flattened to a single line without unescaped + newline characters. + + The generated A2UI MUST conform to this A2UI JSON: + \`\`\`json + ${catalog}. + \`\`\` + + ## Protocol + When building the \`createSurface\` message, you MUST set the \`catalogId\` to + reference the appropriate catalog schema URL. + + You MUST follow the strict message sequence (\`createSurface\` -> + \`updateComponents\` -> \`updateDataModel\`) and use JSON Pointers for data + binding. + + ## Validation + + A complete A2UI payload consists of one or more message objects sent as + continuous JSON objects (or JSON Lines). Every message object MUST include a + top-level \`"version": "v0.9"\` field. + + The four primary messages you must use to manage a UI surface are: + + 1. **\`createSurface\`**: Sent **FIRST** to signal the client to create a new + surface. It defines the \`catalogId\` and optional \`theme\` parameters. + 2. **\`updateComponents\`**: Used to define or update the UI component tree. You + must provide a flat list of components. One component MUST have an \`id\` of + \`"root"\`. + 3. **\`updateDataModel\`**: Used to define or update data values that the + components bind to. + 4. **\`deleteSurface\`**: Signals the client to destroy the surface. + + ## Lifecycle and Ordering + + Typical sequence: \`createSurface\` -> \`updateComponents\` -> \`updateDataModel\` (or + combined/interleaved after creation). + + ## Examples + + * **Simple Example**: A basic column with text: + \`\`\`jsonl + {"version": "v0.9", "createSurface": {"surfaceId": "main", "catalogId": "https://a2ui.org/specification/v0_9/material_catalog.json"}} + {"version": "v0.9", "updateComponents": {"surfaceId": "main", "components": [{"id": "root", "component": "MaterialColumn", "children": ["header", "content"]}, {"id": "header", "component": "MaterialText", "text": "Welcome"}, {"id": "content", "component": "MaterialText", "text": {"path": "/message"}}]}} + {"version": "v0.9", "updateDataModel": {"surfaceId": "main", "path": "/message", "value": "Hello, world!"}} + \`\`\` + + * **Complex Form Example**: A vacation booking form demonstrating advanced + Material form controls (\`MaterialDatepicker\`, \`MaterialSelect\`, + \`MaterialSlideToggle\`) and buttons using the modernized Material catalog: + \`\`\`jsonl + {"version": "v0.9", "createSurface": {"surfaceId": "vacation_booking", "catalogId": "https://a2ui.org/specification/v0_9/material_catalog.json"}} + {"version": "v0.9", "updateComponents": {"surfaceId": "vacation_booking", "components": [{"id": "root", "component": "MaterialColumn", "children": ["title", "destination_input", "checkin_datepicker", "checkout_datepicker", "room_type_select", "passenger_select", "flexible_dates_toggle", "search_button"]}, {"id": "title", "component": "MaterialText", "text": {"path": "/title_label"}, "usageHint": "h1"}, {"id": "destination_input", "component": "MaterialInput", "label": {"path": "/destination_label"}, "value": {"path": "/destination_value"}}, {"id": "checkin_datepicker", "component": "MaterialDatepicker", "label": {"path": "/checkin_label"}, "value": {"path": "/checkin_value"}}, {"id": "checkout_datepicker", "component": "MaterialDatepicker", "label": {"path": "/checkout_label"}, "value": {"path": "/checkout_value"}}, {"id": "room_type_select", "component": "MaterialSelect", "label": {"path": "/room_type_label"}, "value": {"path": "/room_type_value"}, "options": [{"label": "Standard Room", "value": "standard"}, {"label": "Deluxe Suite", "value": "deluxe"}]}, {"id": "passenger_select", "component": "MaterialSelect", "label": {"path": "/passenger_label"}, "value": {"path": "/passenger_value"}, "options": [{"label": "1 Passenger", "value": "1"}, {"label": "2 Passengers", "value": "2"}, {"label": "3+ Passengers", "value": "3"}]}, {"id": "flexible_dates_toggle", "component": "MaterialSlideToggle", "label": {"path": "/flexible_dates_label"}, "checked": {"path": "/flexible_dates_checked"}, "color": "primary"}, {"id": "search_button", "component": "MaterialButton", "label": {"path": "/search_label"}, "action": {"event": {"name": "searchVacation"}}}]}} + {"version": "v0.9", "updateDataModel": {"surfaceId": "vacation_booking", "value": {"title_label": "Book Your Dream Vacation", "destination_label": "Destination", "destination_value": "Hawaii", "checkin_label": "Check-in Date", "checkin_value": "2026-07-01", "checkout_label": "Check-out Date", "checkout_value": "2026-07-14", "room_type_label": "Room Type", "room_type_value": "standard", "passenger_label": "Passengers", "passenger_value": "2", "flexible_dates_label": "Flexible Dates (+/- 3 days)", "flexible_dates_checked": true, "search_label": "Search Flights & Hotels"}}} + \`\`\` + + * **Dynamic List Example**: An example using templates to render a list of + items. + \`\`\`jsonl + {"version": "v0.9", "createSurface": {"surfaceId": "dynamic_list_demo", "catalogId": "https://a2ui.org/specification/v0_9/material_catalog.json"}} + {"version": "v0.9", "updateComponents": {"surfaceId": "dynamic_list_demo", "components": [{"id": "root", "component": "MaterialColumn", "children": ["title", "list_container"]}, {"id": "title", "component": "MaterialText", "text": "Dynamic List Demo"}, {"id": "list_container", "component": "MaterialColumn", "children": {"componentId": "item_template", "path": "/items"}}, {"id": "item_template", "component": "MaterialText", "text": {"path": "text"}}]}} + {"version": "v0.9", "updateDataModel": {"surfaceId": "dynamic_list_demo", "value": {"items": [{"text": "Item One"}, {"text": "Item Two"}]}}} + \`\`\` + + ## Data Binding + Every component property value MUST come from the data model (with minor + exceptions for static primitives). + + When referencing data in the data model, you MUST use valid JSON Pointer syntax + starting with \`/\`. + + ## Actions and Context + + When defining actions (e.g., on buttons), the \`context\` payload is a standard + JSON object, rather than an array of key-value pairs. + + Example action definition: + + \`\`\`jsonl + "action": { + "event": { + "name": "selectItem", + "context": { + "itemId": "12345", + "itemName": {"path": "/selected/name"} + } + } + } + \`\`\` + + `; + } +} diff --git a/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.ng.html b/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.ng.html new file mode 100644 index 00000000..73c82349 --- /dev/null +++ b/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.ng.html @@ -0,0 +1,55 @@ + + +@if (collapsed()) { +
+ +
+} @else { +
+ Assistant + +
+
+ @if (hasKey()) { + + } @else { +
+ +

Add your Gemini API key to start generating.

+ + Add Gemini API key + +
+ } +
+} diff --git a/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.scss b/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.scss new file mode 100644 index 00000000..16d87d13 --- /dev/null +++ b/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.scss @@ -0,0 +1,102 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// The docked width is driven by an inline host style binding (see the +// component `host` metadata) so the rail/panel transition is the single source +// of truth. This host only owns the non-width chrome: full height, the divider +// facing the Dockview region, its own scroll containment, and flex layout. +:host { + display: flex; + flex-direction: column; + flex: 0 0 auto; + height: 100%; + box-sizing: border-box; + overflow: hidden; + border-right: 1px solid var(--mat-sys-outline-variant); + background: var(--mat-sys-surface); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + min-height: 48px; + padding: 0 4px 0 16px; + box-sizing: border-box; + border-bottom: 1px solid var(--mat-sys-outline-variant); +} + +.header__title { + font-family: var(--mat-sys-title-small-font, Roboto, sans-serif); + font-size: var(--mat-sys-title-small-size, 14px); + font-weight: var(--mat-sys-title-small-weight, 500); + color: var(--mat-sys-on-surface); +} + +// The chat region owns its own vertical scroll so long threads never push the +// header off-screen or spill into the Dockview region. +.chat { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + + copilot-chat { + display: block; + flex: 1 1 auto; + min-height: 0; + } +} + +.rail { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + padding-top: 4px; + box-sizing: border-box; +} + +// Bring-your-own-key empty state shown in place of the chat when no Gemini key +// is configured. Centered within the chat region's own scroll container. +.no-key { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px; + box-sizing: border-box; + text-align: center; +} + +.no-key__icon { + width: 32px; + height: 32px; + font-size: 32px; + color: var(--mat-sys-on-surface-variant); +} + +.no-key__message { + margin: 0; + max-width: 280px; + font-family: var(--mat-sys-body-medium-font, Roboto, sans-serif); + font-size: var(--mat-sys-body-medium-size, 14px); + color: var(--mat-sys-on-surface-variant); +} diff --git a/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.spec.ts b/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.spec.ts new file mode 100644 index 00000000..066554aa --- /dev/null +++ b/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.spec.ts @@ -0,0 +1,265 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {Component, input, signal} from '@angular/core'; +import {provideRouter} from '@angular/router'; +import {describe, it, expect, beforeEach, vi} from 'vitest'; +import {provideNoopAnimations} from '@angular/platform-browser/animations'; +import {CopilotChat, CopilotKit, provideCopilotKit} from '@copilotkit/angular'; +import {CopilotSidebar} from './copilot-sidebar'; +import {AppConfigProvider} from '../../settings/app-config-provider/app-config-provider'; +import {CatalogManagement} from '../../storage/catalog-management/catalog-management'; + +/** + * Lightweight stand-in for the heavy real `` view. It shares the + * `copilot-chat` selector and `agentId` input so the sidebar template resolves + * against it, keeping this a focused unit test of the sidebar shell rather than + * a boot of the full CopilotKit chat runtime. + */ +@Component({ + // Must mirror the real component's selector so the sidebar template resolves. + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'copilot-chat', + standalone: true, + template: '', +}) +class CopilotChatStub { + readonly agentId = input(''); +} + +/** + * Minimal fake for {@link AppConfigProvider} exposing only the reactive + * `geminiApiKey` the sidebar reads, as a writable signal tests can flip to + * exercise the key gate. + */ +class FakeAppConfigProvider { + readonly geminiApiKey = signal('test-key'); +} + +/** + * Minimal fake for {@link CatalogManagement} exposing only the reactive + * `activeCatalog` the sidebar reads when tailoring starter chips. + */ +class FakeCatalogManagement { + readonly activeCatalog = signal<{title?: string} | null>(null); +} + +describe('CopilotSidebar', () => { + let fixture: ComponentFixture; + let fakeConfig: FakeAppConfigProvider; + let fakeCatalog: FakeCatalogManagement; + + function host(): HTMLElement { + return fixture.nativeElement as HTMLElement; + } + + beforeEach(async () => { + fakeConfig = new FakeAppConfigProvider(); + fakeCatalog = new FakeCatalogManagement(); + + await TestBed.configureTestingModule({ + imports: [CopilotSidebar], + providers: [ + provideNoopAnimations(), + provideRouter([]), + provideCopilotKit({agents: {}}), + {provide: AppConfigProvider, useValue: fakeConfig}, + {provide: CatalogManagement, useValue: fakeCatalog}, + ], + }) + .overrideComponent(CopilotSidebar, { + remove: {imports: [CopilotChat]}, + add: {imports: [CopilotChatStub]}, + }) + .compileComponents(); + + fixture = TestBed.createComponent(CopilotSidebar); + fixture.detectChanges(); + }); + + it('mounts and renders a bound to the default agent', () => { + const chat = host().querySelector('copilot-chat'); + expect(chat).toBeTruthy(); + }); + + it('hides the chat and shows the add-key CTA when there is no Gemini key', () => { + fakeConfig.geminiApiKey.set(''); + fixture.detectChanges(); + + expect(host().querySelector('copilot-chat')).toBeNull(); + + const cta = host().querySelector('a[href="/settings"]'); + expect(cta).toBeTruthy(); + expect(cta!.textContent).toContain('Add Gemini API key'); + expect(host().textContent).toContain('Add your Gemini API key to start generating.'); + }); + + it('shows the chat and hides the add-key CTA once a key is present', () => { + // No key -> chat hidden. AppConfigProvider trims keys at the source (a + // whitespace-only key surfaces here as an empty string), so the sidebar + // only needs to check for emptiness. + fakeConfig.geminiApiKey.set(''); + fixture.detectChanges(); + expect(host().querySelector('copilot-chat')).toBeNull(); + + fakeConfig.geminiApiKey.set('a-real-key'); + fixture.detectChanges(); + + expect(host().querySelector('copilot-chat')).toBeTruthy(); + expect(host().querySelector('a[href="/settings"]')).toBeNull(); + }); + + it('exposes four generic starter chips when no catalog is active', () => { + const chips = fixture.componentInstance.starterSuggestions(); + + expect(chips.map(chip => chip.title)).toEqual([ + 'Book a Car', + 'A sign-up form', + 'A pricing card', + 'A product dashboard header', + ]); + for (const chip of chips) { + expect(chip.title.trim().length).toBeGreaterThan(0); + expect(chip.message.trim().length).toBeGreaterThan(0); + } + }); + + it('tailors the starter chip prompts to the active catalog name', () => { + fakeCatalog.activeCatalog.set({title: 'Acme Kit'}); + fixture.detectChanges(); + + const chips = fixture.componentInstance.starterSuggestions(); + for (const chip of chips) { + expect(chip.message).toContain('Acme Kit'); + } + }); + + it('publishes the starter chips to the shared default agent on an empty thread', () => { + const copilotKit = TestBed.inject(CopilotKit); + // The real reloads suggestions when a config is registered; + // replicate that here since the chat view is stubbed in this unit test. + copilotKit.core.reloadSuggestions('default'); + + const published = copilotKit.core.getSuggestions('default').suggestions; + const expected = fixture.componentInstance.starterSuggestions(); + + expect(published.map(s => s.title)).toEqual(expected.map(c => c.title)); + // Each chip carries the exact prompt that selecting it submits to the store. + expect(published.map(s => s.message)).toEqual(expected.map(c => c.message)); + }); + + it('does not publish starter chips while there is no key', () => { + const copilotKit = TestBed.inject(CopilotKit); + + fakeConfig.geminiApiKey.set(''); + fixture.detectChanges(); + copilotKit.core.reloadSuggestions('default'); + + expect(copilotKit.core.getSuggestions('default').suggestions).toHaveLength(0); + }); + + it('scopes the published config to the default agent and to before the first message', () => { + const copilotKit = TestBed.inject(CopilotKit); + // Drop then restore the key so the registration effect re-runs while spied. + fakeConfig.geminiApiKey.set(''); + fixture.detectChanges(); + const addSpy = vi.spyOn(copilotKit, 'addSuggestionsConfig'); + fakeConfig.geminiApiKey.set('key-again'); + fixture.detectChanges(); + + expect(addSpy).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + consumerAgentId: 'default', + available: 'before-first-message', + }), + ); + }); + + it('withdraws its starter chips when the sidebar is destroyed', () => { + const copilotKit = TestBed.inject(CopilotKit); + copilotKit.core.reloadSuggestions('default'); + expect(copilotKit.core.getSuggestions('default').suggestions.length).toBeGreaterThan(0); + + fixture.destroy(); + copilotKit.core.reloadSuggestions('default'); + + expect(copilotKit.core.getSuggestions('default').suggestions).toHaveLength(0); + }); + + it('is expanded by default at the docked panel width', () => { + expect(fixture.componentInstance.collapsed()).toBe(false); + expect(host().classList.contains('a2ui-composer-copilot-sidebar--collapsed')).toBe(false); + expect(host().style.width).toBe('380px'); + }); + + it('registers the A2UI tool-call renders in its constructor', () => { + const copilotKit = TestBed.inject(CopilotKit); + const names = copilotKit.toolCallRenderConfigs().map(config => config.name); + expect(names).toContain('render_a2ui'); + expect(names).toContain('a2ui_repair'); + }); + + it('collapsing hides the chat, shows the rail, and narrows the host', () => { + fixture.componentInstance.toggleCollapsed(); + fixture.detectChanges(); + + expect(fixture.componentInstance.collapsed()).toBe(true); + expect(host().querySelector('copilot-chat')).toBeNull(); + expect(host().querySelector('.rail')).toBeTruthy(); + expect(host().classList.contains('a2ui-composer-copilot-sidebar--collapsed')).toBe(true); + expect(host().style.width).toBe('48px'); + }); + + it('expanding again re-reveals the chat and restores the docked width', () => { + fixture.componentInstance.toggleCollapsed(); + fixture.detectChanges(); + fixture.componentInstance.toggleCollapsed(); + fixture.detectChanges(); + + expect(fixture.componentInstance.collapsed()).toBe(false); + expect(host().querySelector('copilot-chat')).toBeTruthy(); + expect(host().style.width).toBe('380px'); + }); + + it('collapses when the header toggle button is clicked', () => { + const collapseButton = host().querySelector( + 'button[aria-label="Collapse assistant panel"]', + ); + expect(collapseButton).toBeTruthy(); + + collapseButton!.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.collapsed()).toBe(true); + }); + + it('expands when the collapsed rail toggle button is clicked', () => { + fixture.componentInstance.toggleCollapsed(); + fixture.detectChanges(); + + const expandButton = host().querySelector( + 'button[aria-label="Expand assistant panel"]', + ); + expect(expandButton).toBeTruthy(); + + expandButton!.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.collapsed()).toBe(false); + }); +}); diff --git a/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.ts b/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.ts new file mode 100644 index 00000000..8cae09dd --- /dev/null +++ b/shell/src/app/copilotkit/copilot-sidebar/copilot-sidebar.ts @@ -0,0 +1,123 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Component, computed, effect, inject, signal} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {RouterLink} from '@angular/router'; +import {CopilotChat, CopilotKit} from '@copilotkit/angular'; +import {registerA2uiToolRenders} from '../register-tool-renders'; +import {AppConfigProvider} from '../../settings/app-config-provider/app-config-provider'; +import {CatalogManagement} from '../../storage/catalog-management/catalog-management'; + +/** Id of the single shared in-browser agent the sidebar chat is bound to. */ +const DEFAULT_AGENT_ID = 'default'; + +/** Generic starter prompts shown before the catalog handshake resolves. */ +const GENERIC_STARTERS: readonly string[] = [ + 'Book a Car', + 'A sign-up form', + 'A pricing card', + 'A product dashboard header', +]; + +/** A single clickable starter chip: `title` labels the pill, `message` is the prompt submitted. */ +interface StarterChip { + readonly title: string; + readonly message: string; +} + +/** + * Docked CopilotKit assistant sidebar. Hosts the `` thread bound + * to the in-browser `default` agent inside a fixed-width, full-height rail with + * its own vertical scroll. The panel is collapsible: when collapsed it shrinks + * to a slim rail exposing only an expand affordance. + */ +@Component({ + selector: 'a2ui-composer-copilot-sidebar', + standalone: true, + imports: [CopilotChat, MatButtonModule, MatIconModule, RouterLink], + templateUrl: './copilot-sidebar.ng.html', + styleUrl: './copilot-sidebar.scss', + host: { + '[class.a2ui-composer-copilot-sidebar--collapsed]': 'collapsed()', + '[style.width.px]': 'collapsed() ? railWidthPx : panelWidthPx', + }, +}) +export class CopilotSidebar { + /** Docked width of the expanded chat panel, in pixels. */ + protected readonly panelWidthPx = 380; + + /** Width of the collapsed rail, in pixels. */ + protected readonly railWidthPx = 48; + + /** Whether the sidebar is collapsed to its slim rail. */ + readonly collapsed = signal(false); + + private readonly configProvider = inject(AppConfigProvider); + private readonly catalogManagement = inject(CatalogManagement); + private readonly copilotKit = inject(CopilotKit); + + /** + * Whether the user has supplied a usable Gemini API key. The assistant is + * bring-your-own-key and runs client-side, so the chat must not mount until a + * non-blank key exists. + */ + readonly hasKey = computed(() => !!this.configProvider.geminiApiKey()); + + /** + * Starter prompt chips offered on an empty thread. Tailored to the active + * catalog's name when the preview handshake has resolved one, otherwise a + * generic set. Each chip's `message` is the prompt submitted to the agent. + */ + readonly starterSuggestions = computed(() => { + const catalogTitle = this.catalogManagement.activeCatalog()?.title?.trim(); + return GENERIC_STARTERS.map(title => ({ + title, + message: catalogTitle ? `${title}, using the ${catalogTitle} catalog` : title, + })); + }); + + constructor() { + // Wire up the A2UI generation/repair tool-call renders exactly once, from + // within this component's injection context (required by CopilotKit DI). + registerA2uiToolRenders(); + + // Publish the starter chips as a native CopilotKit suggestions config so the + // shared `` renders them and routes a chosen chip's prompt + // through the one `default` agent store. `before-first-message` scopes them + // to an empty thread (CopilotKit clears them after the first run), and the + // config is only registered while a key exists. The effect re-runs when the + // key or the tailored starters change, and its cleanup withdraws the config + // on re-run and on destroy. + effect(onCleanup => { + if (!this.hasKey()) { + return; + } + const configId = this.copilotKit.addSuggestionsConfig({ + consumerAgentId: DEFAULT_AGENT_ID, + available: 'before-first-message', + suggestions: this.starterSuggestions(), + }); + onCleanup(() => this.copilotKit.removeSuggestionsConfig(configId)); + }); + } + + /** Toggles the sidebar between the expanded panel and the collapsed rail. */ + toggleCollapsed(): void { + this.collapsed.update(value => !value); + } +} diff --git a/shell/src/app/copilotkit/gemini-a2ui-agent/gemini-a2ui-agent.spec.ts b/shell/src/app/copilotkit/gemini-a2ui-agent/gemini-a2ui-agent.spec.ts new file mode 100644 index 00000000..4de6c80a --- /dev/null +++ b/shell/src/app/copilotkit/gemini-a2ui-agent/gemini-a2ui-agent.spec.ts @@ -0,0 +1,178 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {describe, it, expect, beforeEach, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {EventType, type BaseEvent, type RunAgentInput} from '@ag-ui/core'; +import {firstValueFrom, toArray} from 'rxjs'; +import {GeminiA2uiAgent, RENDER_A2UI_TOOL, A2UI_REPAIR_TOOL} from './gemini-a2ui-agent'; +import {A2uiGenerationService, type GenerationResult} from '../a2ui-generation/a2ui-generation.service'; + +class FakeGenerationService { + result: GenerationResult = { + ok: true, + blocks: [{version: 'v0.9'}, {version: 'v0.9'}], + layoutText: '[]', + heals: [], + surfaceTitle: 'Book a Car', + }; + /** Thinking deltas replayed through `opts.onThinking` during generate(). */ + thinkingDeltas: string[] = []; + cancel = vi.fn(); + systemPrompt = () => 'SYSTEM PROMPT'; + generate = vi.fn( + async (_messages: unknown, opts?: {onThinking?: (delta: string, accumulated: string) => void}) => { + let accumulated = ''; + for (const delta of this.thinkingDeltas) { + accumulated += delta; + opts?.onThinking?.(delta, accumulated); + } + return this.result; + }, + ); +} + +function makeInput(userText: string): RunAgentInput { + return { + threadId: 't1', + runId: 'r1', + messages: [{id: 'm1', role: 'user', content: userText}], + tools: [], + context: [], + forwardedProps: {}, + state: {}, + } as unknown as RunAgentInput; +} + +function runToEvents(agent: GeminiA2uiAgent, input: RunAgentInput): Promise { + return firstValueFrom(agent.run(input).pipe(toArray())); +} + +describe('GeminiA2uiAgent', () => { + let fake: FakeGenerationService; + let agent: GeminiA2uiAgent; + + beforeEach(() => { + fake = new FakeGenerationService(); + TestBed.configureTestingModule({ + providers: [GeminiA2uiAgent, {provide: A2uiGenerationService, useValue: fake}], + }); + agent = TestBed.inject(GeminiA2uiAgent); + }); + + it('emits status narration then a render_a2ui tool call on success', async () => { + const events = await runToEvents(agent, makeInput('a booking form')); + const types = events.map(e => e.type); + + expect(types).toEqual([ + EventType.RUN_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + EventType.RUN_FINISHED, + ]); + + const start = events.find(e => e.type === EventType.TOOL_CALL_START) as {toolCallName: string}; + expect(start.toolCallName).toBe(RENDER_A2UI_TOOL); + const argsEvent = events.find(e => e.type === EventType.TOOL_CALL_ARGS) as {delta: string}; + expect(JSON.parse(argsEvent.delta)).toEqual({surfaceTitle: 'Book a Car', blocks: fake.result.blocks}); + // Raw model JSONL is never streamed as assistant content. + const content = events.find(e => e.type === EventType.TEXT_MESSAGE_CONTENT) as {delta: string}; + expect(content.delta).toBe('Generating your UI…'); + }); + + it('streams the model thinking as an AG-UI reasoning message before the tool call', async () => { + fake.thinkingDeltas = ['I need ', 'a booking form']; + const events = await runToEvents(agent, makeInput('a booking form')); + + expect(events.map(e => e.type)).toEqual([ + EventType.RUN_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.REASONING_MESSAGE_START, + EventType.REASONING_MESSAGE_CONTENT, + EventType.REASONING_MESSAGE_CONTENT, + EventType.REASONING_MESSAGE_END, + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + EventType.RUN_FINISHED, + ]); + + const start = events.find(e => e.type === EventType.REASONING_MESSAGE_START) as {role: string}; + expect(start.role).toBe('reasoning'); + const deltas = events + .filter(e => e.type === EventType.REASONING_MESSAGE_CONTENT) + .map(e => (e as {delta: string}).delta); + expect(deltas).toEqual(['I need ', 'a booking form']); + }); + + it('emits no reasoning events when the model returns no thinking', async () => { + const events = await runToEvents(agent, makeInput('a form')); + const types = events.map(e => e.type); + expect(types).not.toContain(EventType.REASONING_MESSAGE_START); + expect(types).not.toContain(EventType.REASONING_MESSAGE_CONTENT); + }); + + it('emits an a2ui_repair tool call when the pipeline healed component names', async () => { + fake.result = {...(fake.result as object as GenerationResult & {ok: true}), heals: [{from: 'textbox', to: 'TextField'}]}; + const events = await runToEvents(agent, makeInput('a form')); + + const toolStarts = events.filter(e => e.type === EventType.TOOL_CALL_START) as Array<{toolCallName: string}>; + expect(toolStarts.map(t => t.toolCallName)).toEqual([RENDER_A2UI_TOOL, A2UI_REPAIR_TOOL]); + const repairArgs = events.filter(e => e.type === EventType.TOOL_CALL_ARGS)[1] as {delta: string}; + expect(JSON.parse(repairArgs.delta)).toEqual({fixes: [{from: 'textbox', to: 'TextField'}]}); + }); + + it('does NOT emit a repair tool call when there are no heals', async () => { + const events = await runToEvents(agent, makeInput('a form')); + const toolStarts = events.filter(e => e.type === EventType.TOOL_CALL_START) as Array<{toolCallName: string}>; + expect(toolStarts.map(t => t.toolCallName)).toEqual([RENDER_A2UI_TOOL]); + }); + + it('maps a generation error to a friendly message + RUN_ERROR', async () => { + fake.result = { + ok: false, + title: 'Invalid API Key', + message: 'The provided Gemini API key is invalid or missing.', + tip: 'Update your key in Settings.', + retryable: true, + }; + const events = await runToEvents(agent, makeInput('a form')); + const types = events.map(e => e.type); + expect(types).toContain(EventType.RUN_ERROR); + expect(types).not.toContain(EventType.TOOL_CALL_START); + const err = events.find(e => e.type === EventType.RUN_ERROR) as {message: string}; + expect(err.message).toBe('The provided Gemini API key is invalid or missing.'); + }); + + it('prepends the system prompt and passes user turns to generate()', async () => { + await runToEvents(agent, makeInput('make a dashboard')); + const passed = fake.generate.mock.calls[0][0]; + expect(passed[0]).toEqual({role: 'system', content: 'SYSTEM PROMPT'}); + expect(passed.at(-1)).toEqual({role: 'user', content: 'make a dashboard'}); + }); + + it('cancels the in-flight generation when the subscription is torn down', () => { + const sub = agent.run(makeInput('x')).subscribe(); + sub.unsubscribe(); + expect(fake.cancel).toHaveBeenCalled(); + }); +}); diff --git a/shell/src/app/copilotkit/gemini-a2ui-agent/gemini-a2ui-agent.ts b/shell/src/app/copilotkit/gemini-a2ui-agent/gemini-a2ui-agent.ts new file mode 100644 index 00000000..2dd19797 --- /dev/null +++ b/shell/src/app/copilotkit/gemini-a2ui-agent/gemini-a2ui-agent.ts @@ -0,0 +1,167 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Injectable, inject} from '@angular/core'; +import {AbstractAgent} from '@ag-ui/client'; +import {EventType, type BaseEvent, type Message, type RunAgentInput} from '@ag-ui/core'; +import {Observable} from 'rxjs'; +import {A2uiGenerationService} from '../a2ui-generation/a2ui-generation.service'; +import {LlmMessage, MessageRole} from '../../chat/llm-client/llm-client'; + +/** The custom tool the agent emits to hand a validated A2UI layout to the UI. */ +export const RENDER_A2UI_TOOL = 'render_a2ui'; +/** The custom tool the agent emits to surface silently-applied component-name heals. */ +export const A2UI_REPAIR_TOOL = 'a2ui_repair'; + +const STATUS_NARRATION = 'Generating your UI…'; + +/** + * In-browser AG-UI agent that wraps the composer's existing client-side Gemini + * generation pipeline ({@link A2uiGenerationService}) and exposes it to the + * CopilotKit sidebar. The raw model JSONL never reaches the conversation: the + * agent emits short, deterministic status narration as an assistant text + * message, then hands the healed/validated A2UI blocks to the UI as a + * `render_a2ui` tool call (plus an `a2ui_repair` tool call when the pipeline + * corrected any component names). No backend — everything runs in the browser. + */ +@Injectable({providedIn: 'root'}) +export class GeminiA2uiAgent extends AbstractAgent { + private readonly generation = inject(A2uiGenerationService); + + constructor() { + super({agentId: 'default'}); + } + + override run(input: RunAgentInput): Observable { + return new Observable(subscriber => { + // Per-run cancellation flag. Scoped to this subscription (not the + // root-singleton instance) so concurrent runs never share the flag. + let cancelled = false; + const threadId = input.threadId; + const runId = input.runId; + const messageId = crypto.randomUUID(); + + const emit = (event: BaseEvent) => { + if (!cancelled) subscriber.next(event); + }; + + void (async () => { + try { + emit({type: EventType.RUN_STARTED, threadId, runId} as BaseEvent); + + // Deterministic, agent-synthesized status text — NOT model prose. + emit({type: EventType.TEXT_MESSAGE_START, messageId, role: 'assistant'} as BaseEvent); + emit({type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta: STATUS_NARRATION} as BaseEvent); + emit({type: EventType.TEXT_MESSAGE_END, messageId} as BaseEvent); + + // Stream the model's thinking as an AG-UI reasoning message so the + // sidebar shows it live in a collapsible reasoning bubble. Only the + // thoughts flow here — the raw JSONL still never reaches the thread. + const reasoningId = crypto.randomUUID(); + let reasoningStarted = false; + const onThinking = (delta: string) => { + if (!delta || cancelled) return; + if (!reasoningStarted) { + reasoningStarted = true; + emit({ + type: EventType.REASONING_MESSAGE_START, + messageId: reasoningId, + role: 'reasoning', + } as BaseEvent); + } + emit({type: EventType.REASONING_MESSAGE_CONTENT, messageId: reasoningId, delta} as BaseEvent); + }; + + const messages = this.toLlmContext(input.messages); + const result = await this.generation.generate(messages, {onThinking}); + if (reasoningStarted) { + emit({type: EventType.REASONING_MESSAGE_END, messageId: reasoningId} as BaseEvent); + } + if (cancelled) { + subscriber.complete(); + return; + } + + if (result.ok) { + this.emitToolCall(emit, RENDER_A2UI_TOOL, messageId, { + surfaceTitle: result.surfaceTitle, + blocks: result.blocks, + }); + if (result.heals.length > 0) { + this.emitToolCall(emit, A2UI_REPAIR_TOOL, messageId, {fixes: result.heals}); + } + emit({type: EventType.RUN_FINISHED, threadId, runId} as BaseEvent); + } else { + // Surface the typed error as a friendly assistant message, then end the run. + const errId = crypto.randomUUID(); + emit({type: EventType.TEXT_MESSAGE_START, messageId: errId, role: 'assistant'} as BaseEvent); + emit({ + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: errId, + delta: `${result.title}: ${result.message}${result.tip ? `\n\n${result.tip}` : ''}`, + } as BaseEvent); + emit({type: EventType.TEXT_MESSAGE_END, messageId: errId} as BaseEvent); + emit({type: EventType.RUN_ERROR, message: result.message} as BaseEvent); + } + subscriber.complete(); + } catch (err) { + if (!cancelled) { + emit({type: EventType.RUN_ERROR, message: err instanceof Error ? err.message : String(err)} as BaseEvent); + } + subscriber.complete(); + } + })(); + + // Teardown: cancel the in-flight Gemini stream when the run is aborted. + return () => { + cancelled = true; + this.generation.cancel(); + }; + }); + } + + /** Streams a complete tool call (START → ARGS → END) as three AG-UI events. */ + private emitToolCall( + emit: (event: BaseEvent) => void, + toolName: string, + parentMessageId: string, + args: unknown, + ): void { + const toolCallId = crypto.randomUUID(); + emit({type: EventType.TOOL_CALL_START, toolCallId, toolCallName: toolName, parentMessageId} as BaseEvent); + emit({type: EventType.TOOL_CALL_ARGS, toolCallId, delta: JSON.stringify(args)} as BaseEvent); + emit({type: EventType.TOOL_CALL_END, toolCallId} as BaseEvent); + } + + /** + * Maps the AG-UI thread into the composer's LlmMessage context: the system + * prompt (catalog-driven, read live) followed by the user/assistant text + * turns. Tool calls and empty turns are dropped. + */ + private toLlmContext(messages: ReadonlyArray): LlmMessage[] { + const context: LlmMessage[] = [{role: MessageRole.SYSTEM, content: this.generation.systemPrompt()}]; + for (const message of messages) { + const content = typeof message.content === 'string' ? message.content.trim() : ''; + if (!content) continue; + if (message.role === 'user') { + context.push({role: MessageRole.USER, content}); + } else if (message.role === 'assistant' && content !== STATUS_NARRATION) { + context.push({role: MessageRole.MODEL, content}); + } + } + return context; + } +} diff --git a/shell/src/app/copilotkit/register-tool-renders.ts b/shell/src/app/copilotkit/register-tool-renders.ts new file mode 100644 index 00000000..edfb7333 --- /dev/null +++ b/shell/src/app/copilotkit/register-tool-renders.ts @@ -0,0 +1,53 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {registerRenderToolCall} from '@copilotkit/angular'; +import {z} from 'zod'; +import {RenderA2uiArgs, RenderA2uiToolRender} from './tool-renders/render-a2ui/render-a2ui-tool-render'; +import {RepairArgs, RepairToolRender} from './tool-renders/repair/repair-tool-render'; + +/** + * Registers the presentational tool-call renders that display A2UI generation + * and repair activity inside the `` thread. + * + * Must be invoked from an Angular injection context (e.g. a component + * constructor), since {@link registerRenderToolCall} wires into CopilotKit DI. + */ +export function registerA2uiToolRenders(): void { + registerRenderToolCall({ + name: 'render_a2ui', + args: z.object({ + surfaceTitle: z.string().optional(), + blocks: z.array(z.unknown()), + }), + component: RenderA2uiToolRender, + }); + + registerRenderToolCall({ + name: 'a2ui_repair', + args: z.object({ + fixes: z + .array( + z.object({ + from: z.string(), + to: z.string(), + }), + ) + .optional(), + }), + component: RepairToolRender, + }); +} diff --git a/shell/src/app/copilotkit/tool-renders/render-a2ui/render-a2ui-tool-render.scss b/shell/src/app/copilotkit/tool-renders/render-a2ui/render-a2ui-tool-render.scss new file mode 100644 index 00000000..f1671598 --- /dev/null +++ b/shell/src/app/copilotkit/tool-renders/render-a2ui/render-a2ui-tool-render.scss @@ -0,0 +1,30 @@ +:host { + display: block; +} + +.a2ui-card { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + background: rgba(0, 0, 0, 0.02); + font-family: system-ui, -apple-system, sans-serif; + font-size: 13px; + line-height: 1.4; +} + +.a2ui-card__title { + font-weight: 600; + color: rgba(0, 0, 0, 0.87); +} + +.a2ui-card__status { + color: rgba(0, 0, 0, 0.6); +} + +.a2ui-card__caption { + font-size: 11px; + color: rgba(0, 0, 0, 0.45); +} diff --git a/shell/src/app/copilotkit/tool-renders/render-a2ui/render-a2ui-tool-render.spec.ts b/shell/src/app/copilotkit/tool-renders/render-a2ui/render-a2ui-tool-render.spec.ts new file mode 100644 index 00000000..8965cc82 --- /dev/null +++ b/shell/src/app/copilotkit/tool-renders/render-a2ui/render-a2ui-tool-render.spec.ts @@ -0,0 +1,84 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {TestBed, ComponentFixture} from '@angular/core/testing'; +import {describe, it, expect, beforeEach} from 'vitest'; +import {AngularToolCall} from '@copilotkit/angular'; +import {RenderA2uiToolRender, RenderA2uiArgs} from './render-a2ui-tool-render'; + +describe('RenderA2uiToolRender', () => { + let fixture: ComponentFixture; + + function render(toolCall: AngularToolCall): HTMLElement { + fixture = TestBed.createComponent(RenderA2uiToolRender); + fixture.componentRef.setInput('toolCall', toolCall); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + beforeEach(async () => { + await TestBed.configureTestingModule({imports: [RenderA2uiToolRender]}).compileComponents(); + }); + + it('renders "Generated Foo" when surfaceTitle is "Foo"', () => { + const el = render({ + status: 'complete', + args: {surfaceTitle: 'Foo', blocks: [{}, {}]}, + result: 'ok', + }); + expect(el.textContent).toContain('Generated Foo'); + }); + + it('falls back to "Generated UI" when surfaceTitle is absent', () => { + const el = render({status: 'executing', args: {blocks: []}, result: undefined}); + expect(el.textContent).toContain('Generated UI'); + }); + + it('switches status text between the executing and complete states', () => { + const executingEl = render({status: 'executing', args: {blocks: []}, result: undefined}); + expect(executingEl.textContent).toContain('Building your UI…'); + expect(executingEl.textContent).not.toContain('Rendered · shown in canvas'); + + const completeEl = render({status: 'complete', args: {blocks: []}, result: 'ok'}); + expect(completeEl.textContent).toContain('Rendered · shown in canvas'); + expect(completeEl.textContent).not.toContain('Building your UI…'); + }); + + it('shows the building status while in-progress', () => { + const el = render({status: 'in-progress', args: {}, result: undefined}); + expect(el.textContent).toContain('Building your UI…'); + }); + + it('shows "2 blocks" for blocks:[{}, {}]', () => { + const el = render({ + status: 'complete', + args: {surfaceTitle: 'X', blocks: [{}, {}]}, + result: 'ok', + }); + expect(el.textContent).toContain('2 blocks'); + }); + + it('pluralizes to the singular "1 block" for a single block', () => { + const el = render({status: 'complete', args: {blocks: [{}]}, result: 'ok'}); + expect(el.textContent).toContain('1 block'); + expect(el.textContent).not.toContain('1 blocks'); + }); + + it('reports "0 blocks" when blocks is absent (in-progress)', () => { + const el = render({status: 'in-progress', args: {}, result: undefined}); + expect(el.textContent).toContain('0 blocks'); + }); +}); diff --git a/shell/src/app/copilotkit/tool-renders/render-a2ui/render-a2ui-tool-render.ts b/shell/src/app/copilotkit/tool-renders/render-a2ui/render-a2ui-tool-render.ts new file mode 100644 index 00000000..098f9cb3 --- /dev/null +++ b/shell/src/app/copilotkit/tool-renders/render-a2ui/render-a2ui-tool-render.ts @@ -0,0 +1,60 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Component, computed, input} from '@angular/core'; +import {AngularToolCall, ToolRenderer} from '@copilotkit/angular'; + +/** Arguments delivered to the `render_a2ui` tool render. */ +export interface RenderA2uiArgs extends Record { + surfaceTitle?: string; + blocks: unknown[]; +} + +/** + * Presentational render for the `render_a2ui` tool call. Displays a compact + * card summarizing the surface being generated; it never invokes the LLM or a + * generation service and derives everything from the tool-call args + status. + */ +@Component({ + selector: 'a2ui-composer-render-a2ui-tool-render', + standalone: true, + template: ` +
+
{{ title() }}
+
{{ statusText() }}
+
{{ blockCaption() }}
+
+ `, + styleUrl: './render-a2ui-tool-render.scss', +}) +export class RenderA2uiToolRender implements ToolRenderer { + readonly toolCall = input.required>(); + + protected readonly title = computed( + () => `Generated ${this.toolCall().args.surfaceTitle ?? 'UI'}`, + ); + + protected readonly statusText = computed(() => + this.toolCall().status === 'complete' + ? 'Rendered · shown in canvas' + : 'Building your UI…', + ); + + protected readonly blockCaption = computed(() => { + const count = this.toolCall().args.blocks?.length ?? 0; + return `${count} block${count === 1 ? '' : 's'}`; + }); +} diff --git a/shell/src/app/copilotkit/tool-renders/repair/repair-tool-render.scss b/shell/src/app/copilotkit/tool-renders/repair/repair-tool-render.scss new file mode 100644 index 00000000..2d598867 --- /dev/null +++ b/shell/src/app/copilotkit/tool-renders/repair/repair-tool-render.scss @@ -0,0 +1,45 @@ +:host { + display: block; +} + +.repair-card { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + background: rgba(0, 0, 0, 0.02); + font-family: system-ui, -apple-system, sans-serif; + font-size: 13px; + line-height: 1.4; + overflow: hidden; +} + +.repair-card__header { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 10px 12px; + border: 0; + background: transparent; + font: inherit; + font-weight: 600; + color: rgba(0, 0, 0, 0.87); + text-align: left; + cursor: pointer; +} + +.repair-card__chevron { + font-size: 11px; + color: rgba(0, 0, 0, 0.45); +} + +.repair-card__list { + margin: 0; + padding: 4px 12px 10px 12px; + list-style: none; +} + +.repair-card__row { + padding: 2px 0; + color: rgba(0, 0, 0, 0.6); + font-variant-numeric: tabular-nums; +} diff --git a/shell/src/app/copilotkit/tool-renders/repair/repair-tool-render.spec.ts b/shell/src/app/copilotkit/tool-renders/repair/repair-tool-render.spec.ts new file mode 100644 index 00000000..ab5064fd --- /dev/null +++ b/shell/src/app/copilotkit/tool-renders/repair/repair-tool-render.spec.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {TestBed, ComponentFixture} from '@angular/core/testing'; +import {describe, it, expect, beforeEach} from 'vitest'; +import {AngularToolCall} from '@copilotkit/angular'; +import {RepairToolRender, RepairArgs} from './repair-tool-render'; + +describe('RepairToolRender', () => { + let fixture: ComponentFixture; + + function render(toolCall: AngularToolCall): HTMLElement { + fixture = TestBed.createComponent(RepairToolRender); + fixture.componentRef.setInput('toolCall', toolCall); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + beforeEach(async () => { + await TestBed.configureTestingModule({imports: [RepairToolRender]}).compileComponents(); + }); + + it('renders nothing when fixes is an empty array', () => { + const el = render({status: 'complete', args: {fixes: []}, result: 'ok'}); + expect(el.textContent?.trim()).toBe(''); + expect(el.querySelector('.repair-card')).toBeNull(); + }); + + it('renders nothing when fixes is absent', () => { + const el = render({status: 'in-progress', args: {}, result: undefined}); + expect(el.textContent?.trim()).toBe(''); + expect(el.querySelector('.repair-card')).toBeNull(); + }); + + it('shows "Fixed 1 issue(s)" and reveals the "from → to" row when expanded', () => { + const el = render({ + status: 'complete', + args: {fixes: [{from: 'textbox', to: 'TextField'}]}, + result: 'ok', + }); + + expect(el.textContent).toContain('Fixed 1 issue(s)'); + // Collapsed by default: the substitution row is not yet rendered. + expect(el.textContent).not.toContain('textbox → TextField'); + + const toggle = el.querySelector('button'); + expect(toggle).not.toBeNull(); + toggle!.click(); + fixture.detectChanges(); + + expect(el.textContent).toContain('textbox → TextField'); + }); + + it('counts every provided fix in the header', () => { + const el = render({ + status: 'complete', + args: { + fixes: [ + {from: 'textbox', to: 'TextField'}, + {from: 'btn', to: 'Button'}, + ], + }, + result: 'ok', + }); + expect(el.textContent).toContain('Fixed 2 issue(s)'); + }); +}); diff --git a/shell/src/app/copilotkit/tool-renders/repair/repair-tool-render.ts b/shell/src/app/copilotkit/tool-renders/repair/repair-tool-render.ts new file mode 100644 index 00000000..393331d8 --- /dev/null +++ b/shell/src/app/copilotkit/tool-renders/repair/repair-tool-render.ts @@ -0,0 +1,77 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Component, computed, input, signal} from '@angular/core'; +import {AngularToolCall, ToolRenderer} from '@copilotkit/angular'; + +/** A single component-type substitution applied by the repair pass. */ +export interface RepairFix { + from: string; + to: string; +} + +/** Arguments delivered to the `a2ui_repair` tool render. */ +export interface RepairArgs extends Record { + fixes?: RepairFix[]; +} + +/** + * Presentational render for the `a2ui_repair` tool call. When no fixes are + * present it renders nothing; otherwise it shows a compact, expandable card + * listing each `from → to` substitution. Pure display over args + status. + */ +@Component({ + selector: 'a2ui-composer-repair-tool-render', + standalone: true, + template: ` + @if (hasFixes()) { +
+ + @if (expanded()) { +
    + @for (fix of fixes(); track $index) { +
  • {{ fix.from }} → {{ fix.to }}
  • + } +
+ } +
+ } + `, + styleUrl: './repair-tool-render.scss', +}) +export class RepairToolRender implements ToolRenderer { + readonly toolCall = input.required>(); + + protected readonly expanded = signal(false); + + protected readonly fixes = computed(() => this.toolCall().args.fixes ?? []); + + protected readonly hasFixes = computed(() => this.fixes().length > 0); + + protected readonly headerText = computed(() => `Fixed ${this.fixes().length} issue(s)`); + + protected toggleExpanded(): void { + this.expanded.update(value => !value); + } +} diff --git a/shell/src/app/shell/composer-workspace/composer-workspace.ng.html b/shell/src/app/shell/composer-workspace/composer-workspace.ng.html index b7f01208..333842ea 100644 --- a/shell/src/app/shell/composer-workspace/composer-workspace.ng.html +++ b/shell/src/app/shell/composer-workspace/composer-workspace.ng.html @@ -15,10 +15,25 @@ -->
-
+ +
+
+ +
+
+
diff --git a/shell/src/app/shell/composer-workspace/composer-workspace.scss b/shell/src/app/shell/composer-workspace/composer-workspace.scss index 5d5aeb07..faa7bd8c 100644 --- a/shell/src/app/shell/composer-workspace/composer-workspace.scss +++ b/shell/src/app/shell/composer-workspace/composer-workspace.scss @@ -31,9 +31,32 @@ } } +// The Dockview area sits beside the docked sidebar. It must be allowed to +// shrink (min-width: 0) so Dockview measures/sizes its panels correctly. +.dockview-region { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; +} + +.dockview-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex: 0 0 auto; + padding: 6px 12px; + box-sizing: border-box; + border-bottom: 1px solid var(--mat-sys-outline-variant); + background: var(--mat-sys-surface-container); +} + .dockview-root { + flex: 1 1 auto; width: 100%; - height: 100%; min-width: 0; min-height: 0; } diff --git a/shell/src/app/shell/composer-workspace/composer-workspace.spec.ts b/shell/src/app/shell/composer-workspace/composer-workspace.spec.ts index 31299dc6..85d92fd1 100644 --- a/shell/src/app/shell/composer-workspace/composer-workspace.spec.ts +++ b/shell/src/app/shell/composer-workspace/composer-workspace.spec.ts @@ -30,13 +30,26 @@ import {LlmClient, LlmMessage} from '../../chat/llm-client/llm-client'; import {StateSync} from '../../chat/state-sync/state-sync'; import {ChatState, LlmLogEntry, LlmLogType} from '../../chat/chat-state/chat-state'; import {PipelineStatus} from '../../chat/pipeline-status/pipeline-status'; +import {CopilotSidebar} from '../../copilotkit/copilot-sidebar/copilot-sidebar'; import { AppConfigProvider, EnvMode, AuthType, ThemePreference, } from '../../settings/app-config-provider/app-config-provider'; -import {signal} from '@angular/core'; +import {Component, signal} from '@angular/core'; + +/** + * Stand-in for the real docked sidebar. Sharing its selector lets the workspace + * template resolve without booting the CopilotKit chat runtime, keeping this + * suite focused on Dockview orchestration (the sidebar has its own spec). + */ +@Component({ + selector: 'a2ui-composer-copilot-sidebar', + standalone: true, + template: '', +}) +class CopilotSidebarStub {} class MockChatState { readonly chatHistory = signal([]); @@ -124,7 +137,12 @@ describe('ComposerWorkspace Dashboard', () => { {provide: AppConfigProvider, useClass: MockAppConfigProvider}, {provide: LlmClient, useClass: MockLlmClient}, ], - }).compileComponents(); + }) + .overrideComponent(ComposerWorkspace, { + remove: {imports: [CopilotSidebar]}, + add: {imports: [CopilotSidebarStub]}, + }) + .compileComponents(); fixture = TestBed.createComponent(ComposerWorkspace); fixture.detectChanges(); @@ -135,19 +153,35 @@ describe('ComposerWorkspace Dashboard', () => { expect(harness).toBeTruthy(); }); - it('mounts all primary feature drawer placeholder components', () => { + it('mounts the default feature drawer components without Chat or Raw', () => { // Dockview dynamically renders panels via componentRefs. // In jsdom without real dimensions, dockview may not attach them all to the DOM, // so we verify they were instantiated by the Angular view container. + // Chat now lives in the docked sidebar and the Raw JSON editor is hidden at + // first load, so the default set is Rendered + DataModel + Events + Errors + + // RawMessages (5 panels). const refs = (fixture.componentInstance as unknown as {componentRefs: unknown[]}).componentRefs; - expect(refs.length).toBe(7); + expect(refs.length).toBe(5); const types = refs.map( (r: unknown) => (r as {componentType: {name: string}}).componentType.name, ); - expect(types).toContain('ChatPanel'); expect(types).toContain('RenderedFrame'); - expect(types).toContain('RawFrame'); expect(types).toContain('DataModel'); + expect(types).toContain('Events'); + expect(types).toContain('Errors'); + expect(types).toContain('RawMessages'); + expect(types).not.toContain('ChatPanel'); + expect(types).not.toContain('RawFrame'); + }); + + it('mounts the CopilotKit sidebar as a sibling of the Dockview region', () => { + const container = fixture.nativeElement.querySelector('.workspace-container'); + const sidebar = container?.querySelector('a2ui-composer-copilot-sidebar'); + const region = container?.querySelector('.dockview-region'); + expect(sidebar).toBeTruthy(); + expect(region).toBeTruthy(); + // Sidebar precedes the Dockview region within the flex container. + expect(sidebar?.nextElementSibling).toBe(region); }); it('delegates clearLogs to all queried child components when clearAllLogs is called', () => { @@ -558,4 +592,97 @@ describe('ComposerWorkspace Dashboard', () => { expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function), true); }); }); + + describe('Source (A2UI JSON editor) panel reveal', () => { + function rawPanel() { + return fixture.componentInstance['dockviewApi']?.getGroupPanel(ComposerPanelId.Raw); + } + + it('hides the A2UI JSON editor at first load', () => { + expect(rawPanel()).toBeUndefined(); + expect(fixture.componentInstance.isSourceOpen()).toBe(false); + }); + + it('toggleSource() reveals then hides the Raw panel and tracks isSourceOpen', async () => { + fixture.componentInstance.toggleSource(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(rawPanel()).toBeDefined(); + expect(fixture.componentInstance.isSourceOpen()).toBe(true); + + fixture.componentInstance.toggleSource(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(rawPanel()).toBeUndefined(); + expect(fixture.componentInstance.isSourceOpen()).toBe(false); + }); + + it('updates the source toggle button label to reflect the panel state', async () => { + const button: HTMLButtonElement = fixture.nativeElement.querySelector('.source-toggle'); + expect(button.textContent).toContain('Show source'); + + fixture.componentInstance.toggleSource(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(button.textContent).toContain('Hide source'); + }); + + it('auto-reveals the Raw panel the first time the pipeline reaches READY', async () => { + const chatState = TestBed.inject(ChatState) as unknown as MockChatState; + expect(rawPanel()).toBeUndefined(); + + chatState.setPipelineStatus(PipelineStatus.READY); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(rawPanel()).toBeDefined(); + expect(fixture.componentInstance.isSourceOpen()).toBe(true); + }); + + it('auto-reveals at most once, even after the user closes the panel', async () => { + const chatState = TestBed.inject(ChatState) as unknown as MockChatState; + + chatState.setPipelineStatus(PipelineStatus.READY); + fixture.detectChanges(); + await fixture.whenStable(); + expect(rawPanel()).toBeDefined(); + + // User closes the source panel. + fixture.componentInstance.toggleSource(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(rawPanel()).toBeUndefined(); + + // A subsequent generation cycle must not re-open it. + chatState.setPipelineStatus(PipelineStatus.IDLE); + chatState.setPipelineStatus(PipelineStatus.READY); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(rawPanel()).toBeUndefined(); + }); + + it('does not auto-reveal when the user has already toggled source', async () => { + const chatState = TestBed.inject(ChatState) as unknown as MockChatState; + + // User opens then closes source before any generation completes. + fixture.componentInstance.toggleSource(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentInstance.toggleSource(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(rawPanel()).toBeUndefined(); + + chatState.setPipelineStatus(PipelineStatus.READY); + fixture.detectChanges(); + await fixture.whenStable(); + + // Auto-reveal is suppressed because the user already controlled it. + expect(rawPanel()).toBeUndefined(); + }); + }); }); diff --git a/shell/src/app/shell/composer-workspace/composer-workspace.ts b/shell/src/app/shell/composer-workspace/composer-workspace.ts index dbf918c1..5e3fb843 100644 --- a/shell/src/app/shell/composer-workspace/composer-workspace.ts +++ b/shell/src/app/shell/composer-workspace/composer-workspace.ts @@ -32,7 +32,8 @@ import { ChangeDetectorRef, } from '@angular/core'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; -import {ChatPanel} from '../../chat/chat-panel/chat-panel'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; import {RawFrame} from '../../preview/raw/raw-frame'; import {RenderedFrame} from '../../preview/rendered/rendered-frame'; import {DataModel} from '../../debug/data-model/data-model'; @@ -49,6 +50,9 @@ import { } from '../../settings/app-config-provider/app-config-provider'; import {LocalStorageInteractions} from '../../storage/local-storage-interactions/local-storage-interactions'; import {LocalStorageKey} from '../../storage/models/local-storage-keys'; +import {ChatState} from '../../chat/chat-state/chat-state'; +import {PipelineStatus} from '../../chat/pipeline-status/pipeline-status'; +import {CopilotSidebar} from '../../copilotkit/copilot-sidebar/copilot-sidebar'; import {DockviewComponent} from 'dockview'; /** Internal interface mapping raw cross-frame workspace telemetry payloads */ @@ -78,6 +82,7 @@ export enum ComposerPanelId { @Component({ selector: 'a2ui-composer-workspace', standalone: true, + imports: [CopilotSidebar, MatButtonModule, MatIconModule], templateUrl: './composer-workspace.ng.html', styleUrl: './composer-workspace.scss', }) @@ -91,6 +96,7 @@ export class ComposerWorkspace implements OnInit, AfterViewInit { private viewContainerRef = inject(ViewContainerRef); private configProvider = inject(AppConfigProvider); private storage = inject(LocalStorageInteractions); + private chatState = inject(ChatState); readonly dockviewRoot = viewChild.required>('dockviewRoot'); @@ -100,6 +106,15 @@ export class ComposerWorkspace implements OnInit, AfterViewInit { unreadErrorsCount = signal(0); isDarkTheme = computed(() => this.configProvider.themePreference() === ThemePreference.DARK); + /** Whether the on-demand A2UI JSON editor ("source") panel is open. */ + readonly isSourceOpen = signal(false); + + /** Guards the first-READY auto-reveal so it fires at most once. */ + private hasAutoRevealedSource = false; + + /** Set once the user opens/closes source manually, suppressing auto-reveal. */ + private userToggledSource = false; + private readonly isDockviewInitialized = signal(false); private dockviewApi!: DockviewComponent; private componentRefs: ComponentRef[] = []; @@ -198,6 +213,22 @@ export class ComposerWorkspace implements OnInit, AfterViewInit { api.updateOptions({className: isDark ? 'dockview-theme-dark' : 'dockview-theme-light'}); } }); + + // Auto-reveal the A2UI JSON editor the first time a generation completes + // (pipeline reaches READY). Fires at most once, and never if the user has + // already toggled the source panel themselves. + effect(() => { + const status = this.chatState.pipelineStatus(); + const initialized = this.isDockviewInitialized(); + if (!initialized || status !== PipelineStatus.READY) return; + untracked(() => { + if (this.hasAutoRevealedSource || this.userToggledSource) return; + this.hasAutoRevealedSource = true; + if (!this.dockviewApi.getGroupPanel(ComposerPanelId.Raw)) { + this.addRawPanel(); + } + }); + }); } ngOnInit(): void { @@ -212,9 +243,6 @@ export class ComposerWorkspace implements OnInit, AfterViewInit { createComponent: options => { let type: Type | undefined; switch (options.name as ComposerPanelId) { - case ComposerPanelId.Chat: - type = ChatPanel; - break; case ComposerPanelId.Rendered: type = RenderedFrame; break; @@ -296,22 +324,13 @@ export class ComposerWorkspace implements OnInit, AfterViewInit { } if (!layoutRestored) { - this.dockviewApi.addPanel({ - id: ComposerPanelId.Chat, - component: ComposerPanelId.Chat, - title: 'Gemini Assistant', - }); + // Chat now lives in the docked CopilotKit sidebar (a Dockview sibling), + // so it is no longer a panel here. The A2UI JSON editor ("Raw") is hidden + // at first load and revealed on demand via toggleSource()/auto-reveal. this.dockviewApi.addPanel({ id: ComposerPanelId.Rendered, component: ComposerPanelId.Rendered, title: 'Rendered A2UI Preview', - position: {direction: 'right', referencePanel: ComposerPanelId.Chat}, - }); - this.dockviewApi.addPanel({ - id: ComposerPanelId.Raw, - component: ComposerPanelId.Raw, - title: 'A2UI JSON Editor', - position: {direction: 'right', referencePanel: ComposerPanelId.Rendered}, }); this.dockviewApi.addPanel({ @@ -360,8 +379,14 @@ export class ComposerWorkspace implements OnInit, AfterViewInit { ); }, 1000); }); - this.dockviewApi.onDidAddPanel(() => this.checkTabOverflow()); - this.dockviewApi.onDidRemovePanel(() => this.checkTabOverflow()); + this.dockviewApi.onDidAddPanel(panel => { + if (panel.id === ComposerPanelId.Raw) this.isSourceOpen.set(true); + this.checkTabOverflow(); + }); + this.dockviewApi.onDidRemovePanel(panel => { + if (panel.id === ComposerPanelId.Raw) this.isSourceOpen.set(false); + this.checkTabOverflow(); + }); this.resizeObserver = new ResizeObserver(() => this.checkTabOverflow()); this.resizeObserver.observe(this.dockviewRoot().nativeElement); @@ -371,6 +396,8 @@ export class ComposerWorkspace implements OnInit, AfterViewInit { const width = this.dockviewRoot().nativeElement.clientWidth || 1000; const height = this.dockviewRoot().nativeElement.clientHeight || 1000; this.dockviewApi.layout(width, height); + // Sync the source toggle with whatever the (possibly restored) layout holds. + this.isSourceOpen.set(!!this.dockviewApi.getGroupPanel(ComposerPanelId.Raw)); this.isDockviewInitialized.set(true); this.checkTabOverflow(); @@ -411,6 +438,37 @@ export class ComposerWorkspace implements OnInit, AfterViewInit { }); } + /** + * Adds the A2UI JSON editor ("Raw") panel to the right of the rendered + * preview, tolerating layouts where that reference panel is absent. + */ + private addRawPanel(): void { + const rendered = this.dockviewApi.getGroupPanel(ComposerPanelId.Rendered); + this.dockviewApi.addPanel({ + id: ComposerPanelId.Raw, + component: ComposerPanelId.Raw, + title: 'A2UI JSON Editor', + ...(rendered + ? {position: {direction: 'right', referencePanel: ComposerPanelId.Rendered}} + : {}), + }); + } + + /** + * Reveals or hides the on-demand A2UI JSON editor panel. Marks the source + * panel as user-controlled so the first-READY auto-reveal no longer fires. + */ + toggleSource(): void { + if (!this.isDockviewInitialized()) return; + this.userToggledSource = true; + const existing = this.dockviewApi.getGroupPanel(ComposerPanelId.Raw); + if (existing) { + existing.api.close(); + } else { + this.addRawPanel(); + } + } + /** * If the tabs don't fit in the available space, then we want the dockview * overflow selector to be displayed. Otherwise, it's redundant, so it diff --git a/shell/src/global_styles.scss b/shell/src/global_styles.scss index 32df975e..4a4c4ce5 100644 --- a/shell/src/global_styles.scss +++ b/shell/src/global_styles.scss @@ -16,6 +16,7 @@ @use '@angular/material' as mat; @import 'dockview/dist/styles/dockview.css'; +@import '@copilotkit/angular/styles.css'; // TEMPORARY (spike): CopilotKit sidebar styles @include mat.core(); diff --git a/yarn.lock b/yarn.lock index e5fb9a9d..b05ccb65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -84,6 +84,54 @@ __metadata: languageName: node linkType: hard +"@ag-ui/client@npm:0.0.57": + version: 0.0.57 + resolution: "@ag-ui/client@npm:0.0.57" + dependencies: + "@ag-ui/core": "npm:0.0.57" + "@ag-ui/encoder": "npm:0.0.57" + "@ag-ui/proto": "npm:0.0.57" + "@types/uuid": "npm:^10.0.0" + compare-versions: "npm:^6.1.1" + fast-json-patch: "npm:^3.1.1" + rxjs: "npm:7.8.1" + untruncate-json: "npm:^0.0.1" + uuid: "npm:^11.1.0" + zod: "npm:^3.22.4" + checksum: 10c0/396f25f91896b1993134d30c7a4604afded351d2fe475c874a48550793d74f5295e444126733865f8b2a7d847839f0fb75369a869e7a73a824f4cb685979fa6c + languageName: node + linkType: hard + +"@ag-ui/core@npm:0.0.57": + version: 0.0.57 + resolution: "@ag-ui/core@npm:0.0.57" + dependencies: + zod: "npm:^3.22.4" + checksum: 10c0/df7ba9682b70fd839945fdd7b0742d71f06782682c9f8d337efe11cdd22cf5091b2a96bb104747e41734abce9a571c1206eb33c3623aa2073399202cb4b85eb2 + languageName: node + linkType: hard + +"@ag-ui/encoder@npm:0.0.57": + version: 0.0.57 + resolution: "@ag-ui/encoder@npm:0.0.57" + dependencies: + "@ag-ui/core": "npm:0.0.57" + "@ag-ui/proto": "npm:0.0.57" + checksum: 10c0/dc95d6107e41adcaa476ceb7d8dc482378866d211f7292c1d3f6959d16b79c917d3f208f01e18f5ddde6cefefe85139da0f2f46507443bdfff9e99c00dde95ea + languageName: node + linkType: hard + +"@ag-ui/proto@npm:0.0.57": + version: 0.0.57 + resolution: "@ag-ui/proto@npm:0.0.57" + dependencies: + "@ag-ui/core": "npm:0.0.57" + "@bufbuild/protobuf": "npm:^2.2.5" + "@protobuf-ts/protoc": "npm:^2.11.1" + checksum: 10c0/21bff76d609a76a948f977312363307610e3928f6a4fc02396aa1d51621991b4ef9090e1998e3ae41213d9bae64497d37cca542b7ed4f2f2aab05777ce05a761 + languageName: node + linkType: hard + "@algolia/abtesting@npm:1.18.0": version: 1.18.0 resolution: "@algolia/abtesting@npm:1.18.0" @@ -2046,6 +2094,103 @@ __metadata: languageName: node linkType: hard +"@bufbuild/protobuf@npm:^2.2.5": + version: 2.13.0 + resolution: "@bufbuild/protobuf@npm:2.13.0" + checksum: 10c0/bb4d39512302887399355ac2ba1228e562c1cae818ff88b4b85b3060758aba5fec94786fe80f69de8878f98e730911c0bd145741adb582d0abf6b42de921e50c + languageName: node + linkType: hard + +"@copilotkit/a2ui-renderer@npm:^1.60.2": + version: 1.64.1 + resolution: "@copilotkit/a2ui-renderer@npm:1.64.1" + dependencies: + "@a2ui/web_core": "npm:0.9.0" + clsx: "npm:^2.1.1" + lit: "npm:^3.3.2" + zod: "npm:^3.25.75" + zod-to-json-schema: "npm:^3.24.1" + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + checksum: 10c0/024488e78a9874e6f97f1e7517a9a12a89347ec595f254194edc7c41b2201112fe154d070ef1c2f18d911147ee7d769543888fe65e1a37ee2651a515a39ab7ca + languageName: node + linkType: hard + +"@copilotkit/angular@npm:^0.1.2": + version: 0.1.2 + resolution: "@copilotkit/angular@npm:0.1.2" + dependencies: + "@ag-ui/client": "npm:0.0.57" + "@ag-ui/core": "npm:0.0.57" + "@copilotkit/a2ui-renderer": "npm:^1.60.2" + "@copilotkit/core": "npm:^1.60.2" + "@copilotkit/shared": "npm:^1.60.2" + "@jetbrains/websandbox": "npm:^1.1.3" + clsx: "npm:^2.1.1" + highlight.js: "npm:^11.11.1" + katex: "npm:^0.16.22" + lucide-angular: "npm:^0.540.0" + marked: "npm:^16.2.0" + rxjs: "npm:^7.8.1" + tailwind-merge: "npm:^2.6.0" + tslib: "npm:^2.6.0" + zod-to-json-schema: "npm:^3.24.5" + peerDependencies: + "@angular/cdk": ^19.0.0 || ^20.0.0 || ^21.0.0 + "@angular/common": ^19.0.0 || ^20.0.0 || ^21.0.0 + "@angular/core": ^19.0.0 || ^20.0.0 || ^21.0.0 + rxjs: ^7.8.0 + checksum: 10c0/bd5d474525d86ad6e54972358b2d77a802ecbef16fe7065564ccfd4352363a8747f11f15049e7bd7025a6e37d930157414eaf9ca899725a19c44dbe6181bdcb3 + languageName: node + linkType: hard + +"@copilotkit/core@npm:^1.60.2": + version: 1.64.1 + resolution: "@copilotkit/core@npm:1.64.1" + dependencies: + "@ag-ui/client": "npm:0.0.57" + "@copilotkit/shared": "npm:1.64.1" + "@tanstack/pacer": "npm:^0.20.1" + phoenix: "npm:^1.8.4" + rxjs: "npm:7.8.1" + zod-to-json-schema: "npm:^3.24.6" + checksum: 10c0/2d8710f4e06a7becfadd7f62bda9edfcbc2c2d4d7c7be51b70c7c5f148a6af5c8e7d243bab9c885fb5ecb141053ac212d524e89c7fe14c16748e2104fb403e03 + languageName: node + linkType: hard + +"@copilotkit/license-verifier@npm:~0.5.0": + version: 0.5.0 + resolution: "@copilotkit/license-verifier@npm:0.5.0" + checksum: 10c0/29ff041d915c99a0bc007178ea6b3fb09f5dfb2a8475628d3619a7e10623e40bad231c477de4e1962183c5bae9ca22a5cb3b747fa58f4549be8982a3af018778 + languageName: node + linkType: hard + +"@copilotkit/shared@npm:1.64.1, @copilotkit/shared@npm:^1.60.2": + version: 1.64.1 + resolution: "@copilotkit/shared@npm:1.64.1" + dependencies: + "@ag-ui/client": "npm:0.0.57" + "@copilotkit/license-verifier": "npm:~0.5.0" + "@segment/analytics-node": "npm:^2.1.2" + "@standard-schema/spec": "npm:^1.0.0" + chalk: "npm:4.1.2" + graphql: "npm:^16.8.1" + partial-json: "npm:^0.1.7" + uuid: "npm:^11.1.0" + zod: "npm:^3.23.3" + zod-to-json-schema: "npm:^3.23.5" + peerDependencies: + "@ag-ui/core": ">=0.0.48" + checksum: 10c0/b5fa334b12ee17f48e846bd333fd83065630fcabe4f2832a4601734c94a541162db3e77439f2aec3e622ff5307ae9e97a75c2784f5d607f602625c855bce702e + languageName: node + linkType: hard + "@csstools/color-helpers@npm:^6.0.2": version: 6.0.2 resolution: "@csstools/color-helpers@npm:6.0.2" @@ -2960,6 +3105,13 @@ __metadata: languageName: node linkType: hard +"@jetbrains/websandbox@npm:^1.1.3": + version: 1.3.1 + resolution: "@jetbrains/websandbox@npm:1.3.1" + checksum: 10c0/0bcdef6ff3e98d94b5ede7cc1dc185144d42127ceb434fb0ca1c6a8792f21135c87a35d347d3b6e3fc61b9931d434dac586941602a0430df4654de1689dd6578 + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5": version: 0.3.13 resolution: "@jridgewell/gen-mapping@npm:0.3.13" @@ -3357,6 +3509,22 @@ __metadata: languageName: node linkType: hard +"@lukeed/csprng@npm:^1.1.0": + version: 1.1.0 + resolution: "@lukeed/csprng@npm:1.1.0" + checksum: 10c0/5d6dcf478af732972083ab2889c294b57f1028fa13c2c240d7a4aaa079c2c75df7ef0dcbdda5419147fc6704b4adf96b2de92f1a9a72ac21c6350c4014fffe6c + languageName: node + linkType: hard + +"@lukeed/uuid@npm:^2.0.0": + version: 2.0.1 + resolution: "@lukeed/uuid@npm:2.0.1" + dependencies: + "@lukeed/csprng": "npm:^1.1.0" + checksum: 10c0/f9cc0385021f352f444d96dd101afd2a0efd3b2e85a61ac67deb8220409f75a6a426ed6525d297d97746f7931e3079ac6218777551a7c82686de7d292220cb1f + languageName: node + linkType: hard + "@modelcontextprotocol/sdk@npm:1.29.0": version: 1.29.0 resolution: "@modelcontextprotocol/sdk@npm:1.29.0" @@ -4269,6 +4437,15 @@ __metadata: languageName: node linkType: hard +"@protobuf-ts/protoc@npm:^2.11.1": + version: 2.11.1 + resolution: "@protobuf-ts/protoc@npm:2.11.1" + bin: + protoc: protoc.js + checksum: 10c0/6a3cbcaeede068c94b48273271426192f438328e13019aafebb88e1153efac38b3ad358603751d7ac8b1d2cbef5ff515eaf960796dcce9daafbe2013255bdb0e + languageName: node + linkType: hard + "@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": version: 1.1.2 resolution: "@protobufjs/aspromise@npm:1.1.2" @@ -4921,6 +5098,42 @@ __metadata: languageName: node linkType: hard +"@segment/analytics-core@npm:1.8.2": + version: 1.8.2 + resolution: "@segment/analytics-core@npm:1.8.2" + dependencies: + "@lukeed/uuid": "npm:^2.0.0" + "@segment/analytics-generic-utils": "npm:1.2.0" + dset: "npm:^3.1.4" + tslib: "npm:^2.4.1" + checksum: 10c0/4f20736f1b40a22e22691098b7fb620ee4db2bce6c557d52444afa2a093bc35defe673218b220a110a2e7b0d993fed7866216991de0179cc95baeed21f1b88a1 + languageName: node + linkType: hard + +"@segment/analytics-generic-utils@npm:1.2.0": + version: 1.2.0 + resolution: "@segment/analytics-generic-utils@npm:1.2.0" + dependencies: + tslib: "npm:^2.4.1" + checksum: 10c0/2f9aebc1027ed2d1afcb02338ed4971f774ce25250c499cac6c1c0020a7376e05b56411d38d36ee1cf0cec49cfdb82e68cd3f5d868b47b9e61b3b668bd27e122 + languageName: node + linkType: hard + +"@segment/analytics-node@npm:^2.1.2": + version: 2.3.0 + resolution: "@segment/analytics-node@npm:2.3.0" + dependencies: + "@lukeed/uuid": "npm:^2.0.0" + "@segment/analytics-core": "npm:1.8.2" + "@segment/analytics-generic-utils": "npm:1.2.0" + buffer: "npm:^6.0.3" + jose: "npm:^5.1.0" + node-fetch: "npm:^2.6.7" + tslib: "npm:^2.4.1" + checksum: 10c0/679ecc9ba5a275a31c5bb220b33fb38d87935e383ddcaabb2424ff60fd9ab0bb70e9931e151bcc11022b583c2e797bd82233ead700b741830e449c883ea27d1a + languageName: node + linkType: hard + "@sigstore/bundle@npm:^4.0.0": version: 4.0.0 resolution: "@sigstore/bundle@npm:4.0.0" @@ -4986,6 +5199,32 @@ __metadata: languageName: node linkType: hard +"@tanstack/devtools-event-client@npm:^0.4.3": + version: 0.4.4 + resolution: "@tanstack/devtools-event-client@npm:0.4.4" + bin: + intent: ./bin/intent.js + checksum: 10c0/81884dc159fb30f896a804f2c0da2c3de75da1a7686850d3d59dedce4d583ea533a0396f9d27fb72f04134116a6193d9e52396674c29adb8be31fb331224e746 + languageName: node + linkType: hard + +"@tanstack/pacer@npm:^0.20.1": + version: 0.20.1 + resolution: "@tanstack/pacer@npm:0.20.1" + dependencies: + "@tanstack/devtools-event-client": "npm:^0.4.3" + "@tanstack/store": "npm:^0.9.3" + checksum: 10c0/95d5abb3ebaf4401703f3079958e6a6bc76c07be20483b28602d3fbc8366fd964bb6a98384eae175dcc8299f7ac01539c87e98e2f3a9ccd6db1df2cba29d359d + languageName: node + linkType: hard + +"@tanstack/store@npm:^0.9.3": + version: 0.9.3 + resolution: "@tanstack/store@npm:0.9.3" + checksum: 10c0/ec022c792c298be0717d7a2d06d6db4459077db775a27d86a2a248f446257e133c5d7c77a74bf6a4fa6993cfaef09b6f58a68a601c3becca834ed0b457ebe824 + languageName: node + linkType: hard + "@ts-morph/common@npm:~0.22.0": version: 0.22.0 resolution: "@ts-morph/common@npm:0.22.0" @@ -5345,6 +5584,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^10.0.0": + version: 10.0.0 + resolution: "@types/uuid@npm:10.0.0" + checksum: 10c0/9a1404bf287164481cb9b97f6bb638f78f955be57c40c6513b7655160beb29df6f84c915aaf4089a1559c216557dc4d2f79b48d978742d3ae10b937420ddac60 + languageName: node + linkType: hard + "@types/ws@npm:^8.5.10": version: 8.18.1 resolution: "@types/ws@npm:8.18.1" @@ -5942,6 +6188,7 @@ __metadata: "@angular/platform-browser": "npm:22.0.7" "@angular/platform-browser-dynamic": "npm:22.0.7" "@angular/router": "npm:22.0.7" + "@copilotkit/angular": "npm:^0.1.2" "@google/genai": "npm:2.11.0" "@monaco-editor/loader": "npm:1.7.0" "@playwright/test": "npm:1.61.1" @@ -6188,6 +6435,15 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^4.1.0": + version: 4.3.0 + resolution: "ansi-styles@npm:4.3.0" + dependencies: + color-convert: "npm:^2.0.1" + checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041 + languageName: node + linkType: hard + "ansi-styles@npm:^6.2.1, ansi-styles@npm:^6.2.3": version: 6.2.3 resolution: "ansi-styles@npm:6.2.3" @@ -6373,7 +6629,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.3.0": +"base64-js@npm:^1.3.0, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf @@ -6553,6 +6809,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 10c0/2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0 + languageName: node + linkType: hard + "bundle-name@npm:^4.1.0": version: 4.1.0 resolution: "bundle-name@npm:4.1.0" @@ -6635,6 +6901,16 @@ __metadata: languageName: node linkType: hard +"chalk@npm:4.1.2": + version: 4.1.2 + resolution: "chalk@npm:4.1.2" + dependencies: + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880 + languageName: node + linkType: hard + "chalk@npm:^5.6.2": version: 5.6.2 resolution: "chalk@npm:5.6.2" @@ -6769,6 +7045,22 @@ __metadata: languageName: node linkType: hard +"color-convert@npm:^2.0.1": + version: 2.0.1 + resolution: "color-convert@npm:2.0.1" + dependencies: + color-name: "npm:~1.1.4" + checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7 + languageName: node + linkType: hard + +"color-name@npm:~1.1.4": + version: 1.1.4 + resolution: "color-name@npm:1.1.4" + checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 + languageName: node + linkType: hard + "colorette@npm:^2.0.10": version: 2.0.20 resolution: "colorette@npm:2.0.20" @@ -6783,6 +7075,20 @@ __metadata: languageName: node linkType: hard +"commander@npm:^8.3.0": + version: 8.3.0 + resolution: "commander@npm:8.3.0" + checksum: 10c0/8b043bb8322ea1c39664a1598a95e0495bfe4ca2fad0d84a92d7d1d8d213e2a155b441d2470c8e08de7c4a28cf2bc6e169211c49e1b21d9f7edc6ae4d9356060 + languageName: node + linkType: hard + +"compare-versions@npm:^6.1.1": + version: 6.1.1 + resolution: "compare-versions@npm:6.1.1" + checksum: 10c0/415205c7627f9e4f358f571266422980c9fe2d99086be0c9a48008ef7c771f32b0fbe8e97a441ffedc3910872f917a0675fe0fe3c3b6d331cda6d8690be06338 + languageName: node + linkType: hard + "compressible@npm:~2.0.18": version: 2.0.18 resolution: "compressible@npm:2.0.18" @@ -7232,6 +7538,13 @@ __metadata: languageName: node linkType: hard +"dset@npm:^3.1.4": + version: 3.1.4 + resolution: "dset@npm:3.1.4" + checksum: 10c0/b67bbd28dd8a539e90c15ffb61100eb64ef995c5270a124d4f99bbb53f4d82f55a051b731ba81f3215dd9dce2b4c8d69927dc20b3be1c5fc88bab159467aa438 + languageName: node + linkType: hard + "dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -7901,6 +8214,13 @@ __metadata: languageName: node linkType: hard +"fast-json-patch@npm:^3.1.1": + version: 3.1.1 + resolution: "fast-json-patch@npm:3.1.1" + checksum: 10c0/8a0438b4818bb53153275fe5b38033610e8c9d9eb11869e6a7dc05eb92fa70f3caa57015e344eb3ae1e71c7a75ad4cc6bc2dc9e0ff281d6ed8ecd44505210ca8 + languageName: node + linkType: hard + "fast-json-stable-stringify@npm:^2.0.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" @@ -8339,6 +8659,13 @@ __metadata: languageName: node linkType: hard +"graphql@npm:^16.8.1": + version: 16.14.2 + resolution: "graphql@npm:16.14.2" + checksum: 10c0/a95a96961eaff55cc9fe9d31fae6f33499ac988b972d07ea5085024cb1333f515b902f376e7393a5489aa82200a8aff3eb96580e4d1b69d702ed19b6eb1ce97a + languageName: node + linkType: hard + "handle-thing@npm:^2.0.0": version: 2.0.1 resolution: "handle-thing@npm:2.0.1" @@ -8369,6 +8696,13 @@ __metadata: languageName: node linkType: hard +"highlight.js@npm:^11.11.1": + version: 11.11.1 + resolution: "highlight.js@npm:11.11.1" + checksum: 10c0/40f53ac19dac079891fcefd5bd8a21cf2e8931fd47da5bd1dca73b7e4375c1defed0636fc39120c639b9c44119b7d110f7f0c15aa899557a5a1c8910f3c0144c + languageName: node + linkType: hard + "hono@npm:^4.11.4": version: 4.12.31 resolution: "hono@npm:4.12.31" @@ -8588,6 +8922,13 @@ __metadata: languageName: node linkType: hard +"ieee754@npm:^1.2.1": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb + languageName: node + linkType: hard + "ignore-walk@npm:^8.0.0": version: 8.0.0 resolution: "ignore-walk@npm:8.0.0" @@ -8926,6 +9267,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^5.1.0": + version: 5.10.0 + resolution: "jose@npm:5.10.0" + checksum: 10c0/e20d9fc58d7e402f2e5f04e824b8897d5579aae60e64cb88ebdea1395311c24537bf4892f7de413fab1acf11e922797fb1b42269bc8fc65089a3749265ccb7b0 + languageName: node + linkType: hard + "jose@npm:^6.1.3": version: 6.2.3 resolution: "jose@npm:6.2.3" @@ -9112,6 +9460,17 @@ __metadata: languageName: node linkType: hard +"katex@npm:^0.16.22": + version: 0.16.47 + resolution: "katex@npm:0.16.47" + dependencies: + commander: "npm:^8.3.0" + bin: + katex: cli.js + checksum: 10c0/b10f4d0651c60771a48444879e4227255e26e2b2ec061b1ee4b08934863ad2324ba8dbb772455f7768aeb14dfcc13bcd309174a0ddd5ef954a607f644a197710 + languageName: node + linkType: hard + "keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -9403,7 +9762,7 @@ __metadata: languageName: node linkType: hard -"lit@npm:3.3.3, lit@npm:^2.0.0 || ^3.0.0, lit@npm:^3.3.3": +"lit@npm:3.3.3, lit@npm:^2.0.0 || ^3.0.0, lit@npm:^3.3.2, lit@npm:^3.3.3": version: 3.3.3 resolution: "lit@npm:3.3.3" dependencies: @@ -9540,6 +9899,18 @@ __metadata: languageName: node linkType: hard +"lucide-angular@npm:^0.540.0": + version: 0.540.0 + resolution: "lucide-angular@npm:0.540.0" + dependencies: + tslib: "npm:^2.3.0" + peerDependencies: + "@angular/common": 13.x - 20.x + "@angular/core": 13.x - 20.x + checksum: 10c0/ecfd935487ec90724460c1a6e3d84a28732702db150208a894f62aa97e68464fa9ae40339f989c15fec9594a3a73f94c4c3b8052b83e8bd9d5f1faad4a3a56b4 + languageName: node + linkType: hard + "magic-string@npm:0.30.21, magic-string@npm:^0.30.21": version: 0.30.21 resolution: "magic-string@npm:0.30.21" @@ -9624,6 +9995,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:^16.2.0": + version: 16.4.2 + resolution: "marked@npm:16.4.2" + bin: + marked: bin/marked.js + checksum: 10c0/fc6051142172454f2023f3d6b31cca92879ec8e1b96457086a54c70354c74b00e1b6543a76a1fad6d399366f52b90a848f6ffb8e1d65a5baff87f3ba9b8f1847 + languageName: node + linkType: hard + "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" @@ -10115,6 +10495,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.6.7": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8 + languageName: node + linkType: hard + "node-fetch@npm:^3.3.2": version: 3.3.2 resolution: "node-fetch@npm:3.3.2" @@ -10631,6 +11025,13 @@ __metadata: languageName: node linkType: hard +"partial-json@npm:^0.1.7": + version: 0.1.7 + resolution: "partial-json@npm:0.1.7" + checksum: 10c0/cd5f994c3a5ca903918c028a6947ebc1d46459234c1c57c7ab98e234d8dca49cb46b05a71889ee422b39d1f66b95c59a5ce3a6ae06966aca95a8960ad20c12d2 + languageName: node + linkType: hard + "path-browserify@npm:^1.0.1": version: 1.0.1 resolution: "path-browserify@npm:1.0.1" @@ -10690,6 +11091,13 @@ __metadata: languageName: node linkType: hard +"phoenix@npm:^1.8.4": + version: 1.8.9 + resolution: "phoenix@npm:1.8.9" + checksum: 10c0/9f661db2c0e3986ec3cce4969ec4906a02e15675bd9123fbb8ae7a8caf61213ce20420be505f52dc0a9de9117021a21e9b1344964400530e26474e45d7819c86 + languageName: node + linkType: hard + "picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" @@ -12315,6 +12723,13 @@ __metadata: languageName: node linkType: hard +"tailwind-merge@npm:^2.6.0": + version: 2.6.1 + resolution: "tailwind-merge@npm:2.6.1" + checksum: 10c0/f9b5d7ba37f6c6dc7bb7a090f08252e8d827b5abfc1031bf468c5274ce104409e7952a0075a3e009aab53adda8c6d133bc1dd9d3427e2ae5bc00306f9ce1fbff + languageName: node + linkType: hard + "tapable@npm:^2.2.1, tapable@npm:^2.3.0, tapable@npm:^2.3.3": version: 2.3.3 resolution: "tapable@npm:2.3.3" @@ -12511,6 +12926,13 @@ __metadata: languageName: node linkType: hard +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11 + languageName: node + linkType: hard + "tree-dump@npm:^1.0.3, tree-dump@npm:^1.1.0": version: 1.1.0 resolution: "tree-dump@npm:1.1.0" @@ -12539,7 +12961,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": +"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.4.1, tslib@npm:^2.6.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -12718,6 +13140,13 @@ __metadata: languageName: node linkType: hard +"untruncate-json@npm:^0.0.1": + version: 0.0.1 + resolution: "untruncate-json@npm:0.0.1" + checksum: 10c0/9fbd68098fcbee72c7178a36de624732dea67527117859540c546de9c03403ecb28a41f82bc8097f9f66f40485c749e5170d935e580648f0b7f9412641ad899c + languageName: node + linkType: hard + "update-browserslist-db@npm:^1.2.3": version: 1.2.3 resolution: "update-browserslist-db@npm:1.2.3" @@ -12755,6 +13184,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^11.1.0": + version: 11.1.1 + resolution: "uuid@npm:11.1.1" + bin: + uuid: dist/esm/bin/uuid + checksum: 10c0/9e3af58eba872ece5a5e76f4773a94fc78a0ef2c2444c38dbe6b42f41dadf76c01850fd783604f27986f6195e6286aef064d45987d401b2a33127b98ddf7c0c5 + languageName: node + linkType: hard + "uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" @@ -13066,6 +13504,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db + languageName: node + linkType: hard + "webidl-conversions@npm:^8.0.1": version: 8.0.1 resolution: "webidl-conversions@npm:8.0.1" @@ -13261,6 +13706,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5 + languageName: node + linkType: hard + "which@npm:^2.0.1": version: 2.0.2 resolution: "which@npm:2.0.2" @@ -13460,7 +13915,7 @@ __metadata: languageName: node linkType: hard -"zod-to-json-schema@npm:^3.25.1, zod-to-json-schema@npm:^3.25.2": +"zod-to-json-schema@npm:^3.23.5, zod-to-json-schema@npm:^3.24.1, zod-to-json-schema@npm:^3.24.5, zod-to-json-schema@npm:^3.24.6, zod-to-json-schema@npm:^3.25.1, zod-to-json-schema@npm:^3.25.2": version: 3.25.2 resolution: "zod-to-json-schema@npm:3.25.2" peerDependencies: @@ -13483,7 +13938,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25.76": +"zod@npm:^3.22.4, zod@npm:^3.23.3, zod@npm:^3.25.75, zod@npm:^3.25.76": version: 3.25.76 resolution: "zod@npm:3.25.76" checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c