wip: complete rework first round
This commit is contained in:
64
src/main/a2ui/catalog.ts
Normal file
64
src/main/a2ui/catalog.ts
Normal 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
393
src/main/a2ui/generator.ts
Normal 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
134
src/main/a2ui/types.ts
Normal 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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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).`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user