wip: complete rework first round

This commit is contained in:
2026-02-26 09:27:22 +01:00
parent c70f4b9154
commit affd62ca79
78 changed files with 2635 additions and 4053 deletions

64
src/main/a2ui/catalog.ts Normal file
View File

@@ -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<A2UIComponentType, A2UICatalogEntry>();
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')}`;
}

393
src/main/a2ui/generator.ts Normal file
View File

@@ -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<string, unknown> }>;
}
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<string, (conversationId: string, args: Record<string, unknown>) => 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<string, unknown>,
): A2UIServerMessage[] | null {
const generator = GENERATORS[toolName];
if (!generator) {
return null;
}
return generator(conversationId, toolArgs);
}

134
src/main/a2ui/types.ts Normal file
View File

@@ -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<string, unknown>;
/** 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<string, unknown>;
/** 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<string, unknown>;
}
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<string, unknown>;
}
// ---- Surface State (renderer-side) ----
export interface A2UISurfaceState {
surfaceId: string;
conversationId: string;
components: Map<string, A2UIComponent>;
rootIds: string[];
dataModel: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
// ---- Resolved Component Tree (for rendering) ----
export interface A2UIResolvedComponent {
id: string;
type: A2UIComponentType;
properties: Record<string, unknown>;
/** 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';

View File

@@ -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<string>;
private readonly disabledWidgets: Set<string>;
private readonly disabledTools: Set<string>;
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,
};
}
}

View File

@@ -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;
}

View File

@@ -1,30 +0,0 @@
export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger';
export interface ActionPolicyResolution {
level: ActionPolicyLevel;
requiresConfirmation: boolean;
}
const ACTION_POLICY_MAP: Record<string, ActionPolicyLevel> = {
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',
};
}

View File

@@ -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,
};
}

View File

@@ -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<string, unknown>;
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<string, unknown> = {
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<string, unknown>;
}> {
const extracted: Array<{
id: string;
action: string;
label?: string;
payload?: Record<string, unknown>;
}> = [];
const walk = (nodes: unknown[], parentId: string) => {
nodes.forEach((node, index) => {
const typedNode = node as Record<string, unknown>;
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<string, unknown> | undefined,
});
}
if (type === 'card' && Array.isArray(typedNode.actions)) {
typedNode.actions.forEach((cardAction, cardActionIndex) => {
const typedCardAction = cardAction as Record<string, unknown>;
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<string, unknown> | undefined,
});
}
});
}
if (type === 'tabs' && Array.isArray(typedNode.tabs)) {
typedNode.tabs.forEach((tabNode, tabIndex) => {
const typedTab = tabNode as Record<string, unknown>;
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(),
};
}
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
capabilities: ProtocolCapabilitySnapshot;
}
export interface ProtocolValidationError {
code: 'AGUI_PROTOCOL_VALIDATION_ERROR';
message: string;
details?: string[];
}
export interface ProtocolValidationResult<T> {
ok: boolean;
value?: T;
error?: ProtocolValidationError;
}

View File

@@ -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<typeof assistantPanelElementSchema>;
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;

View File

@@ -1,270 +0,0 @@
import { assistantPanelSpecSchema, type AssistantPanelSpec } from './uiSchema';
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> {
const chartType = record.chartType;
const normalized: Record<string, unknown> = {
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<string, unknown>[] {
if (Array.isArray(tabValue)) {
return tabValue
.map((entry) => normalizeElement(entry))
.filter((entry): entry is Record<string, unknown> => Boolean(entry));
}
const normalized = normalizeElement(tabValue);
return normalized ? [normalized] : [];
}
function normalizeTabsElement(record: Record<string, unknown>): Record<string, unknown> | 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<string, unknown>[] } => Boolean(entry));
if (normalizedTabs.length === 0) {
return null;
}
return {
...record,
tabs: normalizedTabs,
};
}
function normalizeElement(value: unknown): Record<string, unknown> | 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<string, unknown> => 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,
};
}

View File

@@ -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<ProtocolResponseEnvelope> {
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<ProtocolRequestEnvelope> {
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';

View File

@@ -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<string | null>;
setSetting(key: string, value: string): Promise<void>;
}
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<void> {
await this.adapter.setSetting(
keyForConversation(checkpoint.conversationId),
JSON.stringify(checkpoint),
);
}
async load(conversationId: string): Promise<WorkflowCheckpoint | null> {
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;
}
}
}

View File

@@ -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';
}
}

View File

@@ -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).`;
}
/**

View File

@@ -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<string, AbortController> = 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<SendMessageResult> {
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<string, unknown>,
);
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<string, unknown>);
@@ -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<Record<string, unknown>> = [
@@ -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'],
},
},
];
}

View File

@@ -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<string, unknown> }) => {
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 };
}
});
}
/**

View File

@@ -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

View File

@@ -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<string, unknown>;
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<ChatApiKeyStatus>;
// Settings
getProtocolHealth: () => Promise<ProtocolTelemetrySnapshot>;
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<boolean>;
// 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<void>;
getHistory: (conversationId: string) => Promise<ChatMessage[]>;
@@ -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;

View File

@@ -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<A2UIComponentProps>;
const COMPONENT_REGISTRY: Record<string, ComponentRenderer> = {
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<A2UIRendererProps> = ({
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 (
<Renderer
key={component.id}
component={component}
surfaceId={surfaceId}
onAction={onAction}
onDataChange={onDataChange}
renderChildren={renderChildren}
/>
);
};
if (tree.length === 0) {
return null;
}
return (
<div className="a2ui-surface assistant-panel-controls chat-surface-section">
{tree.map(renderComponent)}
</div>
);
};

View File

@@ -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<string, A2UISurfaceState>();
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<string, unknown> {
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<string, unknown>,
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<string, unknown>)[key];
}
return current;
}
/**
* Set a value in a JSON object using a JSON Pointer (RFC 6901).
*/
export function setValueAtPointer(
obj: Record<string, unknown>,
pointer: string,
value: unknown,
): void {
if (!pointer || pointer === '/') {
return;
}
const parts = pointer.split('/').filter(Boolean);
let current: Record<string, unknown> = 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<string, unknown>;
}
const lastKey = parts[parts.length - 1].replace(/~1/g, '/').replace(/~0/g, '~');
current[lastKey] = value;
}

