feat(a2ui): Implement A2UI backend with question handling and WebSocket support

- Added A2UITypes for defining question structures and answers.
- Created A2UIWebSocketHandler for managing WebSocket connections and message handling.
- Developed ask-question tool for interactive user questions via A2UI.
- Introduced platformUtils for platform detection and shell command handling.
- Centralized TypeScript types in index.ts for better organization.
- Implemented compatibility checks for hook templates based on platform requirements.
This commit is contained in:
catlog22
2026-01-31 15:27:12 +08:00
parent 4e009bb03a
commit 715ef12c92
163 changed files with 19495 additions and 715 deletions

View File

@@ -0,0 +1,115 @@
// ========================================
// A2UI Backend Type Definitions
// ========================================
// Shared types for A2UI protocol on the backend
import { z } from 'zod';
// ========== Question Types ==========
/** Question type enum */
export const QuestionTypeSchema = z.enum([
'confirm',
'select',
'input',
'multi-select',
]);
export type QuestionType = z.infer<typeof QuestionTypeSchema>;
/** Question option for select/multi-select questions */
export const QuestionOptionSchema = z.object({
value: z.string(),
label: z.string(),
description: z.string().optional(),
});
export type QuestionOption = z.infer<typeof QuestionOptionSchema>;
/** Question definition */
export const QuestionSchema = z.object({
// Question identification
id: z.string(),
type: QuestionTypeSchema,
// Question content
title: z.string(),
message: z.string().optional(),
description: z.string().optional(),
// Options for select/multi-select
options: z.array(QuestionOptionSchema).optional(),
// Default values
defaultValue: z.union([z.string(), z.array(z.string()), z.boolean()]).optional(),
// Validation
required: z.boolean().default(false),
min: z.number().optional(),
max: z.number().optional(),
// UI hints
placeholder: z.string().optional(),
});
export type Question = z.infer<typeof QuestionSchema>;
// ========== Answer Types ==========
/** Question answer */
export const QuestionAnswerSchema = z.object({
questionId: z.string(),
value: z.union([z.string(), z.array(z.string()), z.boolean()]),
cancelled: z.boolean().optional(),
});
export type QuestionAnswer = z.infer<typeof QuestionAnswerSchema>;
// ========== Ask Question Parameters ==========
/** Parameters for ask_question tool */
export const AskQuestionParamsSchema = z.object({
question: QuestionSchema,
timeout: z.number().optional().default(300000), // 5 minutes default
surfaceId: z.string().optional(),
});
export type AskQuestionParams = z.infer<typeof AskQuestionParamsSchema>;
// ========== Ask Question Result ==========
/** Result from ask_question tool execution */
export const AskQuestionResultSchema = z.object({
success: z.boolean(),
surfaceId: z.string(),
cancelled: z.boolean(),
answers: z.array(QuestionAnswerSchema),
timestamp: z.string(),
error: z.string().optional(),
});
export type AskQuestionResult = z.infer<typeof AskQuestionResultSchema>;
// ========== Pending Question State ==========
/** Pending question waiting for user response */
export interface PendingQuestion {
id: string;
surfaceId: string;
question: Question;
timestamp: number;
timeout: number;
resolve: (result: AskQuestionResult) => void;
reject: (error: Error) => void;
}
// ========== A2UI Surface for Questions ==========
/** Generate A2UI surface for a question */
export interface QuestionSurface {
surfaceUpdate: {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
};
}

View File

