feat(a2ui): enhance A2UI notification handling and multi-select support

This commit is contained in:
catlog22
2026-02-04 11:11:55 +08:00
parent c6093ef741
commit 1a05551d00
14 changed files with 539 additions and 94 deletions

View File

@@ -58,6 +58,8 @@ export class A2UIWebSocketHandler {
timestamp: number;
}>();
private multiSelectSelections = new Map<string, Set<string>>();
private answerCallback?: (answer: QuestionAnswer) => boolean;
/**
@@ -84,6 +86,7 @@ export class A2UIWebSocketHandler {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
displayMode?: 'popup' | 'panel';
}): number {
const message = {
type: 'a2ui-surface',
@@ -93,12 +96,18 @@ export class A2UIWebSocketHandler {
// Track active surface
const questionId = surfaceUpdate.initialState?.questionId as string | undefined;
const questionType = surfaceUpdate.initialState?.questionType as string | undefined;
if (questionId) {
this.activeSurfaces.set(questionId, {
surfaceId: surfaceUpdate.surfaceId,
questionId,
timestamp: Date.now(),
});
if (questionType === 'multi-select') {
// Selection state is updated via a2ui-action messages ("toggle") and resolved on "submit"
this.multiSelectSelections.set(questionId, new Set<string>());
}
}
// Broadcast to all clients
@@ -130,6 +139,7 @@ export class A2UIWebSocketHandler {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
displayMode?: 'popup' | 'panel';
}
): boolean {
const message = {
@@ -188,11 +198,78 @@ export class A2UIWebSocketHandler {
// Remove from active surfaces if answered/cancelled
if (handled) {
this.activeSurfaces.delete(answer.questionId);
this.multiSelectSelections.delete(answer.questionId);
}
return handled;
}
/**
* Try to interpret a2ui-action messages as ask_question answers.
* This keeps the frontend generic: it only sends actions; the backend resolves question answers.
*/
handleQuestionAction(
action: A2UIActionMessage,
answerCallback: (answer: QuestionAnswer) => boolean
): boolean {
const params = action.parameters ?? {};
const questionId = typeof params.questionId === 'string' ? params.questionId : undefined;
if (!questionId) {
return false;
}
const resolveAndCleanup = (answer: QuestionAnswer): boolean => {
const handled = answerCallback(answer);
if (handled) {
this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId);
}
return handled;
};
switch (action.actionId) {
case 'confirm':
return resolveAndCleanup({ questionId, value: true, cancelled: false });
case 'cancel':
return resolveAndCleanup({ questionId, value: false, cancelled: true });
case 'answer': {
const value = params.value;
if (typeof value !== 'string' && typeof value !== 'boolean' && !Array.isArray(value)) {
return false;
}
return resolveAndCleanup({ questionId, value: value as string | boolean | string[], cancelled: false });
}
case 'toggle': {
const value = params.value;
const checked = params.checked;
if (typeof value !== 'string' || typeof checked !== 'boolean') {
return false;
}
const selected = this.multiSelectSelections.get(questionId) ?? new Set<string>();
if (checked) {
selected.add(value);
} else {
selected.delete(value);
}
this.multiSelectSelections.set(questionId, selected);
return true;
}
case 'submit': {
const selected = this.multiSelectSelections.get(questionId) ?? new Set<string>();
return resolveAndCleanup({ questionId, value: Array.from(selected), cancelled: false });
}
default:
return false;
}
}
/**
* Cancel an active surface
* @param questionId - Question ID to cancel
@@ -222,6 +299,7 @@ export class A2UIWebSocketHandler {
}
this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId);
return true;
}
@@ -284,7 +362,13 @@ export function handleA2UIMessage(
// Handle A2UI action messages
if (data.type === 'a2ui-action') {
a2uiHandler.handleAction(data as A2UIActionMessage);
const action = data as A2UIActionMessage;
a2uiHandler.handleAction(action);
// If this action belongs to an ask_question surface, interpret it as an answer update/submit.
if (answerCallback) {
a2uiHandler.handleQuestionAction(action, answerCallback);
}
return true;
}

View File

@@ -571,5 +571,82 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
return true;
}
// API: Test ask_question popup (for development testing)
if (pathname === '/api/test/ask-question' && req.method === 'GET') {
try {
// Import the A2UI handler
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
// Create a test surface with displayMode: 'popup'
const testSurface = {
surfaceId: `test-question-${Date.now()}`,
components: [
{
id: 'title',
component: {
Text: {
text: { literalString: 'Test Popup Question' },
usageHint: 'h3',
},
},
},
{
id: 'message',
component: {
Text: {
text: { literalString: 'This is a test popup card. Does it appear in the center?' },
usageHint: 'p',
},
},
},
{
id: 'confirm-btn',
component: {
Button: {
onClick: { actionId: 'confirm', parameters: { questionId: 'test-q-1' } },
content: {
Text: { text: { literalString: 'Confirm' } },
},
variant: 'primary',
},
},
},
{
id: 'cancel-btn',
component: {
Button: {
onClick: { actionId: 'cancel', parameters: { questionId: 'test-q-1' } },
content: {
Text: { text: { literalString: 'Cancel' } },
},
variant: 'secondary',
},
},
},
],
initialState: {
questionId: 'test-q-1',
questionType: 'confirm',
},
displayMode: 'popup' as const,
};
// Send the surface via WebSocket
const sentCount = a2uiWebSocketHandler.sendSurface(testSurface);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: 'Test popup sent',
sentToClients: sentCount,
surfaceId: testSurface.surfaceId
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to send test popup', details: String(err) }));
}
return true;
}
return false;
}

View File

@@ -165,41 +165,51 @@ export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _he
console.log(`[WS] Client connected (${wsClients.size} total)`);
// Handle incoming messages
let pendingBuffer = Buffer.alloc(0);
socket.on('data', (buffer: Buffer) => {
// Buffers may contain partial frames or multiple frames; accumulate and parse in a loop.
pendingBuffer = Buffer.concat([pendingBuffer, buffer]);
try {
const frame = parseWebSocketFrame(buffer);
if (!frame) return;
while (true) {
const frame = parseWebSocketFrame(pendingBuffer);
if (!frame) return;
const { opcode, payload } = frame;
const { opcode, payload, frameLength } = frame;
pendingBuffer = pendingBuffer.slice(frameLength);
switch (opcode) {
case 0x1: // Text frame
if (payload) {
console.log('[WS] Received:', payload);
// Try to handle as A2UI message
const handledAsA2UI = handleA2UIMessage(payload, a2uiWebSocketHandler, handleAnswer);
if (handledAsA2UI) {
console.log('[WS] Handled as A2UI message');
switch (opcode) {
case 0x1: // Text frame
if (payload) {
console.log('[WS] Received:', payload);
// Try to handle as A2UI message
const handledAsA2UI = handleA2UIMessage(payload, a2uiWebSocketHandler, handleAnswer);
if (handledAsA2UI) {
console.log('[WS] Handled as A2UI message');
}
}
break;
case 0x8: // Close frame
socket.end();
return;
case 0x9: { // Ping frame - respond with Pong
const pongFrame = Buffer.alloc(2);
pongFrame[0] = 0x8A; // Pong opcode with FIN bit
pongFrame[1] = 0x00; // No payload
socket.write(pongFrame);
break;
}
break;
case 0x8: // Close frame
socket.end();
break;
case 0x9: // Ping frame - respond with Pong
const pongFrame = Buffer.alloc(2);
pongFrame[0] = 0x8A; // Pong opcode with FIN bit
pongFrame[1] = 0x00; // No payload
socket.write(pongFrame);
break;
case 0xA: // Pong frame - ignore
break;
default:
// Ignore other frame types (binary, continuation)
break;
case 0xA: // Pong frame - ignore
break;
default:
// Ignore other frame types (binary, continuation)
break;
}
}
} catch (e) {
// Ignore parse errors
// On parse error, drop the buffered data to avoid unbounded growth.
pendingBuffer = Buffer.alloc(0);
}
});
@@ -218,7 +228,7 @@ export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _he
* Parse WebSocket frame (simplified)
* Returns { opcode, payload } or null
*/
export function parseWebSocketFrame(buffer: Buffer): { opcode: number; payload: string } | null {
export function parseWebSocketFrame(buffer: Buffer): { opcode: number; payload: string; frameLength: number } | null {
if (buffer.length < 2) return null;
const firstByte = buffer[0];
@@ -234,19 +244,25 @@ export function parseWebSocketFrame(buffer: Buffer): { opcode: number; payload:
let offset = 2;
if (payloadLength === 126) {
if (buffer.length < 4) return null;
payloadLength = buffer.readUInt16BE(2);
offset = 4;
} else if (payloadLength === 127) {
if (buffer.length < 10) return null;
payloadLength = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
let mask: Buffer | null = null;
if (isMasked) {
if (buffer.length < offset + 4) return null;
mask = buffer.slice(offset, offset + 4);
offset += 4;
}
const frameLength = offset + payloadLength;
if (buffer.length < frameLength) return null;
const payload = buffer.slice(offset, offset + payloadLength);
if (isMasked && mask) {
@@ -255,7 +271,7 @@ export function parseWebSocketFrame(buffer: Buffer): { opcode: number; payload:
}
}
return { opcode, payload: payload.toString('utf8') };
return { opcode, payload: payload.toString('utf8'), frameLength };
}
/**

View File

@@ -255,6 +255,32 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
},
},
});
// Submit/cancel actions for multi-select so users can choose multiple options before resolving
components.push({
id: 'submit-btn',
component: {
Button: {
onClick: { actionId: 'submit', parameters: { questionId: question.id } },
content: {
Text: { text: { literalString: 'Submit' } },
},
variant: 'primary',
},
},
});
components.push({
id: 'cancel-btn',
component: {
Button: {
onClick: { actionId: 'cancel', parameters: { questionId: question.id } },
content: {
Text: { text: { literalString: 'Cancel' } },
},
variant: 'secondary',
},
},
});
break;
}
@@ -284,6 +310,8 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
options: question.options,
required: question.required,
},
/** Display mode: 'popup' for centered dialog (interactive questions) */
displayMode: 'popup' as const,
},
};
}