mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
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:
115
ccw/src/core/a2ui/A2UITypes.ts
Normal file
115
ccw/src/core/a2ui/A2UITypes.ts
Normal 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>;
|
||||
};
|
||||
}
|
||||
290
ccw/src/core/a2ui/A2UIWebSocketHandler.ts
Normal file
290
ccw/src/core/a2ui/A2UIWebSocketHandler.ts
Normal 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();
|
||||
6
ccw/src/core/a2ui/index.ts
Normal file
6
ccw/src/core/a2ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// ========================================
|
||||
// A2UI Backend - Index
|
||||
// ========================================
|
||||
|
||||
export * from './A2UITypes';
|
||||
export * from './A2UIWebSocketHandler';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
474
ccw/src/tools/ask-question.ts
Normal file
474
ccw/src/tools/ask-question.ts
Normal 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'],
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user