wip: complete rework first round
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -3,6 +3,7 @@
|
|||||||
"npx vitest": true,
|
"npx vitest": true,
|
||||||
"npx tsc": true,
|
"npx tsc": true,
|
||||||
"git remote": true,
|
"git remote": true,
|
||||||
"npx asar": true
|
"npx asar": true,
|
||||||
|
"npx tsx": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
86
API.md
86
API.md
@@ -3073,7 +3073,6 @@ result = await bds.tags.sync_from_posts()
|
|||||||
- [chat.validateApiKey](#chatvalidateapikey)
|
- [chat.validateApiKey](#chatvalidateapikey)
|
||||||
- [chat.setApiKey](#chatsetapikey)
|
- [chat.setApiKey](#chatsetapikey)
|
||||||
- [chat.getApiKey](#chatgetapikey)
|
- [chat.getApiKey](#chatgetapikey)
|
||||||
- [chat.getProtocolHealth](#chatgetprotocolhealth)
|
|
||||||
- [chat.getAvailableModels](#chatgetavailablemodels)
|
- [chat.getAvailableModels](#chatgetavailablemodels)
|
||||||
- [chat.setDefaultModel](#chatsetdefaultmodel)
|
- [chat.setDefaultModel](#chatsetdefaultmodel)
|
||||||
- [chat.getSystemPrompt](#chatgetsystemprompt)
|
- [chat.getSystemPrompt](#chatgetsystemprompt)
|
||||||
@@ -3207,41 +3206,6 @@ result = await bds.chat.get_api_key()
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### chat.getProtocolHealth
|
|
||||||
|
|
||||||
Get AGUI protocol telemetry health snapshot.
|
|
||||||
|
|
||||||
**Parameters**
|
|
||||||
|
|
||||||
- None
|
|
||||||
|
|
||||||
**Response specification**
|
|
||||||
|
|
||||||
- Return type: `ProtocolTelemetrySnapshot`
|
|
||||||
- Data structures: `ProtocolTelemetrySnapshot`
|
|
||||||
|
|
||||||
**Example call**
|
|
||||||
|
|
||||||
```python
|
|
||||||
from bds_api import bds
|
|
||||||
result = await bds.chat.get_protocol_health()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example response**
|
|
||||||
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
'totalTurns': 0,
|
|
||||||
'validEnvelopeTurns': 0,
|
|
||||||
'repairAttempts': 0,
|
|
||||||
'fallbackTurns': 0,
|
|
||||||
'blockedActionCount': 0,
|
|
||||||
'parseValidityRate': 0,
|
|
||||||
'repairRate': 0,
|
|
||||||
'fallbackRate': 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### chat.getAvailableModels
|
### chat.getAvailableModels
|
||||||
|
|
||||||
Get available chat models and selected default.
|
Get available chat models and selected default.
|
||||||
@@ -3522,8 +3486,7 @@ Send message to chat conversation.
|
|||||||
|
|
||||||
**Response specification**
|
**Response specification**
|
||||||
|
|
||||||
- Return type: `{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }`
|
- Return type: `{ success: boolean; message?: string; error?: string }`
|
||||||
- Data structures: `ProtocolResponseEnvelope`
|
|
||||||
|
|
||||||
**Example call**
|
**Example call**
|
||||||
|
|
||||||
@@ -3535,18 +3498,7 @@ result = await bds.chat.send_message(conversation_id='conversation-1', message='
|
|||||||
**Example response**
|
**Example response**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
[
|
{}
|
||||||
{
|
|
||||||
'protocolVersion': None,
|
|
||||||
'assistantText': 'value',
|
|
||||||
'ui': [],
|
|
||||||
'intent': None,
|
|
||||||
'needsInput': False,
|
|
||||||
'actions': [],
|
|
||||||
'confidence': 0,
|
|
||||||
'traceId': 'value'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### chat.abortMessage
|
### chat.abortMessage
|
||||||
@@ -4129,40 +4081,6 @@ A declarative assistant action exposed to the UI runtime.
|
|||||||
|
|
||||||
[↑ Back to Table of contents](#table-of-contents)
|
[↑ Back to Table of contents](#table-of-contents)
|
||||||
|
|
||||||
### ProtocolResponseEnvelope
|
|
||||||
|
|
||||||
Canonical AGUI response envelope returned from chat.sendMessage.
|
|
||||||
|
|
||||||
**Fields**
|
|
||||||
|
|
||||||
- protocolVersion (`'2.0'`, required): Envelope protocol version.
|
|
||||||
- assistantText (`string`, required): Assistant text content rendered in transcript.
|
|
||||||
- ui (`{ specVersion: '1'; elements: unknown[] }`, optional): Optional structured UI payload.
|
|
||||||
- intent (`'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize'`, required): Turn intent classification.
|
|
||||||
- needsInput (`{ required: boolean; fields: ProtocolNeedsInputField[] }`, required): Clarification requirements for next step.
|
|
||||||
- actions (`ProtocolAction[]`, required): Declarative actions available for this turn.
|
|
||||||
- confidence (`number`, required): Model confidence score from 0 to 1.
|
|
||||||
- traceId (`string`, required): Trace id for observability and debugging.
|
|
||||||
|
|
||||||
[↑ Back to Table of contents](#table-of-contents)
|
|
||||||
|
|
||||||
### ProtocolTelemetrySnapshot
|
|
||||||
|
|
||||||
Aggregated protocol telemetry metrics for AGUI response health.
|
|
||||||
|
|
||||||
**Fields**
|
|
||||||
|
|
||||||
- totalTurns (`number`, required): Total number of recorded assistant turns.
|
|
||||||
- validEnvelopeTurns (`number`, required): Turns with schema-valid protocol envelopes.
|
|
||||||
- repairAttempts (`number`, required): Number of response repair attempts.
|
|
||||||
- fallbackTurns (`number`, required): Turns that used protocol fallback response.
|
|
||||||
- blockedActionCount (`number`, required): Count of actions blocked by policy.
|
|
||||||
- parseValidityRate (`number`, required): Ratio of valid envelopes to total turns.
|
|
||||||
- repairRate (`number`, required): Ratio of repair attempts to total turns.
|
|
||||||
- fallbackRate (`number`, required): Ratio of fallback turns to total turns.
|
|
||||||
|
|
||||||
[↑ Back to Table of contents](#table-of-contents)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Generated from contract at 2026-02-25T00:00:00.000Z.
|
Generated from contract at 2026-02-25T00:00:00.000Z.
|
||||||
|
|||||||
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.
|
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.
|
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.
|
- 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.
|
- 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.
|
- 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_post_media: Get media files linked to a post (featured images, galleries).
|
||||||
- get_media_posts: Get posts that use a specific media file.
|
- 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:
|
When answering questions:
|
||||||
1. USE THE TOOLS to find information. Never make up data about posts or media.
|
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.
|
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.
|
3. Be concise and helpful. Format post information clearly when displaying it.
|
||||||
4. If a search returns no results, suggest alternative queries or filters.
|
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.
|
5. When asked to describe or analyze an image, use the view_image tool to see the actual image content.
|
||||||
|
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.
|
||||||
Agentic UI Contract:
|
7. When you need user input for a multi-field operation, use render_form to present a structured form.
|
||||||
- You may include structured UI payloads in your assistant response so the app can render interactive widgets.
|
8. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media).`;
|
||||||
- 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.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,13 +16,8 @@ import { ChatEngine } from './ChatEngine';
|
|||||||
import { PostEngine } from './PostEngine';
|
import { PostEngine } from './PostEngine';
|
||||||
import { MediaEngine } from './MediaEngine';
|
import { MediaEngine } from './MediaEngine';
|
||||||
import { getPostMediaEngine } from './PostMediaEngine';
|
import { getPostMediaEngine } from './PostMediaEngine';
|
||||||
import { ProtocolResponseBuilder } from '../agentic/protocol/responseBuilder';
|
import { isRenderTool, generateFromToolCall } from '../a2ui/generator';
|
||||||
import { CapabilityRegistryService } from '../agentic/capabilities/registry';
|
import type { A2UIServerMessage } from '../a2ui/types';
|
||||||
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';
|
|
||||||
|
|
||||||
// OpenCode Zen API endpoints
|
// OpenCode Zen API endpoints
|
||||||
const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages';
|
const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages';
|
||||||
@@ -79,15 +74,12 @@ export interface SendMessageOptions {
|
|||||||
onDelta?: (delta: string) => void;
|
onDelta?: (delta: string) => void;
|
||||||
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
||||||
onToolResult?: (result: { name: string; result: unknown }) => void;
|
onToolResult?: (result: { name: string; result: unknown }) => void;
|
||||||
|
onA2UIMessage?: (message: A2UIServerMessage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageResult {
|
export interface SendMessageResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
envelope?: ProtocolResponseEnvelope;
|
|
||||||
protocolVersion?: '2.0';
|
|
||||||
traceId?: string;
|
|
||||||
warnings?: string[];
|
|
||||||
error?: string;
|
error?: string;
|
||||||
toolCalls?: Array<{ name: string; args: unknown }>;
|
toolCalls?: Array<{ name: string; args: unknown }>;
|
||||||
}
|
}
|
||||||
@@ -142,22 +134,9 @@ export class OpenCodeManager {
|
|||||||
private postEngine: PostEngine;
|
private postEngine: PostEngine;
|
||||||
private mediaEngine: MediaEngine;
|
private mediaEngine: MediaEngine;
|
||||||
private getMainWindow: () => BrowserWindow | null;
|
private getMainWindow: () => BrowserWindow | null;
|
||||||
private protocolResponseBuilder: ProtocolResponseBuilder;
|
|
||||||
private capabilityRegistry: CapabilityRegistryService;
|
|
||||||
private turnStateMachine: AgentTurnStateMachine;
|
|
||||||
private workflowCheckpointStore: WorkflowCheckpointStore;
|
|
||||||
private apiKey: string = '';
|
private apiKey: string = '';
|
||||||
private abortControllers: Map<string, AbortController> = new Map();
|
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(
|
constructor(
|
||||||
chatEngine: ChatEngine,
|
chatEngine: ChatEngine,
|
||||||
postEngine: PostEngine,
|
postEngine: PostEngine,
|
||||||
@@ -168,13 +147,6 @@ export class OpenCodeManager {
|
|||||||
this.postEngine = postEngine;
|
this.postEngine = postEngine;
|
||||||
this.mediaEngine = mediaEngine;
|
this.mediaEngine = mediaEngine;
|
||||||
this.getMainWindow = getMainWindow;
|
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,
|
userMessage: string,
|
||||||
options: SendMessageOptions = {}
|
options: SendMessageOptions = {}
|
||||||
): Promise<SendMessageResult> {
|
): Promise<SendMessageResult> {
|
||||||
const { metadata, onDelta, onToolCall, onToolResult } = options;
|
const { metadata, onDelta, onToolCall, onToolResult, onA2UIMessage } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const readyCheck = await this.checkReady();
|
const readyCheck = await this.checkReady();
|
||||||
@@ -303,52 +275,30 @@ export class OpenCodeManager {
|
|||||||
// Get system prompt
|
// Get system prompt
|
||||||
const systemMessage = conversation.messages.find(m => m.role === 'system');
|
const systemMessage = conversation.messages.find(m => m.role === 'system');
|
||||||
const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
|
const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
|
||||||
const protocolSystemPrompt = `${systemPrompt}\n\n${this.protocolBoundaryInstructions}`;
|
|
||||||
|
|
||||||
// Build message history from DB (excluding system messages)
|
// Build message history from DB (excluding system messages)
|
||||||
const dbMessages = conversation.messages.filter(m => m.role !== 'system');
|
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
|
// Add the new user message
|
||||||
dbMessages.push({
|
dbMessages.push({
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: userMessageForModel,
|
content: userMessage,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let fullResponse = '';
|
let fullResponse = '';
|
||||||
const toolCallsCollected: Array<{ name: string; args: unknown }> = [];
|
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 (
|
const requestProvider = async (
|
||||||
prompt: string,
|
prompt: string,
|
||||||
messages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>,
|
messages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>,
|
||||||
@@ -360,6 +310,8 @@ export class OpenCodeManager {
|
|||||||
messages,
|
messages,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
{ onDelta, onToolCall, onToolResult },
|
{ onDelta, onToolCall, onToolResult },
|
||||||
|
conversationId,
|
||||||
|
emitA2UIMessages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,12 +321,14 @@ export class OpenCodeManager {
|
|||||||
messages,
|
messages,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
{ onDelta, onToolCall, onToolResult },
|
{ onDelta, onToolCall, onToolResult },
|
||||||
|
conversationId,
|
||||||
|
emitA2UIMessages,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId);
|
console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId);
|
||||||
const firstResult = await requestProvider(protocolSystemPrompt, dbMessages);
|
const firstResult = await requestProvider(systemPrompt, dbMessages);
|
||||||
fullResponse = firstResult.content;
|
fullResponse = firstResult.content;
|
||||||
toolCallsCollected.push(...firstResult.toolCalls);
|
toolCallsCollected.push(...firstResult.toolCalls);
|
||||||
console.log('[OpenCodeManager] fullResponse length:', fullResponse.length);
|
console.log('[OpenCodeManager] fullResponse length:', fullResponse.length);
|
||||||
@@ -384,92 +338,16 @@ export class OpenCodeManager {
|
|||||||
if (!isAborted) {
|
if (!isAborted) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
// On abort, keep whatever was streamed so far (already in fullResponse or empty)
|
|
||||||
} finally {
|
} finally {
|
||||||
this.abortControllers.delete(conversationId);
|
this.abortControllers.delete(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCanonicalProtocolEnvelope = (() => {
|
// Save assistant response to history
|
||||||
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.
|
|
||||||
if (fullResponse) {
|
if (fullResponse) {
|
||||||
await this.chatEngine.addMessage({
|
await this.chatEngine.addMessage({
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: protocolResult.envelope.assistantText,
|
content: fullResponse,
|
||||||
toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : undefined,
|
toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : undefined,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
@@ -485,11 +363,7 @@ export class OpenCodeManager {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: protocolResult.envelope.assistantText,
|
message: fullResponse,
|
||||||
envelope: protocolResult.envelope,
|
|
||||||
protocolVersion: protocolResult.envelope.protocolVersion,
|
|
||||||
traceId: protocolResult.traceId,
|
|
||||||
warnings: protocolResult.warnings,
|
|
||||||
toolCalls: toolCallsCollected.length > 0 ? toolCallsCollected : undefined,
|
toolCalls: toolCallsCollected.length > 0 ? toolCallsCollected : undefined,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -510,7 +384,9 @@ export class OpenCodeManager {
|
|||||||
onDelta?: (delta: string) => void;
|
onDelta?: (delta: string) => void;
|
||||||
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
||||||
onToolResult?: (result: { name: string; result: unknown }) => void;
|
onToolResult?: (result: { name: string; result: unknown }) => void;
|
||||||
}
|
},
|
||||||
|
conversationId: string,
|
||||||
|
emitA2UIMessages: (messages: A2UIServerMessage[]) => void,
|
||||||
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
|
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
|
||||||
const tools = this.getToolDefinitions();
|
const tools = this.getToolDefinitions();
|
||||||
const allToolCalls: Array<{ name: string; args: unknown }> = [];
|
const allToolCalls: Array<{ name: string; args: unknown }> = [];
|
||||||
@@ -601,6 +477,29 @@ export class OpenCodeManager {
|
|||||||
callbacks.onToolCall({ name: toolName, args: toolArgs });
|
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
|
// Execute the tool
|
||||||
const result = await this.executeTool(toolName, toolArgs as Record<string, unknown>);
|
const result = await this.executeTool(toolName, toolArgs as Record<string, unknown>);
|
||||||
|
|
||||||
@@ -673,7 +572,9 @@ export class OpenCodeManager {
|
|||||||
onDelta?: (delta: string) => void;
|
onDelta?: (delta: string) => void;
|
||||||
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
||||||
onToolResult?: (result: { name: string; result: unknown }) => void;
|
onToolResult?: (result: { name: string; result: unknown }) => void;
|
||||||
}
|
},
|
||||||
|
conversationId: string,
|
||||||
|
emitA2UIMessages: (messages: A2UIServerMessage[]) => void,
|
||||||
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
|
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
|
||||||
// Build OpenAI-format messages
|
// Build OpenAI-format messages
|
||||||
const messages: Array<Record<string, unknown>> = [
|
const messages: Array<Record<string, unknown>> = [
|
||||||
@@ -787,6 +688,25 @@ export class OpenCodeManager {
|
|||||||
callbacks.onToolCall({ name: toolName, args: toolArgs });
|
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);
|
const result = await this.executeTool(toolName, toolArgs);
|
||||||
|
|
||||||
if (callbacks.onToolResult) {
|
if (callbacks.onToolResult) {
|
||||||
@@ -978,6 +898,156 @@ export class OpenCodeManager {
|
|||||||
required: ['mediaId'],
|
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 { getPostEngine } from '../engine/PostEngine';
|
||||||
import { getMediaEngine } from '../engine/MediaEngine';
|
import { getMediaEngine } from '../engine/MediaEngine';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { getProtocolTelemetryService } from '../agentic/observability/protocolTelemetry';
|
|
||||||
|
|
||||||
let chatEngine: ChatEngine | null = null;
|
let chatEngine: ChatEngine | null = null;
|
||||||
let openCodeManager: OpenCodeManager | null = null;
|
let openCodeManager: OpenCodeManager | null = null;
|
||||||
@@ -136,10 +135,6 @@ export function registerChatHandlers(): void {
|
|||||||
|
|
||||||
// ============ Chat Settings ============
|
// ============ Chat Settings ============
|
||||||
|
|
||||||
ipcMain.handle('chat:getProtocolHealth', async () => {
|
|
||||||
return getProtocolTelemetryService().getSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get available models
|
// Get available models
|
||||||
ipcMain.handle('chat:getAvailableModels', async () => {
|
ipcMain.handle('chat:getAvailableModels', async () => {
|
||||||
try {
|
try {
|
||||||
@@ -283,6 +278,11 @@ export function registerChatHandlers(): void {
|
|||||||
mainWindow.webContents.send('chat-tool-result', { conversationId, result });
|
mainWindow.webContents.send('chat-tool-result', { conversationId, result });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onA2UIMessage: (message) => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send('a2ui-message', { conversationId, message });
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -379,6 +379,20 @@ export function registerChatHandlers(): void {
|
|||||||
return { success: false, error: (error as Error).message };
|
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'),
|
getApiKey: () => ipcRenderer.invoke('chat:getApiKey'),
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
getProtocolHealth: () => ipcRenderer.invoke('chat:getProtocolHealth'),
|
|
||||||
getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'),
|
getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'),
|
||||||
setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId),
|
setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId),
|
||||||
getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'),
|
getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'),
|
||||||
@@ -334,6 +333,14 @@ export const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.on('chat-title-updated', subscription);
|
ipcRenderer.on('chat-title-updated', subscription);
|
||||||
return () => ipcRenderer.removeListener('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
|
// Event listeners
|
||||||
|
|||||||
@@ -435,52 +435,9 @@ export interface ChatSendMetadata {
|
|||||||
surface?: 'tab' | 'sidebar';
|
surface?: 'tab' | 'sidebar';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProtocolNeedsInputField {
|
// A2UI types imported for use in ElectronAPI and re-exported for renderer
|
||||||
key: string;
|
import type { A2UIServerMessage, A2UIClientAction } from '../a2ui/types';
|
||||||
label: string;
|
export type { A2UIServerMessage, A2UIClientAction };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SiteValidationReport {
|
export interface SiteValidationReport {
|
||||||
sitemapPath: string;
|
sitemapPath: string;
|
||||||
@@ -764,7 +721,6 @@ export interface ElectronAPI {
|
|||||||
getApiKey: () => Promise<ChatApiKeyStatus>;
|
getApiKey: () => Promise<ChatApiKeyStatus>;
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
getProtocolHealth: () => Promise<ProtocolTelemetrySnapshot>;
|
|
||||||
getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>;
|
getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>;
|
||||||
setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>;
|
setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>;
|
getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>;
|
||||||
@@ -778,7 +734,7 @@ export interface ElectronAPI {
|
|||||||
deleteConversation: (id: string) => Promise<boolean>;
|
deleteConversation: (id: string) => Promise<boolean>;
|
||||||
|
|
||||||
// Messaging
|
// 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 }>;
|
addSystemEvent: (conversationId: string, content: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
abortMessage: (conversationId: string) => Promise<void>;
|
abortMessage: (conversationId: string) => Promise<void>;
|
||||||
getHistory: (conversationId: string) => Promise<ChatMessage[]>;
|
getHistory: (conversationId: string) => Promise<ChatMessage[]>;
|
||||||
@@ -796,6 +752,10 @@ export interface ElectronAPI {
|
|||||||
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
|
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
|
||||||
onToolResult: (callback: (data: ChatToolResult) => void) => () => void;
|
onToolResult: (callback: (data: ChatToolResult) => void) => () => void;
|
||||||
onTitleUpdated: (callback: (data: ChatTitleUpdate) => 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;
|
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
||||||
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||||
|
|||||||
101
src/renderer/a2ui/A2UIRenderer.tsx
Normal file
101
src/renderer/a2ui/A2UIRenderer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
244
src/renderer/a2ui/A2UISurfaceManager.ts
Normal file
244
src/renderer/a2ui/A2UISurfaceManager.ts
Normal 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;
|
||||||
|
}
|
||||||
41
src/renderer/a2ui/components/A2UIButton.tsx
Normal file
41
src/renderer/a2ui/components/A2UIButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
54
src/renderer/a2ui/components/A2UICard.tsx
Normal file
54
src/renderer/a2ui/components/A2UICard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
src/renderer/a2ui/components/A2UIChart.tsx
Normal file
36
src/renderer/a2ui/components/A2UIChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
src/renderer/a2ui/components/A2UICheckBox.tsx
Normal file
32
src/renderer/a2ui/components/A2UICheckBox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
src/renderer/a2ui/components/A2UIChoicePicker.tsx
Normal file
44
src/renderer/a2ui/components/A2UIChoicePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
src/renderer/a2ui/components/A2UIColumn.tsx
Normal file
18
src/renderer/a2ui/components/A2UIColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
src/renderer/a2ui/components/A2UIDateTimeInput.tsx
Normal file
37
src/renderer/a2ui/components/A2UIDateTimeInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
14
src/renderer/a2ui/components/A2UIDivider.tsx
Normal file
14
src/renderer/a2ui/components/A2UIDivider.tsx
Normal 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 />;
|
||||||
|
};
|
||||||
21
src/renderer/a2ui/components/A2UIForm.tsx
Normal file
21
src/renderer/a2ui/components/A2UIForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/renderer/a2ui/components/A2UIImage.tsx
Normal file
48
src/renderer/a2ui/components/A2UIImage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
26
src/renderer/a2ui/components/A2UIList.tsx
Normal file
26
src/renderer/a2ui/components/A2UIList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
22
src/renderer/a2ui/components/A2UIMetric.tsx
Normal file
22
src/renderer/a2ui/components/A2UIMetric.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
src/renderer/a2ui/components/A2UIRow.tsx
Normal file
18
src/renderer/a2ui/components/A2UIRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
src/renderer/a2ui/components/A2UITable.tsx
Normal file
40
src/renderer/a2ui/components/A2UITable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
src/renderer/a2ui/components/A2UITabs.tsx
Normal file
37
src/renderer/a2ui/components/A2UITabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
src/renderer/a2ui/components/A2UIText.tsx
Normal file
16
src/renderer/a2ui/components/A2UIText.tsx
Normal 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>;
|
||||||
|
};
|
||||||
51
src/renderer/a2ui/components/A2UITextField.tsx
Normal file
51
src/renderer/a2ui/components/A2UITextField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
src/renderer/a2ui/components/index.ts
Normal file
17
src/renderer/a2ui/components/index.ts
Normal 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';
|
||||||
111
src/renderer/a2ui/useA2UISurface.ts
Normal file
111
src/renderer/a2ui/useA2UISurface.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { AssistantPanelControls } from './AssistantPanelControls';
|
|
||||||
@@ -3,15 +3,13 @@ import { useAppStore } from '../../store';
|
|||||||
import { resolveAssistantEditorContext } from '../../navigation/assistantPromptContext';
|
import { resolveAssistantEditorContext } from '../../navigation/assistantPromptContext';
|
||||||
import { planAssistantRequest } from '../../navigation/assistantConversation';
|
import { planAssistantRequest } from '../../navigation/assistantConversation';
|
||||||
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
|
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 { ensureConversationId } from '../../navigation/chatSession';
|
||||||
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
||||||
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
|
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
|
||||||
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
|
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
|
||||||
|
import { useA2UISurface } from '../../a2ui/useA2UISurface';
|
||||||
|
import { A2UIRenderer } from '../../a2ui/A2UIRenderer';
|
||||||
import { ChatTranscript } from '../ChatSurface';
|
import { ChatTranscript } from '../ChatSurface';
|
||||||
import { AssistantPanelControls } from '../AssistantPanelControls';
|
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import '../../styles/chatSurface.css';
|
import '../../styles/chatSurface.css';
|
||||||
import './AssistantSidebar.css';
|
import './AssistantSidebar.css';
|
||||||
@@ -23,8 +21,6 @@ export const AssistantSidebar: React.FC = () => {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [conversationId, setConversationId] = 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 [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -57,6 +53,10 @@ export const AssistantSidebar: React.FC = () => {
|
|||||||
stopStreaming,
|
stopStreaming,
|
||||||
getStreamingContent,
|
getStreamingContent,
|
||||||
} = useChatSurfaceState();
|
} = useChatSurfaceState();
|
||||||
|
|
||||||
|
// A2UI surface rendering
|
||||||
|
const { surfaces, dispatchAction, updateLocalData } = useA2UISurface({ conversationId });
|
||||||
|
|
||||||
const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
|
const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
|
||||||
|
|
||||||
const editorContext = useMemo(
|
const editorContext = useMemo(
|
||||||
@@ -169,25 +169,13 @@ export const AssistantSidebar: React.FC = () => {
|
|||||||
throw new Error(sendResult.error || 'Failed to send assistant message');
|
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));
|
|
||||||
} else {
|
|
||||||
const assistantContent = getStreamingContent() || sendResult.message;
|
const assistantContent = getStreamingContent() || sendResult.message;
|
||||||
if (assistantContent) {
|
if (assistantContent) {
|
||||||
const parsedResponse = extractAssistantResponseContent(assistantContent);
|
finalizeAssistantTurn(resolvedConversationId, assistantContent);
|
||||||
finalizeAssistantTurn(resolvedConversationId, parsedResponse.displayText);
|
|
||||||
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
|
|
||||||
setActionPolicies({});
|
|
||||||
} else {
|
} else {
|
||||||
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
|
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
|
||||||
stopStreaming();
|
stopStreaming();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setPrompt('');
|
setPrompt('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -199,57 +187,6 @@ export const AssistantSidebar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
|
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(
|
const result = dispatchAssistantAction(
|
||||||
{
|
{
|
||||||
action,
|
action,
|
||||||
@@ -333,9 +270,15 @@ export const AssistantSidebar: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{panelElements.length > 0 && (
|
{surfaces.map((surface) => (
|
||||||
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} actionPolicies={actionPolicies} />
|
<A2UIRenderer
|
||||||
)}
|
key={surface.surfaceId}
|
||||||
|
surfaceId={surface.surfaceId}
|
||||||
|
tree={surface.tree}
|
||||||
|
onAction={dispatchAction}
|
||||||
|
onDataChange={updateLocalData}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ import { useChatMessageSender } from '../../navigation/useChatMessageSender';
|
|||||||
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
|
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
|
||||||
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
||||||
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
|
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
|
||||||
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
|
import { useA2UISurface } from '../../a2ui/useA2UISurface';
|
||||||
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
|
import { A2UIRenderer } from '../../a2ui/A2UIRenderer';
|
||||||
import { buildActionPoliciesFromEnvelope } from '../../navigation/protocolActionPolicies';
|
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { ChatTranscript } from '../ChatSurface';
|
import { ChatTranscript } from '../ChatSurface';
|
||||||
import { AssistantPanelControls } from '../AssistantPanelControls';
|
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import '../../styles/chatSurface.css';
|
import '../../styles/chatSurface.css';
|
||||||
import './ChatPanel.css';
|
import './ChatPanel.css';
|
||||||
@@ -29,8 +27,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||||
const [apiKeyError, setApiKeyError] = useState('');
|
const [apiKeyError, setApiKeyError] = useState('');
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
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 [actionError, setActionError] = useState<string | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -63,6 +59,9 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
getStreamingContent,
|
getStreamingContent,
|
||||||
} = useChatSurfaceState();
|
} = useChatSurfaceState();
|
||||||
|
|
||||||
|
// A2UI surface rendering
|
||||||
|
const { surfaces, dispatchAction, updateLocalData } = useA2UISurface({ conversationId });
|
||||||
|
|
||||||
// Scroll to bottom when messages change
|
// Scroll to bottom when messages change
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
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
|
// Fall back to the backend result message if streaming didn't capture the content
|
||||||
const assistantContent = getStreamingContent() || (result.success ? result.message : '');
|
const assistantContent = getStreamingContent() || (result.success ? result.message : '');
|
||||||
|
|
||||||
if (result.envelope) {
|
if (assistantContent) {
|
||||||
finalizeAssistantTurn(conversationId, result.envelope.assistantText);
|
finalizeAssistantTurn(conversationId, assistantContent);
|
||||||
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({});
|
|
||||||
} else if (!result.success) {
|
} else if (!result.success) {
|
||||||
// Backend returned an error (API failure, model unavailable, etc.)
|
|
||||||
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
|
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
|
||||||
stopStreaming();
|
stopStreaming();
|
||||||
setPanelElements([]);
|
|
||||||
setActionPolicies({});
|
|
||||||
} else {
|
} 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'));
|
appendAssistantMessage(conversationId, tr('chat.errorEmptyResponse'));
|
||||||
stopStreaming();
|
stopStreaming();
|
||||||
setPanelElements([]);
|
|
||||||
setActionPolicies({});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send message:', error);
|
console.error('Failed to send message:', error);
|
||||||
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
|
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
|
||||||
stopStreaming();
|
stopStreaming();
|
||||||
setPanelElements([]);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
stopStreaming();
|
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>) => {
|
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
|
||||||
if (action === 'submitNeedsInput') {
|
|
||||||
void handleNeedsInputSubmit(payload);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = dispatchAssistantAction(
|
const result = dispatchAssistantAction(
|
||||||
{
|
{
|
||||||
action,
|
action,
|
||||||
@@ -441,9 +370,15 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
endRef={messagesEndRef}
|
endRef={messagesEndRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{panelElements.length > 0 && (
|
{surfaces.map((surface) => (
|
||||||
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} actionPolicies={actionPolicies} />
|
<A2UIRenderer
|
||||||
)}
|
key={surface.surfaceId}
|
||||||
|
surfaceId={surface.surfaceId}
|
||||||
|
tree={surface.tree}
|
||||||
|
onAction={dispatchAction}
|
||||||
|
onDataChange={updateLocalData}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{actionError && <p className="chat-surface-error">{actionError}</p>}
|
{actionError && <p className="chat-surface-error">{actionError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1478,12 +1478,6 @@ interface CategoryCount {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardProtocolHealth {
|
|
||||||
blockedActionCount: number;
|
|
||||||
parseValidityRate: number;
|
|
||||||
fallbackTurns: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const { t: tr, language } = useI18n();
|
const { t: tr, language } = useI18n();
|
||||||
const { posts, media } = useAppStore();
|
const { posts, media } = useAppStore();
|
||||||
@@ -1492,7 +1486,6 @@ const Dashboard: React.FC = () => {
|
|||||||
const [tagCounts, setTagCounts] = useState<TagCount[]>([]);
|
const [tagCounts, setTagCounts] = useState<TagCount[]>([]);
|
||||||
const [tagColors, setTagColors] = useState<Map<string, string>>(new Map());
|
const [tagColors, setTagColors] = useState<Map<string, string>>(new Map());
|
||||||
const [categoryCounts, setCategoryCounts] = useState<CategoryCount[]>([]);
|
const [categoryCounts, setCategoryCounts] = useState<CategoryCount[]>([]);
|
||||||
const [protocolHealth, setProtocolHealth] = useState<DashboardProtocolHealth | null>(null);
|
|
||||||
|
|
||||||
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
|
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
|
||||||
const monthFormatter = useMemo(
|
const monthFormatter = useMemo(
|
||||||
@@ -1503,25 +1496,17 @@ const Dashboard: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
try {
|
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.getDashboardStats(),
|
||||||
window.electronAPI?.posts.getByYearMonth(),
|
window.electronAPI?.posts.getByYearMonth(),
|
||||||
window.electronAPI?.posts.getTagsWithCounts(),
|
window.electronAPI?.posts.getTagsWithCounts(),
|
||||||
window.electronAPI?.posts.getCategoriesWithCounts(),
|
window.electronAPI?.posts.getCategoriesWithCounts(),
|
||||||
loadTagColorMap(),
|
loadTagColorMap(),
|
||||||
window.electronAPI?.chat.getProtocolHealth(),
|
|
||||||
]);
|
]);
|
||||||
if (ds) setStats(ds);
|
if (ds) setStats(ds);
|
||||||
if (ym) setYearMonthData(ym);
|
if (ym) setYearMonthData(ym);
|
||||||
if (tc) setTagCounts(tc);
|
if (tc) setTagCounts(tc);
|
||||||
if (cc) setCategoryCounts(cc);
|
if (cc) setCategoryCounts(cc);
|
||||||
if (protocolHealthSnapshot) {
|
|
||||||
setProtocolHealth({
|
|
||||||
blockedActionCount: protocolHealthSnapshot.blockedActionCount,
|
|
||||||
parseValidityRate: protocolHealthSnapshot.parseValidityRate,
|
|
||||||
fallbackTurns: protocolHealthSnapshot.fallbackTurns,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setTagColors(colorMap);
|
setTagColors(colorMap);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load dashboard stats:', e);
|
console.error('Failed to load dashboard stats:', e);
|
||||||
@@ -1566,9 +1551,6 @@ const Dashboard: React.FC = () => {
|
|||||||
const displayDraftCount = stats?.draftCount ?? 0;
|
const displayDraftCount = stats?.draftCount ?? 0;
|
||||||
const displayPublishedCount = stats?.publishedCount ?? 0;
|
const displayPublishedCount = stats?.publishedCount ?? 0;
|
||||||
const displayArchivedCount = stats?.archivedCount ?? 0;
|
const displayArchivedCount = stats?.archivedCount ?? 0;
|
||||||
const parseValidityPercent = protocolHealth
|
|
||||||
? `${Math.round(protocolHealth.parseValidityRate * 100)}%`
|
|
||||||
: '—';
|
|
||||||
|
|
||||||
const getPostCountLabel = useCallback((count: number) => {
|
const getPostCountLabel = useCallback((count: number) => {
|
||||||
return tr(count === 1 ? 'dashboard.postCount.one' : 'dashboard.postCount.other', { count });
|
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>
|
<span className="stat-tag">{tr('dashboard.stats.categories', { count: categoryCounts.length })}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{timelineEntries.length > 0 && (
|
{timelineEntries.length > 0 && (
|
||||||
|
|||||||
@@ -28,4 +28,3 @@ export { DocumentationView } from './DocumentationView/DocumentationView';
|
|||||||
export { SiteValidationView } from './SiteValidationView';
|
export { SiteValidationView } from './SiteValidationView';
|
||||||
export { ScriptsView } from './ScriptsView/ScriptsView';
|
export { ScriptsView } from './ScriptsView/ScriptsView';
|
||||||
export { AssistantSidebar } from './AssistantSidebar';
|
export { AssistantSidebar } from './AssistantSidebar';
|
||||||
export { AssistantPanelControls } from './AssistantPanelControls';
|
|
||||||
|
|||||||
@@ -156,276 +156,3 @@ export const assistantPanelSpecSchema = z.object({
|
|||||||
|
|
||||||
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
|
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
|
||||||
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { ProtocolResponseEnvelope } from '../types/electron';
|
|
||||||
|
|
||||||
export interface ChatService {
|
export interface ChatService {
|
||||||
createConversation: (title?: string, model?: string) => Promise<{ id: string } | null | undefined>;
|
createConversation: (title?: string, model?: string) => Promise<{ id: string } | null | undefined>;
|
||||||
sendMessage: (
|
sendMessage: (
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
message: string,
|
message: string,
|
||||||
metadata?: SendMessageMetadata,
|
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 {
|
export interface SendMessageMetadata {
|
||||||
@@ -29,10 +27,6 @@ export interface SendConversationMessageInput {
|
|||||||
export interface SendConversationMessageResult {
|
export interface SendConversationMessageResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
envelope?: ProtocolResponseEnvelope;
|
|
||||||
protocolVersion?: '2.0';
|
|
||||||
traceId?: string;
|
|
||||||
warnings?: string[];
|
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +69,5 @@ export async function sendConversationMessage(
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: result.message || '',
|
message: result.message || '',
|
||||||
envelope: result.envelope,
|
|
||||||
protocolVersion: result.protocolVersion,
|
|
||||||
traceId: result.traceId,
|
|
||||||
warnings: result.warnings,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
@@ -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.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.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'),
|
||||||
method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'),
|
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.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.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'),
|
||||||
method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; 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.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.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
|
||||||
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
|
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.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
|
||||||
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
|
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
|
||||||
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
|
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: '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 = {
|
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
|
||||||
|
|||||||
@@ -638,11 +638,11 @@ describe('ChatEngine', () => {
|
|||||||
const result = await chatEngine.getDefaultSystemPrompt();
|
const result = await chatEngine.getDefaultSystemPrompt();
|
||||||
|
|
||||||
expect(result).toContain('Blogging Desktop Server');
|
expect(result).toContain('Blogging Desktop Server');
|
||||||
expect(result).toContain('Available Tools');
|
expect(result).toContain('Available Data Tools');
|
||||||
expect(result).toContain('Agentic UI Contract');
|
expect(result).toContain('UI Render Tools');
|
||||||
expect(result).toContain('specVersion');
|
expect(result).toContain('render_chart');
|
||||||
expect(result).toContain('tabs');
|
expect(result).toContain('tabs');
|
||||||
expect(result).toContain('openSettings');
|
expect(result).toContain('render_form');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return built-in prompt when saved prompt is empty', async () => {
|
it('should return built-in prompt when saved prompt is empty', async () => {
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager';
|
|
||||||
import { getProtocolTelemetryService } from '../../src/main/agentic/observability/protocolTelemetry';
|
|
||||||
|
|
||||||
interface MockConversation {
|
|
||||||
id: string;
|
|
||||||
model?: string;
|
|
||||||
messages: Array<{ role: 'user' | 'assistant' | 'system' | 'tool'; content: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createChatEngineMock(conversation: MockConversation) {
|
|
||||||
const settings = new Map<string, string>();
|
|
||||||
const addMessage = vi.fn().mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
return {
|
|
||||||
getConversation: vi.fn().mockResolvedValue(conversation),
|
|
||||||
addMessage,
|
|
||||||
getDefaultSystemPrompt: vi.fn().mockResolvedValue('system prompt'),
|
|
||||||
getSetting: vi.fn(async (key: string) => settings.get(key) ?? null),
|
|
||||||
setSetting: vi.fn(async (key: string, value: string) => {
|
|
||||||
settings.set(key, value);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('OpenCodeManager protocol integration', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses protocol envelope flow and persists workflow checkpoint for needs_input turns', async () => {
|
|
||||||
const conversation: MockConversation = {
|
|
||||||
id: 'conversation-1',
|
|
||||||
model: 'gpt-5',
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: 'system' },
|
|
||||||
{ role: 'user', content: 'previous user message' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatEngineMock = createChatEngineMock(conversation);
|
|
||||||
const manager = new OpenCodeManager(
|
|
||||||
chatEngineMock as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
() => null,
|
|
||||||
);
|
|
||||||
|
|
||||||
manager.setApiKey('test-api-key');
|
|
||||||
|
|
||||||
const providerSpy = vi.spyOn(manager as never, 'sendOpenAIMessage').mockResolvedValue({
|
|
||||||
content: JSON.stringify({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'Please provide a date range.',
|
|
||||||
intent: 'ask_input',
|
|
||||||
needsInput: {
|
|
||||||
required: true,
|
|
||||||
fields: [{ key: 'dateRange', label: 'Date range', inputType: 'date' }],
|
|
||||||
},
|
|
||||||
actions: [],
|
|
||||||
confidence: 0.82,
|
|
||||||
traceId: 'trace-needs-input',
|
|
||||||
}),
|
|
||||||
toolCalls: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const telemetryBefore = getProtocolTelemetryService().getSnapshot();
|
|
||||||
|
|
||||||
const result = await manager.sendMessage('conversation-1', 'Generate report', {
|
|
||||||
metadata: { surface: 'tab' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.envelope?.protocolVersion).toBe('2.0');
|
|
||||||
expect(result.envelope?.needsInput.required).toBe(true);
|
|
||||||
expect(result.envelope?.needsInput.fields[0]?.key).toBe('dateRange');
|
|
||||||
|
|
||||||
expect(providerSpy).toHaveBeenCalledTimes(1);
|
|
||||||
const providerMessages = providerSpy.mock.calls[0][2] as Array<{ role: string; content?: string }>;
|
|
||||||
const latestUserMessage = providerMessages[providerMessages.length - 1]?.content ?? '';
|
|
||||||
expect(latestUserMessage).toContain('[Protocol request envelope]');
|
|
||||||
expect(latestUserMessage).toContain('"protocolVersion": "2.0"');
|
|
||||||
expect(latestUserMessage).toContain('"surface": "tab"');
|
|
||||||
|
|
||||||
const checkpointKey = 'agui.workflow.conversation-1';
|
|
||||||
expect(chatEngineMock.setSetting).toHaveBeenCalledWith(
|
|
||||||
checkpointKey,
|
|
||||||
expect.any(String),
|
|
||||||
);
|
|
||||||
|
|
||||||
const persistedCheckpoint = await chatEngineMock.getSetting(checkpointKey);
|
|
||||||
const checkpoint = JSON.parse(persistedCheckpoint as string) as { state: string; pendingFields: string[]; lastTraceId: string };
|
|
||||||
expect(checkpoint.state).toBe('awaiting_input');
|
|
||||||
expect(checkpoint.pendingFields).toEqual(['dateRange']);
|
|
||||||
expect(checkpoint.lastTraceId).toBe('trace-needs-input');
|
|
||||||
|
|
||||||
const telemetryAfter = getProtocolTelemetryService().getSnapshot();
|
|
||||||
expect(telemetryAfter.totalTurns).toBe(telemetryBefore.totalTurns + 1);
|
|
||||||
expect(telemetryAfter.validEnvelopeTurns).toBe(telemetryBefore.validEnvelopeTurns + 1);
|
|
||||||
|
|
||||||
expect(chatEngineMock.addMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'Please provide a date range.',
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks unsupported actions and records blocked-action telemetry', async () => {
|
|
||||||
const conversation: MockConversation = {
|
|
||||||
id: 'conversation-2',
|
|
||||||
model: 'gpt-5',
|
|
||||||
messages: [{ role: 'user', content: 'previous user message' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatEngineMock = createChatEngineMock(conversation);
|
|
||||||
const manager = new OpenCodeManager(
|
|
||||||
chatEngineMock as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
() => null,
|
|
||||||
);
|
|
||||||
manager.setApiKey('test-api-key');
|
|
||||||
|
|
||||||
vi.spyOn(manager as never, 'sendOpenAIMessage').mockResolvedValue({
|
|
||||||
content: JSON.stringify({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'Try toggling sidebar.',
|
|
||||||
intent: 'propose_action',
|
|
||||||
needsInput: { required: false, fields: [] },
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: 'a1',
|
|
||||||
action: 'toggleAssistantSidebar',
|
|
||||||
label: 'Toggle assistant sidebar',
|
|
||||||
policy: 'silent',
|
|
||||||
requiresConfirmation: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
confidence: 0.74,
|
|
||||||
traceId: 'trace-blocked-action',
|
|
||||||
}),
|
|
||||||
toolCalls: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const telemetryBefore = getProtocolTelemetryService().getSnapshot();
|
|
||||||
|
|
||||||
const result = await manager.sendMessage('conversation-2', 'Toggle it', {
|
|
||||||
metadata: { surface: 'tab' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.envelope?.actions).toEqual([]);
|
|
||||||
expect(result.warnings?.some((warning) => warning.includes('Blocked unsupported action: toggleAssistantSidebar'))).toBe(true);
|
|
||||||
|
|
||||||
const telemetryAfter = getProtocolTelemetryService().getSnapshot();
|
|
||||||
expect(telemetryAfter.blockedActionCount).toBe(telemetryBefore.blockedActionCount + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retries once with protocol repair prompt when first output is non-canonical', async () => {
|
|
||||||
const conversation: MockConversation = {
|
|
||||||
id: 'conversation-3',
|
|
||||||
model: 'gpt-5',
|
|
||||||
messages: [{ role: 'user', content: 'show chart' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatEngineMock = createChatEngineMock(conversation);
|
|
||||||
const manager = new OpenCodeManager(
|
|
||||||
chatEngineMock as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
() => null,
|
|
||||||
);
|
|
||||||
manager.setApiKey('test-api-key');
|
|
||||||
|
|
||||||
const sendSpy = vi.spyOn(manager as never, 'sendOpenAIMessage')
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
content: JSON.stringify({
|
|
||||||
title: 'Legacy JSON',
|
|
||||||
widgets: [{ type: 'chart', chartType: 'bar' }],
|
|
||||||
}),
|
|
||||||
toolCalls: [],
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
content: JSON.stringify({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'Here is your chart.',
|
|
||||||
ui: {
|
|
||||||
specVersion: '1',
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
type: 'chart',
|
|
||||||
chartType: 'bar',
|
|
||||||
series: [{ label: '2015', value: 86 }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
intent: 'summarize',
|
|
||||||
needsInput: { required: false, fields: [] },
|
|
||||||
actions: [],
|
|
||||||
confidence: 0.8,
|
|
||||||
traceId: 'trace-retry-success',
|
|
||||||
}),
|
|
||||||
toolCalls: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await manager.sendMessage('conversation-3', 'Build chart', {
|
|
||||||
metadata: { surface: 'tab' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.envelope?.traceId).toBe('trace-retry-success');
|
|
||||||
expect(sendSpy).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
const retryMessages = sendSpy.mock.calls[1]?.[2] as Array<{ role: string; content?: string }>;
|
|
||||||
const lastMessage = retryMessages[retryMessages.length - 1]?.content ?? '';
|
|
||||||
expect(lastMessage).toContain('failed protocol validation');
|
|
||||||
expect(lastMessage).toContain('Return ONLY one valid protocol envelope JSON object');
|
|
||||||
|
|
||||||
expect(chatEngineMock.addMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
conversationId: 'conversation-3',
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'Here is your chart.',
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
80
tests/engine/a2ui/catalog.test.ts
Normal file
80
tests/engine/a2ui/catalog.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
getCatalogEntries,
|
||||||
|
isSupportedComponentType,
|
||||||
|
getCatalogEntry,
|
||||||
|
getCatalogId,
|
||||||
|
buildCatalogDescription,
|
||||||
|
} from '../../../src/main/a2ui/catalog';
|
||||||
|
import { BDS_CATALOG_ID } from '../../../src/main/a2ui/types';
|
||||||
|
|
||||||
|
describe('A2UI catalog', () => {
|
||||||
|
it('returns all 17 catalog entries', () => {
|
||||||
|
const entries = getCatalogEntries();
|
||||||
|
expect(entries).toHaveLength(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a copy of catalog entries to prevent mutation', () => {
|
||||||
|
const entries1 = getCatalogEntries();
|
||||||
|
const entries2 = getCatalogEntries();
|
||||||
|
expect(entries1).not.toBe(entries2);
|
||||||
|
expect(entries1).toEqual(entries2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises all supported component types', () => {
|
||||||
|
const types = [
|
||||||
|
'text', 'button', 'card', 'chart', 'table', 'form',
|
||||||
|
'textField', 'checkBox', 'dateTimeInput', 'choicePicker',
|
||||||
|
'image', 'tabs', 'metric', 'list', 'row', 'column', 'divider',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
expect(isSupportedComponentType(type)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported component types', () => {
|
||||||
|
expect(isSupportedComponentType('video')).toBe(false);
|
||||||
|
expect(isSupportedComponentType('slider')).toBe(false);
|
||||||
|
expect(isSupportedComponentType('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns catalog entry by type', () => {
|
||||||
|
const entry = getCatalogEntry('chart');
|
||||||
|
expect(entry).toEqual({
|
||||||
|
type: 'chart',
|
||||||
|
description: 'Bar, line, or pie chart visualization',
|
||||||
|
custom: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for unknown type', () => {
|
||||||
|
expect(getCatalogEntry('unknown' as never)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the bDS catalog ID', () => {
|
||||||
|
expect(getCatalogId()).toBe(BDS_CATALOG_ID);
|
||||||
|
expect(getCatalogId()).toBe('bds-blogging-v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a catalog description for LLM system prompt', () => {
|
||||||
|
const description = buildCatalogDescription();
|
||||||
|
expect(description).toContain('Supported UI component types:');
|
||||||
|
expect(description).toContain('text: Text block with Markdown support');
|
||||||
|
expect(description).toContain('chart: Bar, line, or pie chart visualization (custom)');
|
||||||
|
expect(description).toContain('table: Data table with columns and rows (custom)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks custom components correctly', () => {
|
||||||
|
const entries = getCatalogEntries();
|
||||||
|
const customEntries = entries.filter((e) => e.custom);
|
||||||
|
const customTypes = customEntries.map((e) => e.type);
|
||||||
|
|
||||||
|
expect(customTypes).toContain('chart');
|
||||||
|
expect(customTypes).toContain('table');
|
||||||
|
expect(customTypes).toContain('metric');
|
||||||
|
expect(customTypes).toContain('form');
|
||||||
|
expect(customTypes).not.toContain('text');
|
||||||
|
expect(customTypes).not.toContain('button');
|
||||||
|
});
|
||||||
|
});
|
||||||
263
tests/engine/a2ui/generator.test.ts
Normal file
263
tests/engine/a2ui/generator.test.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
isRenderTool,
|
||||||
|
generateFromToolCall,
|
||||||
|
generateChart,
|
||||||
|
generateTable,
|
||||||
|
generateForm,
|
||||||
|
generateCard,
|
||||||
|
generateMetric,
|
||||||
|
generateList,
|
||||||
|
generateTabs,
|
||||||
|
} from '../../../src/main/a2ui/generator';
|
||||||
|
import type { A2UIServerMessage } from '../../../src/main/a2ui/types';
|
||||||
|
|
||||||
|
describe('A2UI generator', () => {
|
||||||
|
describe('isRenderTool', () => {
|
||||||
|
it('returns true for all render tools', () => {
|
||||||
|
expect(isRenderTool('render_chart')).toBe(true);
|
||||||
|
expect(isRenderTool('render_table')).toBe(true);
|
||||||
|
expect(isRenderTool('render_form')).toBe(true);
|
||||||
|
expect(isRenderTool('render_card')).toBe(true);
|
||||||
|
expect(isRenderTool('render_metric')).toBe(true);
|
||||||
|
expect(isRenderTool('render_list')).toBe(true);
|
||||||
|
expect(isRenderTool('render_tabs')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-render tools', () => {
|
||||||
|
expect(isRenderTool('search_posts')).toBe(false);
|
||||||
|
expect(isRenderTool('get_post')).toBe(false);
|
||||||
|
expect(isRenderTool('render_unknown')).toBe(false);
|
||||||
|
expect(isRenderTool('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateFromToolCall', () => {
|
||||||
|
it('dispatches to chart generator', () => {
|
||||||
|
const messages = generateFromToolCall('conv-1', 'render_chart', {
|
||||||
|
chartType: 'bar',
|
||||||
|
series: [{ label: 'A', value: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).not.toBeNull();
|
||||||
|
expect(messages!.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(messages![0].type).toBe('createSurface');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown tool', () => {
|
||||||
|
expect(generateFromToolCall('conv-1', 'search_posts', {})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateChart', () => {
|
||||||
|
it('creates surface with chart component and data binding', () => {
|
||||||
|
const messages = generateChart('conv-1', {
|
||||||
|
chartType: 'bar',
|
||||||
|
title: 'Sales',
|
||||||
|
series: [
|
||||||
|
{ label: 'Jan', value: 10 },
|
||||||
|
{ label: 'Feb', value: 20 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(3); // createSurface + updateComponents + updateDataModel
|
||||||
|
|
||||||
|
const createMsg = messages[0] as Extract<A2UIServerMessage, { type: 'createSurface' }>;
|
||||||
|
expect(createMsg.type).toBe('createSurface');
|
||||||
|
expect(createMsg.conversationId).toBe('conv-1');
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.type).toBe('updateComponents');
|
||||||
|
expect(updateMsg.components).toHaveLength(1);
|
||||||
|
expect(updateMsg.components[0].type).toBe('chart');
|
||||||
|
expect(updateMsg.components[0].properties.chartType).toBe('bar');
|
||||||
|
expect(updateMsg.components[0].dataBinding).toBe('/chartData');
|
||||||
|
expect(updateMsg.rootIds).toHaveLength(1);
|
||||||
|
|
||||||
|
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
|
||||||
|
expect(dataMsg.type).toBe('updateDataModel');
|
||||||
|
expect(dataMsg.path).toBe('/chartData');
|
||||||
|
expect(dataMsg.value).toEqual([
|
||||||
|
{ label: 'Jan', value: 10 },
|
||||||
|
{ label: 'Feb', value: 20 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateTable', () => {
|
||||||
|
it('creates surface with table component and row data', () => {
|
||||||
|
const messages = generateTable('conv-1', {
|
||||||
|
title: 'Posts',
|
||||||
|
columns: ['Title', 'Status'],
|
||||||
|
rows: [['Hello', 'published'], ['Draft', 'draft']],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(3);
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].type).toBe('table');
|
||||||
|
expect(updateMsg.components[0].properties.columns).toEqual(['Title', 'Status']);
|
||||||
|
|
||||||
|
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
|
||||||
|
expect(dataMsg.path).toBe('/tableRows');
|
||||||
|
expect(dataMsg.value).toEqual([['Hello', 'published'], ['Draft', 'draft']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCard', () => {
|
||||||
|
it('creates surface with card component', () => {
|
||||||
|
const messages = generateCard('conv-1', {
|
||||||
|
title: 'My Card',
|
||||||
|
body: 'Card body text',
|
||||||
|
subtitle: 'Optional subtitle',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(2); // No data model for card
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].type).toBe('card');
|
||||||
|
expect(updateMsg.components[0].properties.title).toBe('My Card');
|
||||||
|
expect(updateMsg.components[0].properties.body).toBe('Card body text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes card actions when provided', () => {
|
||||||
|
const messages = generateCard('conv-1', {
|
||||||
|
title: 'Action Card',
|
||||||
|
body: 'Has actions',
|
||||||
|
actions: [{ label: 'Open', action: 'openPost', payload: { postId: 'p1' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].actions).toEqual([
|
||||||
|
{ eventType: 'click', action: 'openPost', payload: { postId: 'p1' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateMetric', () => {
|
||||||
|
it('creates surface with metric component', () => {
|
||||||
|
const messages = generateMetric('conv-1', {
|
||||||
|
label: 'Total Posts',
|
||||||
|
value: '42',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].type).toBe('metric');
|
||||||
|
expect(updateMsg.components[0].properties.label).toBe('Total Posts');
|
||||||
|
expect(updateMsg.components[0].properties.value).toBe('42');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateList', () => {
|
||||||
|
it('creates surface with list component and item data', () => {
|
||||||
|
const messages = generateList('conv-1', {
|
||||||
|
title: 'Tags',
|
||||||
|
items: ['react', 'typescript', 'electron'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(3);
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].type).toBe('list');
|
||||||
|
|
||||||
|
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
|
||||||
|
expect(dataMsg.path).toBe('/listItems');
|
||||||
|
expect(dataMsg.value).toEqual(['react', 'typescript', 'electron']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateForm', () => {
|
||||||
|
it('creates surface with form, field components, and submit button', () => {
|
||||||
|
const messages = generateForm('conv-1', {
|
||||||
|
title: 'Edit Post',
|
||||||
|
submitLabel: 'Save',
|
||||||
|
fields: [
|
||||||
|
{ key: 'title', label: 'Title', inputType: 'text', defaultValue: 'Hello' },
|
||||||
|
{ key: 'draft', label: 'Draft', inputType: 'checkbox', defaultValue: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// createSurface + updateComponents + 2 updateDataModel (one per default value)
|
||||||
|
expect(messages).toHaveLength(4);
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
// form + 2 fields + 1 submit button = 4
|
||||||
|
expect(updateMsg.components).toHaveLength(4);
|
||||||
|
|
||||||
|
const formComponent = updateMsg.components.find((c) => c.type === 'form');
|
||||||
|
expect(formComponent).toBeDefined();
|
||||||
|
expect(formComponent!.children).toHaveLength(3); // 2 fields + submit button
|
||||||
|
|
||||||
|
const textField = updateMsg.components.find((c) => c.type === 'textField');
|
||||||
|
expect(textField).toBeDefined();
|
||||||
|
expect(textField!.dataBinding).toBe('/formData/title');
|
||||||
|
|
||||||
|
const checkBox = updateMsg.components.find((c) => c.type === 'checkBox');
|
||||||
|
expect(checkBox).toBeDefined();
|
||||||
|
expect(checkBox!.dataBinding).toBe('/formData/draft');
|
||||||
|
|
||||||
|
const submitButton = updateMsg.components.find((c) => c.type === 'button');
|
||||||
|
expect(submitButton).toBeDefined();
|
||||||
|
expect(submitButton!.properties.label).toBe('Save');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps select inputType to choicePicker', () => {
|
||||||
|
const messages = generateForm('conv-1', {
|
||||||
|
submitLabel: 'Go',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
inputType: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'Draft', value: 'draft' },
|
||||||
|
{ label: 'Published', value: 'published' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
const picker = updateMsg.components.find((c) => c.type === 'choicePicker');
|
||||||
|
expect(picker).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps date inputType to dateTimeInput', () => {
|
||||||
|
const messages = generateForm('conv-1', {
|
||||||
|
submitLabel: 'Set',
|
||||||
|
fields: [{ key: 'date', label: 'Date', inputType: 'date' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
const dateInput = updateMsg.components.find((c) => c.type === 'dateTimeInput');
|
||||||
|
expect(dateInput).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateTabs', () => {
|
||||||
|
it('creates surface with tabs and child components', () => {
|
||||||
|
const messages = generateTabs('conv-1', {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
label: 'Overview',
|
||||||
|
content: [{ type: 'text', text: 'Tab content' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Details',
|
||||||
|
content: [{ type: 'metric', label: 'Count', value: '5' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(2); // createSurface + updateComponents
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
const tabsComponent = updateMsg.components.find((c) => c.type === 'tabs');
|
||||||
|
expect(tabsComponent).toBeDefined();
|
||||||
|
expect(tabsComponent!.children).toHaveLength(2);
|
||||||
|
expect(tabsComponent!.properties.tabLabels).toEqual(['Overview', 'Details']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
345
tests/engine/a2ui/surfaceManager.test.ts
Normal file
345
tests/engine/a2ui/surfaceManager.test.ts
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
A2UISurfaceManager,
|
||||||
|
getValueAtPointer,
|
||||||
|
setValueAtPointer,
|
||||||
|
} from '../../../src/renderer/a2ui/A2UISurfaceManager';
|
||||||
|
import type { A2UIServerMessage, A2UIComponent } from '../../../src/main/a2ui/types';
|
||||||
|
|
||||||
|
describe('A2UISurfaceManager', () => {
|
||||||
|
function createTestComponent(overrides: Partial<A2UIComponent> = {}): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id: 'comp-1',
|
||||||
|
type: 'text',
|
||||||
|
properties: { text: 'Hello' },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createSurface', () => {
|
||||||
|
it('creates a new surface with empty state', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = manager.getSurface('surface-1');
|
||||||
|
expect(surface).toBeDefined();
|
||||||
|
expect(surface!.conversationId).toBe('conv-1');
|
||||||
|
expect(surface!.components.size).toBe(0);
|
||||||
|
expect(surface!.rootIds).toEqual([]);
|
||||||
|
expect(surface!.dataModel).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies listeners on surface creation', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
const listener = vi.fn();
|
||||||
|
manager.onChange(listener);
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith('surface-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateComponents', () => {
|
||||||
|
it('adds components to an existing surface', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const component = createTestComponent();
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateComponents',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
components: [component],
|
||||||
|
rootIds: ['comp-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = manager.getSurface('surface-1');
|
||||||
|
expect(surface!.components.size).toBe(1);
|
||||||
|
expect(surface!.components.get('comp-1')).toEqual(component);
|
||||||
|
expect(surface!.rootIds).toEqual(['comp-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores updateComponents for non-existent surfaces', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
const listener = vi.fn();
|
||||||
|
manager.onChange(listener);
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateComponents',
|
||||||
|
surfaceId: 'nonexistent',
|
||||||
|
components: [createTestComponent()],
|
||||||
|
rootIds: ['comp-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateDataModel', () => {
|
||||||
|
it('sets a value in the data model', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateDataModel',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
path: '/chartData',
|
||||||
|
value: [{ label: 'A', value: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataModel = manager.getDataModel('surface-1');
|
||||||
|
expect(dataModel).toEqual({ chartData: [{ label: 'A', value: 1 }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores updateDataModel for non-existent surfaces', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateDataModel',
|
||||||
|
surfaceId: 'nonexistent',
|
||||||
|
path: '/foo',
|
||||||
|
value: 'bar',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.getDataModel('nonexistent')).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteSurface', () => {
|
||||||
|
it('removes a surface', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.getSurface('surface-1')).toBeDefined();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'deleteSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.getSurface('surface-1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSurfaceIds', () => {
|
||||||
|
it('returns surface IDs for a specific conversation', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's3', conversationId: 'conv-2' });
|
||||||
|
|
||||||
|
expect(manager.getSurfaceIds('conv-1')).toEqual(['s1', 's2']);
|
||||||
|
expect(manager.getSurfaceIds('conv-2')).toEqual(['s3']);
|
||||||
|
expect(manager.getSurfaceIds('conv-3')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveTree', () => {
|
||||||
|
it('resolves a flat component buffer into a nested tree', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateComponents',
|
||||||
|
surfaceId: 's1',
|
||||||
|
components: [
|
||||||
|
{ id: 'root', type: 'column', properties: {}, children: ['child-1', 'child-2'] },
|
||||||
|
{ id: 'child-1', type: 'text', properties: { text: 'Hello' } },
|
||||||
|
{ id: 'child-2', type: 'button', properties: { label: 'Click' } },
|
||||||
|
],
|
||||||
|
rootIds: ['root'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = manager.resolveTree('s1');
|
||||||
|
|
||||||
|
expect(tree).toHaveLength(1);
|
||||||
|
expect(tree[0].type).toBe('column');
|
||||||
|
expect(tree[0].children).toHaveLength(2);
|
||||||
|
expect(tree[0].children[0].type).toBe('text');
|
||||||
|
expect(tree[0].children[0].properties.text).toBe('Hello');
|
||||||
|
expect(tree[0].children[1].type).toBe('button');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves data bindings to bound values', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateComponents',
|
||||||
|
surfaceId: 's1',
|
||||||
|
components: [
|
||||||
|
{ id: 'chart-1', type: 'chart', properties: { chartType: 'bar' }, dataBinding: '/data' },
|
||||||
|
],
|
||||||
|
rootIds: ['chart-1'],
|
||||||
|
});
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateDataModel',
|
||||||
|
surfaceId: 's1',
|
||||||
|
path: '/data',
|
||||||
|
value: [1, 2, 3],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = manager.resolveTree('s1');
|
||||||
|
expect(tree[0].boundValue).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for non-existent surface', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
expect(manager.resolveTree('nonexistent')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out unresolvable child references', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateComponents',
|
||||||
|
surfaceId: 's1',
|
||||||
|
components: [
|
||||||
|
{ id: 'root', type: 'column', properties: {}, children: ['child-1', 'missing'] },
|
||||||
|
{ id: 'child-1', type: 'text', properties: { text: 'Hello' } },
|
||||||
|
],
|
||||||
|
rootIds: ['root'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = manager.resolveTree('s1');
|
||||||
|
expect(tree[0].children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateLocalData', () => {
|
||||||
|
it('updates data model and notifies listeners', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.onChange(listener);
|
||||||
|
|
||||||
|
manager.updateLocalData('s1', '/formData/name', 'John');
|
||||||
|
|
||||||
|
expect(manager.getDataModel('s1')).toEqual({ formData: { name: 'John' } });
|
||||||
|
expect(listener).toHaveBeenCalledWith('s1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores updates for non-existent surfaces', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
const listener = vi.fn();
|
||||||
|
manager.onChange(listener);
|
||||||
|
|
||||||
|
manager.updateLocalData('nonexistent', '/foo', 'bar');
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearConversation', () => {
|
||||||
|
it('removes all surfaces for a conversation', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's3', conversationId: 'conv-2' });
|
||||||
|
|
||||||
|
manager.clearConversation('conv-1');
|
||||||
|
|
||||||
|
expect(manager.getSurfaceIds('conv-1')).toEqual([]);
|
||||||
|
expect(manager.getSurfaceIds('conv-2')).toEqual(['s3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onChange', () => {
|
||||||
|
it('returns an unsubscribe function', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
const unsubscribe = manager.onChange(listener);
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JSON Pointer utilities', () => {
|
||||||
|
describe('getValueAtPointer', () => {
|
||||||
|
it('returns the root object for empty or "/" pointer', () => {
|
||||||
|
const obj = { foo: 'bar' };
|
||||||
|
expect(getValueAtPointer(obj, '')).toBe(obj);
|
||||||
|
expect(getValueAtPointer(obj, '/')).toBe(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets a top-level value', () => {
|
||||||
|
expect(getValueAtPointer({ name: 'Alice' }, '/name')).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets a nested value', () => {
|
||||||
|
const obj = { a: { b: { c: 42 } } };
|
||||||
|
expect(getValueAtPointer(obj, '/a/b/c')).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for missing paths', () => {
|
||||||
|
expect(getValueAtPointer({ a: 1 }, '/b')).toBeUndefined();
|
||||||
|
expect(getValueAtPointer({ a: 1 }, '/a/b/c')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles escaped pointer characters', () => {
|
||||||
|
const obj = { 'a/b': { '~c': 'value' } };
|
||||||
|
expect(getValueAtPointer(obj, '/a~1b/~0c')).toBe('value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setValueAtPointer', () => {
|
||||||
|
it('sets a top-level value', () => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
setValueAtPointer(obj, '/name', 'Alice');
|
||||||
|
expect(obj.name).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates intermediate objects for nested paths', () => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
setValueAtPointer(obj, '/a/b/c', 42);
|
||||||
|
expect(obj).toEqual({ a: { b: { c: 42 } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for empty or root pointer', () => {
|
||||||
|
const obj = { foo: 'bar' };
|
||||||
|
setValueAtPointer(obj, '', 'new');
|
||||||
|
setValueAtPointer(obj, '/', 'new');
|
||||||
|
expect(obj).toEqual({ foo: 'bar' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles escaped pointer characters', () => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
setValueAtPointer(obj, '/a~1b', 'value');
|
||||||
|
expect(obj['a/b']).toBe('value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { CapabilityRegistryService } from '../../../../src/main/agentic/capabilities/registry';
|
|
||||||
|
|
||||||
describe('CapabilityRegistryService', () => {
|
|
||||||
it('returns per-surface capability differences', () => {
|
|
||||||
const registry = new CapabilityRegistryService();
|
|
||||||
|
|
||||||
const tabCapabilities = registry.getSnapshot({ surface: 'tab' });
|
|
||||||
const sidebarCapabilities = registry.getSnapshot({ surface: 'sidebar' });
|
|
||||||
|
|
||||||
expect(tabCapabilities.widgets).toContain('tabs');
|
|
||||||
expect(sidebarCapabilities.widgets).toContain('tabs');
|
|
||||||
expect(tabCapabilities.actions).toContain('toggleSidebar');
|
|
||||||
expect(sidebarCapabilities.actions).toContain('toggleAssistantSidebar');
|
|
||||||
expect(tabCapabilities.actions).not.toContain('toggleAssistantSidebar');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits disabled capabilities from active lists', () => {
|
|
||||||
const registry = new CapabilityRegistryService({
|
|
||||||
disabledActions: ['openSettings'],
|
|
||||||
disabledWidgets: ['chart'],
|
|
||||||
disabledTools: ['view_image'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = registry.getSnapshot({ surface: 'tab' });
|
|
||||||
|
|
||||||
expect(snapshot.actions).not.toContain('openSettings');
|
|
||||||
expect(snapshot.widgets).not.toContain('chart');
|
|
||||||
expect(snapshot.tools).not.toContain('view_image');
|
|
||||||
expect(snapshot.disabled).toEqual(expect.arrayContaining(['action:openSettings', 'widget:chart', 'tool:view_image']));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
ProtocolTelemetryService,
|
|
||||||
getProtocolTelemetryService,
|
|
||||||
} from '../../../../src/main/agentic/observability/protocolTelemetry';
|
|
||||||
|
|
||||||
describe('ProtocolTelemetryService', () => {
|
|
||||||
it('returns zero rates before any turns are recorded', () => {
|
|
||||||
const telemetry = new ProtocolTelemetryService();
|
|
||||||
const snapshot = telemetry.getSnapshot();
|
|
||||||
|
|
||||||
expect(snapshot.totalTurns).toBe(0);
|
|
||||||
expect(snapshot.parseValidityRate).toBe(0);
|
|
||||||
expect(snapshot.repairRate).toBe(0);
|
|
||||||
expect(snapshot.fallbackRate).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tracks parse validity, repairs, fallback, and blocked actions', () => {
|
|
||||||
const telemetry = new ProtocolTelemetryService();
|
|
||||||
|
|
||||||
telemetry.recordTurn({
|
|
||||||
validEnvelope: true,
|
|
||||||
repairAttempted: false,
|
|
||||||
fallbackUsed: false,
|
|
||||||
blockedActions: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
telemetry.recordTurn({
|
|
||||||
validEnvelope: true,
|
|
||||||
repairAttempted: true,
|
|
||||||
fallbackUsed: false,
|
|
||||||
blockedActions: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
telemetry.recordTurn({
|
|
||||||
validEnvelope: false,
|
|
||||||
repairAttempted: true,
|
|
||||||
fallbackUsed: true,
|
|
||||||
blockedActions: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = telemetry.getSnapshot();
|
|
||||||
|
|
||||||
expect(snapshot.totalTurns).toBe(3);
|
|
||||||
expect(snapshot.validEnvelopeTurns).toBe(2);
|
|
||||||
expect(snapshot.repairAttempts).toBe(2);
|
|
||||||
expect(snapshot.fallbackTurns).toBe(1);
|
|
||||||
expect(snapshot.blockedActionCount).toBe(3);
|
|
||||||
expect(snapshot.parseValidityRate).toBeCloseTo(2 / 3, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the same singleton telemetry service instance', () => {
|
|
||||||
const first = getProtocolTelemetryService();
|
|
||||||
const second = getProtocolTelemetryService();
|
|
||||||
|
|
||||||
expect(first).toBe(second);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { resolveActionPolicy } from '../../../../src/main/agentic/policy/actionPolicy';
|
|
||||||
|
|
||||||
describe('action policy', () => {
|
|
||||||
it('marks dangerous actions as requiring explicit confirmation', () => {
|
|
||||||
const policy = resolveActionPolicy('deletePost');
|
|
||||||
expect(policy.level).toBe('danger');
|
|
||||||
expect(policy.requiresConfirmation).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks configurable but safe navigation actions as confirm', () => {
|
|
||||||
const policy = resolveActionPolicy('openSettings');
|
|
||||||
expect(policy.level).toBe('confirm');
|
|
||||||
expect(policy.requiresConfirmation).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults unknown actions to danger', () => {
|
|
||||||
const policy = resolveActionPolicy('unknownAction');
|
|
||||||
expect(policy.level).toBe('danger');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { ProtocolResponseBuilder } from '../../../../src/main/agentic/protocol/responseBuilder';
|
|
||||||
|
|
||||||
describe('ProtocolResponseBuilder', () => {
|
|
||||||
it('builds canonical envelope from mixed text + AGUI payload', () => {
|
|
||||||
const builder = new ProtocolResponseBuilder();
|
|
||||||
|
|
||||||
const raw = [
|
|
||||||
'I found weak months.',
|
|
||||||
'```json',
|
|
||||||
'{"specVersion":"1","elements":[{"type":"chart","chartType":"bar","series":[{"label":"Jan","value":10}]}]}',
|
|
||||||
'```',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = builder.build({
|
|
||||||
rawAssistantOutput: raw,
|
|
||||||
surface: 'tab',
|
|
||||||
capabilities: {
|
|
||||||
widgets: ['chart'],
|
|
||||||
actions: ['openPost'],
|
|
||||||
tools: ['search_posts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.envelope.ui?.elements).toHaveLength(1);
|
|
||||||
expect(result.envelope.assistantText).toContain('I found weak months');
|
|
||||||
expect(result.envelope.protocolVersion).toBe('2.0');
|
|
||||||
expect(result.repairAttempted).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('repairs non-canonical envelope keys and validates output', () => {
|
|
||||||
const builder = new ProtocolResponseBuilder();
|
|
||||||
|
|
||||||
const raw = JSON.stringify({
|
|
||||||
protocol_version: '2.0',
|
|
||||||
assistant_text: 'Need more details',
|
|
||||||
intent: 'ask_input',
|
|
||||||
needs_input: {
|
|
||||||
required: true,
|
|
||||||
fields: [{ key: 'date', label: 'Date', inputType: 'date' }],
|
|
||||||
},
|
|
||||||
actions: [],
|
|
||||||
confidence: 0.8,
|
|
||||||
trace_id: 'trace-manual',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = builder.build({
|
|
||||||
rawAssistantOutput: raw,
|
|
||||||
surface: 'sidebar',
|
|
||||||
capabilities: {
|
|
||||||
widgets: ['form'],
|
|
||||||
actions: ['openPost'],
|
|
||||||
tools: ['search_posts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.repairAttempted).toBe(true);
|
|
||||||
expect(result.envelope.assistantText).toBe('Need more details');
|
|
||||||
expect(result.envelope.needsInput.required).toBe(true);
|
|
||||||
expect(result.envelope.needsInput.fields).toHaveLength(1);
|
|
||||||
expect(result.validationError).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to safe summarize envelope when payload is invalid', () => {
|
|
||||||
const builder = new ProtocolResponseBuilder();
|
|
||||||
|
|
||||||
const raw = '{"specVersion":"9","elements":[]}';
|
|
||||||
|
|
||||||
const result = builder.build({
|
|
||||||
rawAssistantOutput: raw,
|
|
||||||
surface: 'tab',
|
|
||||||
capabilities: {
|
|
||||||
widgets: ['chart'],
|
|
||||||
actions: ['openPost'],
|
|
||||||
tools: ['search_posts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.envelope.intent).toBe('summarize');
|
|
||||||
expect(result.envelope.ui).toBeUndefined();
|
|
||||||
expect(result.envelope.assistantText).toContain('specVersion');
|
|
||||||
expect(result.traceId.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks actions that are unavailable for the active surface capabilities', () => {
|
|
||||||
const builder = new ProtocolResponseBuilder();
|
|
||||||
|
|
||||||
const raw = JSON.stringify({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'Open settings?',
|
|
||||||
intent: 'propose_action',
|
|
||||||
needsInput: { required: false, fields: [] },
|
|
||||||
actions: [{
|
|
||||||
id: 'a1',
|
|
||||||
action: 'openSettings',
|
|
||||||
label: 'Open Settings',
|
|
||||||
policy: 'confirm',
|
|
||||||
requiresConfirmation: true,
|
|
||||||
}],
|
|
||||||
confidence: 0.7,
|
|
||||||
traceId: 'trace-abc',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = builder.build({
|
|
||||||
rawAssistantOutput: raw,
|
|
||||||
surface: 'tab',
|
|
||||||
capabilities: {
|
|
||||||
widgets: ['chart'],
|
|
||||||
actions: ['openPost'],
|
|
||||||
tools: ['search_posts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.envelope.actions).toHaveLength(0);
|
|
||||||
expect(result.warnings.some((warning) => warning.includes('openSettings'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts declarative actions from UI elements and applies policy defaults', () => {
|
|
||||||
const builder = new ProtocolResponseBuilder();
|
|
||||||
|
|
||||||
const raw = JSON.stringify({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'Choose an option',
|
|
||||||
intent: 'propose_action',
|
|
||||||
needsInput: { required: false, fields: [] },
|
|
||||||
actions: [],
|
|
||||||
ui: {
|
|
||||||
specVersion: '1',
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
type: 'card',
|
|
||||||
title: 'Actions',
|
|
||||||
body: 'Pick one',
|
|
||||||
actions: [{ label: 'Delete', action: 'deletePost' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'tabs',
|
|
||||||
tabs: [
|
|
||||||
{
|
|
||||||
id: 'first',
|
|
||||||
label: 'First',
|
|
||||||
elements: [{ type: 'action', label: 'Open post', action: 'openPost' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
confidence: 0.7,
|
|
||||||
traceId: 'trace-ui-actions',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = builder.build({
|
|
||||||
rawAssistantOutput: raw,
|
|
||||||
surface: 'tab',
|
|
||||||
capabilities: {
|
|
||||||
widgets: ['card', 'tabs', 'action'],
|
|
||||||
actions: ['deletePost', 'openPost'],
|
|
||||||
tools: ['search_posts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.envelope.actions).toHaveLength(2);
|
|
||||||
expect(result.envelope.actions[0]).toEqual(expect.objectContaining({
|
|
||||||
action: 'deletePost',
|
|
||||||
policy: 'danger',
|
|
||||||
requiresConfirmation: true,
|
|
||||||
}));
|
|
||||||
expect(result.envelope.actions[1]).toEqual(expect.objectContaining({
|
|
||||||
action: 'openPost',
|
|
||||||
policy: 'silent',
|
|
||||||
requiresConfirmation: false,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops invalid ui payloads from canonical envelopes before renderer consumption', () => {
|
|
||||||
const builder = new ProtocolResponseBuilder();
|
|
||||||
|
|
||||||
const raw = JSON.stringify({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'Here is the result',
|
|
||||||
intent: 'summarize',
|
|
||||||
needsInput: { required: false, fields: [] },
|
|
||||||
actions: [],
|
|
||||||
ui: {
|
|
||||||
specVersion: '1',
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
type: 'chart',
|
|
||||||
chartType: 'bar',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
confidence: 0.7,
|
|
||||||
traceId: 'trace-invalid-ui',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = builder.build({
|
|
||||||
rawAssistantOutput: raw,
|
|
||||||
surface: 'tab',
|
|
||||||
capabilities: {
|
|
||||||
widgets: ['chart'],
|
|
||||||
actions: ['openPost'],
|
|
||||||
tools: ['search_posts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.envelope.ui).toBeUndefined();
|
|
||||||
expect(result.warnings.some((warning) => warning.includes('Invalid ui payload'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes non-canonical ui element fields inside canonical envelopes', () => {
|
|
||||||
const builder = new ProtocolResponseBuilder();
|
|
||||||
|
|
||||||
const raw = JSON.stringify({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'Distribution chart ready.',
|
|
||||||
ui: {
|
|
||||||
specVersion: '1',
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
type: 'chart',
|
|
||||||
chartType: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: ['aside', 'article'],
|
|
||||||
datasets: [{ data: [181, 53] }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
content: 'Category breakdown',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
intent: 'summarize',
|
|
||||||
needsInput: { required: false, fields: [] },
|
|
||||||
actions: [],
|
|
||||||
confidence: 0.95,
|
|
||||||
traceId: 'trace-normalize-ui',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = builder.build({
|
|
||||||
rawAssistantOutput: raw,
|
|
||||||
surface: 'tab',
|
|
||||||
capabilities: {
|
|
||||||
widgets: ['chart', 'text'],
|
|
||||||
actions: ['openPost'],
|
|
||||||
tools: ['search_posts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const elements = result.envelope.ui?.elements as Array<{ type: string; series?: Array<{ label: string; value: number }>; text?: string }>;
|
|
||||||
expect(elements).toHaveLength(2);
|
|
||||||
expect(elements[0]?.type).toBe('chart');
|
|
||||||
expect(elements[0]?.series).toEqual([
|
|
||||||
{ label: 'aside', value: 181 },
|
|
||||||
{ label: 'article', value: 53 },
|
|
||||||
]);
|
|
||||||
expect(elements[1]).toEqual({ type: 'text', text: 'Category breakdown' });
|
|
||||||
expect(result.warnings.some((warning) => warning.includes('Normalized non-canonical ui payload'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { extractAssistantUiSpec } from '../../../../src/main/agentic/protocol/uiSpecParser';
|
|
||||||
|
|
||||||
describe('extractAssistantUiSpec', () => {
|
|
||||||
it('extracts fenced JSON spec and preserves assistant text around it', () => {
|
|
||||||
const input = [
|
|
||||||
'Here is your dashboard.',
|
|
||||||
'```json',
|
|
||||||
'{"specVersion":"1","elements":[{"type":"text","text":"Hello"}]}',
|
|
||||||
'```',
|
|
||||||
'Anything else?',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = extractAssistantUiSpec(input);
|
|
||||||
|
|
||||||
expect(result.ui).not.toBeNull();
|
|
||||||
expect(result.ui?.specVersion).toBe('1');
|
|
||||||
expect(result.ui?.elements).toHaveLength(1);
|
|
||||||
expect(result.assistantText).toContain('Here is your dashboard.');
|
|
||||||
expect(result.assistantText).toContain('Anything else?');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes markdown element into canonical text element', () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
specVersion: '1',
|
|
||||||
elements: [{ type: 'markdown', content: '## Title' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = extractAssistantUiSpec(input);
|
|
||||||
const first = result.ui?.elements[0] as { type: string; text?: string };
|
|
||||||
|
|
||||||
expect(result.ui).not.toBeNull();
|
|
||||||
expect(first.type).toBe('text');
|
|
||||||
expect(first.text).toBe('## Title');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes chart data datasets into chart series', () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
specVersion: '1',
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
type: 'chart',
|
|
||||||
chartType: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: ['Jan', 'Feb'],
|
|
||||||
datasets: [{ data: [10, 20] }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = extractAssistantUiSpec(input);
|
|
||||||
const first = result.ui?.elements[0] as { series?: Array<{ label: string; value: number }>; data?: unknown };
|
|
||||||
|
|
||||||
expect(result.ui).not.toBeNull();
|
|
||||||
expect(first.series).toEqual([
|
|
||||||
{ label: 'Jan', value: 10 },
|
|
||||||
{ label: 'Feb', value: 20 },
|
|
||||||
]);
|
|
||||||
expect(first.data).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes tabs content to nested elements arrays', () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
type: 'tabs',
|
|
||||||
tabs: [
|
|
||||||
{
|
|
||||||
title: 'Overview',
|
|
||||||
content: { type: 'text', text: 'Summary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = extractAssistantUiSpec(input);
|
|
||||||
const tabs = result.ui?.elements[0] as {
|
|
||||||
tabs: Array<{ id: string; label: string; elements: Array<{ type: string; text?: string }> }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(result.ui).not.toBeNull();
|
|
||||||
expect(tabs.tabs).toHaveLength(1);
|
|
||||||
expect(tabs.tabs[0].id).toBe('tab-1');
|
|
||||||
expect(tabs.tabs[0].label).toBe('Overview');
|
|
||||||
expect(tabs.tabs[0].elements[0]).toEqual({ type: 'text', text: 'Summary' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns plain assistant text when malformed JSON is provided', () => {
|
|
||||||
const input = '{"specVersion":"1","elements":[{"type":"text"}';
|
|
||||||
const result = extractAssistantUiSpec(input);
|
|
||||||
|
|
||||||
expect(result.ui).toBeNull();
|
|
||||||
expect(result.assistantText).toBe(input);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
validateProtocolRequestEnvelope,
|
|
||||||
validateProtocolResponseEnvelope,
|
|
||||||
type ProtocolResponseEnvelope,
|
|
||||||
} from '../../../../src/main/agentic/protocol/validator';
|
|
||||||
|
|
||||||
describe('agentic protocol validator', () => {
|
|
||||||
it('validates canonical response envelope', () => {
|
|
||||||
const envelope: ProtocolResponseEnvelope = {
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'Done',
|
|
||||||
intent: 'summarize',
|
|
||||||
needsInput: {
|
|
||||||
required: false,
|
|
||||||
fields: [],
|
|
||||||
},
|
|
||||||
actions: [],
|
|
||||||
confidence: 0.82,
|
|
||||||
traceId: 'trace-abc',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = validateProtocolResponseEnvelope(envelope);
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
expect(result.error).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects response envelope with unknown properties in strict mode', () => {
|
|
||||||
const result = validateProtocolResponseEnvelope({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'Done',
|
|
||||||
intent: 'summarize',
|
|
||||||
needsInput: { required: false, fields: [] },
|
|
||||||
actions: [],
|
|
||||||
confidence: 0.8,
|
|
||||||
traceId: 'trace-abc',
|
|
||||||
extra: 'nope',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
expect(result.error?.code).toBe('AGUI_PROTOCOL_VALIDATION_ERROR');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects needsInput.required=true without fields', () => {
|
|
||||||
const result = validateProtocolResponseEnvelope({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'Need details',
|
|
||||||
intent: 'ask_input',
|
|
||||||
needsInput: { required: true, fields: [] },
|
|
||||||
actions: [],
|
|
||||||
confidence: 0.9,
|
|
||||||
traceId: 'trace-xyz',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
expect(result.error?.message).toContain('needsInput.fields');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validates canonical request envelope with capabilities', () => {
|
|
||||||
const result = validateProtocolRequestEnvelope({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
surface: 'tab',
|
|
||||||
messages: [{ role: 'user', content: 'Create a chart' }],
|
|
||||||
context: { projectId: 'project-1' },
|
|
||||||
capabilities: {
|
|
||||||
widgets: ['chart', 'form'],
|
|
||||||
actions: ['openPost'],
|
|
||||||
tools: ['search_posts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid request envelope and returns structured protocol error', () => {
|
|
||||||
const result = validateProtocolRequestEnvelope({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
surface: 'tab',
|
|
||||||
messages: [{ role: 'invalid-role', content: 'Create a chart' }],
|
|
||||||
context: { projectId: 'project-1' },
|
|
||||||
capabilities: {
|
|
||||||
widgets: ['chart'],
|
|
||||||
actions: ['openPost'],
|
|
||||||
tools: ['search_posts'],
|
|
||||||
},
|
|
||||||
unknown: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
expect(result.error?.code).toBe('AGUI_PROTOCOL_VALIDATION_ERROR');
|
|
||||||
expect(result.error?.details?.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
WorkflowCheckpointStore,
|
|
||||||
type WorkflowCheckpointSettingsAdapter,
|
|
||||||
} from '../../../../src/main/agentic/workflow/checkpointStore';
|
|
||||||
|
|
||||||
class InMemorySettingsAdapter implements WorkflowCheckpointSettingsAdapter {
|
|
||||||
private readonly store = new Map<string, string>();
|
|
||||||
|
|
||||||
async getSetting(key: string): Promise<string | null> {
|
|
||||||
return this.store.get(key) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setSetting(key: string, value: string): Promise<void> {
|
|
||||||
this.store.set(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('WorkflowCheckpointStore', () => {
|
|
||||||
it('persists and reloads workflow checkpoints by conversation id', async () => {
|
|
||||||
const adapter = new InMemorySettingsAdapter();
|
|
||||||
const store = new WorkflowCheckpointStore(adapter);
|
|
||||||
|
|
||||||
await store.save({
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
state: 'awaiting_input',
|
|
||||||
pendingFields: ['date'],
|
|
||||||
lastTraceId: 'trace-1',
|
|
||||||
updatedAt: new Date('2026-02-25T10:00:00.000Z').toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const loaded = await store.load('conversation-1');
|
|
||||||
|
|
||||||
expect(loaded).not.toBeNull();
|
|
||||||
expect(loaded?.state).toBe('awaiting_input');
|
|
||||||
expect(loaded?.pendingFields).toEqual(['date']);
|
|
||||||
expect(loaded?.lastTraceId).toBe('trace-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when no checkpoint exists', async () => {
|
|
||||||
const adapter = new InMemorySettingsAdapter();
|
|
||||||
const store = new WorkflowCheckpointStore(adapter);
|
|
||||||
|
|
||||||
const loaded = await store.load('missing-conversation');
|
|
||||||
|
|
||||||
expect(loaded).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for malformed checkpoint JSON', async () => {
|
|
||||||
const adapter = new InMemorySettingsAdapter();
|
|
||||||
await adapter.setSetting('agui.workflow.conversation-2', '{not-valid-json');
|
|
||||||
const store = new WorkflowCheckpointStore(adapter);
|
|
||||||
|
|
||||||
const loaded = await store.load('conversation-2');
|
|
||||||
|
|
||||||
expect(loaded).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when stored checkpoint conversation id does not match', async () => {
|
|
||||||
const adapter = new InMemorySettingsAdapter();
|
|
||||||
await adapter.setSetting(
|
|
||||||
'agui.workflow.conversation-3',
|
|
||||||
JSON.stringify({
|
|
||||||
conversationId: 'other-conversation',
|
|
||||||
state: 'planning',
|
|
||||||
pendingFields: [],
|
|
||||||
lastTraceId: 'trace-mismatch',
|
|
||||||
updatedAt: new Date('2026-02-25T10:00:00.000Z').toISOString(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const store = new WorkflowCheckpointStore(adapter);
|
|
||||||
const loaded = await store.load('conversation-3');
|
|
||||||
|
|
||||||
expect(loaded).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { AgentTurnStateMachine } from '../../../../src/main/agentic/workflow/turnStateMachine';
|
|
||||||
|
|
||||||
describe('AgentTurnStateMachine', () => {
|
|
||||||
it('transitions to awaiting_input when envelope requests required input', () => {
|
|
||||||
const stateMachine = new AgentTurnStateMachine();
|
|
||||||
|
|
||||||
const next = stateMachine.transition({
|
|
||||||
previousState: 'planning',
|
|
||||||
envelope: {
|
|
||||||
intent: 'ask_input',
|
|
||||||
needsInput: {
|
|
||||||
required: true,
|
|
||||||
fields: [{ key: 'date', label: 'Date', inputType: 'date' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(next).toBe('awaiting_input');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('transitions to completed when summarize intent has no required input', () => {
|
|
||||||
const stateMachine = new AgentTurnStateMachine();
|
|
||||||
|
|
||||||
const next = stateMachine.transition({
|
|
||||||
previousState: 'observing',
|
|
||||||
envelope: {
|
|
||||||
intent: 'summarize',
|
|
||||||
needsInput: {
|
|
||||||
required: false,
|
|
||||||
fields: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(next).toBe('completed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('transitions to executing when intent requests action execution', () => {
|
|
||||||
const stateMachine = new AgentTurnStateMachine();
|
|
||||||
|
|
||||||
const next = stateMachine.transition({
|
|
||||||
previousState: 'planning',
|
|
||||||
envelope: {
|
|
||||||
intent: 'execute_action',
|
|
||||||
needsInput: {
|
|
||||||
required: false,
|
|
||||||
fields: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(next).toBe('executing');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('transitions to observing when proposing actions', () => {
|
|
||||||
const stateMachine = new AgentTurnStateMachine();
|
|
||||||
|
|
||||||
const next = stateMachine.transition({
|
|
||||||
previousState: 'planning',
|
|
||||||
envelope: {
|
|
||||||
intent: 'propose_action',
|
|
||||||
needsInput: {
|
|
||||||
required: false,
|
|
||||||
fields: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(next).toBe('observing');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns to executing after awaiting input when input is no longer required', () => {
|
|
||||||
const stateMachine = new AgentTurnStateMachine();
|
|
||||||
|
|
||||||
const next = stateMachine.transition({
|
|
||||||
previousState: 'awaiting_input',
|
|
||||||
envelope: {
|
|
||||||
intent: 'analyze',
|
|
||||||
needsInput: {
|
|
||||||
required: false,
|
|
||||||
fields: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(next).toBe('executing');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stays in planning for non-terminal analyze intent by default', () => {
|
|
||||||
const stateMachine = new AgentTurnStateMachine();
|
|
||||||
|
|
||||||
const next = stateMachine.transition({
|
|
||||||
previousState: 'planning',
|
|
||||||
envelope: {
|
|
||||||
intent: 'analyze',
|
|
||||||
needsInput: {
|
|
||||||
required: false,
|
|
||||||
fields: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(next).toBe('planning');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,21 +9,6 @@ const mainWindowMock = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const protocolSnapshot = {
|
|
||||||
totalTurns: 3,
|
|
||||||
validEnvelopeTurns: 2,
|
|
||||||
repairAttempts: 1,
|
|
||||||
fallbackTurns: 1,
|
|
||||||
blockedActionCount: 2,
|
|
||||||
parseValidityRate: 2 / 3,
|
|
||||||
repairRate: 1 / 3,
|
|
||||||
fallbackRate: 1 / 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
const telemetryServiceMock = {
|
|
||||||
getSnapshot: vi.fn(() => protocolSnapshot),
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatEngineInstances: Array<Record<string, any>> = [];
|
const chatEngineInstances: Array<Record<string, any>> = [];
|
||||||
const openCodeManagerInstances: Array<Record<string, any>> = [];
|
const openCodeManagerInstances: Array<Record<string, any>> = [];
|
||||||
|
|
||||||
@@ -50,10 +35,6 @@ vi.mock('../../src/main/engine/MediaEngine', () => ({
|
|||||||
getMediaEngine: vi.fn(() => ({})),
|
getMediaEngine: vi.fn(() => ({})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../src/main/agentic/observability/protocolTelemetry', () => ({
|
|
||||||
getProtocolTelemetryService: vi.fn(() => telemetryServiceMock),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../src/main/engine/ChatEngine', () => ({
|
vi.mock('../../src/main/engine/ChatEngine', () => ({
|
||||||
ChatEngine: class {
|
ChatEngine: class {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -85,18 +66,6 @@ vi.mock('../../src/main/engine/OpenCodeManager', () => ({
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'assistant reply',
|
message: 'assistant reply',
|
||||||
envelope: {
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'assistant reply',
|
|
||||||
intent: 'summarize',
|
|
||||||
needsInput: { required: false, fields: [] },
|
|
||||||
actions: [],
|
|
||||||
confidence: 0.9,
|
|
||||||
traceId: 'trace-1',
|
|
||||||
},
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
traceId: 'trace-1',
|
|
||||||
warnings: [],
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
abortMessage: vi.fn(async () => ({ success: true })),
|
abortMessage: vi.fn(async () => ({ success: true })),
|
||||||
@@ -116,7 +85,6 @@ describe('chatHandlers', () => {
|
|||||||
webContentsSend.mockReset();
|
webContentsSend.mockReset();
|
||||||
chatEngineInstances.length = 0;
|
chatEngineInstances.length = 0;
|
||||||
openCodeManagerInstances.length = 0;
|
openCodeManagerInstances.length = 0;
|
||||||
telemetryServiceMock.getSnapshot.mockClear();
|
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,19 +93,6 @@ describe('chatHandlers', () => {
|
|||||||
await mod.cleanupChatHandlers();
|
await mod.cleanupChatHandlers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns protocol health snapshot from telemetry service', async () => {
|
|
||||||
const mod = await import('../../src/main/ipc/chatHandlers');
|
|
||||||
mod.registerChatHandlers();
|
|
||||||
|
|
||||||
const handler = registeredHandlers.get('chat:getProtocolHealth');
|
|
||||||
expect(handler).toBeDefined();
|
|
||||||
|
|
||||||
const result = await handler!();
|
|
||||||
|
|
||||||
expect(telemetryServiceMock.getSnapshot).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result).toEqual(protocolSnapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('streams sendMessage callbacks through main window events', async () => {
|
it('streams sendMessage callbacks through main window events', async () => {
|
||||||
const mod = await import('../../src/main/ipc/chatHandlers');
|
const mod = await import('../../src/main/ipc/chatHandlers');
|
||||||
mod.initializeChatHandlers(() => mainWindowMock as never);
|
mod.initializeChatHandlers(() => mainWindowMock as never);
|
||||||
@@ -154,7 +109,6 @@ describe('chatHandlers', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.envelope?.protocolVersion).toBe('2.0');
|
|
||||||
|
|
||||||
const manager = openCodeManagerInstances[0];
|
const manager = openCodeManagerInstances[0];
|
||||||
expect(manager.setApiKey).toHaveBeenCalledWith('stored-key');
|
expect(manager.setApiKey).toHaveBeenCalledWith('stored-key');
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ describe('AssistantSidebar wiring', () => {
|
|||||||
validateApiKey: vi.fn(),
|
validateApiKey: vi.fn(),
|
||||||
setApiKey: vi.fn(),
|
setApiKey: vi.fn(),
|
||||||
getApiKey: vi.fn(),
|
getApiKey: vi.fn(),
|
||||||
getProtocolHealth: vi.fn(),
|
|
||||||
getAvailableModels: vi.fn(),
|
getAvailableModels: vi.fn(),
|
||||||
setDefaultModel: vi.fn(),
|
setDefaultModel: vi.fn(),
|
||||||
getSystemPrompt: vi.fn(),
|
getSystemPrompt: vi.fn(),
|
||||||
@@ -37,6 +36,8 @@ describe('AssistantSidebar wiring', () => {
|
|||||||
onToolCall,
|
onToolCall,
|
||||||
onToolResult,
|
onToolResult,
|
||||||
onTitleUpdated,
|
onTitleUpdated,
|
||||||
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
|
dispatchA2UIAction: vi.fn(),
|
||||||
} as never;
|
} as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -55,18 +55,7 @@ describe('Editor dashboard timeline', () => {
|
|||||||
]);
|
]);
|
||||||
(window as any).electronAPI.posts.getTagsWithCounts = vi.fn().mockResolvedValue([]);
|
(window as any).electronAPI.posts.getTagsWithCounts = vi.fn().mockResolvedValue([]);
|
||||||
(window as any).electronAPI.posts.getCategoriesWithCounts = vi.fn().mockResolvedValue([]);
|
(window as any).electronAPI.posts.getCategoriesWithCounts = vi.fn().mockResolvedValue([]);
|
||||||
(window as any).electronAPI.chat = {
|
(window as any).electronAPI.chat = {};
|
||||||
getProtocolHealth: vi.fn().mockResolvedValue({
|
|
||||||
totalTurns: 10,
|
|
||||||
validEnvelopeTurns: 9,
|
|
||||||
repairAttempts: 1,
|
|
||||||
fallbackTurns: 0,
|
|
||||||
blockedActionCount: 2,
|
|
||||||
parseValidityRate: 0.9,
|
|
||||||
repairRate: 0.1,
|
|
||||||
fallbackRate: 0,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
(window as any).electronAPI.tags = {
|
(window as any).electronAPI.tags = {
|
||||||
getAll: vi.fn().mockResolvedValue([]),
|
getAll: vi.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
@@ -94,17 +83,4 @@ describe('Editor dashboard timeline', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('2024')).toBeInTheDocument();
|
expect(screen.getByText('2024')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders protocol telemetry stats in dashboard', async () => {
|
|
||||||
render(<Editor />);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('90%')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('2 blocked actions')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { extractAssistantPanelSpec, extractAssistantResponseContent } from '../../../src/renderer/navigation/assistantPanelSpec';
|
|
||||||
|
|
||||||
describe('assistantPanelSpec', () => {
|
|
||||||
it('extracts valid spec from fenced json block', () => {
|
|
||||||
const raw = [
|
|
||||||
'Here is the analysis summary.',
|
|
||||||
'```json',
|
|
||||||
'{"specVersion":"1","elements":[{"type":"metric","label":"Drafts","value":"12"}]}',
|
|
||||||
'```',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = extractAssistantPanelSpec(raw);
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result?.specVersion).toBe('1');
|
|
||||||
expect(result?.elements).toHaveLength(1);
|
|
||||||
expect(result?.elements[0]).toEqual({ type: 'metric', label: 'Drafts', value: '12' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for invalid schema payload', () => {
|
|
||||||
const raw = '{"specVersion":"1","elements":[{"type":"table","columns":[]}]}';
|
|
||||||
const result = extractAssistantPanelSpec(raw);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores yaml payloads to keep the protocol JSON-only', () => {
|
|
||||||
const raw = [
|
|
||||||
'Here is your chart.',
|
|
||||||
'```yaml',
|
|
||||||
'specVersion: "1"',
|
|
||||||
'elements:',
|
|
||||||
' - type: chart',
|
|
||||||
' chartType: bar',
|
|
||||||
' title: Posts by Month',
|
|
||||||
' series:',
|
|
||||||
' - label: Jan',
|
|
||||||
' value: 10',
|
|
||||||
' - label: Feb',
|
|
||||||
' value: 20',
|
|
||||||
'```',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = extractAssistantPanelSpec(raw);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts text plus ui payload from mixed assistant response', () => {
|
|
||||||
const raw = [
|
|
||||||
'I found two weak months. Please confirm how to proceed.',
|
|
||||||
'```json',
|
|
||||||
'{"specVersion":"1","elements":[{"type":"chart","chartType":"bar","title":"Posts by Month","series":[{"label":"Jan","value":10},{"label":"Feb","value":20}]}]}',
|
|
||||||
'```',
|
|
||||||
].join('\n\n');
|
|
||||||
|
|
||||||
const result = extractAssistantResponseContent(raw);
|
|
||||||
|
|
||||||
expect(result.displayText).toContain('I found two weak months');
|
|
||||||
expect(result.panelSpec).not.toBeNull();
|
|
||||||
expect(result.panelSpec?.elements[0]).toMatchObject({ type: 'chart', chartType: 'bar' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes tab-channel envelope payloads into canonical panel spec', () => {
|
|
||||||
const raw = JSON.stringify({
|
|
||||||
type: 'tab',
|
|
||||||
title: 'Posts mit Tag spielen',
|
|
||||||
id: 'spielen-tag-analysis',
|
|
||||||
content: {
|
|
||||||
type: 'tabs',
|
|
||||||
tabs: [
|
|
||||||
{
|
|
||||||
id: 'yearly-chart',
|
|
||||||
title: 'Jahresübersicht',
|
|
||||||
content: {
|
|
||||||
type: 'chart',
|
|
||||||
chartType: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: ['2011', '2013'],
|
|
||||||
datasets: [{ data: [2, 8] }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = extractAssistantPanelSpec(raw);
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result?.specVersion).toBe('1');
|
|
||||||
expect(result?.elements[0]).toMatchObject({ type: 'tabs' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes chartjs-like chart payloads to series format', () => {
|
|
||||||
const raw = JSON.stringify({
|
|
||||||
specVersion: '1',
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
type: 'chart',
|
|
||||||
chartType: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: ['Jan', 'Feb', 'Mar'],
|
|
||||||
datasets: [{ data: [23, 10, 14] }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = extractAssistantPanelSpec(raw);
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result?.elements[0]).toMatchObject({
|
|
||||||
type: 'chart',
|
|
||||||
chartType: 'bar',
|
|
||||||
series: [
|
|
||||||
{ label: 'Jan', value: 23 },
|
|
||||||
{ label: 'Feb', value: 10 },
|
|
||||||
{ label: 'Mar', value: 14 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses extended widgets including chart, form, datePicker, card, image, input and tabs', () => {
|
|
||||||
const raw = JSON.stringify({
|
|
||||||
specVersion: '1',
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
type: 'chart',
|
|
||||||
chartType: 'bar',
|
|
||||||
title: 'Posts by Month',
|
|
||||||
series: [
|
|
||||||
{ label: 'Jan', value: 10 },
|
|
||||||
{ label: 'Feb', value: 20 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'input',
|
|
||||||
key: 'query',
|
|
||||||
label: 'Search Query',
|
|
||||||
inputType: 'text',
|
|
||||||
placeholder: 'Find post',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'datePicker',
|
|
||||||
key: 'publishDate',
|
|
||||||
label: 'Publish Date',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'form',
|
|
||||||
formId: 'meta-form',
|
|
||||||
title: 'Update Metadata',
|
|
||||||
submitLabel: 'Apply',
|
|
||||||
action: 'updatePostMetadata',
|
|
||||||
fields: [
|
|
||||||
{ key: 'title', label: 'Title', inputType: 'text' },
|
|
||||||
{ key: 'isDraft', label: 'Draft', inputType: 'checkbox' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'card',
|
|
||||||
title: 'Suggestion',
|
|
||||||
body: 'Consider adding tags.',
|
|
||||||
actions: [
|
|
||||||
{ label: 'Open Tags', action: 'switchView', payload: { view: 'tags' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'image',
|
|
||||||
src: 'https://example.com/image.png',
|
|
||||||
alt: 'Preview',
|
|
||||||
caption: 'Generated preview',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'tabs',
|
|
||||||
tabs: [
|
|
||||||
{
|
|
||||||
id: 'summary',
|
|
||||||
label: 'Summary',
|
|
||||||
elements: [{ type: 'text', text: 'Summary text' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'details',
|
|
||||||
label: 'Details',
|
|
||||||
elements: [{ type: 'metric', label: 'Count', value: '42' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = extractAssistantPanelSpec(raw);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result?.elements).toHaveLength(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses canonical protocol envelope JSON and extracts assistant text plus ui spec', () => {
|
|
||||||
const raw = JSON.stringify({
|
|
||||||
protocolVersion: '2.0',
|
|
||||||
assistantText: 'Here is your chart.',
|
|
||||||
ui: {
|
|
||||||
specVersion: '1',
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
type: 'chart',
|
|
||||||
chartType: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: ['aside', 'article'],
|
|
||||||
datasets: [{ data: [181, 53] }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
content: 'Breakdown details',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
intent: 'summarize',
|
|
||||||
needsInput: { required: false, fields: [] },
|
|
||||||
actions: [],
|
|
||||||
confidence: 0.9,
|
|
||||||
traceId: 'trace-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = extractAssistantResponseContent(raw);
|
|
||||||
|
|
||||||
expect(result.displayText).toBe('Here is your chart.');
|
|
||||||
expect(result.panelSpec).not.toBeNull();
|
|
||||||
expect(result.panelSpec?.elements[0]).toMatchObject({
|
|
||||||
type: 'chart',
|
|
||||||
series: [
|
|
||||||
{ label: 'aside', value: 181 },
|
|
||||||
{ label: 'article', value: 53 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(result.panelSpec?.elements[1]).toEqual({
|
|
||||||
type: 'text',
|
|
||||||
text: 'Breakdown details',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar';
|
import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar';
|
||||||
import { AssistantPanelControls } from '../../../src/renderer/components/AssistantPanelControls';
|
|
||||||
import { useAppStore } from '../../../src/renderer/store';
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
describe('assistant sidebar guard rails', () => {
|
describe('assistant sidebar guard rails', () => {
|
||||||
@@ -14,7 +13,6 @@ describe('assistant sidebar guard rails', () => {
|
|||||||
validateApiKey: vi.fn(),
|
validateApiKey: vi.fn(),
|
||||||
setApiKey: vi.fn(),
|
setApiKey: vi.fn(),
|
||||||
getApiKey: vi.fn(),
|
getApiKey: vi.fn(),
|
||||||
getProtocolHealth: vi.fn(),
|
|
||||||
getAvailableModels: vi.fn(),
|
getAvailableModels: vi.fn(),
|
||||||
setDefaultModel: vi.fn(),
|
setDefaultModel: vi.fn(),
|
||||||
getSystemPrompt: vi.fn(),
|
getSystemPrompt: vi.fn(),
|
||||||
@@ -36,6 +34,8 @@ describe('assistant sidebar guard rails', () => {
|
|||||||
onToolCall: vi.fn(() => vi.fn()),
|
onToolCall: vi.fn(() => vi.fn()),
|
||||||
onToolResult: vi.fn(() => vi.fn()),
|
onToolResult: vi.fn(() => vi.fn()),
|
||||||
onTitleUpdated: vi.fn(() => vi.fn()),
|
onTitleUpdated: vi.fn(() => vi.fn()),
|
||||||
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
|
dispatchA2UIAction: vi.fn(),
|
||||||
} as never;
|
} as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,60 +44,4 @@ describe('assistant sidebar guard rails', () => {
|
|||||||
|
|
||||||
expect(useAppStore.getState().tabs.some((tab) => tab.type === 'chat')).toBe(false);
|
expect(useAppStore.getState().tabs.some((tab) => tab.type === 'chat')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders rich assistant panel widget branches at runtime', () => {
|
|
||||||
const onAction = vi.fn();
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
React.createElement(AssistantPanelControls, {
|
|
||||||
elements: [
|
|
||||||
{ type: 'chart', chartType: 'bar', title: 'Trend', series: [{ label: 'Jan', value: 10 }] },
|
|
||||||
{
|
|
||||||
type: 'form',
|
|
||||||
formId: 'f1',
|
|
||||||
submitLabel: 'Submit',
|
|
||||||
action: 'submitNeedsInput',
|
|
||||||
fields: [{ key: 'name', label: 'Name', inputType: 'text', required: true }],
|
|
||||||
},
|
|
||||||
{ type: 'datePicker', key: 'date', label: 'Date', submitLabel: 'Pick', action: 'submitNeedsInput' },
|
|
||||||
{ type: 'card', title: 'Card', body: 'Body', actions: [{ label: 'Open', action: 'openSettings' }] },
|
|
||||||
{ type: 'image', src: 'https://example.com/a.png', caption: 'Image', action: 'openSettings' },
|
|
||||||
{
|
|
||||||
type: 'tabs',
|
|
||||||
tabs: [{ id: 'tab-1', label: 'Tab 1', elements: [{ type: 'text', text: 'Inside tab' }] }],
|
|
||||||
},
|
|
||||||
{ type: 'input', key: 'query', label: 'Query', inputType: 'text', submitLabel: 'Run', action: 'openSettings' },
|
|
||||||
],
|
|
||||||
onAction,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.querySelector('.assistant-panel-chart')).not.toBeNull();
|
|
||||||
expect(container.querySelector('.assistant-panel-form')).not.toBeNull();
|
|
||||||
expect(container.querySelector('.assistant-panel-card')).not.toBeNull();
|
|
||||||
expect(container.querySelector('.assistant-panel-image')).not.toBeNull();
|
|
||||||
expect(container.querySelector('.assistant-panel-tabs')).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enforces action confirmation policy before dispatching assistant actions', () => {
|
|
||||||
const onAction = vi.fn();
|
|
||||||
const confirmMock = vi.fn().mockReturnValue(true);
|
|
||||||
Object.defineProperty(window, 'confirm', {
|
|
||||||
value: confirmMock,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { getByText } = render(
|
|
||||||
React.createElement(AssistantPanelControls, {
|
|
||||||
elements: [{ type: 'action', label: 'Open Settings', action: 'openSettings' }],
|
|
||||||
actionPolicies: { openSettings: 'confirm' },
|
|
||||||
onAction,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(getByText('Open Settings'));
|
|
||||||
|
|
||||||
expect(confirmMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onAction).toHaveBeenCalledWith('openSettings', undefined);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ describe('chat surface mode usage guards', () => {
|
|||||||
validateApiKey: vi.fn(),
|
validateApiKey: vi.fn(),
|
||||||
setApiKey: vi.fn(),
|
setApiKey: vi.fn(),
|
||||||
getApiKey: vi.fn(),
|
getApiKey: vi.fn(),
|
||||||
getProtocolHealth: vi.fn(),
|
|
||||||
getAvailableModels: vi.fn().mockResolvedValue({
|
getAvailableModels: vi.fn().mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
||||||
@@ -46,6 +45,8 @@ describe('chat surface mode usage guards', () => {
|
|||||||
onToolCall: vi.fn(() => vi.fn()),
|
onToolCall: vi.fn(() => vi.fn()),
|
||||||
onToolResult: vi.fn(() => vi.fn()),
|
onToolResult: vi.fn(() => vi.fn()),
|
||||||
onTitleUpdated: vi.fn(() => vi.fn()),
|
onTitleUpdated: vi.fn(() => vi.fn()),
|
||||||
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
|
dispatchA2UIAction: vi.fn(),
|
||||||
} as never;
|
} as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ describe('chat surface shared usage guards', () => {
|
|||||||
validateApiKey: vi.fn(),
|
validateApiKey: vi.fn(),
|
||||||
setApiKey: vi.fn(),
|
setApiKey: vi.fn(),
|
||||||
getApiKey: vi.fn(),
|
getApiKey: vi.fn(),
|
||||||
getProtocolHealth: vi.fn(),
|
|
||||||
getAvailableModels: vi.fn().mockResolvedValue({
|
getAvailableModels: vi.fn().mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
||||||
@@ -49,6 +48,8 @@ describe('chat surface shared usage guards', () => {
|
|||||||
onToolCall: vi.fn(() => vi.fn()),
|
onToolCall: vi.fn(() => vi.fn()),
|
||||||
onToolResult: vi.fn(() => vi.fn()),
|
onToolResult: vi.fn(() => vi.fn()),
|
||||||
onTitleUpdated: vi.fn(() => vi.fn()),
|
onTitleUpdated: vi.fn(() => vi.fn()),
|
||||||
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
|
dispatchA2UIAction: vi.fn(),
|
||||||
} as never;
|
} as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { buildActionPoliciesFromEnvelope } from '../../../src/renderer/navigation/protocolActionPolicies';
|
|
||||||
|
|
||||||
describe('buildActionPoliciesFromEnvelope', () => {
|
|
||||||
it('preserves server-provided action policies', () => {
|
|
||||||
const result = buildActionPoliciesFromEnvelope({
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: 'a1',
|
|
||||||
action: 'openSettings',
|
|
||||||
policy: 'confirm',
|
|
||||||
requiresConfirmation: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
needsInput: {
|
|
||||||
required: false,
|
|
||||||
fields: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
openSettings: 'confirm',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds confirm policy for submitNeedsInput when clarification is required', () => {
|
|
||||||
const result = buildActionPoliciesFromEnvelope({
|
|
||||||
actions: [],
|
|
||||||
needsInput: {
|
|
||||||
required: true,
|
|
||||||
fields: [{ key: 'date', label: 'Date', inputType: 'date' }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.submitNeedsInput).toBe('confirm');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not override explicit server policy for submitNeedsInput', () => {
|
|
||||||
const result = buildActionPoliciesFromEnvelope({
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: 'a1',
|
|
||||||
action: 'submitNeedsInput',
|
|
||||||
policy: 'danger',
|
|
||||||
requiresConfirmation: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
needsInput: {
|
|
||||||
required: true,
|
|
||||||
fields: [{ key: 'title', label: 'Title', inputType: 'text' }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.submitNeedsInput).toBe('danger');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { toClarificationElements } from '../../../src/renderer/navigation/protocolNeedsInput';
|
|
||||||
|
|
||||||
describe('protocolNeedsInput', () => {
|
|
||||||
it('builds a clarification form element when required fields are provided', () => {
|
|
||||||
const elements = toClarificationElements({
|
|
||||||
required: true,
|
|
||||||
fields: [
|
|
||||||
{ key: 'date', label: 'Date', inputType: 'date', required: true },
|
|
||||||
{ key: 'category', label: 'Category', inputType: 'select', options: [{ label: 'A', value: 'a' }] },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(elements).toHaveLength(1);
|
|
||||||
expect(elements[0]).toMatchObject({
|
|
||||||
type: 'form',
|
|
||||||
formId: 'agui-needs-input',
|
|
||||||
action: 'submitNeedsInput',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty elements when input is not required', () => {
|
|
||||||
const elements = toClarificationElements({
|
|
||||||
required: false,
|
|
||||||
fields: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(elements).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -25,7 +25,6 @@ describe('pythonApiContractV1', () => {
|
|||||||
'app.getSystemLanguage',
|
'app.getSystemLanguage',
|
||||||
'chat.getConversations',
|
'chat.getConversations',
|
||||||
'chat.sendMessage',
|
'chat.sendMessage',
|
||||||
'chat.getProtocolHealth',
|
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ describe('pythonApiContractV1', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('documents chat.sendMessage protocol envelope return contract and metadata input', () => {
|
it('documents chat.sendMessage return contract and metadata input', () => {
|
||||||
expect(getPythonApiMethodContract('chat.sendMessage')).toEqual({
|
expect(getPythonApiMethodContract('chat.sendMessage')).toEqual({
|
||||||
method: 'chat.sendMessage',
|
method: 'chat.sendMessage',
|
||||||
description: 'Send message to chat conversation.',
|
description: 'Send message to chat conversation.',
|
||||||
@@ -65,7 +64,7 @@ describe('pythonApiContractV1', () => {
|
|||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
returns: "{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }",
|
returns: '{ success: boolean; message?: string; error?: string }',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,8 +80,6 @@ describe('pythonApiContractV1', () => {
|
|||||||
expect.objectContaining({ name: 'PostData' }),
|
expect.objectContaining({ name: 'PostData' }),
|
||||||
expect.objectContaining({ name: 'MediaData' }),
|
expect.objectContaining({ name: 'MediaData' }),
|
||||||
expect.objectContaining({ name: 'ProjectData' }),
|
expect.objectContaining({ name: 'ProjectData' }),
|
||||||
expect.objectContaining({ name: 'ProtocolResponseEnvelope' }),
|
|
||||||
expect.objectContaining({ name: 'ProtocolTelemetrySnapshot' }),
|
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,16 +29,12 @@ describe('invokePythonApiMethodV1', () => {
|
|||||||
const getProjectMetadata = vi.fn().mockResolvedValue({ name: 'My Project' });
|
const getProjectMetadata = vi.fn().mockResolvedValue({ name: 'My Project' });
|
||||||
const getAllProjects = vi.fn().mockResolvedValue([{ id: 'prj-1', name: 'Main' }]);
|
const getAllProjects = vi.fn().mockResolvedValue([{ id: 'prj-1', name: 'Main' }]);
|
||||||
const getAllPosts = vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 });
|
const getAllPosts = vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 });
|
||||||
const getProtocolHealth = vi.fn().mockResolvedValue({ totalTurns: 1, parseValidityRate: 1 });
|
|
||||||
|
|
||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
electronAPI: {
|
electronAPI: {
|
||||||
projects: {
|
projects: {
|
||||||
getAll: getAllProjects,
|
getAll: getAllProjects,
|
||||||
},
|
},
|
||||||
chat: {
|
|
||||||
getProtocolHealth,
|
|
||||||
},
|
|
||||||
posts: {
|
posts: {
|
||||||
search: searchPosts,
|
search: searchPosts,
|
||||||
getAll: getAllPosts,
|
getAll: getAllPosts,
|
||||||
@@ -53,12 +49,10 @@ describe('invokePythonApiMethodV1', () => {
|
|||||||
await expect(invokePythonApiMethodV1('posts.getAll', { options: { limit: 10, offset: 5 } })).resolves.toEqual({ items: [], hasMore: false, total: 0 });
|
await expect(invokePythonApiMethodV1('posts.getAll', { options: { limit: 10, offset: 5 } })).resolves.toEqual({ items: [], hasMore: false, total: 0 });
|
||||||
await expect(invokePythonApiMethodV1('posts.search', { query: 'hit' })).resolves.toEqual([{ id: 'p1', title: 'Hit' }]);
|
await expect(invokePythonApiMethodV1('posts.search', { query: 'hit' })).resolves.toEqual([{ id: 'p1', title: 'Hit' }]);
|
||||||
await expect(invokePythonApiMethodV1('meta.getProjectMetadata', {})).resolves.toEqual({ name: 'My Project' });
|
await expect(invokePythonApiMethodV1('meta.getProjectMetadata', {})).resolves.toEqual({ name: 'My Project' });
|
||||||
await expect(invokePythonApiMethodV1('chat.getProtocolHealth', {})).resolves.toEqual({ totalTurns: 1, parseValidityRate: 1 });
|
|
||||||
expect(getAllProjects).toHaveBeenCalledWith();
|
expect(getAllProjects).toHaveBeenCalledWith();
|
||||||
expect(getAllPosts).toHaveBeenCalledWith({ limit: 10, offset: 5 });
|
expect(getAllPosts).toHaveBeenCalledWith({ limit: 10, offset: 5 });
|
||||||
expect(searchPosts).toHaveBeenCalledWith('hit');
|
expect(searchPosts).toHaveBeenCalledWith('hit');
|
||||||
expect(getProjectMetadata).toHaveBeenCalledWith();
|
expect(getProjectMetadata).toHaveBeenCalledWith();
|
||||||
expect(getProtocolHealth).toHaveBeenCalledWith();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unknown methods and malformed args', async () => {
|
it('rejects unknown methods and malformed args', async () => {
|
||||||
@@ -72,9 +66,6 @@ describe('invokePythonApiMethodV1', () => {
|
|||||||
projects: {
|
projects: {
|
||||||
getAll: vi.fn(),
|
getAll: vi.fn(),
|
||||||
},
|
},
|
||||||
chat: {
|
|
||||||
getProtocolHealth: vi.fn(),
|
|
||||||
},
|
|
||||||
meta: {
|
meta: {
|
||||||
getProjectMetadata: vi.fn(),
|
getProjectMetadata: vi.fn(),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user