@@ -0,0 +1,290 @@
// ========================================
// A2UI WebSocket Handler
// ========================================
// WebSocket transport for A2UI surfaces and actions
import type { Duplex } from 'stream';
import type { IncomingMessage } from 'http';
import { createWebSocketFrame, parseWebSocketFrame, wsClients } from '../websocket.js';
import type { QuestionAnswer, AskQuestionParams, Question } from './A2UITypes.js';
// ========== A2UI Message Types ==========
/** A2UI WebSocket message types */
export type A2UIMessageType =
| 'a2ui-surface' // Send surface to frontend
| 'a2ui-action'; // Receive action from frontend
/** A2UI surface message - sent to frontend */
export interface A2UISurfaceMessage {
type: 'a2ui-surface';
surfaceUpdate: {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
};
timestamp: string;
}
/** A2UI action message - received from frontend */
export interface A2UIActionMessage {
type: 'a2ui-action';
actionId: string;
surfaceId: string;
parameters?: Record<string, unknown>;
timestamp: string;
}
/** A2UI question answer message - received from frontend */
export interface A2UIQuestionAnswerMessage {
type: 'a2ui-answer';
questionId: string;
surfaceId: string;
value: unknown;
cancelled: boolean;
timestamp: string;
}
// ========== A2UI Handler ==========
/**
* A2UI WebSocket Handler
* Manages A2UI surface distribution and action handling
*/
export class A2UIWebSocketHandler {
private activeSurfaces = new Map<string, {
surfaceId: string;
questionId: string;
timestamp: number;
}>();
/**
* Send A2UI surface to all connected clients
* @param surfaceUpdate - A2UI surface update to send
* @returns Number of clients notified
*/
sendSurface(surfaceUpdate: {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
}): number {
const message: A2UISurfaceMessage = {
type: 'a2ui-surface',
surfaceUpdate,
timestamp: new Date().toISOString(),
};
// Track active surface
const questionId = surfaceUpdate.initialState?.questionId as string | undefined;
if (questionId) {
this.activeSurfaces.set(questionId, {
surfaceId: surfaceUpdate.surfaceId,
questionId,
timestamp: Date.now(),
});
}
// Broadcast to all clients
const frame = createWebSocketFrame(message);
let sentCount = 0;
for (const client of wsClients) {
try {
client.write(frame);
sentCount++;
} catch (e) {
wsClients.delete(client);
}
}
console.log(`[A2UI] Sent surface ${surfaceUpdate.surfaceId} to ${sentCount} clients`);
return sentCount;
}
/**
* Send A2UI surface to specific client
* @param client - Specific WebSocket client
* @param surfaceUpdate - A2UI surface update to send
* @returns True if sent successfully
*/
sendSurfaceToClient(
client: Duplex,
surfaceUpdate: {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
}
): boolean {
const message: A2UISurfaceMessage = {
type: 'a2ui-surface',
surfaceUpdate,
timestamp: new Date().toISOString(),
};
try {
const frame = createWebSocketFrame(message);
client.write(frame);
return true;
} catch (e) {
wsClients.delete(client);
return false;
}
}
/**
* Handle incoming A2UI action from frontend
* @param action - Action message from frontend
* @param callback - Optional callback to handle the action
* @returns True if action was handled
*/
handleAction(action: A2UIActionMessage, callback?: (action: A2UIActionMessage) => void): boolean {
console.log(`[A2UI] Received action: ${action.actionId} for surface ${action.surfaceId}`);
// Call callback if provided
if (callback) {
callback(action);
return true;
}
return true;
}
/**
* Handle incoming A2UI question answer from frontend
* @param answer - Answer message from frontend
* @param callback - Callback to process the answer
* @returns True if answer was handled
*/
handleAnswer(answer: A2UIQuestionAnswerMessage, callback: (answer: QuestionAnswer) => boolean): boolean {
console.log(`[A2UI] Received answer for question ${answer.questionId}: cancelled=${answer.cancelled}`);
// Convert to QuestionAnswer format
const questionAnswer: QuestionAnswer = {
questionId: answer.questionId,
value: answer.value,
cancelled: answer.cancelled,
};
// Call callback
const handled = callback(questionAnswer);
// Remove from active surfaces if answered/cancelled
if (handled) {
this.activeSurfaces.delete(answer.questionId);
}
return handled;
}
/**
* Cancel an active surface
* @param questionId - Question ID to cancel
* @returns True if surface was cancelled
*/
cancelSurface(questionId: string): boolean {
const surface = this.activeSurfaces.get(questionId);
if (!surface) {
return false;
}
// Send cancel notification to frontend
const message = {
type: 'a2ui-cancel' as const,
surfaceId: surface.surfaceId,
questionId,
timestamp: new Date().toISOString(),
};
const frame = createWebSocketFrame(message);
for (const client of wsClients) {
try {
client.write(frame);
} catch (e) {
wsClients.delete(client);
}
}
this.activeSurfaces.delete(questionId);
return true;
}
/**
* Get active surfaces
* @returns Array of active surface info
*/
getActiveSurfaces(): Array<{
surfaceId: string;
questionId: string;
timestamp: number;
}> {
return Array.from(this.activeSurfaces.values());
}
/**
* Clear all active surfaces
*/
clearSurfaces(): void {
this.activeSurfaces.clear();
}
/**
* Remove stale surfaces (older than specified time)
* @param maxAge - Maximum age in milliseconds
* @returns Number of surfaces removed
*/
removeStaleSurfaces(maxAge: number = 3600000): number {
const now = Date.now();
let removed = 0;
for (const [questionId, surface] of this.activeSurfaces) {
if (now - surface.timestamp > maxAge) {
this.activeSurfaces.delete(questionId);
removed++;
}
}
return removed;
}
}
// ========== WebSocket Integration ==========
/**
* Handle A2UI messages in WebSocket data handler
* Called from main WebSocket handler
* @param payload - Message payload
* @param a2uiHandler - A2UI handler instance
* @param answerCallback - Callback for question answers
* @returns True if message was handled as A2UI message
*/
export function handleA2UIMessage(
payload: string,
a2uiHandler: A2UIWebSocketHandler,
answerCallback?: (answer: QuestionAnswer) => boolean
): boolean {
try {
const data = JSON.parse(payload);
// Handle A2UI action messages
if (data.type === 'a2ui-action') {
a2uiHandler.handleAction(data as A2UIActionMessage);
return true;
}
// Handle A2UI answer messages
if (data.type === 'a2ui-answer' && answerCallback) {
a2uiHandler.handleAnswer(data as A2UIQuestionAnswerMessage, answerCallback);
return true;
}
return false;
} catch (e) {
console.error('[A2UI] Failed to parse message:', e);
return false;
}
}
// ========== Singleton Export ==========
/** Global A2UI WebSocket handler instance */
export const a2uiWebSocketHandler = new A2UIWebSocketHandler();