View File

@@ -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<A2UIComponentProps> = ({ 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 (
<button type="button" onClick={handleClick}>
{label}
</button>
);
};

View File

@@ -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<A2UIComponentProps> = ({ 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 (
<article className="assistant-panel-card">
<h4>{title}</h4>
{subtitle && <p className="assistant-panel-card-subtitle">{subtitle}</p>}
<p>{body}</p>
{actions.length > 0 && (
<div className="assistant-panel-card-actions">
{actions.map((actionDef, index) => (
<button
key={`${component.id}-action-${index}`}
type="button"
onClick={() => triggerAction(actionDef)}
>
{String(actionDef.payload?.label ?? actionDef.action)}
</button>
))}
</div>
)}
</article>
);
};

View File

@@ -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<A2UIComponentProps> = ({ 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 (
<div className="assistant-panel-chart">
{title && <p className="assistant-panel-chart-title">{title}</p>}
<div className="assistant-panel-chart-type">{chartType}</div>
{series.map((entry, index) => (
<div key={`${component.id}-series-${index}`} className="assistant-panel-chart-item">
<span>{entry.label}</span>
<progress value={entry.value} max={maxValue || 1} />
<span>{entry.value}</span>
</div>
))}
</div>
);
};

View File

@@ -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<A2UIComponentProps> = ({ 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 (
<label className="assistant-panel-checkbox">
<input
type="checkbox"
checked={checked}
onChange={(e) => handleChange(e.target.checked)}
/>
<span>{label}</span>
</label>
);
};

View File

@@ -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<A2UIComponentProps> = ({ 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 (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<select
className="assistant-panel-widget-input chat-surface-input"
value={value}
onChange={(e) => handleChange(e.target.value)}
>
{options.map((option) => (
<option key={`${component.id}-opt-${option.value}`} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
};

View File

@@ -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<A2UIComponentProps> = ({ component, renderChildren }) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{renderChildren?.(component.children)}
</div>
);
};

View File

@@ -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<A2UIComponentProps> = ({ 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 (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<input
className="assistant-panel-widget-input chat-surface-input"
type="date"
value={value}
min={min}
max={max}
onChange={(e) => handleChange(e.target.value)}
/>
</div>
);
};

View File

@@ -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<A2UIComponentProps> = () => {
return <hr />;
};

View File

@@ -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<A2UIComponentProps> = ({ component, renderChildren }) => {
const title = component.properties.title as string | undefined;
return (
<div className="assistant-panel-form">
{title && <p className="assistant-panel-form-title">{title}</p>}
{renderChildren?.(component.children)}
</div>
);
};

View File

@@ -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<A2UIComponentProps> = ({ 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 (
<figure className="assistant-panel-image">
<img
src={src}
alt={alt}
onClick={handleClick}
/>
{caption && <figcaption>{caption}</figcaption>}
</figure>
);
};

View File

@@ -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<A2UIComponentProps> = ({ component }) => {
const title = component.properties.title as string | undefined;
const items = (component.boundValue as string[]) ?? [];
return (
<div>
{title && <p>{title}</p>}
<ul>
{items.map((item, index) => (
<li key={`${component.id}-item-${index}`}>{item}</li>
))}
</ul>
</div>
);
};

View File

@@ -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<A2UIComponentProps> = ({ component }) => {
const label = String(component.properties.label ?? '');
const value = String(component.properties.value ?? '');
return (
<div className="assistant-panel-metric">
<span className="assistant-panel-metric-label">{label}</span>
<strong className="assistant-panel-metric-value">{value}</strong>
</div>
);
};

View File

@@ -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<A2UIComponentProps> = ({ component, renderChildren }) => {
return (
<div style={{ display: 'flex', flexDirection: 'row', gap: '8px' }}>
{renderChildren?.(component.children)}
</div>
);
};

View File

@@ -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<A2UIComponentProps> = ({ component }) => {
const columns = (component.properties.columns as string[]) ?? [];
const rows = (component.boundValue as string[][]) ?? [];
const title = component.properties.title as string | undefined;
return (
<div>
{title && <p>{title}</p>}
<table className="assistant-panel-table">
<thead>
<tr>
{columns.map((column, colIndex) => (
<th key={`${component.id}-col-${colIndex}`}>{column}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={`${component.id}-row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`${component.id}-cell-${rowIndex}-${cellIndex}`}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -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<A2UIComponentProps> = ({ component, renderChildren }) => {
const tabLabels = (component.properties.tabLabels as string[]) ?? [];
const [activeTab, setActiveTab] = useState(0);
const children = component.children;
return (
<div className="assistant-panel-tabs">
<div className="assistant-panel-tab-strip">
{tabLabels.map((label, index) => (
<button
key={`${component.id}-tab-${index}`}
type="button"
className={`assistant-panel-tab-button ${index === activeTab ? 'active' : ''}`}
onClick={() => setActiveTab(index)}
>
{label}
</button>
))}
</div>
<div className="assistant-panel-tab-panel">
{children[activeTab] && renderChildren?.([children[activeTab]])}
</div>
</div>
);
};

View File

@@ -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<A2UIComponentProps> = ({ component }) => {
const text = String(component.properties.text ?? '');
return <Markdown>{text}</Markdown>;
};

View File

@@ -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<A2UIComponentProps> = ({ 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 (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<textarea
className="assistant-panel-widget-input chat-surface-input"
value={value}
placeholder={placeholder}
onChange={(e) => handleChange(e.target.value)}
rows={3}
/>
</div>
);
}
return (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<input
className="assistant-panel-widget-input chat-surface-input"
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => handleChange(e.target.value)}
/>
</div>
);
};

View File

@@ -0,0 +1,17 @@
export { A2UIText } from './A2UIText';
export { A2UIButton } from './A2UIButton';
export { A2UICard } from './A2UICard';
export { A2UIChart } from './A2UIChart';
export { A2UITable } from './A2UITable';
export { A2UIForm } from './A2UIForm';
export { A2UITextField } from './A2UITextField';
export { A2UICheckBox } from './A2UICheckBox';
export { A2UIDateTimeInput } from './A2UIDateTimeInput';
export { A2UIChoicePicker } from './A2UIChoicePicker';
export { A2UIImage } from './A2UIImage';
export { A2UITabs } from './A2UITabs';
export { A2UIMetric } from './A2UIMetric';
export { A2UIList } from './A2UIList';
export { A2UIRow } from './A2UIRow';
export { A2UIColumn } from './A2UIColumn';
export { A2UIDivider } from './A2UIDivider';

View File

@@ -0,0 +1,111 @@
/**
* React hook for A2UI surface state.
*
* Wraps A2UISurfaceManager and provides reactive state for React components.
* Subscribes to IPC events and feeds messages into the surface manager.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { A2UISurfaceManager } from './A2UISurfaceManager';
import type { A2UIResolvedComponent, A2UIServerMessage, A2UIClientAction } from '../../main/a2ui/types';
interface UseA2UISurfaceInput {
conversationId: string | null;
}
interface UseA2UISurfaceResult {
/** All active surface trees for this conversation */
surfaces: Array<{ surfaceId: string; tree: A2UIResolvedComponent[] }>;
/** Dispatch an action back to the main process */
dispatchAction: (action: A2UIClientAction) => void;
/** Update a local data binding (for form inputs) */
updateLocalData: (surfaceId: string, path: string, value: unknown) => void;
/** Get the data model for a surface */
getDataModel: (surfaceId: string) => Record<string, unknown>;
/** Clear all surfaces for the conversation */
clearSurfaces: () => void;
}
export function useA2UISurface(input: UseA2UISurfaceInput): UseA2UISurfaceResult {
const { conversationId } = input;
const managerRef = useRef<A2UISurfaceManager>(new A2UISurfaceManager());
const [renderTick, setRenderTick] = useState(0);
// Subscribe to surface changes
useEffect(() => {
const manager = managerRef.current;
const unsubscribe = manager.onChange(() => {
setRenderTick((prev) => prev + 1);
});
return unsubscribe;
}, []);
// Subscribe to A2UI IPC events
useEffect(() => {
if (!conversationId) {
return;
}
const unsubscribe = window.electronAPI?.chat.onA2UIMessage?.((data: { conversationId: string; message: A2UIServerMessage }) => {
if (data.conversationId === conversationId) {
managerRef.current.processMessage(data.message);
}
});
return () => {
unsubscribe?.();
};
}, [conversationId]);
// Clear surfaces when conversation changes
useEffect(() => {
return () => {
if (conversationId) {
managerRef.current.clearConversation(conversationId);
}
};
}, [conversationId]);
const surfaces = useMemo(() => {
// renderTick ensures this recalculates on surface changes
void renderTick;
if (!conversationId) {
return [];
}
const manager = managerRef.current;
const surfaceIds = manager.getSurfaceIds(conversationId);
return surfaceIds.map((surfaceId) => ({
surfaceId,
tree: manager.resolveTree(surfaceId),
}));
}, [conversationId, renderTick]);
const dispatchAction = useCallback((action: A2UIClientAction) => {
window.electronAPI?.chat.dispatchA2UIAction?.(action);
}, []);
const updateLocalData = useCallback((surfaceId: string, path: string, value: unknown) => {
managerRef.current.updateLocalData(surfaceId, path, value);
}, []);
const getDataModel = useCallback((surfaceId: string) => {
return managerRef.current.getDataModel(surfaceId);
}, []);
const clearSurfaces = useCallback(() => {
if (conversationId) {
managerRef.current.clearConversation(conversationId);
}
}, [conversationId]);
return {
surfaces,
dispatchAction,
updateLocalData,
getDataModel,
clearSurfaces,
};
}

View File

@@ -1,173 +0,0 @@
.assistant-panel-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.assistant-panel-metric {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 8px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
}
.assistant-panel-metric-label {
font-size: 12px;
opacity: 0.85;
}
.assistant-panel-metric-value {
font-size: 14px;
}
.assistant-panel-table {
width: 100%;
border-collapse: collapse;
}
.assistant-panel-table th,
.assistant-panel-table td {
border: 1px solid var(--vscode-panel-border);
padding: 6px;
font-size: 12px;
text-align: left;
}
.assistant-panel-widget-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.assistant-panel-widget-label {
font-size: 12px;
opacity: 0.9;
}
.assistant-panel-widget-input {
width: 100%;
padding: 8px;
}
.assistant-panel-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.assistant-panel-chart {
display: flex;
flex-direction: column;
gap: 6px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
}
.assistant-panel-chart-title {
margin: 0;
font-weight: 600;
}
.assistant-panel-chart-type {
font-size: 11px;
text-transform: uppercase;
opacity: 0.7;
}
.assistant-panel-chart-item {
display: grid;
grid-template-columns: minmax(48px, auto) 1fr auto;
gap: 8px;
align-items: center;
font-size: 12px;
}
.assistant-panel-chart-item progress {
width: 100%;
}
.assistant-panel-form {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
}
.assistant-panel-form-title {
margin: 0;
font-weight: 600;
}
.assistant-panel-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.assistant-panel-card h4,
.assistant-panel-card p {
margin: 0;
}
.assistant-panel-card-subtitle {
font-size: 12px;
opacity: 0.8;
}
.assistant-panel-card-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.assistant-panel-image {
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.assistant-panel-image img {
max-width: 100%;
border-radius: 6px;
border: 1px solid var(--vscode-panel-border);
}
.assistant-panel-image figcaption {
font-size: 12px;
opacity: 0.85;
}
.assistant-panel-tabs {
display: flex;
flex-direction: column;
gap: 8px;
}
.assistant-panel-tab-strip {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.assistant-panel-tab-button.active {
border-color: var(--vscode-focusBorder);
}
.assistant-panel-tab-panel {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -1,310 +0,0 @@
import React, { useState } from 'react';
import type { AssistantPanelElement } from '../../navigation/assistantPanelSpec';
import './AssistantPanelControls.css';
interface AssistantPanelControlsProps {
elements: AssistantPanelElement[];
onAction: (action: string, payload?: Record<string, unknown>) => void;
actionPolicies?: Record<string, 'silent' | 'confirm' | 'danger'>;
}
export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({ elements, onAction, actionPolicies = {} }) => {
const [widgetValues, setWidgetValues] = useState<Record<string, unknown>>({});
const [activeTabByWidget, setActiveTabByWidget] = useState<Record<string, string>>({});
const setWidgetValue = (key: string, value: unknown) => {
setWidgetValues((previous) => ({
...previous,
[key]: value,
}));
};
const getWidgetValue = (key: string, defaultValue?: unknown) =>
Object.prototype.hasOwnProperty.call(widgetValues, key) ? widgetValues[key] : defaultValue;
const triggerAction = (action: string, payload?: Record<string, unknown>, label?: string) => {
const policy = actionPolicies[action] || 'silent';
if (policy !== 'silent') {
const confirmationText = label || action;
const confirmed = window.confirm(confirmationText);
if (!confirmed) {
return;
}
}
onAction(action, payload);
};
const renderInputControl = (
key: string,
label: string,
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number',
options?: Array<{ label: string; value: string }>,
placeholder?: string,
defaultValue?: string | number | boolean,
) => {
if (inputType === 'textarea') {
return (
<textarea
className="assistant-panel-widget-input chat-surface-input"
value={String(getWidgetValue(key, defaultValue ?? ''))}
placeholder={placeholder}
onChange={(event) => setWidgetValue(key, event.target.value)}
rows={3}
/>
);
}
if (inputType === 'select') {
return (
<select
className="assistant-panel-widget-input chat-surface-input"
value={String(getWidgetValue(key, defaultValue ?? (options?.[0]?.value ?? '')))}
onChange={(event) => setWidgetValue(key, event.target.value)}
>
{(options ?? []).map((option) => (
<option key={`${key}-${option.value}`} value={option.value}>{option.label}</option>
))}
</select>
);
}
if (inputType === 'checkbox') {
return (
<label className="assistant-panel-checkbox">
<input
type="checkbox"
checked={Boolean(getWidgetValue(key, defaultValue ?? false))}
onChange={(event) => setWidgetValue(key, event.target.checked)}
/>
<span>{label}</span>
</label>
);
}
const type = inputType === 'number' ? 'number' : inputType === 'date' ? 'date' : 'text';
return (
<input
className="assistant-panel-widget-input chat-surface-input"
type={type}
value={String(getWidgetValue(key, defaultValue ?? ''))}
placeholder={placeholder}
onChange={(event) => setWidgetValue(key, event.target.value)}
/>
);
};
const renderPanelElement = (element: AssistantPanelElement, indexPath: string): React.ReactNode => {
if (element.type === 'text') {
return <p key={`assistant-element-${indexPath}`}>{element.text}</p>;
}
if (element.type === 'metric') {
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-metric">
<span className="assistant-panel-metric-label">{element.label}</span>
<strong className="assistant-panel-metric-value">{element.value}</strong>
</div>
);
}
if (element.type === 'list') {
return (
<div key={`assistant-element-${indexPath}`}>
{element.title && <p>{element.title}</p>}
<ul>
{element.items.map((item, itemIndex) => <li key={`assistant-list-item-${indexPath}-${itemIndex}`}>{item}</li>)}
</ul>
</div>
);
}
if (element.type === 'table') {
return (
<table key={`assistant-element-${indexPath}`} className="assistant-panel-table">
<thead>
<tr>
{element.columns.map((column, columnIndex) => <th key={`assistant-table-column-${indexPath}-${columnIndex}`}>{column}</th>)}
</tr>
</thead>
<tbody>
{element.rows.map((row, rowIndex) => (
<tr key={`assistant-table-row-${indexPath}-${rowIndex}`}>
{row.map((cell, cellIndex) => <td key={`assistant-table-cell-${indexPath}-${rowIndex}-${cellIndex}`}>{cell}</td>)}
</tr>
))}
</tbody>
</table>
);
}
if (element.type === 'chart') {
const maxValue = Math.max(...element.series.map((item) => item.value), 0);
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-chart">
{element.title && <p className="assistant-panel-chart-title">{element.title}</p>}
<div className="assistant-panel-chart-type">{element.chartType}</div>
{element.series.map((entry, seriesIndex) => (
<div key={`assistant-chart-item-${indexPath}-${seriesIndex}`} className="assistant-panel-chart-item">
<span>{entry.label}</span>
<progress value={entry.value} max={maxValue || 1}></progress>
<span>{entry.value}</span>
</div>
))}
</div>
);
}
if (element.type === 'input') {
const currentValue = getWidgetValue(element.key, element.defaultValue);
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-widget-block">
{element.inputType !== 'checkbox' && <label className="assistant-panel-widget-label">{element.label}</label>}
{renderInputControl(element.key, element.label, element.inputType, element.options, element.placeholder, element.defaultValue)}
{element.action && element.submitLabel && (
<button
type="button"
onClick={() => triggerAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue }, element.submitLabel)}
>
{element.submitLabel}
</button>
)}
</div>
);
}
if (element.type === 'datePicker') {
const currentValue = String(getWidgetValue(element.key, element.defaultValue ?? ''));
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{element.label}</label>
<input
className="assistant-panel-widget-input chat-surface-input"
type="date"
min={element.min}
max={element.max}
value={currentValue}
onChange={(event) => setWidgetValue(element.key, event.target.value)}
/>
{element.action && element.submitLabel && (
<button
type="button"
onClick={() => triggerAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue }, element.submitLabel)}
>
{element.submitLabel}
</button>
)}
</div>
);
}
if (element.type === 'form') {
const onSubmit = () => {
const values = element.fields.reduce<Record<string, unknown>>((accumulator, field) => {
accumulator[field.key] = getWidgetValue(field.key, field.defaultValue);
return accumulator;
}, {});
triggerAction(element.action, {
...(element.payload ?? {}),
formId: element.formId,
values,
}, element.submitLabel);
};
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-form">
{element.title && <p className="assistant-panel-form-title">{element.title}</p>}
{element.fields.map((field, fieldIndex) => (
<div key={`assistant-form-field-${indexPath}-${fieldIndex}`} className="assistant-panel-widget-block">
{field.inputType !== 'checkbox' && <label className="assistant-panel-widget-label">{field.label}</label>}
{renderInputControl(field.key, field.label, field.inputType, field.options, field.placeholder, field.defaultValue)}
</div>
))}
<button type="button" onClick={onSubmit}>{element.submitLabel}</button>
</div>
);
}
if (element.type === 'card') {
return (
<article key={`assistant-element-${indexPath}`} className="assistant-panel-card">
<h4>{element.title}</h4>
{element.subtitle && <p className="assistant-panel-card-subtitle">{element.subtitle}</p>}
<p>{element.body}</p>
{element.actions && element.actions.length > 0 && (
<div className="assistant-panel-card-actions">
{element.actions.map((action, actionIndex) => (
<button
key={`assistant-card-action-${indexPath}-${actionIndex}`}
type="button"
onClick={() => triggerAction(action.action, action.payload, action.label)}
>
{action.label}
</button>
))}
</div>
)}
</article>
);
}
if (element.type === 'image') {
return (
<figure key={`assistant-element-${indexPath}`} className="assistant-panel-image">
<img
src={element.src}
alt={element.alt || ''}
onClick={() => {
if (element.action) {
triggerAction(element.action, element.payload, element.caption || element.alt || element.action);
}
}}
/>
{element.caption && <figcaption>{element.caption}</figcaption>}
</figure>
);
}
if (element.type === 'tabs') {
const widgetKey = element.widgetId || `tabs-${indexPath}`;
const activeTabId = activeTabByWidget[widgetKey] || element.defaultTabId || element.tabs[0].id;
const activeTab = element.tabs.find((tab) => tab.id === activeTabId) ?? element.tabs[0];
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-tabs">
<div className="assistant-panel-tab-strip">
{element.tabs.map((tab) => (
<button
key={`assistant-tab-${indexPath}-${tab.id}`}
type="button"
className={`assistant-panel-tab-button ${tab.id === activeTab.id ? 'active' : ''}`}
onClick={() => setActiveTabByWidget((previous) => ({ ...previous, [widgetKey]: tab.id }))}
>
{tab.label}
</button>
))}
</div>
<div className="assistant-panel-tab-panel">
{activeTab.elements.map((childElement, childIndex) => renderPanelElement(childElement, `${indexPath}-tab-${childIndex}`))}
</div>
</div>
);
}
return (
<button key={`assistant-element-${indexPath}`} type="button" onClick={() => triggerAction(element.action, element.payload, element.label)}>
{element.label}
</button>
);
};
return (
<div className="assistant-panel-controls chat-surface-section">
{elements.map((element, index) => renderPanelElement(element, `${index}`))}
</div>
);
};
export default AssistantPanelControls;

View File

@@ -1 +0,0 @@
export { AssistantPanelControls } from './AssistantPanelControls';

View File

@@ -3,15 +3,13 @@ import { useAppStore } from '../../store';
import { resolveAssistantEditorContext } from '../../navigation/assistantPromptContext';
import { planAssistantRequest } from '../../navigation/assistantConversation';
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
import { buildActionPoliciesFromEnvelope } from '../../navigation/protocolActionPolicies';
import { ensureConversationId } from '../../navigation/chatSession';
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
import { useA2UISurface } from '../../a2ui/useA2UISurface';
import { A2UIRenderer } from '../../a2ui/A2UIRenderer';
import { ChatTranscript } from '../ChatSurface';
import { AssistantPanelControls } from '../AssistantPanelControls';
import { useI18n } from '../../i18n';
import '../../styles/chatSurface.css';
import './AssistantSidebar.css';
@@ -23,8 +21,6 @@ export const AssistantSidebar: React.FC = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [conversationId, setConversationId] = useState<string | null>(null);
const [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
const [actionPolicies, setActionPolicies] = useState<Record<string, 'silent' | 'confirm' | 'danger'>>({});
const [actionError, setActionError] = useState<string | null>(null);
const {
@@ -57,6 +53,10 @@ export const AssistantSidebar: React.FC = () => {
stopStreaming,
getStreamingContent,
} = useChatSurfaceState();
// A2UI surface rendering
const { surfaces, dispatchAction, updateLocalData } = useA2UISurface({ conversationId });
const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
const editorContext = useMemo(
@@ -169,24 +169,12 @@ export const AssistantSidebar: React.FC = () => {
throw new Error(sendResult.error || 'Failed to send assistant message');
}
if (sendResult.envelope) {
finalizeAssistantTurn(resolvedConversationId, sendResult.envelope.assistantText);
const uiElements = Array.isArray(sendResult.envelope.ui?.elements)
? (sendResult.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(sendResult.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(sendResult.envelope));
const assistantContent = getStreamingContent() || sendResult.message;
if (assistantContent) {
finalizeAssistantTurn(resolvedConversationId, assistantContent);
} else {
const assistantContent = getStreamingContent() || sendResult.message;
if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(resolvedConversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
} else {
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
stopStreaming();
}
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
stopStreaming();
}
setPrompt('');
@@ -199,57 +187,6 @@ export const AssistantSidebar: React.FC = () => {
};
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
if (action === 'submitNeedsInput' && conversationId) {
const values = payload?.values;
if (!values || typeof values !== 'object') {
setActionError(tr('assistantSidebar.error.actionFailed'));
return;
}
const clarificationMessage = `needs_input_response: ${JSON.stringify(values)}`;
beginUserTurn(conversationId, clarificationMessage);
void sendChatMessage({
conversationId,
message: clarificationMessage,
metadata: { surface: 'sidebar' },
}).then((sendResult) => {
if (!sendResult.success) {
appendAssistantMessage(
conversationId,
tr('chat.errorPrefix', { error: sendResult.error || tr('chat.errorNoResponse') }),
);
stopStreaming();
return;
}
if (sendResult.envelope) {
finalizeAssistantTurn(conversationId, sendResult.envelope.assistantText);
const uiElements = Array.isArray(sendResult.envelope.ui?.elements)
? (sendResult.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(sendResult.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(sendResult.envelope));
return;
}
const assistantContent = getStreamingContent() || sendResult.message;
if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
}
}).catch((error) => {
console.error('Failed to submit assistant clarification:', error);
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming();
});
return;
}
const result = dispatchAssistantAction(
{
action,
@@ -333,9 +270,15 @@ export const AssistantSidebar: React.FC = () => {
</div>
)}
{panelElements.length > 0 && (
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} actionPolicies={actionPolicies} />
)}
{surfaces.map((surface) => (
<A2UIRenderer
key={surface.surfaceId}
surfaceId={surface.surfaceId}
tree={surface.tree}
onAction={dispatchAction}
onDataChange={updateLocalData}
/>
))}
</div>
);
};

View File

@@ -4,12 +4,10 @@ import { useChatMessageSender } from '../../navigation/useChatMessageSender';
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
import { buildActionPoliciesFromEnvelope } from '../../navigation/protocolActionPolicies';
import { useA2UISurface } from '../../a2ui/useA2UISurface';
import { A2UIRenderer } from '../../a2ui/A2UIRenderer';
import { useAppStore } from '../../store';
import { ChatTranscript } from '../ChatSurface';
import { AssistantPanelControls } from '../AssistantPanelControls';
import { useI18n } from '../../i18n';
import '../../styles/chatSurface.css';
import './ChatPanel.css';
@@ -29,8 +27,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
const [apiKeyInput, setApiKeyInput] = useState('');
const [apiKeyError, setApiKeyError] = useState('');
const [isValidating, setIsValidating] = useState(false);
const [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
const [actionPolicies, setActionPolicies] = useState<Record<string, 'silent' | 'confirm' | 'danger'>>({});
const [actionError, setActionError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -63,6 +59,9 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
getStreamingContent,
} = useChatSurfaceState();
// A2UI surface rendering
const { surfaces, dispatchAction, updateLocalData } = useA2UISurface({ conversationId });
// Scroll to bottom when messages change
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -193,37 +192,19 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
// Fall back to the backend result message if streaming didn't capture the content
const assistantContent = getStreamingContent() || (result.success ? result.message : '');
if (result.envelope) {
finalizeAssistantTurn(conversationId, result.envelope.assistantText);
const uiElements = Array.isArray(result.envelope.ui?.elements)
? (result.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(result.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(result.envelope));
} else if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
if (assistantContent) {
finalizeAssistantTurn(conversationId, assistantContent);
} else if (!result.success) {
// Backend returned an error (API failure, model unavailable, etc.)
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
stopStreaming();
setPanelElements([]);
setActionPolicies({});
} else {
// No content from streaming AND no error, but also no success message
// This can happen with some models that don't return content properly
appendAssistantMessage(conversationId, tr('chat.errorEmptyResponse'));
stopStreaming();
setPanelElements([]);
setActionPolicies({});
}
} catch (error) {
console.error('Failed to send message:', error);
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming();
setPanelElements([]);
} finally {
if (isStreaming) {
stopStreaming();
@@ -239,59 +220,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
}
};
const handleNeedsInputSubmit = async (payload?: Record<string, unknown>) => {
const values = payload?.values;
if (!values || typeof values !== 'object') {
setActionError(tr('assistantSidebar.error.actionFailed'));
return;
}
const clarificationMessage = `needs_input_response: ${JSON.stringify(values)}`;
beginUserTurn(conversationId, clarificationMessage);
try {
const result = await sendChatMessage({
conversationId,
message: clarificationMessage,
metadata: { surface: 'tab' },
});
if (!result.success) {
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
stopStreaming();
return;
}
if (result.envelope) {
finalizeAssistantTurn(conversationId, result.envelope.assistantText);
const uiElements = Array.isArray(result.envelope.ui?.elements)
? (result.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(result.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(result.envelope));
return;
}
const assistantContent = getStreamingContent() || result.message;
if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
}
} catch (error) {
console.error('Failed to submit clarification:', error);
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming();
}
};
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
if (action === 'submitNeedsInput') {
void handleNeedsInputSubmit(payload);
return;
}
const result = dispatchAssistantAction(
{
action,
@@ -441,9 +370,15 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
endRef={messagesEndRef}
/>
{panelElements.length > 0 && (
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} actionPolicies={actionPolicies} />
)}
{surfaces.map((surface) => (
<A2UIRenderer
key={surface.surfaceId}
surfaceId={surface.surfaceId}
tree={surface.tree}
onAction={dispatchAction}
onDataChange={updateLocalData}
/>
))}
{actionError && <p className="chat-surface-error">{actionError}</p>}
</div>

View File

@@ -1478,12 +1478,6 @@ interface CategoryCount {
count: number;
}
interface DashboardProtocolHealth {
blockedActionCount: number;
parseValidityRate: number;
fallbackTurns: number;
}
const Dashboard: React.FC = () => {
const { t: tr, language } = useI18n();
const { posts, media } = useAppStore();
@@ -1492,7 +1486,6 @@ const Dashboard: React.FC = () => {
const [tagCounts, setTagCounts] = useState<TagCount[]>([]);
const [tagColors, setTagColors] = useState<Map<string, string>>(new Map());
const [categoryCounts, setCategoryCounts] = useState<CategoryCount[]>([]);
const [protocolHealth, setProtocolHealth] = useState<DashboardProtocolHealth | null>(null);
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
const monthFormatter = useMemo(
@@ -1503,25 +1496,17 @@ const Dashboard: React.FC = () => {
useEffect(() => {
const loadStats = async () => {
try {
const [ds, ym, tc, cc, colorMap, protocolHealthSnapshot] = await Promise.all([
const [ds, ym, tc, cc, colorMap] = await Promise.all([
window.electronAPI?.posts.getDashboardStats(),
window.electronAPI?.posts.getByYearMonth(),
window.electronAPI?.posts.getTagsWithCounts(),
window.electronAPI?.posts.getCategoriesWithCounts(),
loadTagColorMap(),
window.electronAPI?.chat.getProtocolHealth(),
]);
if (ds) setStats(ds);
if (ym) setYearMonthData(ym);
if (tc) setTagCounts(tc);
if (cc) setCategoryCounts(cc);
if (protocolHealthSnapshot) {
setProtocolHealth({
blockedActionCount: protocolHealthSnapshot.blockedActionCount,
parseValidityRate: protocolHealthSnapshot.parseValidityRate,
fallbackTurns: protocolHealthSnapshot.fallbackTurns,
});
}
setTagColors(colorMap);
} catch (e) {
console.error('Failed to load dashboard stats:', e);
@@ -1566,9 +1551,6 @@ const Dashboard: React.FC = () => {
const displayDraftCount = stats?.draftCount ?? 0;
const displayPublishedCount = stats?.publishedCount ?? 0;
const displayArchivedCount = stats?.archivedCount ?? 0;
const parseValidityPercent = protocolHealth
? `${Math.round(protocolHealth.parseValidityRate * 100)}%`
: '—';
const getPostCountLabel = useCallback((count: number) => {
return tr(count === 1 ? 'dashboard.postCount.one' : 'dashboard.postCount.other', { count });
@@ -1615,14 +1597,6 @@ const Dashboard: React.FC = () => {
<span className="stat-tag">{tr('dashboard.stats.categories', { count: categoryCounts.length })}</span>
</div>
</div>
<div className="stat-card">
<div className="stat-number">{parseValidityPercent}</div>
<div className="stat-label">{tr('dashboard.stats.protocolHealth')}</div>
<div className="stat-breakdown">
<span className="stat-tag">{tr('dashboard.stats.blockedActions', { count: protocolHealth?.blockedActionCount ?? 0 })}</span>
<span className="stat-tag">{tr('dashboard.stats.fallbackTurns', { count: protocolHealth?.fallbackTurns ?? 0 })}</span>
</div>
</div>
</div>
{timelineEntries.length > 0 && (

View File

@@ -28,4 +28,3 @@ export { DocumentationView } from './DocumentationView/DocumentationView';
export { SiteValidationView } from './SiteValidationView';
export { ScriptsView } from './ScriptsView/ScriptsView';
export { AssistantSidebar } from './AssistantSidebar';
export { AssistantPanelControls } from './AssistantPanelControls';

View File

@@ -156,276 +156,3 @@ export const assistantPanelSpecSchema = z.object({
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;
export interface AssistantResponseContent {
displayText: string;
panelSpec: AssistantPanelSpec | null;
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> | null {
const chartType = record.chartType;
const normalized: Record<string, unknown> = {
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<string, unknown>[] {
if (Array.isArray(tabValue)) {
return tabValue.map((entry) => normalizeElement(entry)).filter((entry): entry is Record<string, unknown> => Boolean(entry));
}
const normalized = normalizeElement(tabValue);
return normalized ? [normalized] : [];
}
function normalizeTabsElement(record: Record<string, unknown>): Record<string, unknown> | 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<string, unknown>[] } => Boolean(entry));
if (normalizedTabs.length === 0) {
return null;
}
return {
...record,
tabs: normalizedTabs,
};
}
function normalizeElement(value: unknown): Record<string, unknown> | 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',
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<string, unknown> => Boolean(element));
if (normalizedElements.length === 0) {
return null;
}
const asSpec = {
specVersion: '1',
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',
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 extractAssistantPanelSpec(message: string): AssistantPanelSpec | null {
return extractAssistantResponseContent(message).panelSpec;
}
export function extractAssistantResponseContent(message: string): AssistantResponseContent {
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 displayText = trimmed.replace(match[0], '').trim();
return {
displayText,
panelSpec: parsed,
};
}
}
const parsedWholeMessage = parseSpecCandidate(trimmed);
let displayText = parsedWholeMessage ? '' : trimmed;
if (parsedWholeMessage) {
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (parsed.protocolVersion === '2.0' && typeof parsed.assistantText === 'string') {
displayText = parsed.assistantText;
}
} catch {
// no-op
}
}
return {
displayText,
panelSpec: parsedWholeMessage,
};
}

View File

@@ -1,12 +1,10 @@
import type { ProtocolResponseEnvelope } from '../types/electron';
export interface ChatService {
createConversation: (title?: string, model?: string) => Promise<{ id: string } | null | undefined>;
sendMessage: (
conversationId: string,
message: string,
metadata?: SendMessageMetadata,
) => Promise<{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string } | null | undefined>;
) => Promise<{ success: boolean; message?: string; error?: string } | null | undefined>;
}
export interface SendMessageMetadata {
@@ -29,10 +27,6 @@ export interface SendConversationMessageInput {
export interface SendConversationMessageResult {
success: boolean;
message: string;
envelope?: ProtocolResponseEnvelope;
protocolVersion?: '2.0';
traceId?: string;
warnings?: string[];
error?: string;
}
@@ -75,9 +69,5 @@ export async function sendConversationMessage(
return {
success: true,
message: result.message || '',
envelope: result.envelope,
protocolVersion: result.protocolVersion,
traceId: result.traceId,
warnings: result.warnings,
};
}

View File

@@ -1,18 +0,0 @@
import type { ProtocolResponseEnvelope } from '../types/electron';
export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger';
export function buildActionPoliciesFromEnvelope(
envelope: Pick<ProtocolResponseEnvelope, 'actions' | 'needsInput'>,
): Record<string, ActionPolicyLevel> {
const policies = envelope.actions.reduce<Record<string, ActionPolicyLevel>>((accumulator, action) => {
accumulator[action.action] = action.policy;
return accumulator;
}, {});
if (envelope.needsInput.required && envelope.needsInput.fields.length > 0 && !policies.submitNeedsInput) {
policies.submitNeedsInput = 'confirm';
}
return policies;
}

View File

@@ -1,38 +0,0 @@
import type { ProtocolNeedsInputField, ProtocolResponseEnvelope } from '../types/electron';
import type { AssistantPanelElement } from './assistantPanelSpec';
function toFormField(field: ProtocolNeedsInputField): {
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;
} {
return {
key: field.key,
label: field.label,
inputType: field.inputType,
placeholder: field.placeholder,
defaultValue: field.defaultValue,
options: field.options,
required: field.required,
};
}
export function toClarificationElements(
needsInput: ProtocolResponseEnvelope['needsInput'],
): AssistantPanelElement[] {
if (!needsInput.required || needsInput.fields.length === 0) {
return [];
}
return [{
type: 'form',
formId: 'agui-needs-input',
submitLabel: needsInput.fields[0].label,
action: 'submitNeedsInput',
fields: needsInput.fields.map(toFormField),
}];
}

View File

@@ -176,7 +176,6 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'),
method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'),
method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'),
method('chat.getProtocolHealth', 'Get AGUI protocol telemetry health snapshot.', [], 'ProtocolTelemetrySnapshot'),
method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'),
method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'),
method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'),
@@ -186,7 +185,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], "{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }"),
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], '{ success: boolean; message?: string; error?: string }'),
method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
@@ -385,34 +384,6 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'requiresConfirmation', type: 'boolean', required: true, description: 'Whether confirmation is required before dispatch.' },
],
},
{
name: 'ProtocolResponseEnvelope',
description: 'Canonical AGUI response envelope returned from chat.sendMessage.',
fields: [
{ name: 'protocolVersion', type: "'2.0'", required: true, description: 'Envelope protocol version.' },
{ name: 'assistantText', type: 'string', required: true, description: 'Assistant text content rendered in transcript.' },
{ name: 'ui', type: "{ specVersion: '1'; elements: unknown[] }", required: false, description: 'Optional structured UI payload.' },
{ name: 'intent', type: "'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize'", required: true, description: 'Turn intent classification.' },
{ name: 'needsInput', type: '{ required: boolean; fields: ProtocolNeedsInputField[] }', required: true, description: 'Clarification requirements for next step.' },
{ name: 'actions', type: 'ProtocolAction[]', required: true, description: 'Declarative actions available for this turn.' },
{ name: 'confidence', type: 'number', required: true, description: 'Model confidence score from 0 to 1.' },
{ name: 'traceId', type: 'string', required: true, description: 'Trace id for observability and debugging.' },
],
},
{
name: 'ProtocolTelemetrySnapshot',
description: 'Aggregated protocol telemetry metrics for AGUI response health.',
fields: [
{ name: 'totalTurns', type: 'number', required: true, description: 'Total number of recorded assistant turns.' },
{ name: 'validEnvelopeTurns', type: 'number', required: true, description: 'Turns with schema-valid protocol envelopes.' },
{ name: 'repairAttempts', type: 'number', required: true, description: 'Number of response repair attempts.' },
{ name: 'fallbackTurns', type: 'number', required: true, description: 'Turns that used protocol fallback response.' },
{ name: 'blockedActionCount', type: 'number', required: true, description: 'Count of actions blocked by policy.' },
{ name: 'parseValidityRate', type: 'number', required: true, description: 'Ratio of valid envelopes to total turns.' },
{ name: 'repairRate', type: 'number', required: true, description: 'Ratio of repair attempts to total turns.' },
{ name: 'fallbackRate', type: 'number', required: true, description: 'Ratio of fallback turns to total turns.' },
],
},
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {