wip: first run of implementation

This commit is contained in:
2026-02-25 20:29:01 +01:00
parent 2e203fa3a9
commit 20ea499a6f
40 changed files with 2170 additions and 22 deletions

View File

@@ -0,0 +1,94 @@
import type { AgentSurface, ProtocolCapabilitySnapshot } from '../protocol/types';
interface CapabilityRegistryOptions {
disabledActions?: string[];
disabledWidgets?: string[];
disabledTools?: string[];
}
interface CapabilitySnapshotInput {
surface: AgentSurface;
}
const COMMON_WIDGETS = [
'text',
'metric',
'list',
'table',
'action',
'chart',
'form',
'input',
'datePicker',
'card',
'image',
'tabs',
] as const;
const COMMON_ACTIONS = [
'openSettings',
'openPost',
'openMedia',
'openPanel',
'setActiveView',
'toggleSidebar',
'togglePanel',
'toggleAssistantSidebar',
] as const;
const COMMON_TOOLS = [
'search_posts',
'read_post',
'list_posts',
'get_media',
'list_media',
'update_post_metadata',
'update_media_metadata',
'list_tags',
'list_categories',
'view_image',
'get_post_backlinks',
'get_post_outlinks',
'get_post_media',
'get_media_posts',
] as const;
function unique(values: string[]): string[] {
return Array.from(new Set(values));
}
export class CapabilityRegistryService {
private readonly disabledActions: Set<string>;
private readonly disabledWidgets: Set<string>;
private readonly disabledTools: Set<string>;
constructor(options: CapabilityRegistryOptions = {}) {
this.disabledActions = new Set(options.disabledActions ?? []);
this.disabledWidgets = new Set(options.disabledWidgets ?? []);
this.disabledTools = new Set(options.disabledTools ?? []);
}
getSnapshot(input: CapabilitySnapshotInput): ProtocolCapabilitySnapshot {
const widgets = COMMON_WIDGETS.filter((widget) => !this.disabledWidgets.has(widget));
const surfaceActions = input.surface === 'tab'
? COMMON_ACTIONS.filter((action) => action !== 'toggleAssistantSidebar')
: COMMON_ACTIONS.filter((action) => action !== 'toggleSidebar');
const actions = surfaceActions.filter((action) => !this.disabledActions.has(action));
const tools = COMMON_TOOLS.filter((tool) => !this.disabledTools.has(tool));
const disabled = unique([
...Array.from(this.disabledActions).map((action) => `action:${action}`),
...Array.from(this.disabledWidgets).map((widget) => `widget:${widget}`),
...Array.from(this.disabledTools).map((tool) => `tool:${tool}`),
]);
return {
widgets: [...widgets],
actions: [...actions],
tools: [...tools],
disabled,
};
}
}

View File

@@ -0,0 +1,63 @@
export interface ProtocolTurnTelemetryInput {
validEnvelope: boolean;
repairAttempted: boolean;
fallbackUsed: boolean;
blockedActions: number;
}
export interface ProtocolTelemetrySnapshot {
totalTurns: number;
validEnvelopeTurns: number;
repairAttempts: number;
fallbackTurns: number;
blockedActionCount: number;
parseValidityRate: number;
repairRate: number;
fallbackRate: number;
}
export class ProtocolTelemetryService {
private totalTurns = 0;
private validEnvelopeTurns = 0;
private repairAttempts = 0;
private fallbackTurns = 0;
private blockedActionCount = 0;
recordTurn(input: ProtocolTurnTelemetryInput): void {
this.totalTurns += 1;
if (input.validEnvelope) {
this.validEnvelopeTurns += 1;
}
if (input.repairAttempted) {
this.repairAttempts += 1;
}
if (input.fallbackUsed) {
this.fallbackTurns += 1;
}
this.blockedActionCount += input.blockedActions;
}
getSnapshot(): ProtocolTelemetrySnapshot {
const denominator = this.totalTurns || 1;
return {
totalTurns: this.totalTurns,
validEnvelopeTurns: this.validEnvelopeTurns,
repairAttempts: this.repairAttempts,
fallbackTurns: this.fallbackTurns,
blockedActionCount: this.blockedActionCount,
parseValidityRate: this.validEnvelopeTurns / denominator,
repairRate: this.repairAttempts / denominator,
fallbackRate: this.fallbackTurns / denominator,
};
}
}
let protocolTelemetryService: ProtocolTelemetryService | null = null;
export function getProtocolTelemetryService(): ProtocolTelemetryService {
if (!protocolTelemetryService) {
protocolTelemetryService = new ProtocolTelemetryService();
}
return protocolTelemetryService;
}

View File

@@ -0,0 +1,30 @@
export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger';
export interface ActionPolicyResolution {
level: ActionPolicyLevel;
requiresConfirmation: boolean;
}
const ACTION_POLICY_MAP: Record<string, ActionPolicyLevel> = {
openPost: 'silent',
openMedia: 'silent',
openPanel: 'silent',
setActiveView: 'silent',
toggleSidebar: 'silent',
togglePanel: 'silent',
toggleAssistantSidebar: 'silent',
openSettings: 'confirm',
updatePostMetadata: 'confirm',
updateMediaMetadata: 'confirm',
submitNeedsInput: 'confirm',
deletePost: 'danger',
deleteMedia: 'danger',
};
export function resolveActionPolicy(action: string): ActionPolicyResolution {
const level = ACTION_POLICY_MAP[action] ?? 'danger';
return {
level,
requiresConfirmation: level !== 'silent',
};
}

View File

@@ -0,0 +1,9 @@
import type { ProtocolValidationError } from './types';
export function createProtocolValidationError(message: string, details?: string[]): ProtocolValidationError {
return {
code: 'AGUI_PROTOCOL_VALIDATION_ERROR',
message,
details,
};
}

View File

@@ -0,0 +1,312 @@
import { randomUUID } from 'crypto';
import type {
AgentSurface,
ProtocolCapabilitySnapshot,
ProtocolIntent,
ProtocolResponseEnvelope,
ProtocolValidationError,
} from './types';
import { validateProtocolResponseEnvelope } from './validator';
import { extractAssistantUiSpec } from './uiSpecParser';
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 normalizedDirectEnvelope = this.applyActionPolicies(directEnvelope);
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 normalizedRepairedEnvelope = this.applyActionPolicies(repaired);
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 normalizedBaseEnvelope = this.applyActionPolicies(baseEnvelope);
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 parseCanonicalEnvelope(raw: string): ProtocolResponseEnvelope | null {
try {
const parsed = JSON.parse(raw);
const validated = validateProtocolResponseEnvelope(parsed);
return validated.ok && validated.value ? validated.value : null;
} catch {
return null;
}
}
private repairRawEnvelope(raw: string): ProtocolResponseEnvelope | null {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const looksLikeEnvelope = Boolean(
parsed.assistantText
|| parsed.assistant_text
|| parsed.intent
|| parsed.needsInput
|| parsed.needs_input
|| parsed.actions,
);
if (!looksLikeEnvelope) {
return null;
}
const repaired: Record<string, unknown> = {
protocolVersion: parsed.protocolVersion ?? parsed.protocol_version ?? '2.0',
assistantText: parsed.assistantText ?? parsed.assistant_text ?? '',
ui: parsed.ui,
intent: parsed.intent ?? 'summarize',
needsInput: parsed.needsInput ?? parsed.needs_input ?? { required: false, fields: [] },
actions: parsed.actions ?? [],
confidence: typeof parsed.confidence === 'number' ? parsed.confidence : 0.6,
traceId: parsed.traceId ?? parsed.trace_id ?? randomUUID(),
};
const validated = validateProtocolResponseEnvelope(repaired);
return validated.ok && validated.value ? validated.value : null;
} catch {
return null;
}
}
private deriveIntent(text: string, hasUi: boolean, needsInput: boolean): ProtocolIntent {
if (needsInput) {
return 'ask_input';
}
if (hasUi) {
return 'propose_action';
}
if (text.trim().length === 0) {
return 'summarize';
}
return 'analyze';
}
private applyCapabilityGuards(
envelope: ProtocolResponseEnvelope,
capabilities: ProtocolCapabilitySnapshot,
): { filteredEnvelope: ProtocolResponseEnvelope; warnings: string[] } {
const warnings: string[] = [];
const filteredActions = envelope.actions.filter((action) => {
const supported = capabilities.actions.includes(action.action);
if (!supported) {
warnings.push(`Blocked unsupported action: ${action.action}`);
}
return supported;
});
const filteredUiElements = envelope.ui?.elements.filter((element) => {
const typedElement = element as { type?: string };
const elementType = typedElement?.type;
if (!elementType) {
return true;
}
const supported = capabilities.widgets.includes(elementType);
if (!supported) {
warnings.push(`Blocked unsupported widget: ${elementType}`);
}
return supported;
});
return {
filteredEnvelope: {
...envelope,
ui: envelope.ui && filteredUiElements
? {
specVersion: '1',
elements: filteredUiElements,
}
: envelope.ui,
actions: filteredActions,
},
warnings,
};
}
private applyActionPolicies(envelope: ProtocolResponseEnvelope): ProtocolResponseEnvelope {
const actionList = envelope.actions.length > 0
? envelope.actions
: this.extractActionsFromUi(envelope.ui?.elements ?? []);
const normalizedActions = actionList.map((action, index) => {
const policy = resolveActionPolicy(action.action);
return {
id: action.id || `agui-action-${index + 1}`,
action: action.action,
label: action.label,
payload: action.payload,
policy: policy.level,
requiresConfirmation: policy.requiresConfirmation,
};
});
return {
...envelope,
actions: normalizedActions,
};
}
private extractActionsFromUi(elements: unknown[]): Array<{
id: string;
action: string;
label?: string;
payload?: Record<string, unknown>;
}> {
const extracted: Array<{
id: string;
action: string;
label?: string;
payload?: Record<string, unknown>;
}> = [];
const walk = (nodes: unknown[], parentId: string) => {
nodes.forEach((node, index) => {
const typedNode = node as Record<string, unknown>;
const type = typeof typedNode?.type === 'string' ? typedNode.type : '';
const nodeId = `${parentId}-${index + 1}`;
if ((type === 'action' || type === 'input' || type === 'datePicker' || type === 'form' || type === 'image') && typeof typedNode.action === 'string') {
extracted.push({
id: `ui-${nodeId}`,
action: typedNode.action,
label: typeof typedNode.label === 'string' ? typedNode.label : undefined,
payload: typedNode.payload as Record<string, unknown> | undefined,
});
}
if (type === 'card' && Array.isArray(typedNode.actions)) {
typedNode.actions.forEach((cardAction, cardActionIndex) => {
const typedCardAction = cardAction as Record<string, unknown>;
if (typeof typedCardAction.action === 'string') {
extracted.push({
id: `ui-${nodeId}-card-${cardActionIndex + 1}`,
action: typedCardAction.action,
label: typeof typedCardAction.label === 'string' ? typedCardAction.label : undefined,
payload: typedCardAction.payload as Record<string, unknown> | undefined,
});
}
});
}
if (type === 'tabs' && Array.isArray(typedNode.tabs)) {
typedNode.tabs.forEach((tabNode, tabIndex) => {
const typedTab = tabNode as Record<string, unknown>;
if (Array.isArray(typedTab.elements)) {
walk(typedTab.elements, `${nodeId}-tab-${tabIndex + 1}`);
}
});
}
});
};
walk(elements, 'root');
return extracted;
}
private fallbackEnvelope(rawAssistantOutput: string): ProtocolResponseEnvelope {
return {
protocolVersion: '2.0',
assistantText: rawAssistantOutput,
intent: 'summarize',
needsInput: {
required: false,
fields: [],
},
actions: [],
confidence: 0.3,
traceId: randomUUID(),
};
}
}

