mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat(a2ui): enhance A2UI notification handling and multi-select support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user