From affd62ca798c9fa3508bef1779988461428ef5d2 Mon Sep 17 00:00:00 2001 From: hugo Date: Thu, 26 Feb 2026 09:27:22 +0100 Subject: [PATCH] wip: complete rework first round --- .vscode/settings.json | 3 +- API.md | 86 +--- src/main/a2ui/catalog.ts | 64 +++ src/main/a2ui/generator.ts | 393 ++++++++++++++++++ src/main/a2ui/types.ts | 134 ++++++ src/main/agentic/capabilities/registry.ts | 94 ----- .../observability/protocolTelemetry.ts | 63 --- src/main/agentic/policy/actionPolicy.ts | 30 -- src/main/agentic/protocol/errors.ts | 9 - src/main/agentic/protocol/responseBuilder.ts | 357 ---------------- src/main/agentic/protocol/types.ts | 82 ---- src/main/agentic/protocol/uiSchema.ts | 154 ------- src/main/agentic/protocol/uiSpecParser.ts | 270 ------------ src/main/agentic/protocol/validator.ts | 112 ----- src/main/agentic/workflow/checkpointStore.ts | 50 --- src/main/agentic/workflow/turnStateMachine.ts | 45 -- src/main/engine/ChatEngine.ts | 26 +- src/main/engine/OpenCodeManager.ts | 370 ++++++++++------- src/main/ipc/chatHandlers.ts | 24 +- src/main/preload.ts | 9 +- src/main/shared/electronApi.ts | 56 +-- src/renderer/a2ui/A2UIRenderer.tsx | 101 +++++ src/renderer/a2ui/A2UISurfaceManager.ts | 244 +++++++++++ src/renderer/a2ui/components/A2UIButton.tsx | 41 ++ src/renderer/a2ui/components/A2UICard.tsx | 54 +++ src/renderer/a2ui/components/A2UIChart.tsx | 36 ++ src/renderer/a2ui/components/A2UICheckBox.tsx | 32 ++ .../a2ui/components/A2UIChoicePicker.tsx | 44 ++ src/renderer/a2ui/components/A2UIColumn.tsx | 18 + .../a2ui/components/A2UIDateTimeInput.tsx | 37 ++ src/renderer/a2ui/components/A2UIDivider.tsx | 14 + src/renderer/a2ui/components/A2UIForm.tsx | 21 + src/renderer/a2ui/components/A2UIImage.tsx | 48 +++ src/renderer/a2ui/components/A2UIList.tsx | 26 ++ src/renderer/a2ui/components/A2UIMetric.tsx | 22 + src/renderer/a2ui/components/A2UIRow.tsx | 18 + src/renderer/a2ui/components/A2UITable.tsx | 40 ++ src/renderer/a2ui/components/A2UITabs.tsx | 37 ++ src/renderer/a2ui/components/A2UIText.tsx | 16 + .../a2ui/components/A2UITextField.tsx | 51 +++ src/renderer/a2ui/components/index.ts | 17 + src/renderer/a2ui/useA2UISurface.ts | 111 +++++ .../AssistantPanelControls.css | 173 -------- .../AssistantPanelControls.tsx | 310 -------------- .../AssistantPanelControls/index.ts | 1 - .../AssistantSidebar/AssistantSidebar.tsx | 97 +---- .../components/ChatPanel/ChatPanel.tsx | 97 +---- src/renderer/components/Editor/Editor.tsx | 28 +- src/renderer/components/index.ts | 1 - src/renderer/navigation/assistantPanelSpec.ts | 273 ------------ src/renderer/navigation/chatSession.ts | 12 +- .../navigation/protocolActionPolicies.ts | 18 - src/renderer/navigation/protocolNeedsInput.ts | 38 -- src/renderer/python/pythonApiContractV1.ts | 31 +- tests/engine/ChatEngine.test.ts | 8 +- tests/engine/OpenCodeManager.protocol.test.ts | 225 ---------- tests/engine/a2ui/catalog.test.ts | 80 ++++ tests/engine/a2ui/generator.test.ts | 263 ++++++++++++ tests/engine/a2ui/surfaceManager.test.ts | 345 +++++++++++++++ .../agentic/capabilities/registry.test.ts | 32 -- .../observability/protocolTelemetry.test.ts | 58 --- .../agentic/policy/actionPolicy.test.ts | 21 - .../agentic/protocol/responseBuilder.test.ts | 262 ------------ .../agentic/protocol/uiSpecParser.test.ts | 93 ----- .../engine/agentic/protocol/validator.test.ts | 93 ----- .../agentic/workflow/checkpointStore.test.ts | 77 ---- .../agentic/workflow/stateMachine.test.ts | 106 ----- tests/ipc/chatHandlers.test.ts | 48 +-- .../AssistantSidebar.wiring.test.tsx | 3 +- .../EditorDashboardTimeline.test.tsx | 26 +- .../navigation/assistantPanelSpec.test.ts | 240 ----------- .../navigation/assistantSidebarGuards.test.ts | 62 +-- .../chatSurfaceModeUsageGuards.test.ts | 3 +- .../navigation/chatSurfaceUsageGuards.test.ts | 3 +- .../navigation/protocolActionPolicies.test.ts | 56 --- .../navigation/protocolNeedsInput.test.ts | 30 -- .../python/pythonApiContractV1.test.ts | 7 +- .../python/pythonApiInvokerV1.test.ts | 9 - 78 files changed, 2635 insertions(+), 4053 deletions(-) create mode 100644 src/main/a2ui/catalog.ts create mode 100644 src/main/a2ui/generator.ts create mode 100644 src/main/a2ui/types.ts delete mode 100644 src/main/agentic/capabilities/registry.ts delete mode 100644 src/main/agentic/observability/protocolTelemetry.ts delete mode 100644 src/main/agentic/policy/actionPolicy.ts delete mode 100644 src/main/agentic/protocol/errors.ts delete mode 100644 src/main/agentic/protocol/responseBuilder.ts delete mode 100644 src/main/agentic/protocol/types.ts delete mode 100644 src/main/agentic/protocol/uiSchema.ts delete mode 100644 src/main/agentic/protocol/uiSpecParser.ts delete mode 100644 src/main/agentic/protocol/validator.ts delete mode 100644 src/main/agentic/workflow/checkpointStore.ts delete mode 100644 src/main/agentic/workflow/turnStateMachine.ts create mode 100644 src/renderer/a2ui/A2UIRenderer.tsx create mode 100644 src/renderer/a2ui/A2UISurfaceManager.ts create mode 100644 src/renderer/a2ui/components/A2UIButton.tsx create mode 100644 src/renderer/a2ui/components/A2UICard.tsx create mode 100644 src/renderer/a2ui/components/A2UIChart.tsx create mode 100644 src/renderer/a2ui/components/A2UICheckBox.tsx create mode 100644 src/renderer/a2ui/components/A2UIChoicePicker.tsx create mode 100644 src/renderer/a2ui/components/A2UIColumn.tsx create mode 100644 src/renderer/a2ui/components/A2UIDateTimeInput.tsx create mode 100644 src/renderer/a2ui/components/A2UIDivider.tsx create mode 100644 src/renderer/a2ui/components/A2UIForm.tsx create mode 100644 src/renderer/a2ui/components/A2UIImage.tsx create mode 100644 src/renderer/a2ui/components/A2UIList.tsx create mode 100644 src/renderer/a2ui/components/A2UIMetric.tsx create mode 100644 src/renderer/a2ui/components/A2UIRow.tsx create mode 100644 src/renderer/a2ui/components/A2UITable.tsx create mode 100644 src/renderer/a2ui/components/A2UITabs.tsx create mode 100644 src/renderer/a2ui/components/A2UIText.tsx create mode 100644 src/renderer/a2ui/components/A2UITextField.tsx create mode 100644 src/renderer/a2ui/components/index.ts create mode 100644 src/renderer/a2ui/useA2UISurface.ts delete mode 100644 src/renderer/components/AssistantPanelControls/AssistantPanelControls.css delete mode 100644 src/renderer/components/AssistantPanelControls/AssistantPanelControls.tsx delete mode 100644 src/renderer/components/AssistantPanelControls/index.ts delete mode 100644 src/renderer/navigation/protocolActionPolicies.ts delete mode 100644 src/renderer/navigation/protocolNeedsInput.ts delete mode 100644 tests/engine/OpenCodeManager.protocol.test.ts create mode 100644 tests/engine/a2ui/catalog.test.ts create mode 100644 tests/engine/a2ui/generator.test.ts create mode 100644 tests/engine/a2ui/surfaceManager.test.ts delete mode 100644 tests/engine/agentic/capabilities/registry.test.ts delete mode 100644 tests/engine/agentic/observability/protocolTelemetry.test.ts delete mode 100644 tests/engine/agentic/policy/actionPolicy.test.ts delete mode 100644 tests/engine/agentic/protocol/responseBuilder.test.ts delete mode 100644 tests/engine/agentic/protocol/uiSpecParser.test.ts delete mode 100644 tests/engine/agentic/protocol/validator.test.ts delete mode 100644 tests/engine/agentic/workflow/checkpointStore.test.ts delete mode 100644 tests/engine/agentic/workflow/stateMachine.test.ts delete mode 100644 tests/renderer/navigation/assistantPanelSpec.test.ts delete mode 100644 tests/renderer/navigation/protocolActionPolicies.test.ts delete mode 100644 tests/renderer/navigation/protocolNeedsInput.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 6cadd54..c394720 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "npx vitest": true, "npx tsc": true, "git remote": true, - "npx asar": true + "npx asar": true, + "npx tsx": true } } \ No newline at end of file diff --git a/API.md b/API.md index 5a4d18d..dc56416 100644 --- a/API.md +++ b/API.md @@ -3073,7 +3073,6 @@ result = await bds.tags.sync_from_posts() - [chat.validateApiKey](#chatvalidateapikey) - [chat.setApiKey](#chatsetapikey) - [chat.getApiKey](#chatgetapikey) -- [chat.getProtocolHealth](#chatgetprotocolhealth) - [chat.getAvailableModels](#chatgetavailablemodels) - [chat.setDefaultModel](#chatsetdefaultmodel) - [chat.getSystemPrompt](#chatgetsystemprompt) @@ -3207,41 +3206,6 @@ result = await bds.chat.get_api_key() } ``` -### chat.getProtocolHealth - -Get AGUI protocol telemetry health snapshot. - -**Parameters** - -- None - -**Response specification** - -- Return type: `ProtocolTelemetrySnapshot` -- Data structures: `ProtocolTelemetrySnapshot` - -**Example call** - -```python -from bds_api import bds -result = await bds.chat.get_protocol_health() -``` - -**Example response** - -```python -{ - 'totalTurns': 0, - 'validEnvelopeTurns': 0, - 'repairAttempts': 0, - 'fallbackTurns': 0, - 'blockedActionCount': 0, - 'parseValidityRate': 0, - 'repairRate': 0, - 'fallbackRate': 0 -} -``` - ### chat.getAvailableModels Get available chat models and selected default. @@ -3522,8 +3486,7 @@ Send message to chat conversation. **Response specification** -- Return type: `{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }` -- Data structures: `ProtocolResponseEnvelope` +- Return type: `{ success: boolean; message?: string; error?: string }` **Example call** @@ -3535,18 +3498,7 @@ result = await bds.chat.send_message(conversation_id='conversation-1', message=' **Example response** ```python -[ -{ - 'protocolVersion': None, - 'assistantText': 'value', - 'ui': [], - 'intent': None, - 'needsInput': False, - 'actions': [], - 'confidence': 0, - 'traceId': 'value' -} -] +{} ``` ### chat.abortMessage @@ -4129,40 +4081,6 @@ A declarative assistant action exposed to the UI runtime. [↑ Back to Table of contents](#table-of-contents) -### ProtocolResponseEnvelope - -Canonical AGUI response envelope returned from chat.sendMessage. - -**Fields** - -- protocolVersion (`'2.0'`, required): Envelope protocol version. -- assistantText (`string`, required): Assistant text content rendered in transcript. -- ui (`{ specVersion: '1'; elements: unknown[] }`, optional): Optional structured UI payload. -- intent (`'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize'`, required): Turn intent classification. -- needsInput (`{ required: boolean; fields: ProtocolNeedsInputField[] }`, required): Clarification requirements for next step. -- actions (`ProtocolAction[]`, required): Declarative actions available for this turn. -- confidence (`number`, required): Model confidence score from 0 to 1. -- traceId (`string`, required): Trace id for observability and debugging. - -[↑ Back to Table of contents](#table-of-contents) - -### ProtocolTelemetrySnapshot - -Aggregated protocol telemetry metrics for AGUI response health. - -**Fields** - -- totalTurns (`number`, required): Total number of recorded assistant turns. -- validEnvelopeTurns (`number`, required): Turns with schema-valid protocol envelopes. -- repairAttempts (`number`, required): Number of response repair attempts. -- fallbackTurns (`number`, required): Turns that used protocol fallback response. -- blockedActionCount (`number`, required): Count of actions blocked by policy. -- parseValidityRate (`number`, required): Ratio of valid envelopes to total turns. -- repairRate (`number`, required): Ratio of repair attempts to total turns. -- fallbackRate (`number`, required): Ratio of fallback turns to total turns. - -[↑ Back to Table of contents](#table-of-contents) - --- Generated from contract at 2026-02-25T00:00:00.000Z. diff --git a/src/main/a2ui/catalog.ts b/src/main/a2ui/catalog.ts new file mode 100644 index 0000000..6a59c40 --- /dev/null +++ b/src/main/a2ui/catalog.ts @@ -0,0 +1,64 @@ +/** + * A2UI Component Catalog for bDS + * + * Defines which A2UI component types the bDS client supports. + * This catalog is used to: + * 1. Inform the LLM (via system prompt) what UI components are available + * 2. Validate incoming A2UI messages + * 3. Map component types to React renderers + */ + +import type { A2UICatalogEntry, A2UIComponentType } from './types'; +import { BDS_CATALOG_ID } from './types'; + +const CATALOG_ENTRIES: A2UICatalogEntry[] = [ + { type: 'text', description: 'Text block with Markdown support' }, + { type: 'button', description: 'Clickable button that dispatches an action' }, + { type: 'card', description: 'Card with title, subtitle, body, and action buttons' }, + { type: 'chart', description: 'Bar, line, or pie chart visualization', custom: true }, + { type: 'table', description: 'Data table with columns and rows', custom: true }, + { type: 'textField', description: 'Text input field with data binding' }, + { type: 'checkBox', description: 'Checkbox input with data binding' }, + { type: 'dateTimeInput', description: 'Date/time picker input' }, + { type: 'choicePicker', description: 'Select/dropdown with options' }, + { type: 'image', description: 'Image with optional caption and click action' }, + { type: 'tabs', description: 'Tabbed container for organizing content' }, + { type: 'metric', description: 'Key-value metric display', custom: true }, + { type: 'list', description: 'Ordered or unordered item list' }, + { type: 'form', description: 'Form container with fields and submit button', custom: true }, + { type: 'row', description: 'Horizontal layout container' }, + { type: 'column', description: 'Vertical layout container' }, + { type: 'divider', description: 'Visual separator' }, +]; + +const catalogMap = new Map(); +for (const entry of CATALOG_ENTRIES) { + catalogMap.set(entry.type, entry); +} + +export function getCatalogEntries(): A2UICatalogEntry[] { + return [...CATALOG_ENTRIES]; +} + +export function isSupportedComponentType(type: string): type is A2UIComponentType { + return catalogMap.has(type as A2UIComponentType); +} + +export function getCatalogEntry(type: A2UIComponentType): A2UICatalogEntry | undefined { + return catalogMap.get(type); +} + +export function getCatalogId(): string { + return BDS_CATALOG_ID; +} + +/** + * Build a description of supported components for inclusion in the LLM system prompt. + */ +export function buildCatalogDescription(): string { + const lines = CATALOG_ENTRIES.map((entry) => { + const suffix = entry.custom ? ' (custom)' : ''; + return ` - ${entry.type}: ${entry.description}${suffix}`; + }); + return `Supported UI component types:\n${lines.join('\n')}`; +} diff --git a/src/main/a2ui/generator.ts b/src/main/a2ui/generator.ts new file mode 100644 index 0000000..6a805e9 --- /dev/null +++ b/src/main/a2ui/generator.ts @@ -0,0 +1,393 @@ +/** + * A2UI Generator + * + * Converts tool call results from the LLM into A2UI server messages. + * Each render_* tool call produces a set of A2UI messages: + * - createSurface (if new surface needed) + * - updateComponents (add/update components) + * - updateDataModel (set data values) + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { + A2UIServerMessage, + A2UIComponent, +} from './types'; + +function makeId(prefix: string): string { + return `${prefix}-${uuidv4().slice(0, 8)}`; +} + +function createSurfaceMessages( + conversationId: string, + components: A2UIComponent[], + rootIds: string[], + dataEntries?: Array<{ path: string; value: unknown }>, +): A2UIServerMessage[] { + const surfaceId = makeId('surface'); + const messages: A2UIServerMessage[] = [ + { + type: 'createSurface', + surfaceId, + conversationId, + }, + { + type: 'updateComponents', + surfaceId, + components, + rootIds, + }, + ]; + + if (dataEntries) { + for (const entry of dataEntries) { + messages.push({ + type: 'updateDataModel', + surfaceId, + path: entry.path, + value: entry.value, + }); + } + } + + return messages; +} + +// ---- Tool argument interfaces ---- + +export interface RenderChartArgs { + chartType: 'bar' | 'line' | 'pie'; + title?: string; + series: Array<{ label: string; value: number }>; +} + +export interface RenderTableArgs { + title?: string; + columns: string[]; + rows: string[][]; +} + +export interface RenderFormField { + key: string; + label: string; + inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number'; + placeholder?: string; + defaultValue?: string | number | boolean; + options?: Array<{ label: string; value: string }>; + required?: boolean; +} + +export interface RenderFormArgs { + title?: string; + fields: RenderFormField[]; + submitLabel: string; + submitAction?: string; +} + +export interface RenderCardArgs { + title: string; + body: string; + subtitle?: string; + actions?: Array<{ label: string; action: string; payload?: Record }>; +} + +export interface RenderMetricArgs { + label: string; + value: string; +} + +export interface RenderListArgs { + title?: string; + items: string[]; +} + +export interface RenderTabArgs { + label: string; + content: Array<{ + type: string; + [key: string]: unknown; + }>; +} + +export interface RenderTabsArgs { + tabs: RenderTabArgs[]; +} + +// ---- Generators ---- + +export function generateChart( + conversationId: string, + args: RenderChartArgs, +): A2UIServerMessage[] { + const chartId = makeId('chart'); + const component: A2UIComponent = { + id: chartId, + type: 'chart', + properties: { + chartType: args.chartType, + title: args.title, + }, + dataBinding: '/chartData', + }; + + return createSurfaceMessages( + conversationId, + [component], + [chartId], + [{ path: '/chartData', value: args.series }], + ); +} + +export function generateTable( + conversationId: string, + args: RenderTableArgs, +): A2UIServerMessage[] { + const tableId = makeId('table'); + const components: A2UIComponent[] = [ + { + id: tableId, + type: 'table', + properties: { + title: args.title, + columns: args.columns, + }, + dataBinding: '/tableRows', + }, + ]; + + return createSurfaceMessages( + conversationId, + components, + [tableId], + [{ path: '/tableRows', value: args.rows }], + ); +} + +export function generateForm( + conversationId: string, + args: RenderFormArgs, +): A2UIServerMessage[] { + const formId = makeId('form'); + const fieldComponents: A2UIComponent[] = []; + const fieldIds: string[] = []; + + for (const field of args.fields) { + const fieldId = makeId('field'); + fieldIds.push(fieldId); + + let componentType: A2UIComponent['type'] = 'textField'; + if (field.inputType === 'checkbox') { + componentType = 'checkBox'; + } else if (field.inputType === 'date') { + componentType = 'dateTimeInput'; + } else if (field.inputType === 'select') { + componentType = 'choicePicker'; + } + + fieldComponents.push({ + id: fieldId, + type: componentType, + properties: { + key: field.key, + label: field.label, + inputType: field.inputType, + placeholder: field.placeholder, + defaultValue: field.defaultValue, + options: field.options, + required: field.required, + }, + dataBinding: `/formData/${field.key}`, + }); + } + + const submitId = makeId('submit'); + fieldComponents.push({ + id: submitId, + type: 'button', + properties: { + label: args.submitLabel, + }, + actions: [ + { + eventType: 'click', + action: args.submitAction || 'submitForm', + payload: { formId }, + }, + ], + }); + + const formComponent: A2UIComponent = { + id: formId, + type: 'form', + properties: { + title: args.title, + submitLabel: args.submitLabel, + }, + children: [...fieldIds, submitId], + }; + + // Set initial data model values for fields with defaults + const dataEntries: Array<{ path: string; value: unknown }> = []; + for (const field of args.fields) { + if (field.defaultValue !== undefined) { + dataEntries.push({ path: `/formData/${field.key}`, value: field.defaultValue }); + } + } + + return createSurfaceMessages( + conversationId, + [formComponent, ...fieldComponents], + [formId], + dataEntries.length > 0 ? dataEntries : undefined, + ); +} + +export function generateCard( + conversationId: string, + args: RenderCardArgs, +): A2UIServerMessage[] { + const cardId = makeId('card'); + const cardActions = args.actions?.map((a) => ({ + eventType: 'click', + action: a.action, + payload: a.payload, + })); + + const component: A2UIComponent = { + id: cardId, + type: 'card', + properties: { + title: args.title, + body: args.body, + subtitle: args.subtitle, + }, + actions: cardActions, + }; + + return createSurfaceMessages(conversationId, [component], [cardId]); +} + +export function generateMetric( + conversationId: string, + args: RenderMetricArgs, +): A2UIServerMessage[] { + const metricId = makeId('metric'); + const component: A2UIComponent = { + id: metricId, + type: 'metric', + properties: { + label: args.label, + value: args.value, + }, + }; + + return createSurfaceMessages(conversationId, [component], [metricId]); +} + +export function generateList( + conversationId: string, + args: RenderListArgs, +): A2UIServerMessage[] { + const listId = makeId('list'); + const component: A2UIComponent = { + id: listId, + type: 'list', + properties: { + title: args.title, + }, + dataBinding: '/listItems', + }; + + return createSurfaceMessages( + conversationId, + [component], + [listId], + [{ path: '/listItems', value: args.items }], + ); +} + +export function generateTabs( + conversationId: string, + args: RenderTabsArgs, +): A2UIServerMessage[] { + const tabsId = makeId('tabs'); + const tabComponents: A2UIComponent[] = []; + const tabIds: string[] = []; + + for (const tab of args.tabs) { + const tabId = makeId('tab'); + tabIds.push(tabId); + + const childComponents: A2UIComponent[] = []; + const childIds: string[] = []; + + for (const contentItem of tab.content) { + const childId = makeId('child'); + childIds.push(childId); + childComponents.push({ + id: childId, + type: contentItem.type as A2UIComponent['type'], + properties: { ...contentItem, type: undefined }, + }); + } + + tabComponents.push({ + id: tabId, + type: 'column', + properties: { label: tab.label }, + children: childIds, + }); + + tabComponents.push(...childComponents); + } + + const tabsComponent: A2UIComponent = { + id: tabsId, + type: 'tabs', + properties: { + tabLabels: args.tabs.map((t) => t.label), + }, + children: tabIds, + }; + + return createSurfaceMessages( + conversationId, + [tabsComponent, ...tabComponents], + [tabsId], + ); +} + +// ---- Tool name to generator dispatch ---- + +const GENERATORS: Record) => A2UIServerMessage[]> = { + render_chart: (cid, args) => generateChart(cid, args as unknown as RenderChartArgs), + render_table: (cid, args) => generateTable(cid, args as unknown as RenderTableArgs), + render_form: (cid, args) => generateForm(cid, args as unknown as RenderFormArgs), + render_card: (cid, args) => generateCard(cid, args as unknown as RenderCardArgs), + render_metric: (cid, args) => generateMetric(cid, args as unknown as RenderMetricArgs), + render_list: (cid, args) => generateList(cid, args as unknown as RenderListArgs), + render_tabs: (cid, args) => generateTabs(cid, args as unknown as RenderTabsArgs), +}; + +/** + * Check if a tool name is a UI-rendering tool. + */ +export function isRenderTool(toolName: string): boolean { + return toolName in GENERATORS; +} + +/** + * Generate A2UI messages for a render tool call. + * Returns null if the tool name is not a render tool. + */ +export function generateFromToolCall( + conversationId: string, + toolName: string, + toolArgs: Record, +): A2UIServerMessage[] | null { + const generator = GENERATORS[toolName]; + if (!generator) { + return null; + } + return generator(conversationId, toolArgs); +} diff --git a/src/main/a2ui/types.ts b/src/main/a2ui/types.ts new file mode 100644 index 0000000..03da853 --- /dev/null +++ b/src/main/a2ui/types.ts @@ -0,0 +1,134 @@ +/** + * A2UI v0.9 types for bDS + * + * Implements the core A2UI protocol concepts: + * - JSONL streaming via IPC (not HTTP/SSE) + * - 4 server message types: createSurface, updateComponents, updateDataModel, deleteSurface + * - Flat component model with ID references + * - Data binding via JSON Pointer paths (RFC 6901) + * - Actions dispatched from client back to server + * + * @see https://a2ui.org + */ + +// ---- Component Types ---- + +export type A2UIComponentType = + | 'text' + | 'button' + | 'card' + | 'chart' + | 'table' + | 'form' + | 'textField' + | 'checkBox' + | 'dateTimeInput' + | 'choicePicker' + | 'image' + | 'tabs' + | 'metric' + | 'list' + | 'row' + | 'column' + | 'divider'; + +export interface A2UIComponent { + id: string; + type: A2UIComponentType; + properties: Record; + /** JSON Pointer path for data binding (RFC 6901) */ + dataBinding?: string; + /** Ordered child component IDs */ + children?: string[]; + /** Actions this component can dispatch */ + actions?: A2UIComponentAction[]; +} + +export interface A2UIComponentAction { + eventType: string; + action: string; + payload?: Record; + /** Policy for this action: silent = no confirm, confirm = ask user, danger = warn */ + policy?: 'silent' | 'confirm' | 'danger'; +} + +// ---- Server Messages (main → renderer) ---- + +export interface A2UICreateSurface { + type: 'createSurface'; + surfaceId: string; + conversationId: string; + metadata?: Record; +} + +export interface A2UIUpdateComponents { + type: 'updateComponents'; + surfaceId: string; + components: A2UIComponent[]; + /** Root component IDs for top-level rendering order */ + rootIds?: string[]; +} + +export interface A2UIUpdateDataModel { + type: 'updateDataModel'; + surfaceId: string; + /** JSON Pointer path (RFC 6901) */ + path: string; + value: unknown; +} + +export interface A2UIDeleteSurface { + type: 'deleteSurface'; + surfaceId: string; +} + +export type A2UIServerMessage = + | A2UICreateSurface + | A2UIUpdateComponents + | A2UIUpdateDataModel + | A2UIDeleteSurface; + +// ---- Client Actions (renderer → main) ---- + +export interface A2UIClientAction { + surfaceId: string; + componentId: string; + action: string; + payload?: Record; +} + +// ---- Surface State (renderer-side) ---- + +export interface A2UISurfaceState { + surfaceId: string; + conversationId: string; + components: Map; + rootIds: string[]; + dataModel: Record; + metadata?: Record; +} + +// ---- Resolved Component Tree (for rendering) ---- + +export interface A2UIResolvedComponent { + id: string; + type: A2UIComponentType; + properties: Record; + /** JSON Pointer path for data binding (carried from raw component) */ + dataBinding?: string; + /** Resolved value from data binding */ + boundValue?: unknown; + actions?: A2UIComponentAction[]; + children: A2UIResolvedComponent[]; +} + +// ---- Catalog ---- + +export interface A2UICatalogEntry { + type: A2UIComponentType; + description: string; + /** Whether this is a standard A2UI component or a custom bDS extension */ + custom?: boolean; +} + +export const BDS_CATALOG_ID = 'bds-blogging-v1'; diff --git a/src/main/agentic/capabilities/registry.ts b/src/main/agentic/capabilities/registry.ts deleted file mode 100644 index 27a46db..0000000 --- a/src/main/agentic/capabilities/registry.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { AgentSurface, ProtocolCapabilitySnapshot } from '../protocol/types'; - -interface CapabilityRegistryOptions { - disabledActions?: string[]; - disabledWidgets?: string[]; - disabledTools?: string[]; -} - -interface CapabilitySnapshotInput { - surface: AgentSurface; -} - -const COMMON_WIDGETS = [ - 'text', - 'metric', - 'list', - 'table', - 'action', - 'chart', - 'form', - 'input', - 'datePicker', - 'card', - 'image', - 'tabs', -] as const; - -const COMMON_ACTIONS = [ - 'openSettings', - 'openPost', - 'openMedia', - 'openPanel', - 'setActiveView', - 'toggleSidebar', - 'togglePanel', - 'toggleAssistantSidebar', -] as const; - -const COMMON_TOOLS = [ - 'search_posts', - 'read_post', - 'list_posts', - 'get_media', - 'list_media', - 'update_post_metadata', - 'update_media_metadata', - 'list_tags', - 'list_categories', - 'view_image', - 'get_post_backlinks', - 'get_post_outlinks', - 'get_post_media', - 'get_media_posts', -] as const; - -function unique(values: string[]): string[] { - return Array.from(new Set(values)); -} - -export class CapabilityRegistryService { - private readonly disabledActions: Set; - private readonly disabledWidgets: Set; - private readonly disabledTools: Set; - - constructor(options: CapabilityRegistryOptions = {}) { - this.disabledActions = new Set(options.disabledActions ?? []); - this.disabledWidgets = new Set(options.disabledWidgets ?? []); - this.disabledTools = new Set(options.disabledTools ?? []); - } - - getSnapshot(input: CapabilitySnapshotInput): ProtocolCapabilitySnapshot { - const widgets = COMMON_WIDGETS.filter((widget) => !this.disabledWidgets.has(widget)); - - const surfaceActions = input.surface === 'tab' - ? COMMON_ACTIONS.filter((action) => action !== 'toggleAssistantSidebar') - : COMMON_ACTIONS.filter((action) => action !== 'toggleSidebar'); - - const actions = surfaceActions.filter((action) => !this.disabledActions.has(action)); - const tools = COMMON_TOOLS.filter((tool) => !this.disabledTools.has(tool)); - - const disabled = unique([ - ...Array.from(this.disabledActions).map((action) => `action:${action}`), - ...Array.from(this.disabledWidgets).map((widget) => `widget:${widget}`), - ...Array.from(this.disabledTools).map((tool) => `tool:${tool}`), - ]); - - return { - widgets: [...widgets], - actions: [...actions], - tools: [...tools], - disabled, - }; - } -} diff --git a/src/main/agentic/observability/protocolTelemetry.ts b/src/main/agentic/observability/protocolTelemetry.ts deleted file mode 100644 index 6b62ffb..0000000 --- a/src/main/agentic/observability/protocolTelemetry.ts +++ /dev/null @@ -1,63 +0,0 @@ -export interface ProtocolTurnTelemetryInput { - validEnvelope: boolean; - repairAttempted: boolean; - fallbackUsed: boolean; - blockedActions: number; -} - -export interface ProtocolTelemetrySnapshot { - totalTurns: number; - validEnvelopeTurns: number; - repairAttempts: number; - fallbackTurns: number; - blockedActionCount: number; - parseValidityRate: number; - repairRate: number; - fallbackRate: number; -} - -export class ProtocolTelemetryService { - private totalTurns = 0; - private validEnvelopeTurns = 0; - private repairAttempts = 0; - private fallbackTurns = 0; - private blockedActionCount = 0; - - recordTurn(input: ProtocolTurnTelemetryInput): void { - this.totalTurns += 1; - if (input.validEnvelope) { - this.validEnvelopeTurns += 1; - } - if (input.repairAttempted) { - this.repairAttempts += 1; - } - if (input.fallbackUsed) { - this.fallbackTurns += 1; - } - this.blockedActionCount += input.blockedActions; - } - - getSnapshot(): ProtocolTelemetrySnapshot { - const denominator = this.totalTurns || 1; - return { - totalTurns: this.totalTurns, - validEnvelopeTurns: this.validEnvelopeTurns, - repairAttempts: this.repairAttempts, - fallbackTurns: this.fallbackTurns, - blockedActionCount: this.blockedActionCount, - parseValidityRate: this.validEnvelopeTurns / denominator, - repairRate: this.repairAttempts / denominator, - fallbackRate: this.fallbackTurns / denominator, - }; - } -} - -let protocolTelemetryService: ProtocolTelemetryService | null = null; - -export function getProtocolTelemetryService(): ProtocolTelemetryService { - if (!protocolTelemetryService) { - protocolTelemetryService = new ProtocolTelemetryService(); - } - - return protocolTelemetryService; -} diff --git a/src/main/agentic/policy/actionPolicy.ts b/src/main/agentic/policy/actionPolicy.ts deleted file mode 100644 index 60e828a..0000000 --- a/src/main/agentic/policy/actionPolicy.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger'; - -export interface ActionPolicyResolution { - level: ActionPolicyLevel; - requiresConfirmation: boolean; -} - -const ACTION_POLICY_MAP: Record = { - openPost: 'silent', - openMedia: 'silent', - openPanel: 'silent', - setActiveView: 'silent', - toggleSidebar: 'silent', - togglePanel: 'silent', - toggleAssistantSidebar: 'silent', - openSettings: 'confirm', - updatePostMetadata: 'confirm', - updateMediaMetadata: 'confirm', - submitNeedsInput: 'confirm', - deletePost: 'danger', - deleteMedia: 'danger', -}; - -export function resolveActionPolicy(action: string): ActionPolicyResolution { - const level = ACTION_POLICY_MAP[action] ?? 'danger'; - return { - level, - requiresConfirmation: level !== 'silent', - }; -} diff --git a/src/main/agentic/protocol/errors.ts b/src/main/agentic/protocol/errors.ts deleted file mode 100644 index aed9910..0000000 --- a/src/main/agentic/protocol/errors.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ProtocolValidationError } from './types'; - -export function createProtocolValidationError(message: string, details?: string[]): ProtocolValidationError { - return { - code: 'AGUI_PROTOCOL_VALIDATION_ERROR', - message, - details, - }; -} diff --git a/src/main/agentic/protocol/responseBuilder.ts b/src/main/agentic/protocol/responseBuilder.ts deleted file mode 100644 index ad598b1..0000000 --- a/src/main/agentic/protocol/responseBuilder.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { randomUUID } from 'crypto'; -import type { - AgentSurface, - ProtocolCapabilitySnapshot, - ProtocolIntent, - ProtocolResponseEnvelope, - ProtocolValidationError, -} from './types'; -import { validateProtocolResponseEnvelope } from './validator'; -import { extractAssistantUiSpec, normalizeAssistantUiSpec } from './uiSpecParser'; -import { assistantPanelSpecSchema } from './uiSchema'; -import { resolveActionPolicy } from '../policy/actionPolicy'; - -export interface ProtocolResponseBuildInput { - rawAssistantOutput: string; - surface: AgentSurface; - capabilities: ProtocolCapabilitySnapshot; -} - -export interface ProtocolResponseBuildResult { - envelope: ProtocolResponseEnvelope; - traceId: string; - repairAttempted: boolean; - warnings: string[]; - validationError?: ProtocolValidationError; -} - -export class ProtocolResponseBuilder { - build(input: ProtocolResponseBuildInput): ProtocolResponseBuildResult { - const warnings: string[] = []; - - const directEnvelope = this.parseCanonicalEnvelope(input.rawAssistantOutput); - if (directEnvelope) { - const sanitizedDirectEnvelope = this.sanitizeUiPayload(directEnvelope, warnings); - const normalizedDirectEnvelope = this.applyActionPolicies(sanitizedDirectEnvelope); - const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedDirectEnvelope, input.capabilities); - warnings.push(...capabilityWarnings); - const validated = validateProtocolResponseEnvelope(filteredEnvelope); - if (validated.ok && validated.value) { - return { - envelope: validated.value, - traceId: validated.value.traceId, - repairAttempted: false, - warnings, - }; - } - - const fallback = this.fallbackEnvelope(input.rawAssistantOutput); - return { - envelope: fallback, - traceId: fallback.traceId, - repairAttempted: true, - warnings, - validationError: validated.error, - }; - } - - const repaired = this.repairRawEnvelope(input.rawAssistantOutput); - if (repaired) { - const sanitizedRepairedEnvelope = this.sanitizeUiPayload(repaired, warnings); - const normalizedRepairedEnvelope = this.applyActionPolicies(sanitizedRepairedEnvelope); - const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedRepairedEnvelope, input.capabilities); - warnings.push(...capabilityWarnings); - const validated = validateProtocolResponseEnvelope(filteredEnvelope); - if (validated.ok && validated.value) { - return { - envelope: validated.value, - traceId: validated.value.traceId, - repairAttempted: true, - warnings, - }; - } - } - - const parsedUi = extractAssistantUiSpec(input.rawAssistantOutput); - const jsonLikeOutput = input.rawAssistantOutput.trim().startsWith('{') - || input.rawAssistantOutput.trim().startsWith('['); - const baseEnvelope: ProtocolResponseEnvelope = { - protocolVersion: '2.0', - assistantText: parsedUi.assistantText, - ui: parsedUi.ui || undefined, - intent: jsonLikeOutput - ? 'summarize' - : this.deriveIntent(parsedUi.assistantText, Boolean(parsedUi.ui), false), - needsInput: { - required: false, - fields: [], - }, - actions: [], - confidence: 0.7, - traceId: randomUUID(), - }; - - const sanitizedBaseEnvelope = this.sanitizeUiPayload(baseEnvelope, warnings); - const normalizedBaseEnvelope = this.applyActionPolicies(sanitizedBaseEnvelope); - const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedBaseEnvelope, input.capabilities); - warnings.push(...capabilityWarnings); - - const validated = validateProtocolResponseEnvelope(filteredEnvelope); - if (validated.ok && validated.value) { - return { - envelope: validated.value, - traceId: validated.value.traceId, - repairAttempted: false, - warnings, - }; - } - - const fallback = this.fallbackEnvelope(input.rawAssistantOutput); - return { - envelope: fallback, - traceId: fallback.traceId, - repairAttempted: true, - warnings, - validationError: validated.error, - }; - } - - private sanitizeUiPayload(envelope: ProtocolResponseEnvelope, warnings: string[]): ProtocolResponseEnvelope { - if (!envelope.ui) { - return envelope; - } - - const parsedUi = assistantPanelSpecSchema.safeParse(envelope.ui); - if (parsedUi.success) { - return { - ...envelope, - ui: parsedUi.data, - }; - } - - const normalizedUi = normalizeAssistantUiSpec(envelope.ui); - if (normalizedUi) { - warnings.push('Normalized non-canonical ui payload to canonical AGUI schema'); - return { - ...envelope, - ui: normalizedUi, - }; - } - - warnings.push('Invalid ui payload removed from response envelope'); - return { - ...envelope, - ui: undefined, - }; - } - - private extractJsonFromMarkdown(raw: string): string { - const trimmed = raw.trim(); - const match = trimmed.match(/```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/i); - if (match) { - return match[1].trim(); - } - return trimmed; - } - - private parseCanonicalEnvelope(raw: string): ProtocolResponseEnvelope | null { - try { - const jsonString = this.extractJsonFromMarkdown(raw); - const parsed = JSON.parse(jsonString); - const validated = validateProtocolResponseEnvelope(parsed); - return validated.ok && validated.value ? validated.value : null; - } catch { - return null; - } - } - - private repairRawEnvelope(raw: string): ProtocolResponseEnvelope | null { - try { - const jsonString = this.extractJsonFromMarkdown(raw); - const parsed = JSON.parse(jsonString) as Record; - const looksLikeEnvelope = Boolean( - parsed.assistantText - || parsed.assistant_text - || parsed.intent - || parsed.needsInput - || parsed.needs_input - || parsed.actions - || parsed.ui, - ); - - if (!looksLikeEnvelope) { - return null; - } - - const repaired: Record = { - protocolVersion: parsed.protocolVersion ?? parsed.protocol_version ?? '2.0', - assistantText: parsed.assistantText ?? parsed.assistant_text ?? '', - ui: parsed.ui, - intent: parsed.intent ?? 'summarize', - needsInput: parsed.needsInput ?? parsed.needs_input ?? { required: false, fields: [] }, - actions: parsed.actions ?? [], - confidence: typeof parsed.confidence === 'number' ? parsed.confidence : 0.6, - traceId: parsed.traceId ?? parsed.trace_id ?? randomUUID(), - }; - - const validated = validateProtocolResponseEnvelope(repaired); - return validated.ok && validated.value ? validated.value : null; - } catch { - return null; - } - } - - private deriveIntent(text: string, hasUi: boolean, needsInput: boolean): ProtocolIntent { - if (needsInput) { - return 'ask_input'; - } - - if (hasUi) { - return 'propose_action'; - } - - if (text.trim().length === 0) { - return 'summarize'; - } - - return 'analyze'; - } - - private applyCapabilityGuards( - envelope: ProtocolResponseEnvelope, - capabilities: ProtocolCapabilitySnapshot, - ): { filteredEnvelope: ProtocolResponseEnvelope; warnings: string[] } { - const warnings: string[] = []; - - const filteredActions = envelope.actions.filter((action) => { - const supported = capabilities.actions.includes(action.action); - if (!supported) { - warnings.push(`Blocked unsupported action: ${action.action}`); - } - return supported; - }); - - const filteredUiElements = envelope.ui?.elements.filter((element) => { - const typedElement = element as { type?: string }; - const elementType = typedElement?.type; - if (!elementType) { - return true; - } - - const supported = capabilities.widgets.includes(elementType); - if (!supported) { - warnings.push(`Blocked unsupported widget: ${elementType}`); - } - return supported; - }); - - return { - filteredEnvelope: { - ...envelope, - ui: envelope.ui && filteredUiElements - ? { - specVersion: '1', - elements: filteredUiElements, - } - : envelope.ui, - actions: filteredActions, - }, - warnings, - }; - } - - private applyActionPolicies(envelope: ProtocolResponseEnvelope): ProtocolResponseEnvelope { - const actionList = envelope.actions.length > 0 - ? envelope.actions - : this.extractActionsFromUi(envelope.ui?.elements ?? []); - - const normalizedActions = actionList.map((action, index) => { - const policy = resolveActionPolicy(action.action); - return { - id: action.id || `agui-action-${index + 1}`, - action: action.action, - label: action.label, - payload: action.payload, - policy: policy.level, - requiresConfirmation: policy.requiresConfirmation, - }; - }); - - return { - ...envelope, - actions: normalizedActions, - }; - } - - private extractActionsFromUi(elements: unknown[]): Array<{ - id: string; - action: string; - label?: string; - payload?: Record; - }> { - const extracted: Array<{ - id: string; - action: string; - label?: string; - payload?: Record; - }> = []; - - const walk = (nodes: unknown[], parentId: string) => { - nodes.forEach((node, index) => { - const typedNode = node as Record; - const type = typeof typedNode?.type === 'string' ? typedNode.type : ''; - const nodeId = `${parentId}-${index + 1}`; - - if ((type === 'action' || type === 'input' || type === 'datePicker' || type === 'form' || type === 'image') && typeof typedNode.action === 'string') { - extracted.push({ - id: `ui-${nodeId}`, - action: typedNode.action, - label: typeof typedNode.label === 'string' ? typedNode.label : undefined, - payload: typedNode.payload as Record | undefined, - }); - } - - if (type === 'card' && Array.isArray(typedNode.actions)) { - typedNode.actions.forEach((cardAction, cardActionIndex) => { - const typedCardAction = cardAction as Record; - if (typeof typedCardAction.action === 'string') { - extracted.push({ - id: `ui-${nodeId}-card-${cardActionIndex + 1}`, - action: typedCardAction.action, - label: typeof typedCardAction.label === 'string' ? typedCardAction.label : undefined, - payload: typedCardAction.payload as Record | undefined, - }); - } - }); - } - - if (type === 'tabs' && Array.isArray(typedNode.tabs)) { - typedNode.tabs.forEach((tabNode, tabIndex) => { - const typedTab = tabNode as Record; - if (Array.isArray(typedTab.elements)) { - walk(typedTab.elements, `${nodeId}-tab-${tabIndex + 1}`); - } - }); - } - }); - }; - - walk(elements, 'root'); - return extracted; - } - - private fallbackEnvelope(rawAssistantOutput: string): ProtocolResponseEnvelope { - return { - protocolVersion: '2.0', - assistantText: rawAssistantOutput, - intent: 'summarize', - needsInput: { - required: false, - fields: [], - }, - actions: [], - confidence: 0.3, - traceId: randomUUID(), - }; - } -} diff --git a/src/main/agentic/protocol/types.ts b/src/main/agentic/protocol/types.ts deleted file mode 100644 index c915427..0000000 --- a/src/main/agentic/protocol/types.ts +++ /dev/null @@ -1,82 +0,0 @@ -export type AgentSurface = 'tab' | 'sidebar'; - -export type ProtocolIntent = - | 'analyze' - | 'ask_input' - | 'propose_action' - | 'execute_action' - | 'summarize'; - -export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger'; - -export interface ProtocolNeedsInputField { - key: string; - label: string; - inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number'; - required?: boolean; - options?: Array<{ label: string; value: string }>; - placeholder?: string; - defaultValue?: string | number | boolean; -} - -export interface ProtocolNeedsInput { - required: boolean; - fields: ProtocolNeedsInputField[]; -} - -export interface ProtocolAction { - id: string; - action: string; - label?: string; - payload?: Record; - policy: ActionPolicyLevel; - requiresConfirmation: boolean; -} - -export interface ProtocolUiSpec { - specVersion: '1'; - elements: unknown[]; -} - -export interface ProtocolResponseEnvelope { - protocolVersion: '2.0'; - assistantText: string; - ui?: ProtocolUiSpec; - intent: ProtocolIntent; - needsInput: ProtocolNeedsInput; - actions: ProtocolAction[]; - confidence: number; - traceId: string; -} - -export interface ProtocolRequestMessage { - role: 'user' | 'assistant' | 'system' | 'tool'; - content: string; -} - -export interface ProtocolCapabilitySnapshot { - widgets: string[]; - actions: string[]; - tools: string[]; - disabled?: string[]; -} - -export interface ProtocolRequestEnvelope { - protocolVersion: '2.0'; - surface: AgentSurface; - messages: ProtocolRequestMessage[]; - context: Record; - capabilities: ProtocolCapabilitySnapshot; -} - -export interface ProtocolValidationError { - code: 'AGUI_PROTOCOL_VALIDATION_ERROR'; - message: string; - details?: string[]; -} - -export interface ProtocolValidationResult { - ok: boolean; - value?: T; - error?: ProtocolValidationError; -} diff --git a/src/main/agentic/protocol/uiSchema.ts b/src/main/agentic/protocol/uiSchema.ts deleted file mode 100644 index a5c7dc3..0000000 --- a/src/main/agentic/protocol/uiSchema.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { z } from 'zod'; - -const inputTypeSchema = z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']); - -const inputOptionSchema = z.object({ - label: z.string().min(1), - value: z.string(), -}).strict(); - -const textElementSchema = z.object({ - type: z.literal('text'), - text: z.string().min(1), -}).strict(); - -const metricElementSchema = z.object({ - type: z.literal('metric'), - label: z.string().min(1), - value: z.string().min(1), -}).strict(); - -const listElementSchema = z.object({ - type: z.literal('list'), - title: z.string().optional(), - items: z.array(z.string().min(1)).min(1), -}).strict(); - -const tableElementSchema = z.object({ - type: z.literal('table'), - columns: z.array(z.string().min(1)).min(1), - rows: z.array(z.array(z.string())).min(1), -}).strict(); - -const actionElementSchema = z.object({ - type: z.literal('action'), - label: z.string().min(1), - action: z.string().min(1), - payload: z.record(z.string(), z.unknown()).optional(), -}).strict(); - -const chartElementSchema = z.object({ - type: z.literal('chart'), - chartType: z.enum(['bar', 'line', 'pie']), - title: z.string().min(1).optional(), - series: z.array(z.object({ - label: z.string().min(1), - value: z.number(), - }).strict()).min(1), -}).strict(); - -const inputElementSchema = z.object({ - type: z.literal('input'), - key: z.string().min(1), - label: z.string().min(1), - inputType: inputTypeSchema, - placeholder: z.string().optional(), - defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(), - options: z.array(inputOptionSchema).optional(), - action: z.string().min(1).optional(), - submitLabel: z.string().min(1).optional(), - payload: z.record(z.string(), z.unknown()).optional(), -}).strict(); - -const datePickerElementSchema = z.object({ - type: z.literal('datePicker'), - key: z.string().min(1), - label: z.string().min(1), - defaultValue: z.string().optional(), - min: z.string().optional(), - max: z.string().optional(), - action: z.string().min(1).optional(), - submitLabel: z.string().min(1).optional(), - payload: z.record(z.string(), z.unknown()).optional(), -}).strict(); - -const formFieldSchema = z.object({ - key: z.string().min(1), - label: z.string().min(1), - inputType: inputTypeSchema, - placeholder: z.string().optional(), - defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(), - options: z.array(inputOptionSchema).optional(), - required: z.boolean().optional(), -}).strict(); - -const formElementSchema = z.object({ - type: z.literal('form'), - formId: z.string().min(1), - title: z.string().optional(), - submitLabel: z.string().min(1), - action: z.string().min(1), - payload: z.record(z.string(), z.unknown()).optional(), - fields: z.array(formFieldSchema).min(1), -}).strict(); - -const cardActionSchema = z.object({ - label: z.string().min(1), - action: z.string().min(1), - payload: z.record(z.string(), z.unknown()).optional(), -}).strict(); - -const cardElementSchema = z.object({ - type: z.literal('card'), - title: z.string().min(1), - body: z.string().min(1), - subtitle: z.string().optional(), - actions: z.array(cardActionSchema).optional(), -}).strict(); - -const imageElementSchema = z.object({ - type: z.literal('image'), - src: z.string().min(1), - alt: z.string().optional(), - caption: z.string().optional(), - action: z.string().min(1).optional(), - payload: z.record(z.string(), z.unknown()).optional(), -}).strict(); - -let assistantPanelElementSchemaRef: z.ZodTypeAny; - -const tabsElementSchema: z.ZodTypeAny = z.lazy(() => z.object({ - type: z.literal('tabs'), - widgetId: z.string().min(1).optional(), - defaultTabId: z.string().min(1).optional(), - tabs: z.array(z.object({ - id: z.string().min(1), - label: z.string().min(1), - elements: z.array(assistantPanelElementSchemaRef).min(1), - }).strict()).min(1), -}).strict()); - -assistantPanelElementSchemaRef = z.union([ - textElementSchema, - metricElementSchema, - listElementSchema, - tableElementSchema, - actionElementSchema, - chartElementSchema, - inputElementSchema, - formElementSchema, - datePickerElementSchema, - cardElementSchema, - imageElementSchema, - tabsElementSchema, -]); - -export const assistantPanelElementSchema = assistantPanelElementSchemaRef; - -export const assistantPanelSpecSchema = z.object({ - specVersion: z.literal('1'), - elements: z.array(assistantPanelElementSchema).min(1), -}).strict(); - -export type AssistantPanelElement = z.infer; -export type AssistantPanelSpec = z.infer; diff --git a/src/main/agentic/protocol/uiSpecParser.ts b/src/main/agentic/protocol/uiSpecParser.ts deleted file mode 100644 index 773a772..0000000 --- a/src/main/agentic/protocol/uiSpecParser.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { assistantPanelSpecSchema, type AssistantPanelSpec } from './uiSchema'; - -function toRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null; - } - - return value as Record; -} - -function normalizeChartElement(record: Record): Record { - const chartType = record.chartType; - const normalized: Record = { - type: 'chart', - chartType: chartType === 'line' || chartType === 'pie' ? chartType : 'bar', - }; - - if (typeof record.title === 'string' && record.title.trim().length > 0) { - normalized.title = record.title; - } - - if (Array.isArray(record.series)) { - const series = record.series - .map((entry) => { - const item = toRecord(entry); - if (!item || typeof item.label !== 'string' || typeof item.value !== 'number') { - return null; - } - - return { - label: item.label, - value: item.value, - }; - }) - .filter((entry): entry is { label: string; value: number } => Boolean(entry)); - - if (series.length > 0) { - normalized.series = series; - return normalized; - } - } - - const dataRecord = toRecord(record.data); - - if (!dataRecord) { - return normalized; - } - - const labels = Array.isArray(dataRecord.labels) ? dataRecord.labels : []; - const datasets = Array.isArray(dataRecord.datasets) ? dataRecord.datasets : []; - const firstDataset = toRecord(datasets[0]); - const values = Array.isArray(firstDataset?.data) ? firstDataset?.data : []; - - if (labels.length === 0 || values.length === 0) { - return normalized; - } - - const series = labels - .map((label, index) => ({ - label: String(label), - value: Number(values[index]), - })) - .filter((entry) => Number.isFinite(entry.value)); - - if (series.length === 0) { - return normalized; - } - - normalized.series = series; - return normalized; -} - -function normalizeTabContent(tabValue: unknown): Record[] { - if (Array.isArray(tabValue)) { - return tabValue - .map((entry) => normalizeElement(entry)) - .filter((entry): entry is Record => Boolean(entry)); - } - - const normalized = normalizeElement(tabValue); - return normalized ? [normalized] : []; -} - -function normalizeTabsElement(record: Record): Record | null { - const tabs = Array.isArray(record.tabs) ? record.tabs : []; - const normalizedTabs = tabs - .map((tabValue, tabIndex) => { - const tabRecord = toRecord(tabValue); - if (!tabRecord) { - return null; - } - - const id = typeof tabRecord.id === 'string' && tabRecord.id.trim().length > 0 - ? tabRecord.id - : `tab-${tabIndex + 1}`; - - const label = typeof tabRecord.label === 'string' && tabRecord.label.trim().length > 0 - ? tabRecord.label - : typeof tabRecord.title === 'string' && tabRecord.title.trim().length > 0 - ? tabRecord.title - : id; - - const elements = Array.isArray(tabRecord.elements) - ? normalizeTabContent(tabRecord.elements) - : normalizeTabContent(tabRecord.content); - - if (elements.length === 0) { - return null; - } - - return { id, label, elements }; - }) - .filter((entry): entry is { id: string; label: string; elements: Record[] } => Boolean(entry)); - - if (normalizedTabs.length === 0) { - return null; - } - - return { - ...record, - tabs: normalizedTabs, - }; -} - -function normalizeElement(value: unknown): Record | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const type = typeof record.type === 'string' ? record.type : ''; - if (type === 'text' && typeof record.content === 'string' && typeof record.text !== 'string') { - return { type: 'text', text: record.content }; - } - - if (type === 'markdown') { - const textValue = typeof record.content === 'string' - ? record.content - : typeof record.text === 'string' - ? record.text - : ''; - - if (!textValue.trim()) { - return null; - } - - return { - type: 'text', - text: textValue, - }; - } - - if (type === 'chart') { - return normalizeChartElement(record); - } - - if (type === 'tabs') { - return normalizeTabsElement(record); - } - - return record; -} - -function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null { - const canonicalResult = assistantPanelSpecSchema.safeParse(parsed); - if (canonicalResult.success) { - return canonicalResult.data; - } - - const record = toRecord(parsed); - if (!record) { - return null; - } - - if (record.protocolVersion === '2.0' && record.ui) { - return normalizeCandidate(record.ui); - } - - if (record.type === 'tab' && record.content) { - return normalizeCandidate(record.content); - } - - if (record.type === 'tabs') { - const tabsElement = normalizeTabsElement(record); - if (!tabsElement) { - return null; - } - - const asSpec = { - specVersion: '1' as const, - elements: [tabsElement], - }; - const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec); - return normalizedResult.success ? normalizedResult.data : null; - } - - if (Array.isArray(record.elements)) { - const normalizedElements = record.elements - .map((element) => normalizeElement(element)) - .filter((element): element is Record => Boolean(element)); - - if (normalizedElements.length === 0) { - return null; - } - - const asSpec = { - specVersion: '1' as const, - elements: normalizedElements, - }; - const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec); - return normalizedResult.success ? normalizedResult.data : null; - } - - const normalizedElement = normalizeElement(record); - if (!normalizedElement) { - return null; - } - - const asSpec = { - specVersion: '1' as const, - elements: [normalizedElement], - }; - const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec); - return normalizedResult.success ? normalizedResult.data : null; -} - -function parseSpecCandidate(raw: string): AssistantPanelSpec | null { - try { - const parsed = JSON.parse(raw); - return normalizeCandidate(parsed); - } catch { - return null; - } -} - -export function normalizeAssistantUiSpec(input: unknown): AssistantPanelSpec | null { - return normalizeCandidate(input); -} - -export interface ParsedAssistantUiResult { - assistantText: string; - ui: AssistantPanelSpec | null; -} - -export function extractAssistantUiSpec(message: string): ParsedAssistantUiResult { - const trimmed = message.trim(); - - const fencedMatches = [...trimmed.matchAll(/```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/gi)]; - for (const match of fencedMatches) { - const candidate = match[1]?.trim(); - if (!candidate) { - continue; - } - - const parsed = parseSpecCandidate(candidate); - if (parsed) { - const assistantText = trimmed.replace(match[0], '').trim(); - return { - assistantText, - ui: parsed, - }; - } - } - - const parsedWholeMessage = parseSpecCandidate(trimmed); - return { - assistantText: parsedWholeMessage ? '' : trimmed, - ui: parsedWholeMessage, - }; -} diff --git a/src/main/agentic/protocol/validator.ts b/src/main/agentic/protocol/validator.ts deleted file mode 100644 index a572254..0000000 --- a/src/main/agentic/protocol/validator.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { z } from 'zod'; -import type { - ProtocolRequestEnvelope, - ProtocolResponseEnvelope, - ProtocolValidationResult, -} from './types'; -import { createProtocolValidationError } from './errors'; - -const needsInputFieldSchema = z.object({ - key: z.string().min(1), - label: z.string().min(1), - inputType: z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']), - required: z.boolean().optional(), - options: z.array(z.object({ label: z.string().min(1), value: z.string() })).optional(), - placeholder: z.string().optional(), - defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(), -}).strict(); - -const needsInputSchema = z.object({ - required: z.boolean(), - fields: z.array(needsInputFieldSchema), -}).strict(); - -const protocolActionSchema = z.object({ - id: z.string().min(1), - action: z.string().min(1), - label: z.string().optional(), - payload: z.record(z.string(), z.unknown()).optional(), - policy: z.enum(['silent', 'confirm', 'danger']), - requiresConfirmation: z.boolean(), -}).strict(); - -const protocolUiSchema = z.object({ - specVersion: z.literal('1'), - elements: z.array(z.unknown()), -}).strict(); - -const protocolResponseEnvelopeSchema = z.object({ - protocolVersion: z.literal('2.0'), - assistantText: z.string(), - ui: protocolUiSchema.optional(), - intent: z.enum(['analyze', 'ask_input', 'propose_action', 'execute_action', 'summarize']), - needsInput: needsInputSchema, - actions: z.array(protocolActionSchema), - confidence: z.number().min(0).max(1), - traceId: z.string().min(1), -}).strict().superRefine((value, context) => { - if (value.needsInput.required && value.needsInput.fields.length === 0) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['needsInput', 'fields'], - message: 'needsInput.fields must include at least one field when needsInput.required is true', - }); - } -}); - -const protocolRequestEnvelopeSchema = z.object({ - protocolVersion: z.literal('2.0'), - surface: z.enum(['tab', 'sidebar']), - messages: z.array(z.object({ role: z.enum(['user', 'assistant', 'system', 'tool']), content: z.string() }).strict()), - context: z.record(z.string(), z.unknown()), - capabilities: z.object({ - widgets: z.array(z.string().min(1)), - actions: z.array(z.string().min(1)), - tools: z.array(z.string().min(1)), - disabled: z.array(z.string().min(1)).optional(), - }).strict(), -}).strict(); - -function toErrorMessage(prefix: string, issues: z.ZodIssue[]): string { - const firstIssue = issues[0]; - const issuePath = firstIssue.path.length > 0 ? firstIssue.path.join('.') : 'root'; - return `${prefix}: ${issuePath} ${firstIssue.message}`; -} - -export function validateProtocolResponseEnvelope(input: unknown): ProtocolValidationResult { - const parsed = protocolResponseEnvelopeSchema.safeParse(input); - if (!parsed.success) { - return { - ok: false, - error: createProtocolValidationError( - toErrorMessage('Invalid protocol response envelope', parsed.error.issues), - parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`), - ), - }; - } - - return { - ok: true, - value: parsed.data, - }; -} - -export function validateProtocolRequestEnvelope(input: unknown): ProtocolValidationResult { - const parsed = protocolRequestEnvelopeSchema.safeParse(input); - if (!parsed.success) { - return { - ok: false, - error: createProtocolValidationError( - toErrorMessage('Invalid protocol request envelope', parsed.error.issues), - parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`), - ), - }; - } - - return { - ok: true, - value: parsed.data, - }; -} - -export type { ProtocolRequestEnvelope, ProtocolResponseEnvelope } from './types'; diff --git a/src/main/agentic/workflow/checkpointStore.ts b/src/main/agentic/workflow/checkpointStore.ts deleted file mode 100644 index e1e9faa..0000000 --- a/src/main/agentic/workflow/checkpointStore.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { AgentTurnState } from './turnStateMachine'; - -export interface WorkflowCheckpoint { - conversationId: string; - state: AgentTurnState; - pendingFields: string[]; - lastTraceId: string; - updatedAt: string; -} - -export interface WorkflowCheckpointSettingsAdapter { - getSetting(key: string): Promise; - setSetting(key: string, value: string): Promise; -} - -function keyForConversation(conversationId: string): string { - return `agui.workflow.${conversationId}`; -} - -export class WorkflowCheckpointStore { - private readonly adapter: WorkflowCheckpointSettingsAdapter; - - constructor(adapter: WorkflowCheckpointSettingsAdapter) { - this.adapter = adapter; - } - - async save(checkpoint: WorkflowCheckpoint): Promise { - await this.adapter.setSetting( - keyForConversation(checkpoint.conversationId), - JSON.stringify(checkpoint), - ); - } - - async load(conversationId: string): Promise { - const raw = await this.adapter.getSetting(keyForConversation(conversationId)); - if (!raw) { - return null; - } - - try { - const parsed = JSON.parse(raw) as WorkflowCheckpoint; - if (!parsed || parsed.conversationId !== conversationId) { - return null; - } - return parsed; - } catch { - return null; - } - } -} diff --git a/src/main/agentic/workflow/turnStateMachine.ts b/src/main/agentic/workflow/turnStateMachine.ts deleted file mode 100644 index 2310aaa..0000000 --- a/src/main/agentic/workflow/turnStateMachine.ts +++ /dev/null @@ -1,45 +0,0 @@ -export type AgentTurnState = - | 'planning' - | 'awaiting_input' - | 'executing' - | 'observing' - | 'completed'; - -interface TurnStateEnvelopeInput { - intent: 'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize'; - needsInput: { - required: boolean; - fields: Array<{ key: string }>; - }; -} - -interface TransitionInput { - previousState: AgentTurnState; - envelope: TurnStateEnvelopeInput; -} - -export class AgentTurnStateMachine { - transition(input: TransitionInput): AgentTurnState { - if (input.envelope.needsInput.required && input.envelope.needsInput.fields.length > 0) { - return 'awaiting_input'; - } - - if (input.envelope.intent === 'execute_action') { - return 'executing'; - } - - if (input.envelope.intent === 'propose_action') { - return 'observing'; - } - - if (input.envelope.intent === 'summarize') { - return 'completed'; - } - - if (input.previousState === 'awaiting_input') { - return 'executing'; - } - - return 'planning'; - } -} diff --git a/src/main/engine/ChatEngine.ts b/src/main/engine/ChatEngine.ts index c5afeb1..c2c29cf 100644 --- a/src/main/engine/ChatEngine.ts +++ b/src/main/engine/ChatEngine.ts @@ -305,7 +305,7 @@ Your role is to help users manage their blog posts and media files using ONLY th IMPORTANT: You do NOT have access to the internet, real-time data, or any external services. You can ONLY access information through the tools listed below. Do not claim otherwise. -Available Tools: +Available Data Tools: - search_posts: Search blog posts using full-text search. Supports category/tag filters. - read_post: Read the full content and metadata of a specific post by ID. - list_posts: List posts with optional filtering by status, category, or tags. @@ -321,24 +321,24 @@ Available Tools: - get_post_media: Get media files linked to a post (featured images, galleries). - get_media_posts: Get posts that use a specific media file. +Available UI Render Tools (use these to show rich interactive elements): +- render_chart: Show data as a bar, line, or pie chart. Use when presenting statistics or comparisons. +- render_table: Show data in a structured table. Use for tabular comparisons and listings. +- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings). +- render_card: Show an information card with title, body, and action buttons. +- render_metric: Show a single KPI or statistic prominently. +- render_list: Show a bulleted list of items. +- render_tabs: Organize information into switchable tabs. + When answering questions: 1. USE THE TOOLS to find information. Never make up data about posts or media. 2. If asked about something outside your tools (weather, news, websites), explain that you can only access the user's local blog content. 3. Be concise and helpful. Format post information clearly when displaying it. 4. If a search returns no results, suggest alternative queries or filters. 5. When asked to describe or analyze an image, use the view_image tool to see the actual image content. - -Agentic UI Contract: -- You may include structured UI payloads in your assistant response so the app can render interactive widgets. -- You DO have the ability to return interactive AGUI payloads (including bar charts) as JSON, even though you cannot draw bitmap images. -- When the user asks for a chart or guided workflow, prefer returning a valid AGUI payload over refusing. -- Place the AGUI payload in the "ui" field of the protocol response envelope. DO NOT output markdown code blocks containing JSON. -- Prefer actionable widgets (cards, forms, tabs, inputs, metrics, tables, charts) when they reduce follow-up friction. -- Keep textual guidance and UI semantically consistent. -- Include only valid, supported action names. Supported actions include: openSettings, openPost, openMedia, openPanel, setActiveView, toggleSidebar, togglePanel, toggleAssistantSidebar. -- Supported element types include: text, metric, list, table, action, chart, form, input, datePicker, card, image, tabs. -- For tabs elements, include each tab with id, label, and nested elements. -- Never invent unsupported specVersion values or unsupported element/action names.`; +6. When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text. +7. When you need user input for a multi-field operation, use render_form to present a structured form. +8. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media).`; } /** diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 86b70a3..2d028e7 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -16,13 +16,8 @@ import { ChatEngine } from './ChatEngine'; import { PostEngine } from './PostEngine'; import { MediaEngine } from './MediaEngine'; import { getPostMediaEngine } from './PostMediaEngine'; -import { ProtocolResponseBuilder } from '../agentic/protocol/responseBuilder'; -import { CapabilityRegistryService } from '../agentic/capabilities/registry'; -import { validateProtocolRequestEnvelope, validateProtocolResponseEnvelope } from '../agentic/protocol/validator'; -import type { ProtocolResponseEnvelope } from '../agentic/protocol/types'; -import { AgentTurnStateMachine, type AgentTurnState } from '../agentic/workflow/turnStateMachine'; -import { WorkflowCheckpointStore } from '../agentic/workflow/checkpointStore'; -import { getProtocolTelemetryService } from '../agentic/observability/protocolTelemetry'; +import { isRenderTool, generateFromToolCall } from '../a2ui/generator'; +import type { A2UIServerMessage } from '../a2ui/types'; // OpenCode Zen API endpoints const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages'; @@ -79,15 +74,12 @@ export interface SendMessageOptions { onDelta?: (delta: string) => void; onToolCall?: (toolCall: { name: string; args: unknown }) => void; onToolResult?: (result: { name: string; result: unknown }) => void; + onA2UIMessage?: (message: A2UIServerMessage) => void; } export interface SendMessageResult { success: boolean; message?: string; - envelope?: ProtocolResponseEnvelope; - protocolVersion?: '2.0'; - traceId?: string; - warnings?: string[]; error?: string; toolCalls?: Array<{ name: string; args: unknown }>; } @@ -142,22 +134,9 @@ export class OpenCodeManager { private postEngine: PostEngine; private mediaEngine: MediaEngine; private getMainWindow: () => BrowserWindow | null; - private protocolResponseBuilder: ProtocolResponseBuilder; - private capabilityRegistry: CapabilityRegistryService; - private turnStateMachine: AgentTurnStateMachine; - private workflowCheckpointStore: WorkflowCheckpointStore; private apiKey: string = ''; private abortControllers: Map = new Map(); - private readonly protocolBoundaryInstructions = `Protocol response requirements (strict): -- Return a single JSON object that matches this exact envelope schema: - {"protocolVersion":"2.0","assistantText":"string","ui":{"specVersion":"1","elements":[]}?,"intent":"analyze|ask_input|propose_action|execute_action|summarize","needsInput":{"required":boolean,"fields":[]},"actions":[],"confidence":number,"traceId":"string"} -- Do not return any top-level shape other than this envelope. -- Do not use legacy top-level keys like title/widgets/tabs/content/data/widgets. -- ui, if present, must use specVersion "1" and canonical element structures only. -- DO NOT output markdown code blocks containing JSON. The entire response must be the JSON envelope. -- If uncertain, return an envelope with assistantText and empty actions/ui rather than alternative JSON formats.`; - constructor( chatEngine: ChatEngine, postEngine: PostEngine, @@ -168,13 +147,6 @@ export class OpenCodeManager { this.postEngine = postEngine; this.mediaEngine = mediaEngine; this.getMainWindow = getMainWindow; - this.protocolResponseBuilder = new ProtocolResponseBuilder(); - this.capabilityRegistry = new CapabilityRegistryService(); - this.turnStateMachine = new AgentTurnStateMachine(); - this.workflowCheckpointStore = new WorkflowCheckpointStore({ - getSetting: async (key: string) => this.chatEngine.getSetting(key), - setSetting: async (key: string, value: string) => this.chatEngine.setSetting(key, value), - }); } /** @@ -271,7 +243,7 @@ export class OpenCodeManager { userMessage: string, options: SendMessageOptions = {} ): Promise { - const { metadata, onDelta, onToolCall, onToolResult } = options; + const { metadata, onDelta, onToolCall, onToolResult, onA2UIMessage } = options; try { const readyCheck = await this.checkReady(); @@ -303,52 +275,30 @@ export class OpenCodeManager { // Get system prompt const systemMessage = conversation.messages.find(m => m.role === 'system'); const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt(); - const protocolSystemPrompt = `${systemPrompt}\n\n${this.protocolBoundaryInstructions}`; // Build message history from DB (excluding system messages) const dbMessages = conversation.messages.filter(m => m.role !== 'system'); - const surface = metadata?.surface || 'tab'; - const capabilities = this.capabilityRegistry.getSnapshot({ surface }); - const requestEnvelope = { - protocolVersion: '2.0' as const, - surface, - messages: dbMessages - .filter((message) => message.role === 'user' || message.role === 'assistant' || message.role === 'system' || message.role === 'tool') - .map((message) => ({ - role: message.role, - content: message.content || '', - })), - context: { - conversationId, - modelId, - }, - capabilities, - }; - const requestValidation = validateProtocolRequestEnvelope(requestEnvelope); - if (!requestValidation.ok) { - return { - success: false, - error: requestValidation.error?.message || 'Invalid protocol request envelope', - }; - } - - const surfaceHint = metadata?.surface - ? `\n\n[Client UI surface: ${metadata.surface}. Render response UI for this surface while keeping content functionally equivalent.]` - : ''; - const capabilityHint = `\n\n[Protocol request envelope]\n${JSON.stringify(requestEnvelope, null, 2)}`; - const userMessageForModel = `${userMessage}${surfaceHint}${capabilityHint}`; // Add the new user message dbMessages.push({ conversationId, role: 'user', - content: userMessageForModel, + content: userMessage, createdAt: new Date(), }); let fullResponse = ''; const toolCallsCollected: Array<{ name: string; args: unknown }> = []; + // Wrap onA2UIMessage emission for render tools + const emitA2UIMessages = (messages: A2UIServerMessage[]) => { + if (onA2UIMessage) { + for (const msg of messages) { + onA2UIMessage(msg); + } + } + }; + const requestProvider = async ( prompt: string, messages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>, @@ -360,6 +310,8 @@ export class OpenCodeManager { messages, abortController.signal, { onDelta, onToolCall, onToolResult }, + conversationId, + emitA2UIMessages, ); } @@ -369,12 +321,14 @@ export class OpenCodeManager { messages, abortController.signal, { onDelta, onToolCall, onToolResult }, + conversationId, + emitA2UIMessages, ); }; try { console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId); - const firstResult = await requestProvider(protocolSystemPrompt, dbMessages); + const firstResult = await requestProvider(systemPrompt, dbMessages); fullResponse = firstResult.content; toolCallsCollected.push(...firstResult.toolCalls); console.log('[OpenCodeManager] fullResponse length:', fullResponse.length); @@ -384,92 +338,16 @@ export class OpenCodeManager { if (!isAborted) { throw error; } - // On abort, keep whatever was streamed so far (already in fullResponse or empty) } finally { this.abortControllers.delete(conversationId); } - const isCanonicalProtocolEnvelope = (() => { - try { - const parsed = JSON.parse(fullResponse); - const validated = validateProtocolResponseEnvelope(parsed); - return validated.ok; - } catch { - return false; - } - })(); - - let protocolResult = this.protocolResponseBuilder.build({ - rawAssistantOutput: fullResponse, - surface, - capabilities, - }); - - if (!isCanonicalProtocolEnvelope && fullResponse.trim().length > 0 && !abortController.signal.aborted) { - const retryReason = protocolResult.validationError?.message || 'previous output was not a canonical protocol envelope'; - const retryPrompt = `Your previous output failed protocol validation: ${retryReason}.\nReturn ONLY one valid protocol envelope JSON object and nothing else.`; - const retryMessages = [ - ...dbMessages, - { - conversationId, - role: 'assistant', - content: fullResponse, - createdAt: new Date(), - }, - { - conversationId, - role: 'user', - content: retryPrompt, - createdAt: new Date(), - }, - ]; - - try { - const retryResult = await requestProvider(protocolSystemPrompt, retryMessages); - fullResponse = retryResult.content; - toolCallsCollected.push(...retryResult.toolCalls); - protocolResult = this.protocolResponseBuilder.build({ - rawAssistantOutput: fullResponse, - surface, - capabilities, - }); - } catch (error) { - console.error('[OpenCodeManager] Protocol retry failed:', (error as Error).message); - } - } - - const previousCheckpoint = await this.workflowCheckpointStore.load(conversationId); - const previousState: AgentTurnState = previousCheckpoint?.state || 'planning'; - const nextState = this.turnStateMachine.transition({ - previousState, - envelope: { - intent: protocolResult.envelope.intent, - needsInput: protocolResult.envelope.needsInput, - }, - }); - - await this.workflowCheckpointStore.save({ - conversationId, - state: nextState, - pendingFields: protocolResult.envelope.needsInput.fields.map((field) => field.key), - lastTraceId: protocolResult.envelope.traceId, - updatedAt: new Date().toISOString(), - }); - - const blockedActionWarnings = protocolResult.warnings.filter((warning) => warning.includes('Blocked unsupported action')); - getProtocolTelemetryService().recordTurn({ - validEnvelope: !protocolResult.validationError, - repairAttempted: protocolResult.repairAttempted, - fallbackUsed: Boolean(protocolResult.validationError), - blockedActions: blockedActionWarnings.length, - }); - - // Save normalized assistant response to history so transcript does not render raw protocol JSON. + // Save assistant response to history if (fullResponse) { await this.chatEngine.addMessage({ conversationId, role: 'assistant', - content: protocolResult.envelope.assistantText, + content: fullResponse, toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : undefined, createdAt: new Date(), }); @@ -485,11 +363,7 @@ export class OpenCodeManager { return { success: true, - message: protocolResult.envelope.assistantText, - envelope: protocolResult.envelope, - protocolVersion: protocolResult.envelope.protocolVersion, - traceId: protocolResult.traceId, - warnings: protocolResult.warnings, + message: fullResponse, toolCalls: toolCallsCollected.length > 0 ? toolCallsCollected : undefined, }; } catch (error) { @@ -510,7 +384,9 @@ export class OpenCodeManager { onDelta?: (delta: string) => void; onToolCall?: (toolCall: { name: string; args: unknown }) => void; onToolResult?: (result: { name: string; result: unknown }) => void; - } + }, + conversationId: string, + emitA2UIMessages: (messages: A2UIServerMessage[]) => void, ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { const tools = this.getToolDefinitions(); const allToolCalls: Array<{ name: string; args: unknown }> = []; @@ -601,6 +477,29 @@ export class OpenCodeManager { callbacks.onToolCall({ name: toolName, args: toolArgs }); } + // Check if this is a render tool — generate A2UI messages instead of executing + if (isRenderTool(toolName)) { + const a2uiMessages = generateFromToolCall( + conversationId, + toolName, + toolArgs as Record, + ); + if (a2uiMessages) { + emitA2UIMessages(a2uiMessages); + } + + if (callbacks.onToolResult) { + callbacks.onToolResult({ name: toolName, result: { success: true, rendered: true } }); + } + + toolResults.push({ + type: 'tool_result', + tool_use_id: toolUseId, + content: JSON.stringify({ success: true, rendered: true }), + }); + continue; + } + // Execute the tool const result = await this.executeTool(toolName, toolArgs as Record); @@ -673,7 +572,9 @@ export class OpenCodeManager { onDelta?: (delta: string) => void; onToolCall?: (toolCall: { name: string; args: unknown }) => void; onToolResult?: (result: { name: string; result: unknown }) => void; - } + }, + conversationId: string, + emitA2UIMessages: (messages: A2UIServerMessage[]) => void, ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { // Build OpenAI-format messages const messages: Array> = [ @@ -787,6 +688,25 @@ export class OpenCodeManager { callbacks.onToolCall({ name: toolName, args: toolArgs }); } + // Check if this is a render tool + if (isRenderTool(toolName)) { + const a2uiMessages = generateFromToolCall(conversationId, toolName, toolArgs); + if (a2uiMessages) { + emitA2UIMessages(a2uiMessages); + } + + if (callbacks.onToolResult) { + callbacks.onToolResult({ name: toolName, result: { success: true, rendered: true } }); + } + + messages.push({ + role: 'tool', + content: JSON.stringify({ success: true, rendered: true }), + tool_call_id: toolCall.id, + }); + continue; + } + const result = await this.executeTool(toolName, toolArgs); if (callbacks.onToolResult) { @@ -978,6 +898,156 @@ export class OpenCodeManager { required: ['mediaId'], }, }, + // ── A2UI Render Tools ── + { + name: 'render_chart', + description: 'Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. The chart will be displayed as a rich UI element in the conversation.', + input_schema: { + type: 'object', + properties: { + chartType: { type: 'string', enum: ['bar', 'line', 'pie'], description: 'The type of chart to render' }, + title: { type: 'string', description: 'Optional chart title' }, + series: { + type: 'array', + items: { + type: 'object', + properties: { + label: { type: 'string', description: 'Data point label' }, + value: { type: 'number', description: 'Data point value' }, + }, + required: ['label', 'value'], + }, + description: 'Array of data points with label and value', + }, + }, + required: ['chartType', 'series'], + }, + }, + { + name: 'render_table', + description: 'Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information. The table will be displayed as a rich UI element.', + input_schema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Optional table title' }, + columns: { type: 'array', items: { type: 'string' }, description: 'Column header names' }, + rows: { type: 'array', items: { type: 'array', items: { type: 'string' } }, description: 'Table rows, each row is an array of cell values' }, + }, + required: ['columns', 'rows'], + }, + }, + { + name: 'render_form', + description: 'Render an interactive form in the chat UI. Use this when you need to collect structured input from the user, such as metadata updates, configuration, or multi-field data entry.', + input_schema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Optional form title' }, + fields: { + type: 'array', + items: { + type: 'object', + properties: { + key: { type: 'string', description: 'Field identifier' }, + label: { type: 'string', description: 'Field label shown to user' }, + inputType: { type: 'string', enum: ['text', 'textarea', 'select', 'checkbox', 'date', 'number'], description: 'Type of input control' }, + placeholder: { type: 'string', description: 'Placeholder text' }, + defaultValue: { description: 'Default value for the field' }, + options: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, value: { type: 'string' } }, required: ['label', 'value'] }, description: 'Options for select fields' }, + required: { type: 'boolean', description: 'Whether the field is required' }, + }, + required: ['key', 'label', 'inputType'], + }, + description: 'Form fields to display', + }, + submitLabel: { type: 'string', description: 'Label for the submit button' }, + submitAction: { type: 'string', description: 'Action to dispatch on submit' }, + }, + required: ['fields', 'submitLabel'], + }, + }, + { + name: 'render_card', + description: 'Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item with a title, body, and optional action buttons.', + input_schema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Card title' }, + body: { type: 'string', description: 'Card body text (supports markdown)' }, + subtitle: { type: 'string', description: 'Optional subtitle' }, + actions: { + type: 'array', + items: { + type: 'object', + properties: { + label: { type: 'string', description: 'Button label' }, + action: { type: 'string', description: 'Action name to dispatch (e.g., openPost, openMedia)' }, + payload: { type: 'object', description: 'Optional action payload' }, + }, + required: ['label', 'action'], + }, + description: 'Optional action buttons on the card', + }, + }, + required: ['title', 'body'], + }, + }, + { + name: 'render_metric', + description: 'Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label, such as post counts, statistics, or status indicators.', + input_schema: { + type: 'object', + properties: { + label: { type: 'string', description: 'Metric label' }, + value: { type: 'string', description: 'Metric value (displayed prominently)' }, + }, + required: ['label', 'value'], + }, + }, + { + name: 'render_list', + description: 'Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.', + input_schema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Optional list title' }, + items: { type: 'array', items: { type: 'string' }, description: 'List items' }, + }, + required: ['items'], + }, + }, + { + name: 'render_tabs', + description: 'Render a tabbed interface in the chat UI. Use this when you want to organize information into multiple tabs that the user can switch between.', + input_schema: { + type: 'object', + properties: { + tabs: { + type: 'array', + items: { + type: 'object', + properties: { + label: { type: 'string', description: 'Tab label' }, + content: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['text', 'metric', 'list'], description: 'Content type' }, + }, + required: ['type'], + }, + description: 'Content items within the tab', + }, + }, + required: ['label', 'content'], + }, + description: 'Array of tabs', + }, + }, + required: ['tabs'], + }, + }, ]; } diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index 1433592..ec8dd92 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -8,7 +8,6 @@ import { OpenCodeManager } from '../engine/OpenCodeManager'; import { getPostEngine } from '../engine/PostEngine'; import { getMediaEngine } from '../engine/MediaEngine'; import { getDatabase } from '../database'; -import { getProtocolTelemetryService } from '../agentic/observability/protocolTelemetry'; let chatEngine: ChatEngine | null = null; let openCodeManager: OpenCodeManager | null = null; @@ -136,10 +135,6 @@ export function registerChatHandlers(): void { // ============ Chat Settings ============ - ipcMain.handle('chat:getProtocolHealth', async () => { - return getProtocolTelemetryService().getSnapshot(); - }); - // Get available models ipcMain.handle('chat:getAvailableModels', async () => { try { @@ -283,6 +278,11 @@ export function registerChatHandlers(): void { mainWindow.webContents.send('chat-tool-result', { conversationId, result }); } }, + onA2UIMessage: (message) => { + if (mainWindow) { + mainWindow.webContents.send('a2ui-message', { conversationId, message }); + } + }, }); return result; @@ -379,6 +379,20 @@ export function registerChatHandlers(): void { return { success: false, error: (error as Error).message }; } }); + + // ============ A2UI Actions ============ + + ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record }) => { + try { + console.log('[Chat IPC] A2UI action dispatched:', action); + // Currently, A2UI actions are handled client-side (navigation, UI toggles). + // Server-side action handling can be added here in the future. + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error dispatching A2UI action:', error); + return { success: false, error: (error as Error).message }; + } + }); } /** diff --git a/src/main/preload.ts b/src/main/preload.ts index b7fe30a..0b0e5d4 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -286,7 +286,6 @@ export const electronAPI: ElectronAPI = { getApiKey: () => ipcRenderer.invoke('chat:getApiKey'), // Settings - getProtocolHealth: () => ipcRenderer.invoke('chat:getProtocolHealth'), getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'), setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId), getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'), @@ -334,6 +333,14 @@ export const electronAPI: ElectronAPI = { ipcRenderer.on('chat-title-updated', subscription); return () => ipcRenderer.removeListener('chat-title-updated', subscription); }, + + // A2UI streaming + onA2UIMessage: (callback: (data: { conversationId: string; message: import('./a2ui/types').A2UIServerMessage }) => void) => { + const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; message: import('./a2ui/types').A2UIServerMessage }) => callback(data); + ipcRenderer.on('a2ui-message', subscription); + return () => ipcRenderer.removeListener('a2ui-message', subscription); + }, + dispatchA2UIAction: (action: import('./a2ui/types').A2UIClientAction) => ipcRenderer.invoke('a2ui:dispatch', action), }, // Event listeners diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 012af51..9f1445c 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -435,52 +435,9 @@ export interface ChatSendMetadata { surface?: 'tab' | 'sidebar'; } -export interface ProtocolNeedsInputField { - key: string; - label: string; - inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number'; - required?: boolean; - options?: Array<{ label: string; value: string }>; - placeholder?: string; - defaultValue?: string | number | boolean; -} - -export interface ProtocolAction { - id: string; - action: string; - label?: string; - payload?: Record; - policy: 'silent' | 'confirm' | 'danger'; - requiresConfirmation: boolean; -} - -export interface ProtocolResponseEnvelope { - protocolVersion: '2.0'; - assistantText: string; - ui?: { - specVersion: '1'; - elements: unknown[]; - }; - intent: 'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize'; - needsInput: { - required: boolean; - fields: ProtocolNeedsInputField[]; - }; - actions: ProtocolAction[]; - confidence: number; - traceId: string; -} - -export interface ProtocolTelemetrySnapshot { - totalTurns: number; - validEnvelopeTurns: number; - repairAttempts: number; - fallbackTurns: number; - blockedActionCount: number; - parseValidityRate: number; - repairRate: number; - fallbackRate: number; -} +// A2UI types imported for use in ElectronAPI and re-exported for renderer +import type { A2UIServerMessage, A2UIClientAction } from '../a2ui/types'; +export type { A2UIServerMessage, A2UIClientAction }; export interface SiteValidationReport { sitemapPath: string; @@ -764,7 +721,6 @@ export interface ElectronAPI { getApiKey: () => Promise; // Settings - getProtocolHealth: () => Promise; getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>; setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>; getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>; @@ -778,7 +734,7 @@ export interface ElectronAPI { deleteConversation: (id: string) => Promise; // Messaging - sendMessage: (conversationId: string, message: string, metadata?: ChatSendMetadata) => Promise<{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }>; + sendMessage: (conversationId: string, message: string, metadata?: ChatSendMetadata) => Promise<{ success: boolean; message?: string; error?: string }>; addSystemEvent: (conversationId: string, content: string) => Promise<{ success: boolean; error?: string }>; abortMessage: (conversationId: string) => Promise; getHistory: (conversationId: string) => Promise; @@ -796,6 +752,10 @@ export interface ElectronAPI { onToolCall: (callback: (data: ChatToolCall) => void) => () => void; onToolResult: (callback: (data: ChatToolResult) => void) => () => void; onTitleUpdated: (callback: (data: ChatTitleUpdate) => void) => () => void; + + // A2UI streaming + onA2UIMessage: (callback: (data: { conversationId: string; message: A2UIServerMessage }) => void) => () => void; + dispatchA2UIAction: (action: A2UIClientAction) => Promise<{ success: boolean; error?: string }>; }; on: (channel: string, callback: (...args: unknown[]) => void) => () => void; once: (channel: string, callback: (...args: unknown[]) => void) => void; diff --git a/src/renderer/a2ui/A2UIRenderer.tsx b/src/renderer/a2ui/A2UIRenderer.tsx new file mode 100644 index 0000000..d4af9a8 --- /dev/null +++ b/src/renderer/a2ui/A2UIRenderer.tsx @@ -0,0 +1,101 @@ +/** + * A2UI Renderer + * + * Maps A2UI resolved component trees to React components. + * Uses the component catalog to look up renderers for each component type. + */ + +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../main/a2ui/types'; +import { A2UIText } from './components/A2UIText'; +import { A2UIButton } from './components/A2UIButton'; +import { A2UICard } from './components/A2UICard'; +import { A2UIChart } from './components/A2UIChart'; +import { A2UITable } from './components/A2UITable'; +import { A2UIForm } from './components/A2UIForm'; +import { A2UITextField } from './components/A2UITextField'; +import { A2UICheckBox } from './components/A2UICheckBox'; +import { A2UIDateTimeInput } from './components/A2UIDateTimeInput'; +import { A2UIChoicePicker } from './components/A2UIChoicePicker'; +import { A2UIImage } from './components/A2UIImage'; +import { A2UITabs } from './components/A2UITabs'; +import { A2UIMetric } from './components/A2UIMetric'; +import { A2UIList } from './components/A2UIList'; +import { A2UIRow } from './components/A2UIRow'; +import { A2UIColumn } from './components/A2UIColumn'; +import { A2UIDivider } from './components/A2UIDivider'; + +export interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +type ComponentRenderer = React.FC; + +const COMPONENT_REGISTRY: Record = { + text: A2UIText, + button: A2UIButton, + card: A2UICard, + chart: A2UIChart, + table: A2UITable, + form: A2UIForm, + textField: A2UITextField, + checkBox: A2UICheckBox, + dateTimeInput: A2UIDateTimeInput, + choicePicker: A2UIChoicePicker, + image: A2UIImage, + tabs: A2UITabs, + metric: A2UIMetric, + list: A2UIList, + row: A2UIRow, + column: A2UIColumn, + divider: A2UIDivider, +}; + +interface A2UIRendererProps { + surfaceId: string; + tree: A2UIResolvedComponent[]; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; +} + +export const A2UIRenderer: React.FC = ({ + surfaceId, + tree, + onAction, + onDataChange, +}) => { + const renderComponent = (component: A2UIResolvedComponent): React.ReactNode => { + const Renderer = COMPONENT_REGISTRY[component.type]; + if (!Renderer) { + return null; + } + + const renderChildren = (children: A2UIResolvedComponent[]): React.ReactNode => + children.map(renderComponent); + + return ( + + ); + }; + + if (tree.length === 0) { + return null; + } + + return ( +
+ {tree.map(renderComponent)} +
+ ); +}; diff --git a/src/renderer/a2ui/A2UISurfaceManager.ts b/src/renderer/a2ui/A2UISurfaceManager.ts new file mode 100644 index 0000000..ac3faab --- /dev/null +++ b/src/renderer/a2ui/A2UISurfaceManager.ts @@ -0,0 +1,244 @@ +/** + * A2UI Surface Manager + * + * Client-side state manager that processes incoming A2UI server messages + * and maintains surface state (component buffer, data model, component tree). + * + * This is a pure state manager with no React dependency — it can be tested + * independently and wrapped by a React hook. + */ + +import type { + A2UIServerMessage, + A2UISurfaceState, + A2UIResolvedComponent, +} from '../../main/a2ui/types'; + +export type SurfaceChangeListener = (surfaceId: string) => void; + +export class A2UISurfaceManager { + private surfaces = new Map(); + private listeners: SurfaceChangeListener[] = []; + + /** + * Process an incoming A2UI server message. + */ + processMessage(message: A2UIServerMessage): void { + switch (message.type) { + case 'createSurface': + this.surfaces.set(message.surfaceId, { + surfaceId: message.surfaceId, + conversationId: message.conversationId, + components: new Map(), + rootIds: [], + dataModel: {}, + metadata: message.metadata, + }); + this.notify(message.surfaceId); + break; + + case 'updateComponents': { + const surface = this.surfaces.get(message.surfaceId); + if (!surface) { + return; + } + + for (const component of message.components) { + surface.components.set(component.id, component); + } + + if (message.rootIds) { + surface.rootIds = message.rootIds; + } + + this.notify(message.surfaceId); + break; + } + + case 'updateDataModel': { + const surface = this.surfaces.get(message.surfaceId); + if (!surface) { + return; + } + + setValueAtPointer(surface.dataModel, message.path, message.value); + this.notify(message.surfaceId); + break; + } + + case 'deleteSurface': + this.surfaces.delete(message.surfaceId); + this.notify(message.surfaceId); + break; + } + } + + /** + * Get all active surface IDs for a conversation. + */ + getSurfaceIds(conversationId: string): string[] { + const ids: string[] = []; + for (const [surfaceId, state] of this.surfaces) { + if (state.conversationId === conversationId) { + ids.push(surfaceId); + } + } + return ids; + } + + /** + * Get raw surface state. + */ + getSurface(surfaceId: string): A2UISurfaceState | undefined { + return this.surfaces.get(surfaceId); + } + + /** + * Resolve the component tree for a surface. + * Converts flat component buffer + ID references into a nested tree. + */ + resolveTree(surfaceId: string): A2UIResolvedComponent[] { + const surface = this.surfaces.get(surfaceId); + if (!surface) { + return []; + } + + return surface.rootIds + .map((id) => this.resolveComponent(surface, id)) + .filter((c): c is A2UIResolvedComponent => c !== null); + } + + /** + * Update the local data model value (for input binding). + */ + updateLocalData(surfaceId: string, path: string, value: unknown): void { + const surface = this.surfaces.get(surfaceId); + if (!surface) { + return; + } + + setValueAtPointer(surface.dataModel, path, value); + this.notify(surfaceId); + } + + /** + * Get the data model for a surface. + */ + getDataModel(surfaceId: string): Record { + return this.surfaces.get(surfaceId)?.dataModel ?? {}; + } + + /** + * Delete all surfaces for a conversation. + */ + clearConversation(conversationId: string): void { + const toDelete: string[] = []; + for (const [surfaceId, state] of this.surfaces) { + if (state.conversationId === conversationId) { + toDelete.push(surfaceId); + } + } + for (const surfaceId of toDelete) { + this.surfaces.delete(surfaceId); + this.notify(surfaceId); + } + } + + /** + * Subscribe to surface changes. + */ + onChange(listener: SurfaceChangeListener): () => void { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } + + private notify(surfaceId: string): void { + for (const listener of this.listeners) { + listener(surfaceId); + } + } + + private resolveComponent( + surface: A2UISurfaceState, + componentId: string, + ): A2UIResolvedComponent | null { + const component = surface.components.get(componentId); + if (!component) { + return null; + } + + const children = (component.children ?? []) + .map((childId) => this.resolveComponent(surface, childId)) + .filter((c): c is A2UIResolvedComponent => c !== null); + + let boundValue: unknown = undefined; + if (component.dataBinding) { + boundValue = getValueAtPointer(surface.dataModel, component.dataBinding); + } + + return { + id: component.id, + type: component.type, + properties: component.properties, + dataBinding: component.dataBinding, + boundValue, + actions: component.actions, + children, + }; + } +} + +/** + * Get a value from a JSON object using a JSON Pointer (RFC 6901). + */ +export function getValueAtPointer( + obj: Record, + pointer: string, +): unknown { + if (!pointer || pointer === '/') { + return obj; + } + + const parts = pointer.split('/').filter(Boolean); + let current: unknown = obj; + + for (const part of parts) { + const key = part.replace(/~1/g, '/').replace(/~0/g, '~'); + if (current === null || current === undefined || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[key]; + } + + return current; +} + +/** + * Set a value in a JSON object using a JSON Pointer (RFC 6901). + */ +export function setValueAtPointer( + obj: Record, + pointer: string, + value: unknown, +): void { + if (!pointer || pointer === '/') { + return; + } + + const parts = pointer.split('/').filter(Boolean); + + let current: Record = obj; + + for (let i = 0; i < parts.length - 1; i++) { + const key = parts[i].replace(/~1/g, '/').replace(/~0/g, '~'); + if (!current[key] || typeof current[key] !== 'object') { + current[key] = {}; + } + current = current[key] as Record; + } + + const lastKey = parts[parts.length - 1].replace(/~1/g, '/').replace(/~0/g, '~'); + current[lastKey] = value; +} diff --git a/src/renderer/a2ui/components/A2UIButton.tsx b/src/renderer/a2ui/components/A2UIButton.tsx new file mode 100644 index 0000000..feeb043 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIButton.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UIButton: React.FC = ({ component, surfaceId, onAction }) => { + const label = String(component.properties.label ?? ''); + + const handleClick = () => { + const actionDef = component.actions?.[0]; + if (!actionDef) { + return; + } + + if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') { + const confirmed = window.confirm(label || actionDef.action); + if (!confirmed) { + return; + } + } + + onAction({ + surfaceId, + componentId: component.id, + action: actionDef.action, + payload: actionDef.payload, + }); + }; + + return ( + + ); +}; diff --git a/src/renderer/a2ui/components/A2UICard.tsx b/src/renderer/a2ui/components/A2UICard.tsx new file mode 100644 index 0000000..c5d843b --- /dev/null +++ b/src/renderer/a2ui/components/A2UICard.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UICard: React.FC = ({ component, surfaceId, onAction }) => { + const title = String(component.properties.title ?? ''); + const body = String(component.properties.body ?? ''); + const subtitle = component.properties.subtitle as string | undefined; + const actions = component.actions ?? []; + + const triggerAction = (actionDef: typeof actions[number]) => { + if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') { + const confirmed = window.confirm(actionDef.action); + if (!confirmed) { + return; + } + } + + onAction({ + surfaceId, + componentId: component.id, + action: actionDef.action, + payload: actionDef.payload, + }); + }; + + return ( +
+

{title}

+ {subtitle &&

{subtitle}

} +

{body}

+ {actions.length > 0 && ( +
+ {actions.map((actionDef, index) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIChart.tsx b/src/renderer/a2ui/components/A2UIChart.tsx new file mode 100644 index 0000000..0487c33 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIChart.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +interface SeriesEntry { + label: string; + value: number; +} + +export const A2UIChart: React.FC = ({ component }) => { + const chartType = String(component.properties.chartType ?? 'bar'); + const title = component.properties.title as string | undefined; + const series = (component.boundValue as SeriesEntry[]) ?? []; + const maxValue = Math.max(...series.map((entry) => entry.value), 0); + + return ( +
+ {title &&

{title}

} +
{chartType}
+ {series.map((entry, index) => ( +
+ {entry.label} + + {entry.value} +
+ ))} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UICheckBox.tsx b/src/renderer/a2ui/components/A2UICheckBox.tsx new file mode 100644 index 0000000..17b1fd4 --- /dev/null +++ b/src/renderer/a2ui/components/A2UICheckBox.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UICheckBox: React.FC = ({ component, surfaceId, onDataChange }) => { + const label = String(component.properties.label ?? ''); + const checked = Boolean(component.boundValue ?? false); + + const handleChange = (newChecked: boolean) => { + if (onDataChange && component.dataBinding) { + onDataChange(surfaceId, component.dataBinding, newChecked); + } + }; + + return ( + + ); +}; diff --git a/src/renderer/a2ui/components/A2UIChoicePicker.tsx b/src/renderer/a2ui/components/A2UIChoicePicker.tsx new file mode 100644 index 0000000..deb9425 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIChoicePicker.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +interface ChoiceOption { + label: string; + value: string; +} + +export const A2UIChoicePicker: React.FC = ({ component, surfaceId, onDataChange }) => { + const label = String(component.properties.label ?? ''); + const options = (component.properties.options as ChoiceOption[]) ?? []; + const value = String(component.boundValue ?? options[0]?.value ?? ''); + + const handleChange = (newValue: string) => { + if (onDataChange && component.dataBinding) { + onDataChange(surfaceId, component.dataBinding, newValue); + } + }; + + return ( +
+ + +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIColumn.tsx b/src/renderer/a2ui/components/A2UIColumn.tsx new file mode 100644 index 0000000..12bd2fd --- /dev/null +++ b/src/renderer/a2ui/components/A2UIColumn.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UIColumn: React.FC = ({ component, renderChildren }) => { + return ( +
+ {renderChildren?.(component.children)} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIDateTimeInput.tsx b/src/renderer/a2ui/components/A2UIDateTimeInput.tsx new file mode 100644 index 0000000..56e6bc1 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIDateTimeInput.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UIDateTimeInput: React.FC = ({ component, surfaceId, onDataChange }) => { + const label = String(component.properties.label ?? ''); + const min = component.properties.min as string | undefined; + const max = component.properties.max as string | undefined; + const value = String(component.boundValue ?? ''); + + const handleChange = (newValue: string) => { + if (onDataChange && component.dataBinding) { + onDataChange(surfaceId, component.dataBinding, newValue); + } + }; + + return ( +
+ + handleChange(e.target.value)} + /> +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIDivider.tsx b/src/renderer/a2ui/components/A2UIDivider.tsx new file mode 100644 index 0000000..e9c08f7 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIDivider.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UIDivider: React.FC = () => { + return
; +}; diff --git a/src/renderer/a2ui/components/A2UIForm.tsx b/src/renderer/a2ui/components/A2UIForm.tsx new file mode 100644 index 0000000..4c9cb51 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIForm.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UIForm: React.FC = ({ component, renderChildren }) => { + const title = component.properties.title as string | undefined; + + return ( +
+ {title &&

{title}

} + {renderChildren?.(component.children)} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIImage.tsx b/src/renderer/a2ui/components/A2UIImage.tsx new file mode 100644 index 0000000..6755723 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIImage.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UIImage: React.FC = ({ component, surfaceId, onAction }) => { + const src = String(component.properties.src ?? ''); + const alt = String(component.properties.alt ?? ''); + const caption = component.properties.caption as string | undefined; + const actionDef = component.actions?.[0]; + + const handleClick = () => { + if (!actionDef) { + return; + } + + if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') { + const confirmed = window.confirm(caption || alt || actionDef.action); + if (!confirmed) { + return; + } + } + + onAction({ + surfaceId, + componentId: component.id, + action: actionDef.action, + payload: actionDef.payload, + }); + }; + + return ( +
+ {alt} + {caption &&
{caption}
} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIList.tsx b/src/renderer/a2ui/components/A2UIList.tsx new file mode 100644 index 0000000..60a6e66 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIList.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UIList: React.FC = ({ component }) => { + const title = component.properties.title as string | undefined; + const items = (component.boundValue as string[]) ?? []; + + return ( +
+ {title &&

{title}

} +
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIMetric.tsx b/src/renderer/a2ui/components/A2UIMetric.tsx new file mode 100644 index 0000000..b81e853 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIMetric.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UIMetric: React.FC = ({ component }) => { + const label = String(component.properties.label ?? ''); + const value = String(component.properties.value ?? ''); + + return ( +
+ {label} + {value} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIRow.tsx b/src/renderer/a2ui/components/A2UIRow.tsx new file mode 100644 index 0000000..8966801 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIRow.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UIRow: React.FC = ({ component, renderChildren }) => { + return ( +
+ {renderChildren?.(component.children)} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UITable.tsx b/src/renderer/a2ui/components/A2UITable.tsx new file mode 100644 index 0000000..8e2b977 --- /dev/null +++ b/src/renderer/a2ui/components/A2UITable.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UITable: React.FC = ({ component }) => { + const columns = (component.properties.columns as string[]) ?? []; + const rows = (component.boundValue as string[][]) ?? []; + const title = component.properties.title as string | undefined; + + return ( +
+ {title &&

{title}

} + + + + {columns.map((column, colIndex) => ( + + ))} + + + + {rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
{column}
{cell}
+
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UITabs.tsx b/src/renderer/a2ui/components/A2UITabs.tsx new file mode 100644 index 0000000..4973eb9 --- /dev/null +++ b/src/renderer/a2ui/components/A2UITabs.tsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UITabs: React.FC = ({ component, renderChildren }) => { + const tabLabels = (component.properties.tabLabels as string[]) ?? []; + const [activeTab, setActiveTab] = useState(0); + + const children = component.children; + + return ( +
+
+ {tabLabels.map((label, index) => ( + + ))} +
+
+ {children[activeTab] && renderChildren?.([children[activeTab]])} +
+
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIText.tsx b/src/renderer/a2ui/components/A2UIText.tsx new file mode 100644 index 0000000..f9d7c89 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIText.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import Markdown from 'marked-react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UIText: React.FC = ({ component }) => { + const text = String(component.properties.text ?? ''); + return {text}; +}; diff --git a/src/renderer/a2ui/components/A2UITextField.tsx b/src/renderer/a2ui/components/A2UITextField.tsx new file mode 100644 index 0000000..81dfcc2 --- /dev/null +++ b/src/renderer/a2ui/components/A2UITextField.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; + +interface A2UIComponentProps { + component: A2UIResolvedComponent; + surfaceId: string; + onAction: (action: A2UIClientAction) => void; + onDataChange?: (surfaceId: string, path: string, value: unknown) => void; + renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; +} + +export const A2UITextField: React.FC = ({ component, surfaceId, onDataChange }) => { + const label = String(component.properties.label ?? ''); + const placeholder = (component.properties.placeholder as string) ?? ''; + const inputType = component.properties.inputType as string | undefined; + const value = String(component.boundValue ?? ''); + + const handleChange = (newValue: string) => { + if (onDataChange && component.dataBinding) { + onDataChange(surfaceId, component.dataBinding, newValue); + } + }; + + if (inputType === 'textarea') { + return ( +
+ +