View File

@@ -0,0 +1,82 @@
export type AgentSurface = 'tab' | 'sidebar';
export type ProtocolIntent =
| 'analyze'
| 'ask_input'
| 'propose_action'
| 'execute_action'
| 'summarize';
export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger';
export interface ProtocolNeedsInputField {
key: string;
label: string;
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number';
required?: boolean;
options?: Array<{ label: string; value: string }>;
placeholder?: string;
defaultValue?: string | number | boolean;
}
export interface ProtocolNeedsInput {
required: boolean;
fields: ProtocolNeedsInputField[];
}
export interface ProtocolAction {
id: string;
action: string;
label?: string;
payload?: Record<string, unknown>;
policy: ActionPolicyLevel;
requiresConfirmation: boolean;
}
export interface ProtocolUiSpec {
specVersion: '1';
elements: unknown[];
}
export interface ProtocolResponseEnvelope {
protocolVersion: '2.0';
assistantText: string;
ui?: ProtocolUiSpec;
intent: ProtocolIntent;
needsInput: ProtocolNeedsInput;
actions: ProtocolAction[];
confidence: number;
traceId: string;
}
export interface ProtocolRequestMessage {
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
}
export interface ProtocolCapabilitySnapshot {
widgets: string[];
actions: string[];
tools: string[];
disabled?: string[];
}
export interface ProtocolRequestEnvelope {
protocolVersion: '2.0';
surface: AgentSurface;
messages: ProtocolRequestMessage[];
context: Record<string, unknown>;
capabilities: ProtocolCapabilitySnapshot;
}
export interface ProtocolValidationError {
code: 'AGUI_PROTOCOL_VALIDATION_ERROR';
message: string;
details?: string[];
}
export interface ProtocolValidationResult<T> {
ok: boolean;
value?: T;
error?: ProtocolValidationError;
}

View File

@@ -0,0 +1,154 @@
import { z } from 'zod';
const inputTypeSchema = z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']);
const inputOptionSchema = z.object({
label: z.string().min(1),
value: z.string(),
}).strict();
const textElementSchema = z.object({
type: z.literal('text'),
text: z.string().min(1),
}).strict();
const metricElementSchema = z.object({
type: z.literal('metric'),
label: z.string().min(1),
value: z.string().min(1),
}).strict();
const listElementSchema = z.object({
type: z.literal('list'),
title: z.string().optional(),
items: z.array(z.string().min(1)).min(1),
}).strict();
const tableElementSchema = z.object({
type: z.literal('table'),
columns: z.array(z.string().min(1)).min(1),
rows: z.array(z.array(z.string())).min(1),
}).strict();
const actionElementSchema = z.object({
type: z.literal('action'),
label: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
}).strict();
const chartElementSchema = z.object({
type: z.literal('chart'),
chartType: z.enum(['bar', 'line', 'pie']),
title: z.string().min(1).optional(),
series: z.array(z.object({
label: z.string().min(1),
value: z.number(),
}).strict()).min(1),
}).strict();
const inputElementSchema = z.object({
type: z.literal('input'),
key: z.string().min(1),
label: z.string().min(1),
inputType: inputTypeSchema,
placeholder: z.string().optional(),
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
options: z.array(inputOptionSchema).optional(),
action: z.string().min(1).optional(),
submitLabel: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
}).strict();
const datePickerElementSchema = z.object({
type: z.literal('datePicker'),
key: z.string().min(1),
label: z.string().min(1),
defaultValue: z.string().optional(),
min: z.string().optional(),
max: z.string().optional(),
action: z.string().min(1).optional(),
submitLabel: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
}).strict();
const formFieldSchema = z.object({
key: z.string().min(1),
label: z.string().min(1),
inputType: inputTypeSchema,
placeholder: z.string().optional(),
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
options: z.array(inputOptionSchema).optional(),
required: z.boolean().optional(),
}).strict();
const formElementSchema = z.object({
type: z.literal('form'),
formId: z.string().min(1),
title: z.string().optional(),
submitLabel: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
fields: z.array(formFieldSchema).min(1),
}).strict();
const cardActionSchema = z.object({
label: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
}).strict();
const cardElementSchema = z.object({
type: z.literal('card'),
title: z.string().min(1),
body: z.string().min(1),
subtitle: z.string().optional(),
actions: z.array(cardActionSchema).optional(),
}).strict();
const imageElementSchema = z.object({
type: z.literal('image'),
src: z.string().min(1),
alt: z.string().optional(),
caption: z.string().optional(),
action: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
}).strict();
let assistantPanelElementSchemaRef: z.ZodTypeAny;
const tabsElementSchema: z.ZodTypeAny = z.lazy(() => z.object({
type: z.literal('tabs'),
widgetId: z.string().min(1).optional(),
defaultTabId: z.string().min(1).optional(),
tabs: z.array(z.object({
id: z.string().min(1),
label: z.string().min(1),
elements: z.array(assistantPanelElementSchemaRef).min(1),
}).strict()).min(1),
}).strict());
assistantPanelElementSchemaRef = z.union([
textElementSchema,
metricElementSchema,
listElementSchema,
tableElementSchema,
actionElementSchema,
chartElementSchema,
inputElementSchema,
formElementSchema,
datePickerElementSchema,
cardElementSchema,
imageElementSchema,
tabsElementSchema,
]);
export const assistantPanelElementSchema = assistantPanelElementSchemaRef;
export const assistantPanelSpecSchema = z.object({
specVersion: z.literal('1'),
elements: z.array(assistantPanelElementSchema).min(1),
}).strict();
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;

View File

@@ -0,0 +1,235 @@
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 normalized: Record<string, unknown> = {
...record,
};
const dataRecord = toRecord(record.data);
if (Array.isArray(record.series)) {
return normalized;
}
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;
delete normalized.data;
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 === '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.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 interface ParsedAssistantUiResult {
assistantText: string;
ui: AssistantPanelSpec | null;
}
export function extractAssistantUiSpec(message: string): ParsedAssistantUiResult {
const trimmed = message.trim();
const fencedMatches = [...trimmed.matchAll(/```(json)?\s*([\s\S]*?)```/gi)];
for (const match of fencedMatches) {
const candidate = match[2]?.trim();
if (!candidate) {
continue;
}
const parsed = parseSpecCandidate(candidate);
if (parsed) {
const assistantText = trimmed.replace(match[0], '').trim();
return {
assistantText,
ui: parsed,
};
}
}
const parsedWholeMessage = parseSpecCandidate(trimmed);
return {
assistantText: parsedWholeMessage ? '' : trimmed,
ui: parsedWholeMessage,
};
}

View File

@@ -0,0 +1,112 @@
import { z } from 'zod';
import type {
ProtocolRequestEnvelope,
ProtocolResponseEnvelope,
ProtocolValidationResult,
} from './types';
import { createProtocolValidationError } from './errors';
const needsInputFieldSchema = z.object({
key: z.string().min(1),
label: z.string().min(1),
inputType: z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']),
required: z.boolean().optional(),
options: z.array(z.object({ label: z.string().min(1), value: z.string() })).optional(),
placeholder: z.string().optional(),
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
}).strict();
const needsInputSchema = z.object({
required: z.boolean(),
fields: z.array(needsInputFieldSchema),
}).strict();
const protocolActionSchema = z.object({
id: z.string().min(1),
action: z.string().min(1),
label: z.string().optional(),
payload: z.record(z.string(), z.unknown()).optional(),
policy: z.enum(['silent', 'confirm', 'danger']),
requiresConfirmation: z.boolean(),
}).strict();
const protocolUiSchema = z.object({
specVersion: z.literal('1'),
elements: z.array(z.unknown()),
}).strict();
const protocolResponseEnvelopeSchema = z.object({
protocolVersion: z.literal('2.0'),
assistantText: z.string(),
ui: protocolUiSchema.optional(),
intent: z.enum(['analyze', 'ask_input', 'propose_action', 'execute_action', 'summarize']),
needsInput: needsInputSchema,
actions: z.array(protocolActionSchema),
confidence: z.number().min(0).max(1),
traceId: z.string().min(1),
}).strict().superRefine((value, context) => {
if (value.needsInput.required && value.needsInput.fields.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['needsInput', 'fields'],
message: 'needsInput.fields must include at least one field when needsInput.required is true',
});
}
});
const protocolRequestEnvelopeSchema = z.object({
protocolVersion: z.literal('2.0'),
surface: z.enum(['tab', 'sidebar']),
messages: z.array(z.object({ role: z.enum(['user', 'assistant', 'system', 'tool']), content: z.string() }).strict()),
context: z.record(z.string(), z.unknown()),
capabilities: z.object({
widgets: z.array(z.string().min(1)),
actions: z.array(z.string().min(1)),
tools: z.array(z.string().min(1)),
disabled: z.array(z.string().min(1)).optional(),
}).strict(),
}).strict();
function toErrorMessage(prefix: string, issues: z.ZodIssue[]): string {
const firstIssue = issues[0];
const issuePath = firstIssue.path.length > 0 ? firstIssue.path.join('.') : 'root';
return `${prefix}: ${issuePath} ${firstIssue.message}`;
}
export function validateProtocolResponseEnvelope(input: unknown): ProtocolValidationResult<ProtocolResponseEnvelope> {
const parsed = protocolResponseEnvelopeSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: createProtocolValidationError(
toErrorMessage('Invalid protocol response envelope', parsed.error.issues),
parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`),
),
};
}
return {
ok: true,
value: parsed.data,
};
}
export function validateProtocolRequestEnvelope(input: unknown): ProtocolValidationResult<ProtocolRequestEnvelope> {
const parsed = protocolRequestEnvelopeSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: createProtocolValidationError(
toErrorMessage('Invalid protocol request envelope', parsed.error.issues),
parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`),
),
};
}
return {
ok: true,
value: parsed.data,
};
}
export type { ProtocolRequestEnvelope, ProtocolResponseEnvelope } from './types';

View File

@@ -0,0 +1,50 @@
import type { AgentTurnState } from './turnStateMachine';
export interface WorkflowCheckpoint {
conversationId: string;
state: AgentTurnState;
pendingFields: string[];
lastTraceId: string;
updatedAt: string;
}
export interface WorkflowCheckpointSettingsAdapter {
getSetting(key: string): Promise<string | null>;
setSetting(key: string, value: string): Promise<void>;
}
function keyForConversation(conversationId: string): string {
return `agui.workflow.${conversationId}`;
}
export class WorkflowCheckpointStore {
private readonly adapter: WorkflowCheckpointSettingsAdapter;
constructor(adapter: WorkflowCheckpointSettingsAdapter) {
this.adapter = adapter;
}
async save(checkpoint: WorkflowCheckpoint): Promise<void> {
await this.adapter.setSetting(
keyForConversation(checkpoint.conversationId),
JSON.stringify(checkpoint),
);
}
async load(conversationId: string): Promise<WorkflowCheckpoint | null> {
const raw = await this.adapter.getSetting(keyForConversation(conversationId));
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as WorkflowCheckpoint;
if (!parsed || parsed.conversationId !== conversationId) {
return null;
}
return parsed;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,45 @@
export type AgentTurnState =
| 'planning'
| 'awaiting_input'
| 'executing'
| 'observing'
| 'completed';
interface TurnStateEnvelopeInput {
intent: 'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize';
needsInput: {
required: boolean;
fields: Array<{ key: string }>;
};
}
interface TransitionInput {
previousState: AgentTurnState;
envelope: TurnStateEnvelopeInput;
}
export class AgentTurnStateMachine {
transition(input: TransitionInput): AgentTurnState {
if (input.envelope.needsInput.required && input.envelope.needsInput.fields.length > 0) {
return 'awaiting_input';
}
if (input.envelope.intent === 'execute_action') {
return 'executing';
}
if (input.envelope.intent === 'propose_action') {
return 'observing';
}
if (input.envelope.intent === 'summarize') {
return 'completed';
}
if (input.previousState === 'awaiting_input') {
return 'executing';
}
return 'planning';
}
}

View File

@@ -16,6 +16,13 @@ import { ChatEngine } from './ChatEngine';
import { PostEngine } from './PostEngine';
import { MediaEngine } from './MediaEngine';
import { getPostMediaEngine } from './PostMediaEngine';
import { ProtocolResponseBuilder } from '../agentic/protocol/responseBuilder';
import { CapabilityRegistryService } from '../agentic/capabilities/registry';
import { validateProtocolRequestEnvelope } 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
const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages';
@@ -77,6 +84,10 @@ export interface SendMessageOptions {
export interface SendMessageResult {
success: boolean;
message?: string;
envelope?: ProtocolResponseEnvelope;
protocolVersion?: '2.0';
traceId?: string;
warnings?: string[];
error?: string;
toolCalls?: Array<{ name: string; args: unknown }>;
}
@@ -131,6 +142,10 @@ export class OpenCodeManager {
private postEngine: PostEngine;
private mediaEngine: MediaEngine;
private getMainWindow: () => BrowserWindow | null;
private protocolResponseBuilder: ProtocolResponseBuilder;
private capabilityRegistry: CapabilityRegistryService;
private turnStateMachine: AgentTurnStateMachine;
private workflowCheckpointStore: WorkflowCheckpointStore;
private apiKey: string = '';
private abortControllers: Map<string, AbortController> = new Map();
@@ -144,6 +159,13 @@ export class OpenCodeManager {
this.postEngine = postEngine;
this.mediaEngine = mediaEngine;
this.getMainWindow = getMainWindow;
this.protocolResponseBuilder = new ProtocolResponseBuilder();
this.capabilityRegistry = new CapabilityRegistryService();
this.turnStateMachine = new AgentTurnStateMachine();
this.workflowCheckpointStore = new WorkflowCheckpointStore({
getSetting: async (key: string) => this.chatEngine.getSetting(key),
setSetting: async (key: string, value: string) => this.chatEngine.setSetting(key, value),
});
}
/**
@@ -275,10 +297,37 @@ export class OpenCodeManager {
// Build message history from DB (excluding system messages)
const dbMessages = conversation.messages.filter(m => m.role !== 'system');
const surface = metadata?.surface || 'tab';
const capabilities = this.capabilityRegistry.getSnapshot({ surface });
const requestEnvelope = {
protocolVersion: '2.0' as const,
surface,
messages: dbMessages
.filter((message) => message.role === 'user' || message.role === 'assistant' || message.role === 'system' || message.role === 'tool')
.map((message) => ({
role: message.role,
content: message.content || '',
})),
context: {
conversationId,
modelId,
},
capabilities,
};
const requestValidation = validateProtocolRequestEnvelope(requestEnvelope);
if (!requestValidation.ok) {
return {
success: false,
error: requestValidation.error?.message || 'Invalid protocol request envelope',
};
}
const surfaceHint = metadata?.surface
? `\n\n[Client UI surface: ${metadata.surface}. Render response UI for this surface while keeping content functionally equivalent.]`
: '';
const userMessageForModel = `${userMessage}${surfaceHint}`;
const capabilityHint = `\n\n[Protocol request envelope]\n${JSON.stringify(requestEnvelope, null, 2)}`;
const userMessageForModel = `${userMessage}${surfaceHint}${capabilityHint}`;
// Add the new user message
dbMessages.push({
conversationId,
@@ -336,6 +385,38 @@ export class OpenCodeManager {
});
}
const protocolResult = this.protocolResponseBuilder.build({
rawAssistantOutput: fullResponse,
surface,
capabilities,
});
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,
});
// Generate title after first exchange
const userMsgCount = conversation.messages.filter(m => m.role === 'user').length;
if (userMsgCount === 0 && fullResponse) {
@@ -346,7 +427,11 @@ export class OpenCodeManager {
return {
success: true,
message: fullResponse,
message: protocolResult.envelope.assistantText,
envelope: protocolResult.envelope,
protocolVersion: protocolResult.envelope.protocolVersion,
traceId: protocolResult.traceId,
warnings: protocolResult.warnings,
toolCalls: toolCallsCollected.length > 0 ? toolCallsCollected : undefined,
};
} catch (error) {

View File

@@ -8,6 +8,7 @@ import { OpenCodeManager } from '../engine/OpenCodeManager';
import { getPostEngine } from '../engine/PostEngine';
import { getMediaEngine } from '../engine/MediaEngine';
import { getDatabase } from '../database';
import { getProtocolTelemetryService } from '../agentic/observability/protocolTelemetry';
let chatEngine: ChatEngine | null = null;
let openCodeManager: OpenCodeManager | null = null;
@@ -135,6 +136,10 @@ export function registerChatHandlers(): void {
// ============ Chat Settings ============
ipcMain.handle('chat:getProtocolHealth', async () => {
return getProtocolTelemetryService().getSnapshot();
});
// Get available models
ipcMain.handle('chat:getAvailableModels', async () => {
try {

View File

@@ -286,6 +286,7 @@ export const electronAPI: ElectronAPI = {
getApiKey: () => ipcRenderer.invoke('chat:getApiKey'),
// Settings
getProtocolHealth: () => ipcRenderer.invoke('chat:getProtocolHealth'),
getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'),
setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId),
getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'),

View File

@@ -435,6 +435,53 @@ export interface ChatSendMetadata {
surface?: 'tab' | 'sidebar';
}
export interface ProtocolNeedsInputField {
key: string;
label: string;
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number';
required?: boolean;
options?: Array<{ label: string; value: string }>;
placeholder?: string;
defaultValue?: string | number | boolean;
}
export interface ProtocolAction {
id: string;
action: string;
label?: string;
payload?: Record<string, unknown>;
policy: 'silent' | 'confirm' | 'danger';
requiresConfirmation: boolean;
}
export interface ProtocolResponseEnvelope {
protocolVersion: '2.0';
assistantText: string;
ui?: {
specVersion: '1';
elements: unknown[];
};
intent: 'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize';
needsInput: {
required: boolean;
fields: ProtocolNeedsInputField[];
};
actions: ProtocolAction[];
confidence: number;
traceId: string;
}
export interface ProtocolTelemetrySnapshot {
totalTurns: number;
validEnvelopeTurns: number;
repairAttempts: number;
fallbackTurns: number;
blockedActionCount: number;
parseValidityRate: number;
repairRate: number;
fallbackRate: number;
}
export interface SiteValidationReport {
sitemapPath: string;
sitemapChanged: boolean;
@@ -717,6 +764,7 @@ export interface ElectronAPI {
getApiKey: () => Promise<ChatApiKeyStatus>;
// Settings
getProtocolHealth: () => Promise<ProtocolTelemetrySnapshot>;
getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>;
setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>;
getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>;
@@ -730,7 +778,7 @@ export interface ElectronAPI {
deleteConversation: (id: string) => Promise<boolean>;
// Messaging
sendMessage: (conversationId: string, message: string, metadata?: ChatSendMetadata) => Promise<{ success: boolean; message?: string; error?: string }>;
sendMessage: (conversationId: string, message: string, metadata?: ChatSendMetadata) => Promise<{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }>;
addSystemEvent: (conversationId: string, content: string) => Promise<{ success: boolean; error?: string }>;
abortMessage: (conversationId: string) => Promise<void>;
getHistory: (conversationId: string) => Promise<ChatMessage[]>;

View File

@@ -5,9 +5,10 @@ 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 }) => {
export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({ elements, onAction, actionPolicies = {} }) => {
const [widgetValues, setWidgetValues] = useState<Record<string, unknown>>({});
const [activeTabByWidget, setActiveTabByWidget] = useState<Record<string, string>>({});
@@ -21,6 +22,20 @@ export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({
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,
@@ -150,7 +165,7 @@ export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({
{element.action && element.submitLabel && (
<button
type="button"
onClick={() => onAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue })}
onClick={() => triggerAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue }, element.submitLabel)}
>
{element.submitLabel}
</button>
@@ -175,7 +190,7 @@ export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({
{element.action && element.submitLabel && (
<button
type="button"
onClick={() => onAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue })}
onClick={() => triggerAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue }, element.submitLabel)}
>
{element.submitLabel}
</button>
@@ -191,11 +206,11 @@ export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({
return accumulator;
}, {});
onAction(element.action, {
triggerAction(element.action, {
...(element.payload ?? {}),
formId: element.formId,
values,
});
}, element.submitLabel);
};
return (
@@ -224,7 +239,7 @@ export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({
<button
key={`assistant-card-action-${indexPath}-${actionIndex}`}
type="button"
onClick={() => onAction(action.action, action.payload)}
onClick={() => triggerAction(action.action, action.payload, action.label)}
>
{action.label}
</button>
@@ -243,7 +258,7 @@ export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({
alt={element.alt || ''}
onClick={() => {
if (element.action) {
onAction(element.action, element.payload);
triggerAction(element.action, element.payload, element.caption || element.alt || element.action);
}
}}
/>
@@ -279,7 +294,7 @@ export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({
}
return (
<button key={`assistant-element-${indexPath}`} type="button" onClick={() => onAction(element.action, element.payload)}>
<button key={`assistant-element-${indexPath}`} type="button" onClick={() => triggerAction(element.action, element.payload, element.label)}>
{element.label}
</button>
);

View File

@@ -4,6 +4,7 @@ import { resolveAssistantEditorContext } from '../../navigation/assistantPromptC
import { planAssistantRequest } from '../../navigation/assistantConversation';
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
import { ensureConversationId } from '../../navigation/chatSession';
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
@@ -22,6 +23,7 @@ export const AssistantSidebar: React.FC = () => {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [conversationId, setConversationId] = useState<string | null>(null);
const [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
const [actionPolicies, setActionPolicies] = useState<Record<string, 'silent' | 'confirm' | 'danger'>>({});
const [actionError, setActionError] = useState<string | null>(null);
const {
@@ -127,10 +129,23 @@ export const AssistantSidebar: React.FC = () => {
throw new Error(sendResult.error || 'Failed to send assistant message');
}
if (sendResult.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(
sendResult.envelope.actions.reduce<Record<string, 'silent' | 'confirm' | 'danger'>>((accumulator, action) => {
accumulator[action.action] = action.policy;
return accumulator;
}, {}),
);
} else if (sendResult.message) {
const parsedResponse = extractAssistantResponseContent(sendResult.message);
finalizeAssistantTurn(resolvedConversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
} else {
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
stopStreaming();
@@ -146,6 +161,61 @@ export const AssistantSidebar: React.FC = () => {
};
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
if (action === 'submitNeedsInput' && conversationId) {
const values = payload?.values;
if (!values || typeof values !== 'object') {
setActionError(tr('assistantSidebar.error.actionFailed'));
return;
}
const clarificationMessage = `needs_input_response: ${JSON.stringify(values)}`;
beginUserTurn(conversationId, clarificationMessage);
void sendChatMessage({
conversationId,
message: clarificationMessage,
metadata: { surface: 'sidebar' },
}).then((sendResult) => {
if (!sendResult.success) {
appendAssistantMessage(
conversationId,
tr('chat.errorPrefix', { error: sendResult.error || tr('chat.errorNoResponse') }),
);
stopStreaming();
return;
}
if (sendResult.envelope) {
finalizeAssistantTurn(conversationId, sendResult.envelope.assistantText);
const uiElements = Array.isArray(sendResult.envelope.ui?.elements)
? (sendResult.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(sendResult.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(
sendResult.envelope.actions.reduce<Record<string, 'silent' | 'confirm' | 'danger'>>((accumulator, action) => {
accumulator[action.action] = action.policy;
return accumulator;
}, {}),
);
return;
}
if (sendResult.message) {
const parsedResponse = extractAssistantResponseContent(sendResult.message);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
}
}).catch((error) => {
console.error('Failed to submit assistant clarification:', error);
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming();
});
return;
}
const result = dispatchAssistantAction(
{
action,
@@ -230,7 +300,7 @@ export const AssistantSidebar: React.FC = () => {
)}
{panelElements.length > 0 && (
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} />
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} actionPolicies={actionPolicies} />
)}
</div>
);

View File

@@ -5,6 +5,7 @@ import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
import { useAppStore } from '../../store';
import { ChatTranscript } from '../ChatSurface';
import { AssistantPanelControls } from '../AssistantPanelControls';
@@ -28,6 +29,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
const [apiKeyError, setApiKeyError] = useState('');
const [isValidating, setIsValidating] = useState(false);
const [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
const [actionPolicies, setActionPolicies] = useState<Record<string, 'silent' | 'confirm' | 'danger'>>({});
const [actionError, setActionError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -190,21 +192,36 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
// Fall back to the backend result message if streaming didn't capture the content
const assistantContent = getStreamingContent() || (result.success ? result.message : '');
if (assistantContent) {
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(
result.envelope.actions.reduce<Record<string, 'silent' | 'confirm' | 'danger'>>((accumulator, action) => {
accumulator[action.action] = action.policy;
return accumulator;
}, {}),
);
} else if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
} else if (!result.success) {
// Backend returned an error (API failure, model unavailable, etc.)
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
stopStreaming();
setPanelElements([]);
setActionPolicies({});
} else {
// No content from streaming AND no error, but also no success message
// This can happen with some models that don't return content properly
appendAssistantMessage(conversationId, tr('chat.errorEmptyResponse'));
stopStreaming();
setPanelElements([]);
setActionPolicies({});
}
} catch (error) {
console.error('Failed to send message:', error);
@@ -226,7 +243,64 @@ 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(
result.envelope.actions.reduce<Record<string, 'silent' | 'confirm' | 'danger'>>((accumulator, action) => {
accumulator[action.action] = action.policy;
return accumulator;
}, {}),
);
return;
}
const assistantContent = getStreamingContent() || result.message;
if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
}
} catch (error) {
console.error('Failed to submit clarification:', error);
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming();
}
};
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
if (action === 'submitNeedsInput') {
void handleNeedsInputSubmit(payload);
return;
}
const result = dispatchAssistantAction(
{
action,
@@ -377,7 +451,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
/>
{panelElements.length > 0 && (
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} />
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} actionPolicies={actionPolicies} />
)}
{actionError && <p className="chat-surface-error">{actionError}</p>}

View File

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

View File

@@ -528,6 +528,9 @@
"dashboard.stats.images": "{count} Bilder",
"dashboard.stats.tags": "Schlagwörter",
"dashboard.stats.categories": "{count} Kategorien",
"dashboard.stats.protocolHealth": "Protokollzustand",
"dashboard.stats.blockedActions": "{count} blockierte Aktionen",
"dashboard.stats.fallbackTurns": "{count} Fallback-Durchläufe",
"dashboard.section.postsOverTime": "Beiträge im Zeitverlauf",
"dashboard.section.tags": "Schlagwörter",
"dashboard.section.categories": "Kategorien",

View File

@@ -528,6 +528,9 @@
"dashboard.stats.images": "{count} images",
"dashboard.stats.tags": "Tags",
"dashboard.stats.categories": "{count} categories",
"dashboard.stats.protocolHealth": "Protocol Health",
"dashboard.stats.blockedActions": "{count} blocked actions",
"dashboard.stats.fallbackTurns": "{count} fallback turns",
"dashboard.section.postsOverTime": "Posts Over Time",
"dashboard.section.tags": "Tags",
"dashboard.section.categories": "Categories",

View File

@@ -528,6 +528,9 @@
"dashboard.stats.images": "{count} imágenes",
"dashboard.stats.tags": "Etiquetas",
"dashboard.stats.categories": "{count} categorías",
"dashboard.stats.protocolHealth": "Salud del protocolo",
"dashboard.stats.blockedActions": "{count} acciones bloqueadas",
"dashboard.stats.fallbackTurns": "{count} respuestas de respaldo",
"dashboard.section.postsOverTime": "Entradas a lo largo del tiempo",
"dashboard.section.tags": "Etiquetas",
"dashboard.section.categories": "Categorías",

View File

@@ -528,6 +528,9 @@
"dashboard.stats.images": "{count} images",
"dashboard.stats.tags": "Étiquettes",
"dashboard.stats.categories": "{count} catégories",
"dashboard.stats.protocolHealth": "Santé du protocole",
"dashboard.stats.blockedActions": "{count} actions bloquées",
"dashboard.stats.fallbackTurns": "{count} tours de secours",
"dashboard.section.postsOverTime": "Articles dans le temps",
"dashboard.section.tags": "Étiquettes",
"dashboard.section.categories": "Catégories",

View File

@@ -528,6 +528,9 @@
"dashboard.stats.images": "{count} immagini",
"dashboard.stats.tags": "Tag",
"dashboard.stats.categories": "{count} categorie",
"dashboard.stats.protocolHealth": "Salute del protocollo",
"dashboard.stats.blockedActions": "{count} azioni bloccate",
"dashboard.stats.fallbackTurns": "{count} risposte di fallback",
"dashboard.section.postsOverTime": "Post nel tempo",
"dashboard.section.tags": "Tag",
"dashboard.section.categories": "Categorie",

View File

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

View File

@@ -0,0 +1,38 @@
import type { ProtocolNeedsInputField, ProtocolResponseEnvelope } from '../types/electron';
import type { AssistantPanelElement } from './assistantPanelSpec';
function toFormField(field: ProtocolNeedsInputField): {
key: string;
label: string;
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number';
placeholder?: string;
defaultValue?: string | number | boolean;
options?: Array<{ label: string; value: string }>;
required?: boolean;
} {
return {
key: field.key,
label: field.label,
inputType: field.inputType,
placeholder: field.placeholder,
defaultValue: field.defaultValue,
options: field.options,
required: field.required,
};
}
export function toClarificationElements(
needsInput: ProtocolResponseEnvelope['needsInput'],
): AssistantPanelElement[] {
if (!needsInput.required || needsInput.fields.length === 0) {
return [];
}
return [{
type: 'form',
formId: 'agui-needs-input',
submitLabel: needsInput.fields[0].label,
action: 'submitNeedsInput',
fields: needsInput.fields.map(toFormField),
}];
}

View File

@@ -176,6 +176,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'),
method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'),
method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'),
method('chat.getProtocolHealth', 'Get AGUI protocol telemetry health snapshot.', [], 'ProtocolTelemetrySnapshot'),
method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'),
method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'),
method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'),
@@ -359,11 +360,25 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'maskedKey', type: 'string', required: true, description: 'Masked key representation for UI display.' },
],
},
{
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 = {
version: '1.3.0',
generatedAt: '2026-02-24T00:00:00.000Z',
version: '1.4.0',
generatedAt: '2026-02-25T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,
};