mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: add Unsplash search hook and API proxy routes
- Implemented `useUnsplashSearch` hook for searching Unsplash photos with debounce. - Created Unsplash API client functions for searching photos and triggering downloads. - Added proxy routes for Unsplash API to handle search requests and background image uploads. - Introduced accessibility utilities for WCAG compliance checks and motion preference management. - Developed theme sharing module for encoding and decoding theme configurations as base64url strings.
This commit is contained in:
@@ -65,9 +65,37 @@ export const QuestionAnswerSchema = z.object({
|
||||
|
||||
export type QuestionAnswer = z.infer<typeof QuestionAnswerSchema>;
|
||||
|
||||
// ========== AskUserQuestion-style Types ==========
|
||||
|
||||
/** AskUserQuestion-style option (value auto-generated from label) */
|
||||
export const SimpleOptionSchema = z.object({
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SimpleOption = z.infer<typeof SimpleOptionSchema>;
|
||||
|
||||
/** AskUserQuestion-style question */
|
||||
export const SimpleQuestionSchema = z.object({
|
||||
question: z.string(), // 问题文本 → 映射到 title
|
||||
header: z.string(), // 短标签 → 映射到 id
|
||||
multiSelect: z.boolean().default(false),
|
||||
options: z.array(SimpleOptionSchema).optional(),
|
||||
});
|
||||
|
||||
export type SimpleQuestion = z.infer<typeof SimpleQuestionSchema>;
|
||||
|
||||
/** 新格式参数 (questions 数组) */
|
||||
export const AskQuestionSimpleParamsSchema = z.object({
|
||||
questions: z.array(SimpleQuestionSchema).min(1).max(4),
|
||||
timeout: z.number().optional(),
|
||||
});
|
||||
|
||||
export type AskQuestionSimpleParams = z.infer<typeof AskQuestionSimpleParamsSchema>;
|
||||
|
||||
// ========== Ask Question Parameters ==========
|
||||
|
||||
/** Parameters for ask_question tool */
|
||||
/** Parameters for ask_question tool (legacy format) */
|
||||
export const AskQuestionParamsSchema = z.object({
|
||||
question: QuestionSchema,
|
||||
timeout: z.number().optional().default(300000), // 5 minutes default
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
// WebSocket transport for A2UI surfaces and actions
|
||||
|
||||
import type { Duplex } from 'stream';
|
||||
import http from 'http';
|
||||
import type { IncomingMessage } from 'http';
|
||||
import { createWebSocketFrame, parseWebSocketFrame, wsClients } from '../websocket.js';
|
||||
import type { QuestionAnswer, AskQuestionParams, Question } from './A2UITypes.js';
|
||||
|
||||
const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
|
||||
|
||||
// ========== A2UI Message Types ==========
|
||||
|
||||
/** A2UI WebSocket message types */
|
||||
@@ -60,8 +63,20 @@ export class A2UIWebSocketHandler {
|
||||
|
||||
private multiSelectSelections = new Map<string, Set<string>>();
|
||||
private singleSelectSelections = new Map<string, string>();
|
||||
private inputValues = new Map<string, string>();
|
||||
|
||||
/** Answers resolved by Dashboard but not yet consumed by MCP polling */
|
||||
private resolvedAnswers = new Map<string, { answer: QuestionAnswer; timestamp: number }>();
|
||||
private resolvedMultiAnswers = new Map<string, { compositeId: string; answers: QuestionAnswer[]; timestamp: number }>();
|
||||
|
||||
private answerCallback?: (answer: QuestionAnswer) => boolean;
|
||||
private multiAnswerCallback?: (compositeId: string, answers: QuestionAnswer[]) => boolean;
|
||||
|
||||
/** Buffered surfaces waiting to be replayed to newly connected clients */
|
||||
private pendingSurfaces: Array<{
|
||||
surfaceUpdate: { surfaceId: string; components: unknown[]; initialState: Record<string, unknown>; displayMode?: 'popup' | 'panel' };
|
||||
message: unknown;
|
||||
}> = [];
|
||||
|
||||
/**
|
||||
* Register callback for handling question answers
|
||||
@@ -71,6 +86,14 @@ export class A2UIWebSocketHandler {
|
||||
this.answerCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for handling multi-question composite answers (submit-all)
|
||||
* @param callback - Function to handle composite answers
|
||||
*/
|
||||
registerMultiAnswerCallback(callback: (compositeId: string, answers: QuestionAnswer[]) => boolean): void {
|
||||
this.multiAnswerCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the registered answer callback
|
||||
*/
|
||||
@@ -78,6 +101,20 @@ export class A2UIWebSocketHandler {
|
||||
return this.answerCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize multi-select tracking for a question (used by multi-page surfaces)
|
||||
*/
|
||||
initMultiSelect(questionId: string): void {
|
||||
this.multiSelectSelections.set(questionId, new Set<string>());
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize single-select tracking for a question (used by multi-page surfaces)
|
||||
*/
|
||||
initSingleSelect(questionId: string): void {
|
||||
this.singleSelectSelections.set(questionId, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send A2UI surface to all connected clients
|
||||
* @param surfaceUpdate - A2UI surface update to send
|
||||
@@ -115,6 +152,13 @@ export class A2UIWebSocketHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// No local WebSocket clients — forward via HTTP to Dashboard server
|
||||
// (Happens when running in MCP stdio process, separate from Dashboard)
|
||||
if (wsClients.size === 0) {
|
||||
this.forwardSurfaceViaDashboard(surfaceUpdate);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Broadcast to all clients
|
||||
const frame = createWebSocketFrame(message);
|
||||
let sentCount = 0;
|
||||
@@ -132,6 +176,72 @@ export class A2UIWebSocketHandler {
|
||||
return sentCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay buffered surfaces to a newly connected client, then clear the buffer.
|
||||
* @param client - The newly connected WebSocket client
|
||||
* @returns Number of surfaces replayed
|
||||
*/
|
||||
replayPendingSurfaces(client: Duplex): number {
|
||||
if (this.pendingSurfaces.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const count = this.pendingSurfaces.length;
|
||||
for (const { surfaceUpdate, message } of this.pendingSurfaces) {
|
||||
try {
|
||||
const frame = createWebSocketFrame(message);
|
||||
client.write(frame);
|
||||
} catch (e) {
|
||||
console.error(`[A2UI] Failed to replay surface ${surfaceUpdate.surfaceId}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[A2UI] Replayed ${count} buffered surface(s) to new client`);
|
||||
this.pendingSurfaces = [];
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward surface to Dashboard server via HTTP POST /api/hook.
|
||||
* Used when running in a separate process (MCP stdio) without local WebSocket clients.
|
||||
*/
|
||||
private forwardSurfaceViaDashboard(surfaceUpdate: {
|
||||
surfaceId: string;
|
||||
components: unknown[];
|
||||
initialState: Record<string, unknown>;
|
||||
displayMode?: 'popup' | 'panel';
|
||||
}): void {
|
||||
// Send flat so the hook handler wraps it as { type, payload: { ...fields } }
|
||||
// which matches the frontend's expected format: data.type === 'a2ui-surface' && data.payload
|
||||
const body = JSON.stringify({
|
||||
type: 'a2ui-surface',
|
||||
surfaceId: surfaceUpdate.surfaceId,
|
||||
components: surfaceUpdate.components,
|
||||
initialState: surfaceUpdate.initialState,
|
||||
displayMode: surfaceUpdate.displayMode,
|
||||
});
|
||||
|
||||
const req = http.request({
|
||||
hostname: 'localhost',
|
||||
port: DASHBOARD_PORT,
|
||||
path: '/api/hook',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error(`[A2UI] Failed to forward surface ${surfaceUpdate.surfaceId} to Dashboard:`, err.message);
|
||||
});
|
||||
|
||||
req.write(body);
|
||||
req.end();
|
||||
|
||||
console.log(`[A2UI] Forwarded surface ${surfaceUpdate.surfaceId} to Dashboard via HTTP`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send A2UI surface to specific client
|
||||
* @param client - Specific WebSocket client
|
||||
@@ -226,12 +336,16 @@ export class A2UIWebSocketHandler {
|
||||
|
||||
const resolveAndCleanup = (answer: QuestionAnswer): boolean => {
|
||||
const handled = answerCallback(answer);
|
||||
if (handled) {
|
||||
this.activeSurfaces.delete(questionId);
|
||||
this.multiSelectSelections.delete(questionId);
|
||||
this.singleSelectSelections.delete(questionId);
|
||||
if (!handled) {
|
||||
// answerCallback couldn't deliver (MCP process has no local pendingQuestions)
|
||||
// Store answer for HTTP polling retrieval
|
||||
this.resolvedAnswers.set(questionId, { answer, timestamp: Date.now() });
|
||||
}
|
||||
return handled;
|
||||
// Always clean up UI state regardless of delivery
|
||||
this.activeSurfaces.delete(questionId);
|
||||
this.multiSelectSelections.delete(questionId);
|
||||
this.singleSelectSelections.delete(questionId);
|
||||
return true;
|
||||
};
|
||||
|
||||
switch (action.actionId) {
|
||||
@@ -278,15 +392,88 @@ export class A2UIWebSocketHandler {
|
||||
}
|
||||
|
||||
case 'submit': {
|
||||
const otherText = this.inputValues.get(`__other__:${questionId}`);
|
||||
|
||||
// Check if this is a single-select or multi-select
|
||||
const singleSelection = this.singleSelectSelections.get(questionId);
|
||||
if (singleSelection !== undefined) {
|
||||
// Single-select submit
|
||||
return resolveAndCleanup({ questionId, value: singleSelection, cancelled: false });
|
||||
// Resolve __other__ to actual text input
|
||||
const value = singleSelection === '__other__' && otherText ? otherText : singleSelection;
|
||||
this.inputValues.delete(`__other__:${questionId}`);
|
||||
return resolveAndCleanup({ questionId, value, cancelled: false });
|
||||
}
|
||||
// Multi-select submit
|
||||
const multiSelected = this.multiSelectSelections.get(questionId) ?? new Set<string>();
|
||||
return resolveAndCleanup({ questionId, value: Array.from(multiSelected), cancelled: false });
|
||||
// Resolve __other__ in multi-select: replace with actual text
|
||||
const values = Array.from(multiSelected).map(v =>
|
||||
v === '__other__' && otherText ? otherText : v
|
||||
);
|
||||
this.inputValues.delete(`__other__:${questionId}`);
|
||||
return resolveAndCleanup({ questionId, value: values, cancelled: false });
|
||||
}
|
||||
|
||||
case 'input-change': {
|
||||
// Track text input value for multi-page surfaces
|
||||
const value = params.value;
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
this.inputValues.set(questionId, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'submit-all': {
|
||||
// Multi-question composite submit
|
||||
const compositeId = typeof params.compositeId === 'string' ? params.compositeId : undefined;
|
||||
const questionIds = Array.isArray(params.questionIds) ? params.questionIds as string[] : undefined;
|
||||
if (!compositeId || !questionIds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Collect answers for all sub-questions
|
||||
const answers: QuestionAnswer[] = [];
|
||||
for (const qId of questionIds) {
|
||||
const singleSel = this.singleSelectSelections.get(qId);
|
||||
const multiSel = this.multiSelectSelections.get(qId);
|
||||
const inputVal = this.inputValues.get(qId);
|
||||
const otherText = this.inputValues.get(`__other__:${qId}`);
|
||||
|
||||
if (singleSel !== undefined) {
|
||||
// Resolve __other__ to actual text input
|
||||
const value = singleSel === '__other__' && otherText ? otherText : singleSel;
|
||||
answers.push({ questionId: qId, value, cancelled: false });
|
||||
} else if (multiSel !== undefined) {
|
||||
// Resolve __other__ in multi-select: replace with actual text
|
||||
const values = Array.from(multiSel).map(v =>
|
||||
v === '__other__' && otherText ? otherText : v
|
||||
);
|
||||
answers.push({ questionId: qId, value: values, cancelled: false });
|
||||
} else if (inputVal !== undefined) {
|
||||
answers.push({ questionId: qId, value: inputVal, cancelled: false });
|
||||
} else {
|
||||
// No value recorded — include empty
|
||||
answers.push({ questionId: qId, value: '', cancelled: false });
|
||||
}
|
||||
|
||||
// Cleanup per-question tracking
|
||||
this.singleSelectSelections.delete(qId);
|
||||
this.multiSelectSelections.delete(qId);
|
||||
this.inputValues.delete(qId);
|
||||
this.inputValues.delete(`__other__:${qId}`);
|
||||
}
|
||||
|
||||
// Call multi-answer callback
|
||||
let handled = false;
|
||||
if (this.multiAnswerCallback) {
|
||||
handled = this.multiAnswerCallback(compositeId, answers);
|
||||
}
|
||||
if (!handled) {
|
||||
// Store for HTTP polling retrieval
|
||||
this.resolvedMultiAnswers.set(compositeId, { compositeId, answers, timestamp: Date.now() });
|
||||
}
|
||||
// Always clean up UI state
|
||||
this.activeSurfaces.delete(compositeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -324,6 +511,7 @@ export class A2UIWebSocketHandler {
|
||||
|
||||
this.activeSurfaces.delete(questionId);
|
||||
this.multiSelectSelections.delete(questionId);
|
||||
this.inputValues.delete(questionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -346,6 +534,32 @@ export class A2UIWebSocketHandler {
|
||||
this.activeSurfaces.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and remove a resolved answer (one-shot read).
|
||||
* Used by MCP HTTP polling to retrieve answers stored by the Dashboard.
|
||||
*/
|
||||
getResolvedAnswer(questionId: string): QuestionAnswer | undefined {
|
||||
const entry = this.resolvedAnswers.get(questionId);
|
||||
if (entry) {
|
||||
this.resolvedAnswers.delete(questionId);
|
||||
return entry.answer;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and remove a resolved multi-answer (one-shot read).
|
||||
* Used by MCP HTTP polling to retrieve composite answers stored by the Dashboard.
|
||||
*/
|
||||
getResolvedMultiAnswer(compositeId: string): QuestionAnswer[] | undefined {
|
||||
const entry = this.resolvedMultiAnswers.get(compositeId);
|
||||
if (entry) {
|
||||
this.resolvedMultiAnswers.delete(compositeId);
|
||||
return entry.answers;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stale surfaces (older than specified time)
|
||||
* @param maxAge - Maximum age in milliseconds
|
||||
@@ -362,6 +576,18 @@ export class A2UIWebSocketHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale resolved answers
|
||||
for (const [id, entry] of this.resolvedAnswers) {
|
||||
if (now - entry.timestamp > maxAge) {
|
||||
this.resolvedAnswers.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id, entry] of this.resolvedMultiAnswers) {
|
||||
if (now - entry.timestamp > maxAge) {
|
||||
this.resolvedMultiAnswers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,8 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boo
|
||||
|
||||
// Build CLI arguments based on index type
|
||||
// Use 'index init' subcommand (new CLI structure)
|
||||
const args = ['index', 'init', targetPath, '--json'];
|
||||
// --force flag ensures full reindex (not incremental)
|
||||
const args = ['index', 'init', targetPath, '--force', '--json'];
|
||||
if (resolvedIndexType === 'normal') {
|
||||
args.push('--no-embeddings');
|
||||
} else {
|
||||
@@ -380,8 +381,10 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boo
|
||||
}
|
||||
}
|
||||
|
||||
// Build CLI arguments for incremental update using 'index update' subcommand
|
||||
const args = ['index', 'update', targetPath, '--json'];
|
||||
// Build CLI arguments for incremental update using 'index init' without --force
|
||||
// 'index init' defaults to incremental mode (skip unchanged files)
|
||||
// 'index update' is only for single-file updates in hooks
|
||||
const args = ['index', 'init', targetPath, '--json'];
|
||||
if (resolvedIndexType === 'normal') {
|
||||
args.push('--no-embeddings');
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
executeCodexLens,
|
||||
installSemantic,
|
||||
} from '../../../tools/codex-lens.js';
|
||||
import { getCodexLensPython } from '../../../utils/codexlens-path.js';
|
||||
import { spawn } from 'child_process';
|
||||
import type { GpuMode } from '../../../tools/codex-lens.js';
|
||||
import { loadLiteLLMApiConfig, getAvailableModelsForType, getProvider, getAllProviders } from '../../../config/litellm-api-config-manager.js';
|
||||
import {
|
||||
@@ -19,6 +21,86 @@ import { extractJSON } from './utils.js';
|
||||
import { getDefaultTool } from '../../../tools/claude-cli-tools.js';
|
||||
import { getCodexLensDataDir } from '../../../utils/codexlens-path.js';
|
||||
|
||||
/**
|
||||
* Execute CodexLens Python API call directly (bypasses CLI for richer API access).
|
||||
*/
|
||||
async function executeCodexLensPythonAPI(
|
||||
apiFunction: string,
|
||||
args: Record<string, unknown>,
|
||||
timeout: number = 60000
|
||||
): Promise<{ success: boolean; results?: unknown; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const pythonScript = `
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import is_dataclass, asdict
|
||||
from codexlens.api import ${apiFunction}
|
||||
|
||||
def to_serializable(obj):
|
||||
if obj is None:
|
||||
return None
|
||||
if is_dataclass(obj) and not isinstance(obj, type):
|
||||
return asdict(obj)
|
||||
if isinstance(obj, list):
|
||||
return [to_serializable(item) for item in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {key: to_serializable(value) for key, value in obj.items()}
|
||||
if isinstance(obj, tuple):
|
||||
return tuple(to_serializable(item) for item in obj)
|
||||
return obj
|
||||
|
||||
try:
|
||||
args = ${JSON.stringify(args)}
|
||||
result = ${apiFunction}(**args)
|
||||
output = to_serializable(result)
|
||||
print(json.dumps({"success": True, "result": output}))
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}))
|
||||
sys.exit(1)
|
||||
`;
|
||||
|
||||
const pythonPath = getCodexLensPython();
|
||||
const child = spawn(pythonPath, ['-c', pythonScript], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
try {
|
||||
const errorData = JSON.parse(stderr || stdout);
|
||||
resolve({ success: false, error: errorData.error || 'Unknown error' });
|
||||
} catch {
|
||||
resolve({ success: false, error: stderr || stdout || `Process exited with code ${code}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
resolve({ success: data.success, results: data.result, error: data.error });
|
||||
} catch (err) {
|
||||
resolve({ success: false, error: `Failed to parse output: ${(err as Error).message}` });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
resolve({ success: false, error: `Failed to execute: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
|
||||
|
||||
@@ -928,5 +1010,154 @@ except Exception as e:
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LSP / SEMANTIC SEARCH API ENDPOINTS
|
||||
// ============================================================
|
||||
|
||||
// API: LSP Status - Check if LSP/semantic search capabilities are available
|
||||
if (pathname === '/api/codexlens/lsp/status') {
|
||||
try {
|
||||
const venvStatus = await checkVenvStatus();
|
||||
if (!venvStatus.ready) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
available: false,
|
||||
semantic_available: false,
|
||||
vector_index: false,
|
||||
error: 'CodexLens not installed'
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check semantic deps and vector index availability in parallel
|
||||
const [semanticStatus, workspaceResult] = await Promise.all([
|
||||
checkSemanticStatus(),
|
||||
executeCodexLens(['status', '--json'])
|
||||
]);
|
||||
|
||||
let hasVectorIndex = false;
|
||||
let projectCount = 0;
|
||||
let embeddingsInfo: Record<string, unknown> = {};
|
||||
|
||||
if (workspaceResult.success) {
|
||||
try {
|
||||
const status = extractJSON(workspaceResult.output ?? '');
|
||||
if (status.success !== false && status.result) {
|
||||
projectCount = status.result.projects_count || 0;
|
||||
embeddingsInfo = status.result.embeddings || {};
|
||||
// Check if any projects have embeddings
|
||||
hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
|
||||
} else if (status.projects_count !== undefined) {
|
||||
projectCount = status.projects_count || 0;
|
||||
embeddingsInfo = status.embeddings || {};
|
||||
hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
|
||||
}
|
||||
} catch {
|
||||
// Parse failed
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
available: semanticStatus.available && hasVectorIndex,
|
||||
semantic_available: semanticStatus.available,
|
||||
vector_index: hasVectorIndex,
|
||||
project_count: projectCount,
|
||||
embeddings: embeddingsInfo,
|
||||
modes: ['fusion', 'vector', 'structural'],
|
||||
strategies: ['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'],
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
available: false,
|
||||
semantic_available: false,
|
||||
vector_index: false,
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: LSP Semantic Search - Advanced semantic search via Python API
|
||||
if (pathname === '/api/codexlens/lsp/search' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const {
|
||||
query,
|
||||
path: projectPath,
|
||||
mode = 'fusion',
|
||||
fusion_strategy = 'rrf',
|
||||
vector_weight = 0.5,
|
||||
structural_weight = 0.3,
|
||||
keyword_weight = 0.2,
|
||||
kind_filter,
|
||||
limit = 20,
|
||||
include_match_reason = false,
|
||||
} = body as {
|
||||
query?: unknown;
|
||||
path?: unknown;
|
||||
mode?: unknown;
|
||||
fusion_strategy?: unknown;
|
||||
vector_weight?: unknown;
|
||||
structural_weight?: unknown;
|
||||
keyword_weight?: unknown;
|
||||
kind_filter?: unknown;
|
||||
limit?: unknown;
|
||||
include_match_reason?: unknown;
|
||||
};
|
||||
|
||||
const resolvedQuery = typeof query === 'string' ? query.trim() : '';
|
||||
if (!resolvedQuery) {
|
||||
return { success: false, error: 'Query parameter is required', status: 400 };
|
||||
}
|
||||
|
||||
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
|
||||
const resolvedMode = typeof mode === 'string' && ['fusion', 'vector', 'structural'].includes(mode) ? mode : 'fusion';
|
||||
const resolvedStrategy = typeof fusion_strategy === 'string' &&
|
||||
['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'].includes(fusion_strategy) ? fusion_strategy : 'rrf';
|
||||
const resolvedVectorWeight = typeof vector_weight === 'number' ? vector_weight : 0.5;
|
||||
const resolvedStructuralWeight = typeof structural_weight === 'number' ? structural_weight : 0.3;
|
||||
const resolvedKeywordWeight = typeof keyword_weight === 'number' ? keyword_weight : 0.2;
|
||||
const resolvedLimit = typeof limit === 'number' ? limit : 20;
|
||||
const resolvedIncludeReason = typeof include_match_reason === 'boolean' ? include_match_reason : false;
|
||||
|
||||
// Build Python API call args
|
||||
const apiArgs: Record<string, unknown> = {
|
||||
project_root: targetPath,
|
||||
query: resolvedQuery,
|
||||
mode: resolvedMode,
|
||||
vector_weight: resolvedVectorWeight,
|
||||
structural_weight: resolvedStructuralWeight,
|
||||
keyword_weight: resolvedKeywordWeight,
|
||||
fusion_strategy: resolvedStrategy,
|
||||
limit: resolvedLimit,
|
||||
include_match_reason: resolvedIncludeReason,
|
||||
};
|
||||
|
||||
if (Array.isArray(kind_filter) && kind_filter.length > 0) {
|
||||
apiArgs.kind_filter = kind_filter;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeCodexLensPythonAPI('semantic_search', apiArgs);
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
results: result.results,
|
||||
query: resolvedQuery,
|
||||
mode: resolvedMode,
|
||||
fusion_strategy: resolvedStrategy,
|
||||
count: Array.isArray(result.results) ? result.results.length : 0,
|
||||
};
|
||||
} else {
|
||||
return { success: false, error: result.error || 'Semantic search failed', status: 500 };
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1197,7 +1197,7 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
// Parse enabled tools from request body
|
||||
const enabledTools = Array.isArray(body.enabledTools) && body.enabledTools.length > 0
|
||||
? (body.enabledTools as string[]).join(',')
|
||||
: 'write_file,edit_file,read_file,core_memory,ask_question';
|
||||
: 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
|
||||
|
||||
// Generate CCW MCP server config
|
||||
// Use cmd /c on Windows to inherit Claude Code's working directory
|
||||
|
||||
@@ -648,5 +648,104 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Test multi-page ask_question popup (for development testing)
|
||||
if (pathname === '/api/test/ask-question-multi' && req.method === 'GET') {
|
||||
try {
|
||||
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
|
||||
|
||||
const compositeId = `multi-${Date.now()}`;
|
||||
const surfaceId = `question-${compositeId}`;
|
||||
|
||||
const testSurface = {
|
||||
surfaceId,
|
||||
components: [
|
||||
// Page 0: Select question
|
||||
{ id: 'page-0-title', page: 0, component: { Text: { text: { literalString: 'Which framework?' }, usageHint: 'h3' } } },
|
||||
{ id: 'page-0-radio-group', page: 0, component: {
|
||||
RadioGroup: {
|
||||
options: [
|
||||
{ label: { literalString: 'React' }, value: 'React', description: { literalString: 'UI library' } },
|
||||
{ label: { literalString: 'Vue' }, value: 'Vue', description: { literalString: 'Progressive framework' } },
|
||||
{ label: { literalString: 'Other' }, value: '__other__', description: { literalString: 'Provide a custom answer' } },
|
||||
],
|
||||
onChange: { actionId: 'select', parameters: { questionId: 'Framework' } },
|
||||
},
|
||||
}},
|
||||
// Page 1: Multi-select question
|
||||
{ id: 'page-1-title', page: 1, component: { Text: { text: { literalString: 'Which features?' }, usageHint: 'h3' } } },
|
||||
{ id: 'page-1-checkbox-0', page: 1, component: {
|
||||
Checkbox: { label: { literalString: 'Auth' }, description: { literalString: 'Authentication' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: 'Auth' } }, checked: { literalBoolean: false } },
|
||||
}},
|
||||
{ id: 'page-1-checkbox-1', page: 1, component: {
|
||||
Checkbox: { label: { literalString: 'Cache' }, description: { literalString: 'Caching layer' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: 'Cache' } }, checked: { literalBoolean: false } },
|
||||
}},
|
||||
{ id: 'page-1-checkbox-other', page: 1, component: {
|
||||
Checkbox: { label: { literalString: 'Other' }, description: { literalString: 'Provide a custom answer' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: '__other__' } }, checked: { literalBoolean: false } },
|
||||
}},
|
||||
],
|
||||
initialState: {
|
||||
questionId: compositeId,
|
||||
questionType: 'multi-question',
|
||||
pages: [
|
||||
{ index: 0, questionId: 'Framework', title: 'Which framework?', type: 'select' },
|
||||
{ index: 1, questionId: 'Features', title: 'Which features?', type: 'multi-select' },
|
||||
],
|
||||
totalPages: 2,
|
||||
},
|
||||
displayMode: 'popup' as const,
|
||||
};
|
||||
|
||||
const sentCount = a2uiWebSocketHandler.sendSurface(testSurface);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Multi-page test popup sent',
|
||||
sentToClients: sentCount,
|
||||
surfaceId,
|
||||
compositeId,
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to send test popup', details: String(err) }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: A2UI answer broker — retrieve answers stored for MCP polling
|
||||
if (pathname === '/api/a2ui/answer' && req.method === 'GET') {
|
||||
const questionId = url.searchParams.get('questionId');
|
||||
const isComposite = url.searchParams.get('composite') === 'true';
|
||||
|
||||
if (!questionId) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'questionId is required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
|
||||
|
||||
if (isComposite) {
|
||||
const answers = a2uiWebSocketHandler.getResolvedMultiAnswer(questionId);
|
||||
if (answers) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ pending: false, answers }));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ pending: true }));
|
||||
}
|
||||
} else {
|
||||
const answer = a2uiWebSocketHandler.getResolvedAnswer(questionId);
|
||||
if (answer) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ pending: false, answer }));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ pending: true }));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
238
ccw/src/core/routes/unsplash-routes.ts
Normal file
238
ccw/src/core/routes/unsplash-routes.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Unsplash Proxy Routes & Background Image Upload
|
||||
* Proxies Unsplash API requests to keep API key server-side.
|
||||
* API key is read from process.env.UNSPLASH_ACCESS_KEY.
|
||||
* Also handles local background image upload and serving.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { homedir } from 'os';
|
||||
import type { RouteContext } from './types.js';
|
||||
|
||||
const UNSPLASH_API = 'https://api.unsplash.com';
|
||||
|
||||
// Background upload config
|
||||
const UPLOADS_DIR = join(homedir(), '.ccw', 'uploads', 'backgrounds');
|
||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
const EXT_MAP: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
};
|
||||
|
||||
function getAccessKey(): string | undefined {
|
||||
return process.env.UNSPLASH_ACCESS_KEY;
|
||||
}
|
||||
|
||||
interface UnsplashPhoto {
|
||||
id: string;
|
||||
urls: { thumb: string; small: string; regular: string };
|
||||
user: { name: string; links: { html: string } };
|
||||
links: { html: string; download_location: string };
|
||||
blur_hash: string | null;
|
||||
}
|
||||
|
||||
interface UnsplashSearchResult {
|
||||
results: UnsplashPhoto[];
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export async function handleBackgroundRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res } = ctx;
|
||||
|
||||
// POST /api/background/upload
|
||||
if (pathname === '/api/background/upload' && req.method === 'POST') {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (!ALLOWED_TYPES.has(contentType)) {
|
||||
res.writeHead(415, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unsupported image type. Only JPEG, PNG, WebP, GIF allowed.' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_UPLOAD_SIZE) {
|
||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'File too large. Maximum size is 10MB.' }));
|
||||
return true;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const ext = EXT_MAP[contentType] || 'bin';
|
||||
const filename = `${Date.now()}-${randomBytes(4).toString('hex')}.${ext}`;
|
||||
|
||||
mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
writeFileSync(join(UPLOADS_DIR, filename), buffer);
|
||||
|
||||
const url = `/api/background/uploads/${filename}`;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ url, filename }));
|
||||
} catch (err) {
|
||||
console.error('[background] Upload error:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Upload failed' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/background/uploads/:filename
|
||||
if (pathname.startsWith('/api/background/uploads/') && req.method === 'GET') {
|
||||
const filename = pathname.slice('/api/background/uploads/'.length);
|
||||
|
||||
// Security: reject path traversal
|
||||
if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid filename' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = join(UPLOADS_DIR, filename);
|
||||
if (!existsSync(filePath)) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'File not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
const mime = MIME_MAP[ext] || 'application/octet-stream';
|
||||
|
||||
try {
|
||||
const data = readFileSync(filePath);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mime,
|
||||
'Content-Length': data.length,
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
});
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
console.error('[background] Serve error:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to read file' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function handleUnsplashRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res } = ctx;
|
||||
|
||||
// GET /api/unsplash/search?query=...&page=1&per_page=20
|
||||
if (pathname === '/api/unsplash/search' && req.method === 'GET') {
|
||||
const accessKey = getAccessKey();
|
||||
if (!accessKey) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const query = url.searchParams.get('query') || '';
|
||||
const page = url.searchParams.get('page') || '1';
|
||||
const perPage = url.searchParams.get('per_page') || '20';
|
||||
|
||||
if (!query.trim()) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing query parameter' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiUrl = `${UNSPLASH_API}/search/photos?query=${encodeURIComponent(query)}&page=${page}&per_page=${perPage}&orientation=landscape`;
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: { Authorization: `Client-ID ${accessKey}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const status = response.status;
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: `Unsplash API error: ${status}` }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as UnsplashSearchResult;
|
||||
|
||||
// Return simplified data
|
||||
const photos = data.results.map((photo) => ({
|
||||
id: photo.id,
|
||||
thumbUrl: photo.urls.thumb,
|
||||
smallUrl: photo.urls.small,
|
||||
regularUrl: photo.urls.regular,
|
||||
photographer: photo.user.name,
|
||||
photographerUrl: photo.user.links.html,
|
||||
photoUrl: photo.links.html,
|
||||
blurHash: photo.blur_hash,
|
||||
downloadLocation: photo.links.download_location,
|
||||
}));
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
photos,
|
||||
total: data.total,
|
||||
totalPages: data.total_pages,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[unsplash] Search error:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to search Unsplash' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/unsplash/download — trigger download event (Unsplash API requirement)
|
||||
if (pathname === '/api/unsplash/download' && req.method === 'POST') {
|
||||
const accessKey = getAccessKey();
|
||||
if (!accessKey) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString());
|
||||
const downloadLocation = body.downloadLocation;
|
||||
|
||||
if (!downloadLocation) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing downloadLocation' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trigger download event (Unsplash API guideline)
|
||||
await fetch(downloadLocation, {
|
||||
headers: { Authorization: `Client-ID ${accessKey}` },
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} catch (err) {
|
||||
console.error('[unsplash] Download trigger error:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to trigger download' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { handleMemoryRoutes } from './routes/memory-routes.js';
|
||||
import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js';
|
||||
import { handleMcpRoutes } from './routes/mcp-routes.js';
|
||||
import { handleHooksRoutes } from './routes/hooks-routes.js';
|
||||
import { handleUnsplashRoutes, handleBackgroundRoutes } from './routes/unsplash-routes.js';
|
||||
import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
|
||||
import { handleGraphRoutes } from './routes/graph-routes.js';
|
||||
import { handleSystemRoutes } from './routes/system-routes.js';
|
||||
@@ -461,7 +462,7 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
const tokenManager = getTokenManager();
|
||||
const secretKey = tokenManager.getSecretKey();
|
||||
tokenManager.getOrCreateAuthToken();
|
||||
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question']);
|
||||
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question', '/api/a2ui/answer']);
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
|
||||
@@ -627,6 +628,16 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleHooksRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Background image upload/serve routes (/api/background/*)
|
||||
if (pathname.startsWith('/api/background/')) {
|
||||
if (await handleBackgroundRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Unsplash proxy routes (/api/unsplash/*)
|
||||
if (pathname.startsWith('/api/unsplash/')) {
|
||||
if (await handleUnsplashRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// CodexLens routes (/api/codexlens/*)
|
||||
if (pathname.startsWith('/api/codexlens/')) {
|
||||
if (await handleCodexLensRoutes(routeContext)) return;
|
||||
@@ -728,11 +739,12 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleFilesRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// System routes (data, health, version, paths, shutdown, notify, storage, dialog)
|
||||
// System routes (data, health, version, paths, shutdown, notify, storage, dialog, a2ui answer broker)
|
||||
if (pathname === '/api/data' || pathname === '/api/health' ||
|
||||
pathname === '/api/version-check' || pathname === '/api/shutdown' ||
|
||||
pathname === '/api/recent-paths' || pathname === '/api/switch-path' ||
|
||||
pathname === '/api/remove-recent-path' || pathname === '/api/system/notify' ||
|
||||
pathname === '/api/a2ui/answer' ||
|
||||
pathname.startsWith('/api/storage/') || pathname.startsWith('/api/dialog/')) {
|
||||
if (await handleSystemRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
@@ -164,6 +164,9 @@ export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _he
|
||||
wsClients.add(socket);
|
||||
console.log(`[WS] Client connected (${wsClients.size} total)`);
|
||||
|
||||
// Replay any buffered A2UI surfaces to the new client
|
||||
a2uiWebSocketHandler.replayPendingSurfaces(socket);
|
||||
|
||||
// Handle incoming messages
|
||||
let pendingBuffer = Buffer.alloc(0);
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ const SERVER_VERSION = '6.2.0';
|
||||
const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT';
|
||||
const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS';
|
||||
|
||||
// Default enabled tools (core set - file operations and core memory only)
|
||||
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'core_memory'];
|
||||
// Default enabled tools (core set - file operations, core memory, and smart search)
|
||||
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'core_memory', 'smart_search'];
|
||||
|
||||
/**
|
||||
* Get list of enabled tools from environment or defaults
|
||||
|
||||
@@ -7,16 +7,18 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
execute,
|
||||
handleAnswer,
|
||||
handleMultiAnswer,
|
||||
cancelQuestion,
|
||||
getPendingQuestions,
|
||||
clearPendingQuestions,
|
||||
} from '../tools/ask-question';
|
||||
handler,
|
||||
} from '../ask-question';
|
||||
import type {
|
||||
Question,
|
||||
QuestionAnswer,
|
||||
AskQuestionParams,
|
||||
AskQuestionResult,
|
||||
} from '../core/a2ui/A2UITypes';
|
||||
} from '../../core/a2ui/A2UITypes';
|
||||
|
||||
describe('ask_question Tool', () => {
|
||||
beforeEach(() => {
|
||||
@@ -44,8 +46,11 @@ describe('ask_question Tool', () => {
|
||||
};
|
||||
|
||||
// Should not throw during validation
|
||||
const result = await execute(params);
|
||||
expect(result).toBeDefined();
|
||||
const executePromise = execute(params);
|
||||
expect(getPendingQuestions()).toHaveLength(1);
|
||||
|
||||
cancelQuestion('test-question-1');
|
||||
await executePromise;
|
||||
});
|
||||
|
||||
it('should validate a valid select question with options', async () => {
|
||||
@@ -60,9 +65,11 @@ describe('ask_question Tool', () => {
|
||||
};
|
||||
|
||||
const params: AskQuestionParams = { question };
|
||||
const result = await execute(params);
|
||||
const executePromise = execute(params);
|
||||
expect(getPendingQuestions()).toHaveLength(1);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
cancelQuestion('test-select');
|
||||
await executePromise;
|
||||
});
|
||||
|
||||
it('should validate a valid input question', async () => {
|
||||
@@ -74,9 +81,11 @@ describe('ask_question Tool', () => {
|
||||
};
|
||||
|
||||
const params: AskQuestionParams = { question };
|
||||
const result = await execute(params);
|
||||
const executePromise = execute(params);
|
||||
expect(getPendingQuestions()).toHaveLength(1);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
cancelQuestion('test-input');
|
||||
await executePromise;
|
||||
});
|
||||
|
||||
it('should validate a valid multi-select question', async () => {
|
||||
@@ -92,9 +101,11 @@ describe('ask_question Tool', () => {
|
||||
};
|
||||
|
||||
const params: AskQuestionParams = { question };
|
||||
const result = await execute(params);
|
||||
const executePromise = execute(params);
|
||||
expect(getPendingQuestions()).toHaveLength(1);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
cancelQuestion('test-multi');
|
||||
await executePromise;
|
||||
});
|
||||
|
||||
it('should reject question with missing id', async () => {
|
||||
@@ -162,7 +173,7 @@ describe('ask_question Tool', () => {
|
||||
const result = await execute(params);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('options');
|
||||
expect(result.error).toContain('option');
|
||||
});
|
||||
|
||||
it('should reject options with missing value', async () => {
|
||||
@@ -554,10 +565,9 @@ describe('ask_question Tool', () => {
|
||||
id: 'test-timeout',
|
||||
type: 'confirm',
|
||||
title: 'Test',
|
||||
timeout: 5000, // 5 seconds
|
||||
};
|
||||
|
||||
const params: AskQuestionParams = { question };
|
||||
const params: AskQuestionParams = { question, timeout: 5000 };
|
||||
const executePromise = execute(params);
|
||||
|
||||
// Fast-forward time
|
||||
@@ -658,7 +668,8 @@ describe('ask_question Tool', () => {
|
||||
};
|
||||
|
||||
const params1: AskQuestionParams = { question };
|
||||
const executePromise1 = execute(params1);
|
||||
// Don't await — first promise becomes orphaned when id is reused
|
||||
execute(params1);
|
||||
|
||||
// Second execution with same ID should replace first
|
||||
const question2: Question = {
|
||||
@@ -671,10 +682,11 @@ describe('ask_question Tool', () => {
|
||||
|
||||
// There should still be only one pending
|
||||
expect(getPendingQuestions()).toHaveLength(1);
|
||||
expect(getPendingQuestions()[0].question.title).toBe('Second');
|
||||
|
||||
// Clean up
|
||||
// Clean up the active pending question
|
||||
cancelQuestion('duplicate-id');
|
||||
await Promise.all([executePromise1, executePromise2]);
|
||||
await executePromise2;
|
||||
});
|
||||
|
||||
it('should handle answer after question is cancelled', async () => {
|
||||
@@ -730,4 +742,204 @@ describe('ask_question Tool', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AskUserQuestion-style Format (via handler)', () => {
|
||||
it('should handle single select question', async () => {
|
||||
const params = {
|
||||
questions: [{
|
||||
question: 'Which library?',
|
||||
header: 'Library',
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: 'React', description: 'UI library' },
|
||||
{ label: 'Vue', description: 'Progressive framework' },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
const handlerPromise = handler(params);
|
||||
|
||||
// Answer the normalized question (id = header)
|
||||
const pending = getPendingQuestions();
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].id).toBe('Library');
|
||||
expect(pending[0].question.type).toBe('select');
|
||||
expect(pending[0].question.title).toBe('Which library?');
|
||||
|
||||
// Options should use label as value
|
||||
expect(pending[0].question.options).toEqual([
|
||||
{ value: 'React', label: 'React', description: 'UI library' },
|
||||
{ value: 'Vue', label: 'Vue', description: 'Progressive framework' },
|
||||
]);
|
||||
|
||||
const answer: QuestionAnswer = {
|
||||
questionId: 'Library',
|
||||
value: 'React',
|
||||
};
|
||||
handleAnswer(answer);
|
||||
|
||||
const result = await handlerPromise;
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.result as any).answersDict).toEqual({ Library: 'React' });
|
||||
});
|
||||
|
||||
it('should handle multiSelect question', async () => {
|
||||
const params = {
|
||||
questions: [{
|
||||
question: 'Which features?',
|
||||
header: 'Features',
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ label: 'Auth', description: 'Authentication' },
|
||||
{ label: 'Cache', description: 'Caching layer' },
|
||||
{ label: 'Logging' },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
const handlerPromise = handler(params);
|
||||
|
||||
const pending = getPendingQuestions();
|
||||
expect(pending[0].question.type).toBe('multi-select');
|
||||
|
||||
const answer: QuestionAnswer = {
|
||||
questionId: 'Features',
|
||||
value: ['Auth', 'Logging'],
|
||||
};
|
||||
handleAnswer(answer);
|
||||
|
||||
const result = await handlerPromise;
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.result as any).answersDict).toEqual({ Features: ['Auth', 'Logging'] });
|
||||
});
|
||||
|
||||
it('should handle input question (no options)', async () => {
|
||||
const params = {
|
||||
questions: [{
|
||||
question: 'What is your name?',
|
||||
header: 'Name',
|
||||
multiSelect: false,
|
||||
}],
|
||||
};
|
||||
|
||||
const handlerPromise = handler(params);
|
||||
|
||||
const pending = getPendingQuestions();
|
||||
expect(pending[0].question.type).toBe('input');
|
||||
|
||||
const answer: QuestionAnswer = {
|
||||
questionId: 'Name',
|
||||
value: 'John',
|
||||
};
|
||||
handleAnswer(answer);
|
||||
|
||||
const result = await handlerPromise;
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.result as any).answersDict).toEqual({ Name: 'John' });
|
||||
});
|
||||
|
||||
it('should handle multiple questions in single multi-page surface', async () => {
|
||||
const params = {
|
||||
questions: [
|
||||
{
|
||||
question: 'Which library?',
|
||||
header: 'Library',
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: 'React' },
|
||||
{ label: 'Vue' },
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Which level?',
|
||||
header: 'Level',
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: 'Beginner' },
|
||||
{ label: 'Advanced' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const handlerPromise = handler(params);
|
||||
|
||||
// A single composite question should be pending
|
||||
const pending = getPendingQuestions();
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].id).toMatch(/^multi-/);
|
||||
|
||||
const compositeId = pending[0].id;
|
||||
|
||||
// Simulate submit-all with answers for all pages
|
||||
handleMultiAnswer(compositeId, [
|
||||
{ questionId: 'Library', value: 'React', cancelled: false },
|
||||
{ questionId: 'Level', value: 'Advanced', cancelled: false },
|
||||
]);
|
||||
|
||||
const result = await handlerPromise;
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.result as any).answersDict).toEqual({
|
||||
Library: 'React',
|
||||
Level: 'Advanced',
|
||||
});
|
||||
});
|
||||
|
||||
it('should cancel multi-question composite on cancel', async () => {
|
||||
const params = {
|
||||
questions: [
|
||||
{
|
||||
question: 'First?',
|
||||
header: 'Q1',
|
||||
multiSelect: false,
|
||||
options: [{ label: 'A' }],
|
||||
},
|
||||
{
|
||||
question: 'Second?',
|
||||
header: 'Q2',
|
||||
multiSelect: false,
|
||||
options: [{ label: 'B' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const handlerPromise = handler(params);
|
||||
|
||||
// Cancel the composite question
|
||||
const pending = getPendingQuestions();
|
||||
expect(pending).toHaveLength(1);
|
||||
cancelQuestion(pending[0].id);
|
||||
|
||||
const result = await handlerPromise;
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result?.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('should still support legacy format via handler', async () => {
|
||||
const params = {
|
||||
question: {
|
||||
id: 'legacy-test',
|
||||
type: 'confirm',
|
||||
title: 'Legacy question?',
|
||||
},
|
||||
};
|
||||
|
||||
const handlerPromise = handler(params as any);
|
||||
|
||||
const pending = getPendingQuestions();
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].id).toBe('legacy-test');
|
||||
|
||||
const answer: QuestionAnswer = {
|
||||
questionId: 'legacy-test',
|
||||
value: true,
|
||||
};
|
||||
handleAnswer(answer);
|
||||
|
||||
const result = await handlerPromise;
|
||||
expect(result.success).toBe(true);
|
||||
// Legacy format should NOT have answersDict
|
||||
expect((result.result as any).answersDict).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,9 +13,19 @@ import type {
|
||||
AskQuestionParams,
|
||||
AskQuestionResult,
|
||||
PendingQuestion,
|
||||
SimpleQuestion,
|
||||
} from '../core/a2ui/A2UITypes.js';
|
||||
import http from 'http';
|
||||
import { a2uiWebSocketHandler } from '../core/a2ui/A2UIWebSocketHandler.js';
|
||||
|
||||
const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
|
||||
// Register multi-answer callback for multi-page question surfaces
|
||||
a2uiWebSocketHandler.registerMultiAnswerCallback(
|
||||
(compositeId: string, answers: QuestionAnswer[]) => handleMultiAnswer(compositeId, answers)
|
||||
);
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
/** Default question timeout (5 minutes) */
|
||||
@@ -114,6 +124,10 @@ function validateAnswer(question: Question, answer: QuestionAnswer): boolean {
|
||||
if (!question.options) {
|
||||
return false;
|
||||
}
|
||||
// Accept __other__ as a valid value (custom input)
|
||||
if (answer.value === '__other__' || answer.value.startsWith('__other__:')) {
|
||||
return true;
|
||||
}
|
||||
return question.options.some((opt) => opt.value === answer.value);
|
||||
|
||||
case 'multi-select':
|
||||
@@ -124,13 +138,51 @@ function validateAnswer(question: Question, answer: QuestionAnswer): boolean {
|
||||
return false;
|
||||
}
|
||||
const validValues = new Set(question.options.map((opt) => opt.value));
|
||||
return answer.value.every((v) => validValues.has(v));
|
||||
// Accept __other__ as a valid value (custom input)
|
||||
validValues.add('__other__');
|
||||
return answer.value.every((v) => typeof v === 'string' && (validValues.has(v) || v.startsWith('__other__:')));
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Simple Format Normalization ==========
|
||||
|
||||
/**
|
||||
* Normalize a SimpleQuestion (AskUserQuestion-style) to internal Question format
|
||||
* @param simple - SimpleQuestion to normalize
|
||||
* @returns Normalized Question
|
||||
*/
|
||||
function normalizeSimpleQuestion(simple: SimpleQuestion): Question {
|
||||
let type: QuestionType;
|
||||
if (simple.options && simple.options.length > 0) {
|
||||
type = simple.multiSelect ? 'multi-select' : 'select';
|
||||
} else {
|
||||
type = 'input';
|
||||
}
|
||||
|
||||
const options: QuestionOption[] | undefined = simple.options?.map((opt) => ({
|
||||
value: opt.label,
|
||||
label: opt.label,
|
||||
description: opt.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: simple.header,
|
||||
type,
|
||||
title: simple.question,
|
||||
options,
|
||||
} as Question;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if params use the new "questions" array format
|
||||
*/
|
||||
function isSimpleFormat(params: Record<string, unknown>): params is { questions: SimpleQuestion[]; timeout?: number } {
|
||||
return Array.isArray(params.questions);
|
||||
}
|
||||
|
||||
// ========== A2UI Surface Generation ==========
|
||||
|
||||
/**
|
||||
@@ -223,6 +275,13 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
|
||||
description: opt.description ? { literalString: opt.description } : undefined,
|
||||
})) || [];
|
||||
|
||||
// Add "Other" option for custom input
|
||||
options.push({
|
||||
label: { literalString: 'Other' },
|
||||
value: '__other__',
|
||||
description: { literalString: 'Provide a custom answer' },
|
||||
});
|
||||
|
||||
// Use RadioGroup for direct selection display (not dropdown)
|
||||
components.push({
|
||||
id: 'radio-group',
|
||||
@@ -267,6 +326,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
|
||||
const options = question.options?.map((opt) => ({
|
||||
label: { literalString: opt.label },
|
||||
value: opt.value,
|
||||
description: opt.description ? { literalString: opt.description } : undefined,
|
||||
})) || [];
|
||||
|
||||
// Add each checkbox as a separate component for better layout control
|
||||
@@ -276,6 +336,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
|
||||
component: {
|
||||
Checkbox: {
|
||||
label: opt.label,
|
||||
...(opt.description && { description: opt.description }),
|
||||
onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: opt.value } },
|
||||
checked: { literalBoolean: false },
|
||||
},
|
||||
@@ -283,6 +344,19 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
|
||||
});
|
||||
});
|
||||
|
||||
// Add "Other" checkbox for custom input
|
||||
components.push({
|
||||
id: 'checkbox-other',
|
||||
component: {
|
||||
Checkbox: {
|
||||
label: { literalString: 'Other' },
|
||||
description: { literalString: 'Provide a custom answer' },
|
||||
onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: '__other__' } },
|
||||
checked: { literalBoolean: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Submit/cancel actions for multi-select so users can choose multiple options before resolving
|
||||
components.push({
|
||||
id: 'submit-btn',
|
||||
@@ -390,7 +464,12 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
|
||||
|
||||
// Send A2UI surface via WebSocket to frontend
|
||||
const a2uiSurface = generateQuestionSurface(question, surfaceId);
|
||||
a2uiWebSocketHandler.sendSurface(a2uiSurface.surfaceUpdate);
|
||||
const sentCount = a2uiWebSocketHandler.sendSurface(a2uiSurface.surfaceUpdate);
|
||||
|
||||
// If no local WS clients, start HTTP polling for answer from Dashboard
|
||||
if (sentCount === 0) {
|
||||
startAnswerPolling(question.id);
|
||||
}
|
||||
|
||||
// Wait for answer
|
||||
const result = await resultPromise;
|
||||
@@ -440,6 +519,85 @@ export function handleAnswer(answer: QuestionAnswer): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle multi-question composite answer from frontend (submit-all)
|
||||
* @param compositeId - The composite question ID (multi-xxx)
|
||||
* @param answers - Array of answers for each page
|
||||
* @returns True if answer was processed
|
||||
*/
|
||||
export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]): boolean {
|
||||
const pending = pendingQuestions.get(compositeId);
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
|
||||
pending.resolve({
|
||||
success: true,
|
||||
surfaceId: pending.surfaceId,
|
||||
cancelled: false,
|
||||
answers,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
pendingQuestions.delete(compositeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== Answer Polling (MCP stdio mode) ==========
|
||||
|
||||
/**
|
||||
* Poll Dashboard server for answers when running in a separate MCP process.
|
||||
* Starts polling GET /api/a2ui/answer and resolves the pending promise when an answer arrives.
|
||||
* Automatically stops when the questionId is no longer in pendingQuestions (timeout cleanup).
|
||||
*/
|
||||
function startAnswerPolling(questionId: string, isComposite: boolean = false): void {
|
||||
const path = `/api/a2ui/answer?questionId=${encodeURIComponent(questionId)}&composite=${isComposite}`;
|
||||
|
||||
const poll = () => {
|
||||
// Stop if the question was already resolved or timed out
|
||||
if (!pendingQuestions.has(questionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const req = http.get({ hostname: 'localhost', port: DASHBOARD_PORT, path }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.pending) {
|
||||
// No answer yet, schedule next poll
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isComposite && Array.isArray(parsed.answers)) {
|
||||
handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]);
|
||||
} else if (!isComposite && parsed.answer) {
|
||||
handleAnswer(parsed.answer as QuestionAnswer);
|
||||
} else {
|
||||
// Unexpected shape, keep polling
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
} catch {
|
||||
// Parse error, keep polling
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
// Network error (Dashboard not reachable), keep trying
|
||||
if (pendingQuestions.has(questionId)) {
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Start first poll after a short delay to give the Dashboard time to receive the surface
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ========== Cleanup ==========
|
||||
|
||||
/**
|
||||
@@ -488,12 +646,70 @@ export function clearPendingQuestions(): void {
|
||||
|
||||
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.',
|
||||
description: `Ask the user a question through an interactive A2UI interface. Supports two calling styles:
|
||||
|
||||
**Style 1 - AskUserQuestion-compatible (recommended)**:
|
||||
\`\`\`json
|
||||
{
|
||||
"questions": [{
|
||||
"question": "Which library?",
|
||||
"header": "Library",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{ "label": "React", "description": "UI library" },
|
||||
{ "label": "Vue", "description": "Progressive framework" }
|
||||
]
|
||||
}]
|
||||
}
|
||||
\`\`\`
|
||||
Response includes \`answersDict\`: \`{ "Library": "React" }\`
|
||||
|
||||
Type inference: options + multiSelect=true → multi-select; options + multiSelect=false → select; no options → input.
|
||||
|
||||
**Style 2 - Legacy format**:
|
||||
\`\`\`json
|
||||
{
|
||||
"question": {
|
||||
"id": "q1",
|
||||
"type": "select",
|
||||
"title": "Which library?",
|
||||
"options": [{ "value": "react", "label": "React" }]
|
||||
}
|
||||
}
|
||||
\`\`\``,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
questions: {
|
||||
type: 'array',
|
||||
description: 'AskUserQuestion-style questions array (1-4 questions). Use this OR "question", not both.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: { type: 'string', description: 'The question text' },
|
||||
header: { type: 'string', description: 'Short label, also used as response key (max 12 chars)' },
|
||||
multiSelect: { type: 'boolean', description: 'Allow multiple selections (default: false)' },
|
||||
options: {
|
||||
type: 'array',
|
||||
description: 'Available choices. Omit for text input.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', description: 'Display text, also used as value' },
|
||||
description: { type: 'string', description: 'Option description' },
|
||||
},
|
||||
required: ['label'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['question', 'header'],
|
||||
},
|
||||
minItems: 1,
|
||||
maxItems: 4,
|
||||
},
|
||||
question: {
|
||||
type: 'object',
|
||||
description: 'Legacy format: single question object. Use this OR "questions", not both.',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Unique identifier for this question' },
|
||||
type: {
|
||||
@@ -524,16 +740,343 @@ export const schema: ToolSchema = {
|
||||
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)' },
|
||||
surfaceId: { type: 'string', description: 'Custom surface ID (auto-generated if not provided). Legacy format only.' },
|
||||
},
|
||||
required: ['question'],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool handler for MCP integration
|
||||
* Wraps the execute function to match the expected handler signature
|
||||
* Supports both legacy format (question object) and AskUserQuestion-style format (questions array)
|
||||
*/
|
||||
export async function handler(params: Record<string, unknown>): Promise<ToolResult<AskQuestionResult>> {
|
||||
if (isSimpleFormat(params)) {
|
||||
return executeSimpleFormat(params.questions, params.timeout);
|
||||
}
|
||||
return execute(params as AskQuestionParams);
|
||||
}
|
||||
|
||||
// ========== Multi-Question Surface Generation ==========
|
||||
|
||||
/**
|
||||
* Page metadata for multi-question surfaces
|
||||
*/
|
||||
interface PageMeta {
|
||||
index: number;
|
||||
questionId: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single A2UI surface containing all questions, each tagged with a page index.
|
||||
* @param questions - Array of SimpleQuestion
|
||||
* @returns Surface update with page-tagged components and page metadata
|
||||
*/
|
||||
function generateMultiQuestionSurface(
|
||||
questions: SimpleQuestion[],
|
||||
surfaceId: string,
|
||||
): {
|
||||
surfaceUpdate: {
|
||||
surfaceId: string;
|
||||
components: unknown[];
|
||||
initialState: Record<string, unknown>;
|
||||
displayMode: 'popup';
|
||||
};
|
||||
pages: PageMeta[];
|
||||
} {
|
||||
const components: unknown[] = [];
|
||||
const pages: PageMeta[] = [];
|
||||
|
||||
for (let pageIdx = 0; pageIdx < questions.length; pageIdx++) {
|
||||
const simpleQ = questions[pageIdx];
|
||||
const question = normalizeSimpleQuestion(simpleQ);
|
||||
const qId = question.id; // header used as id
|
||||
|
||||
pages.push({
|
||||
index: pageIdx,
|
||||
questionId: qId,
|
||||
title: question.title,
|
||||
type: question.type,
|
||||
});
|
||||
|
||||
// Title
|
||||
components.push({
|
||||
id: `page-${pageIdx}-title`,
|
||||
page: pageIdx,
|
||||
component: {
|
||||
Text: {
|
||||
text: { literalString: question.title },
|
||||
usageHint: 'h3',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Message
|
||||
if (question.message) {
|
||||
components.push({
|
||||
id: `page-${pageIdx}-message`,
|
||||
page: pageIdx,
|
||||
component: {
|
||||
Text: {
|
||||
text: { literalString: question.message },
|
||||
usageHint: 'p',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Description
|
||||
if (question.description) {
|
||||
components.push({
|
||||
id: `page-${pageIdx}-description`,
|
||||
page: pageIdx,
|
||||
component: {
|
||||
Text: {
|
||||
text: { literalString: question.description },
|
||||
usageHint: 'small',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Interactive components based on question type
|
||||
switch (question.type) {
|
||||
case 'select': {
|
||||
const options = question.options?.map((opt) => ({
|
||||
label: { literalString: opt.label },
|
||||
value: opt.value,
|
||||
description: opt.description ? { literalString: opt.description } : undefined,
|
||||
})) || [];
|
||||
|
||||
// Add "Other" option for custom input
|
||||
options.push({
|
||||
label: { literalString: 'Other' },
|
||||
value: '__other__',
|
||||
description: { literalString: 'Provide a custom answer' },
|
||||
});
|
||||
|
||||
components.push({
|
||||
id: `page-${pageIdx}-radio-group`,
|
||||
page: pageIdx,
|
||||
component: {
|
||||
RadioGroup: {
|
||||
options,
|
||||
selectedValue: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
|
||||
onChange: { actionId: 'select', parameters: { questionId: qId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'multi-select': {
|
||||
const options = question.options?.map((opt) => ({
|
||||
label: { literalString: opt.label },
|
||||
value: opt.value,
|
||||
description: opt.description ? { literalString: opt.description } : undefined,
|
||||
})) || [];
|
||||
|
||||
options.forEach((opt, idx) => {
|
||||
components.push({
|
||||
id: `page-${pageIdx}-checkbox-${idx}`,
|
||||
page: pageIdx,
|
||||
component: {
|
||||
Checkbox: {
|
||||
label: opt.label,
|
||||
...(opt.description && { description: opt.description }),
|
||||
onChange: { actionId: 'toggle', parameters: { questionId: qId, value: opt.value } },
|
||||
checked: { literalBoolean: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Add "Other" checkbox for custom input
|
||||
components.push({
|
||||
id: `page-${pageIdx}-checkbox-other`,
|
||||
page: pageIdx,
|
||||
component: {
|
||||
Checkbox: {
|
||||
label: { literalString: 'Other' },
|
||||
description: { literalString: 'Provide a custom answer' },
|
||||
onChange: { actionId: 'toggle', parameters: { questionId: qId, value: '__other__' } },
|
||||
checked: { literalBoolean: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'input': {
|
||||
components.push({
|
||||
id: `page-${pageIdx}-input`,
|
||||
page: pageIdx,
|
||||
component: {
|
||||
TextField: {
|
||||
value: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
|
||||
onChange: { actionId: 'input-change', parameters: { questionId: qId } },
|
||||
placeholder: question.placeholder || 'Enter your answer',
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'confirm': {
|
||||
// Confirm type gets handled as a single boolean per page
|
||||
// No extra component — the page navigation handles yes/no
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
surfaceUpdate: {
|
||||
surfaceId,
|
||||
components,
|
||||
initialState: {
|
||||
questionId: `multi-${Date.now()}`,
|
||||
questionType: 'multi-question',
|
||||
pages,
|
||||
totalPages: questions.length,
|
||||
},
|
||||
displayMode: 'popup',
|
||||
},
|
||||
pages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute questions in AskUserQuestion-style format.
|
||||
* Single question: falls back to legacy sequential popup.
|
||||
* Multiple questions: generates a single multi-page surface.
|
||||
*/
|
||||
async function executeSimpleFormat(
|
||||
questions: SimpleQuestion[],
|
||||
timeout?: number,
|
||||
): Promise<ToolResult<AskQuestionResult>> {
|
||||
// Single question: use legacy single-popup flow
|
||||
if (questions.length === 1) {
|
||||
const simpleQ = questions[0];
|
||||
const question = normalizeSimpleQuestion(simpleQ);
|
||||
const params = {
|
||||
question,
|
||||
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
|
||||
} satisfies AskQuestionParams;
|
||||
|
||||
const result = await execute(params);
|
||||
if (!result.success || !result.result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.result.cancelled) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const answersDict: Record<string, string | string[]> = {};
|
||||
if (result.result.answers.length > 0) {
|
||||
const answer = result.result.answers[0];
|
||||
answersDict[simpleQ.header] = answer.value as string | string[];
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
success: true,
|
||||
surfaceId: result.result.surfaceId,
|
||||
cancelled: false,
|
||||
answers: result.result.answers,
|
||||
timestamp: new Date().toISOString(),
|
||||
answersDict,
|
||||
} as AskQuestionResult & { answersDict: Record<string, string | string[]> },
|
||||
};
|
||||
}
|
||||
|
||||
// Multiple questions: single multi-page surface
|
||||
const compositeId = `multi-${Date.now()}`;
|
||||
const surfaceId = `question-${compositeId}`;
|
||||
|
||||
const { surfaceUpdate, pages } = generateMultiQuestionSurface(questions, surfaceId);
|
||||
|
||||
// Create promise for the composite answer
|
||||
const resultPromise = new Promise<AskQuestionResult>((resolve, reject) => {
|
||||
const pendingQuestion: PendingQuestion = {
|
||||
id: compositeId,
|
||||
surfaceId,
|
||||
question: {
|
||||
id: compositeId,
|
||||
type: 'input', // placeholder type — multi-question uses custom answer handling
|
||||
title: 'Multi-question',
|
||||
required: false,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
pendingQuestions.set(compositeId, pendingQuestion);
|
||||
|
||||
// Also register each sub-question's questionId pointing to the same pending entry
|
||||
// so that select/toggle actions on individual questions get tracked
|
||||
for (const page of pages) {
|
||||
// Initialize selection tracking in the websocket handler
|
||||
if (page.type === 'multi-select') {
|
||||
a2uiWebSocketHandler.initMultiSelect(page.questionId);
|
||||
} else if (page.type === 'select') {
|
||||
a2uiWebSocketHandler.initSingleSelect(page.questionId);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (pendingQuestions.has(compositeId)) {
|
||||
pendingQuestions.delete(compositeId);
|
||||
resolve({
|
||||
success: false,
|
||||
surfaceId,
|
||||
cancelled: false,
|
||||
answers: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
error: 'Question timed out',
|
||||
});
|
||||
}
|
||||
}, timeout ?? DEFAULT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
// Send the surface
|
||||
const sentCount = a2uiWebSocketHandler.sendSurface(surfaceUpdate);
|
||||
|
||||
// If no local WS clients, start HTTP polling for answer from Dashboard
|
||||
if (sentCount === 0) {
|
||||
startAnswerPolling(compositeId, true);
|
||||
}
|
||||
|
||||
// Wait for answer
|
||||
const result = await resultPromise;
|
||||
|
||||
// If cancelled, return as-is
|
||||
if (result.cancelled) {
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
// Build answersDict from the answers array
|
||||
const answersDict: Record<string, string | string[]> = {};
|
||||
if (result.answers) {
|
||||
for (const answer of result.answers) {
|
||||
// Find the matching SimpleQuestion by questionId (which maps to header)
|
||||
const simpleQ = questions.find(q => q.header === answer.questionId);
|
||||
if (simpleQ) {
|
||||
answersDict[simpleQ.header] = answer.value as string | string[];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
...result,
|
||||
answersDict,
|
||||
} as AskQuestionResult & { answersDict: Record<string, string | string[]> },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user