View File

@@ -0,0 +1,6 @@
// ========================================
// A2UI Backend - Index
// ========================================
export * from './A2UITypes';
export * from './A2UIWebSocketHandler';

View File

@@ -579,8 +579,8 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleGraphRoutes(routeContext)) return;
}
// CCW routes (/api/ccw/*)
if (pathname.startsWith('/api/ccw/')) {
// CCW routes (/api/ccw and /api/ccw/*)
if (pathname.startsWith('/api/ccw')) {
if (await handleCcwRoutes(routeContext)) return;
}

View File

@@ -0,0 +1,474 @@
// ========================================
// ask_question MCP Tool
// ========================================
// Interactive question tool using A2UI protocol
import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import type {
Question,
QuestionType,
QuestionOption,
QuestionAnswer,
AskQuestionParams,
AskQuestionResult,
PendingQuestion,
} from '../core/a2ui/A2UITypes.js';
// ========== Constants ==========
/** Default question timeout (5 minutes) */
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
/** Map of pending questions waiting for responses */
const pendingQuestions = new Map<string, PendingQuestion>();
// ========== Validation ==========
/**
* Validate question parameters
* @param question - Question to validate
* @returns Validated question or throws error
*/
function validateQuestion(question: unknown): Question {
// Check required fields
if (!question || typeof question !== 'object') {
throw new Error('Question must be an object');
}
const q = question as Record<string, unknown>;
if (!q.id || typeof q.id !== 'string') {
throw new Error('Question must have an id field');
}
if (!q.type || typeof q.type !== 'string') {
throw new Error('Question must have a type field');
}
if (!q.title || typeof q.title !== 'string') {
throw new Error('Question must have a title field');
}
// Validate type
const validTypes: QuestionType[] = ['confirm', 'select', 'input', 'multi-select'];
if (!validTypes.includes(q.type as QuestionType)) {
throw new Error(`Invalid question type: ${q.type}. Must be one of: ${validTypes.join(', ')}`);
}
// Validate options for select/multi-select
if (q.type === 'select' || q.type === 'multi-select') {
if (!Array.isArray(q.options) || q.options.length === 0) {
throw new Error(`Question type '${q.type}' requires at least one option`);
}
for (const opt of q.options) {
if (!opt.value || typeof opt.value !== 'string') {
throw new Error('Each option must have a value field');
}
if (!opt.label || typeof opt.label !== 'string') {
throw new Error('Each option must have a label field');
}
}
}
// Validate timeout
const timeout = typeof q.timeout === 'number' ? q.timeout : DEFAULT_TIMEOUT_MS;
if (timeout < 1000 || timeout > 3600000) {
throw new Error('Timeout must be between 1 second and 1 hour');
}
return q as Question;
}
/**
* Validate answer against question constraints
* @param question - Question definition
* @param answer - Answer to validate
* @returns True if answer is valid
*/
function validateAnswer(question: Question, answer: QuestionAnswer): boolean {
// Check question ID matches
if (answer.questionId !== question.id) {
return false;
}
// Handle cancelled answers
if (answer.cancelled === true) {
return true;
}
// Validate based on question type
switch (question.type) {
case 'confirm':
return typeof answer.value === 'boolean';
case 'input':
return typeof answer.value === 'string';
case 'select':
if (typeof answer.value !== 'string') {
return false;
}
if (!question.options) {
return false;
}
return question.options.some((opt) => opt.value === answer.value);
case 'multi-select':
if (!Array.isArray(answer.value)) {
return false;
}
if (!question.options) {
return false;
}
const validValues = new Set(question.options.map((opt) => opt.value));
return answer.value.every((v) => validValues.has(v));
default:
return false;
}
}
// ========== A2UI Surface Generation ==========
/**
* Generate A2UI surface update for a question
* @param question - Question to render
* @param surfaceId - Surface ID for the question
* @returns A2UI surface update object
*/
function generateQuestionSurface(question: Question, surfaceId: string): {
surfaceUpdate: {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
};
} {
const components: unknown[] = [];
// Add title
components.push({
id: 'title',
component: {
Text: {
text: { literalString: question.title },
usageHint: 'h3',
},
},
});
// Add message if provided
if (question.message) {
components.push({
id: 'message',
component: {
Text: {
text: { literalString: question.message },
usageHint: 'p',
},
},
});
}
// Add description if provided
if (question.description) {
components.push({
id: 'description',
component: {
Text: {
text: { literalString: question.description },
usageHint: 'small',
},
},
});
}
// Add input components based on question type
switch (question.type) {
case 'confirm': {
components.push({
id: 'confirm-btn',
component: {
Button: {
onClick: { actionId: 'confirm', parameters: { questionId: question.id } },
content: {
Text: { text: { literalString: 'Confirm' } },
},
variant: 'primary',
},
},
});
components.push({
id: 'cancel-btn',
component: {
Button: {
onClick: { actionId: 'cancel', parameters: { questionId: question.id } },
content: {
Text: { text: { literalString: 'Cancel' } },
},
variant: 'secondary',
},
},
});
break;
}
case 'select': {
const options = question.options?.map((opt) => ({
label: { literalString: opt.label },
value: opt.value,
})) || [];
components.push({
id: 'select',
component: {
Dropdown: {
options,
selectedValue: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
onChange: { actionId: 'answer', parameters: { questionId: question.id } },
placeholder: question.placeholder || 'Select an option',
},
},
});
break;
}
case 'multi-select': {
const options = question.options?.map((opt) => ({
label: { literalString: opt.label },
value: opt.value,
})) || [];
components.push({
id: 'checkboxes',
component: {
Card: {
content: options.map((opt, idx) => ({
id: `checkbox-${idx}`,
component: {
Checkbox: {
label: opt.label,
onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: opt.value } },
},
},
})),
},
},
});
break;
}
case 'input': {
components.push({
id: 'input',
component: {
TextField: {
value: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
onChange: { actionId: 'answer', parameters: { questionId: question.id } },
placeholder: question.placeholder || 'Enter your answer',
type: 'text',
},
},
});
break;
}
}
return {
surfaceUpdate: {
surfaceId,
components,
initialState: {
questionId: question.id,
questionType: question.type,
options: question.options,
required: question.required,
},
},
};
}
// ========== Question Handler ==========
/**
* Execute ask_question tool
* @param params - Tool parameters
* @returns Tool result with answer or timeout
*/
export async function execute(params: AskQuestionParams): Promise<ToolResult<AskQuestionResult>> {
try {
// Validate question
const question = validateQuestion(params.question);
// Generate surface ID
const surfaceId = params.surfaceId || `question-${question.id}-${Date.now()}`;
// Create promise for answer
const resultPromise = new Promise<AskQuestionResult>((resolve, reject) => {
// Store pending question
const pendingQuestion: PendingQuestion = {
id: question.id,
surfaceId,
question,
timestamp: Date.now(),
timeout: params.timeout || DEFAULT_TIMEOUT_MS,
resolve,
reject,
};
pendingQuestions.set(question.id, pendingQuestion);
// Set timeout
setTimeout(() => {
if (pendingQuestions.has(question.id)) {
pendingQuestions.delete(question.id);
resolve({
success: false,
surfaceId,
cancelled: false,
answers: [],
timestamp: new Date().toISOString(),
error: 'Question timed out',
});
}
}, params.timeout || DEFAULT_TIMEOUT_MS);
});
// TODO: Send A2UI surface via WebSocket
// This will be handled by A2UIWebSocketHandler
// Wait for answer
const result = await resultPromise;
return {
success: true,
result,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
// ========== Answer Handler ==========
/**
* Handle incoming answer from frontend
* @param answer - Answer from user
* @returns True if answer was processed
*/
export function handleAnswer(answer: QuestionAnswer): boolean {
const pending = pendingQuestions.get(answer.questionId);
if (!pending) {
return false;
}
// Validate answer
if (!validateAnswer(pending.question, answer)) {
return false;
}
// Resolve promise
pending.resolve({
success: true,
surfaceId: pending.surfaceId,
cancelled: answer.cancelled || false,
answers: [answer],
timestamp: new Date().toISOString(),
});
// Remove from pending
pendingQuestions.delete(answer.questionId);
return true;
}
// ========== Cleanup ==========
/**
* Cancel a pending question
* @param questionId - Question ID to cancel
* @returns True if question was cancelled
*/
export function cancelQuestion(questionId: string): boolean {
const pending = pendingQuestions.get(questionId);
if (!pending) {
return false;
}
pending.resolve({
success: false,
surfaceId: pending.surfaceId,
cancelled: true,
answers: [],
timestamp: new Date().toISOString(),
error: 'Question cancelled',
});
pendingQuestions.delete(questionId);
return true;
}
/**
* Get all pending questions
* @returns Array of pending questions
*/
export function getPendingQuestions(): PendingQuestion[] {
return Array.from(pendingQuestions.values());
}
/**
* Clear all pending questions
*/
export function clearPendingQuestions(): void {
for (const pending of pendingQuestions.values()) {
pending.reject(new Error('Question cleared'));
}
pendingQuestions.clear();
}
// ========== Tool Schema ==========
export const schema: ToolSchema = {
name: 'ask_question',
description: 'Ask the user a question through an interactive A2UI interface. Supports confirmation dialogs, selection from options, text input, and multi-select checkboxes.',
inputSchema: {
type: 'object',
properties: {
question: {
type: 'object',
properties: {
id: { type: 'string', description: 'Unique identifier for this question' },
type: {
type: 'string',
enum: ['confirm', 'select', 'input', 'multi-select'],
description: 'Question type: confirm (yes/no), select (dropdown), input (text field), multi-select (checkboxes)',
},
title: { type: 'string', description: 'Question title' },
message: { type: 'string', description: 'Additional message text' },
description: { type: 'string', description: 'Helper text' },
options: {
type: 'array',
description: 'Options for select/multi-select questions',
items: {
type: 'object',
properties: {
value: { type: 'string' },
label: { type: 'string' },
description: { type: 'string' },
},
required: ['value', 'label'],
},
},
defaultValue: { type: 'string', description: 'Default value' },
required: { type: 'boolean', description: 'Whether an answer is required' },
placeholder: { type: 'string', description: 'Placeholder text for input fields' },
},
required: ['id', 'type', 'title'],
},
timeout: { type: 'number', description: 'Timeout in milliseconds (default: 300000 / 5 minutes)' },
surfaceId: { type: 'string', description: 'Custom surface ID (auto-generated if not provided)' },
},
required: ['question'],
},
};