mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: enhance dialog and drawer components with new styles and functionality
- Updated Dialog component to support fullscreen mode and added a back button. - Introduced Drawer component for side navigation with customizable size and position. - Added DialogStyleContext for managing dialog style preferences including smart mode and drawer settings. - Implemented pending question service for managing persistent storage of pending questions. - Enhanced WebSocket handling to request pending questions upon frontend readiness. - Created dashboard launcher utility to manage the Dashboard server lifecycle.
This commit is contained in:
@@ -7,7 +7,8 @@ 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';
|
||||
import type { QuestionAnswer, AskQuestionParams, Question, PendingQuestion } from './A2UITypes.js';
|
||||
import { getAllPendingQuestions } from '../services/pending-question-service.js';
|
||||
|
||||
const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
|
||||
|
||||
@@ -604,6 +605,215 @@ export class A2UIWebSocketHandler {
|
||||
|
||||
// ========== WebSocket Integration ==========
|
||||
|
||||
/**
|
||||
* Generate A2UI surface for a pending question
|
||||
* This is used to resend pending questions when frontend reconnects
|
||||
*/
|
||||
function generatePendingQuestionSurface(pq: PendingQuestion): {
|
||||
surfaceId: string;
|
||||
components: unknown[];
|
||||
initialState: Record<string, unknown>;
|
||||
displayMode?: 'popup' | 'panel';
|
||||
} | null {
|
||||
const question = pq.question;
|
||||
const components: unknown[] = [];
|
||||
|
||||
// Add title
|
||||
components.push({
|
||||
id: 'title',
|
||||
component: {
|
||||
Text: {
|
||||
text: { literalString: question.title },
|
||||
usageHint: 'h3',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add message if provided
|
||||
if (question.message) {
|
||||
components.push({
|
||||
id: 'message',
|
||||
component: {
|
||||
Text: {
|
||||
text: { literalString: question.message },
|
||||
usageHint: 'p',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add description if provided
|
||||
if (question.description) {
|
||||
components.push({
|
||||
id: 'description',
|
||||
component: {
|
||||
Text: {
|
||||
text: { literalString: question.description },
|
||||
usageHint: 'small',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add interactive components based on question type
|
||||
switch (question.type) {
|
||||
case 'confirm':
|
||||
components.push({
|
||||
id: 'confirm-btn',
|
||||
component: {
|
||||
Button: {
|
||||
onClick: { actionId: 'confirm', parameters: { questionId: question.id } },
|
||||
content: { Text: { text: { literalString: 'Confirm' } } },
|
||||
variant: 'primary',
|
||||
},
|
||||
},
|
||||
});
|
||||
components.push({
|
||||
id: 'cancel-btn',
|
||||
component: {
|
||||
Button: {
|
||||
onClick: { actionId: 'cancel', parameters: { questionId: question.id } },
|
||||
content: { Text: { text: { literalString: 'Cancel' } } },
|
||||
variant: 'secondary',
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
if (question.options && question.options.length > 0) {
|
||||
const options = question.options.map((opt) => ({
|
||||
label: { literalString: opt.label },
|
||||
value: opt.value,
|
||||
description: opt.description ? { literalString: opt.description } : undefined,
|
||||
isDefault: question.defaultValue !== undefined && opt.value === String(question.defaultValue),
|
||||
}));
|
||||
|
||||
options.push({
|
||||
label: { literalString: 'Other' },
|
||||
value: '__other__',
|
||||
description: { literalString: 'Provide a custom answer' },
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
components.push({
|
||||
id: 'radio-group',
|
||||
component: {
|
||||
RadioGroup: {
|
||||
options,
|
||||
selectedValue: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
|
||||
onChange: { actionId: 'select', parameters: { questionId: question.id } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
case 'multi-select':
|
||||
if (question.options && question.options.length > 0) {
|
||||
question.options.forEach((opt, idx) => {
|
||||
components.push({
|
||||
id: `checkbox-${idx}`,
|
||||
component: {
|
||||
Checkbox: {
|
||||
label: { literalString: opt.label },
|
||||
...(opt.description && { description: { literalString: opt.description } }),
|
||||
onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: opt.value } },
|
||||
checked: { literalBoolean: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
case 'input':
|
||||
components.push({
|
||||
id: 'input',
|
||||
component: {
|
||||
TextField: {
|
||||
value: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
|
||||
onChange: { actionId: 'answer', parameters: { questionId: question.id } },
|
||||
placeholder: question.placeholder || 'Enter your answer',
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
surfaceId: pq.surfaceId,
|
||||
components,
|
||||
initialState: {
|
||||
questionId: question.id,
|
||||
questionType: question.type,
|
||||
options: question.options,
|
||||
required: question.required,
|
||||
timeoutAt: new Date(pq.timestamp + pq.timeout).toISOString(),
|
||||
...(question.defaultValue !== undefined && { defaultValue: question.defaultValue }),
|
||||
},
|
||||
displayMode: 'popup',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle A2UI messages in WebSocket data handler
|
||||
* Called from main WebSocket handler
|
||||
@@ -620,6 +830,23 @@ export function handleA2UIMessage(
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
|
||||
// Handle FRONTEND_READY - frontend requesting pending questions
|
||||
if (data.type === 'FRONTEND_READY' && data.payload?.action === 'requestPendingQuestions') {
|
||||
console.log('[A2UI] Frontend ready, sending pending questions...');
|
||||
const pendingQuestions = getAllPendingQuestions();
|
||||
|
||||
for (const pq of pendingQuestions) {
|
||||
// Regenerate surface for each pending question
|
||||
const surfaceUpdate = generatePendingQuestionSurface(pq);
|
||||
if (surfaceUpdate) {
|
||||
a2uiHandler.sendSurface(surfaceUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[A2UI] Sent ${pendingQuestions.length} pending questions to frontend`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle A2UI action messages
|
||||
if (data.type === 'a2ui-action') {
|
||||
const action = data as A2UIActionMessage;
|
||||
|
||||
244
ccw/src/core/services/pending-question-service.ts
Normal file
244
ccw/src/core/services/pending-question-service.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
// ========================================
|
||||
// Pending Question Service
|
||||
// ========================================
|
||||
// Manages persistent storage of pending questions for ask_question tool
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import type { PendingQuestion } from '../a2ui/A2UITypes.js';
|
||||
|
||||
// Storage configuration
|
||||
const STORAGE_DIR = join(homedir(), '.ccw', 'pending-questions');
|
||||
const STORAGE_FILE = join(STORAGE_DIR, 'questions.json');
|
||||
|
||||
// In-memory cache of pending questions
|
||||
const pendingQuestions = new Map<string, PendingQuestion>();
|
||||
|
||||
// Flag to track if service has been initialized
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Serializable representation of a pending question (without resolve/reject functions)
|
||||
*/
|
||||
interface SerializedPendingQuestion {
|
||||
id: string;
|
||||
surfaceId: string;
|
||||
question: {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message?: string;
|
||||
description?: string;
|
||||
options?: Array<{ value: string; label: string; description?: string }>;
|
||||
defaultValue?: string | string[] | boolean;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
timestamp: number;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the service by loading pending questions from disk.
|
||||
* Called automatically on module load.
|
||||
*/
|
||||
function initialize(): void {
|
||||
if (initialized) return;
|
||||
|
||||
try {
|
||||
// Ensure storage directory exists
|
||||
if (!existsSync(STORAGE_DIR)) {
|
||||
mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Load existing questions from disk
|
||||
if (existsSync(STORAGE_FILE)) {
|
||||
const content = readFileSync(STORAGE_FILE, 'utf8');
|
||||
const serialized: SerializedPendingQuestion[] = JSON.parse(content);
|
||||
|
||||
for (const sq of serialized) {
|
||||
// Create a PendingQuestion with placeholder resolve/reject
|
||||
// These will be replaced when the question is actually awaited
|
||||
const pendingQ: PendingQuestion = {
|
||||
id: sq.id,
|
||||
surfaceId: sq.surfaceId,
|
||||
question: sq.question as PendingQuestion['question'],
|
||||
timestamp: sq.timestamp,
|
||||
timeout: sq.timeout,
|
||||
resolve: () => {
|
||||
console.warn(`[PendingQuestionService] Resolve called for restored question ${sq.id} - no promise attached`);
|
||||
},
|
||||
reject: () => {
|
||||
console.warn(`[PendingQuestionService] Reject called for restored question ${sq.id} - no promise attached`);
|
||||
},
|
||||
};
|
||||
pendingQuestions.set(sq.id, pendingQ);
|
||||
}
|
||||
|
||||
console.log(`[PendingQuestionService] Loaded ${pendingQuestions.size} pending questions from storage`);
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('[PendingQuestionService] Failed to initialize:', error);
|
||||
initialized = true; // Still mark as initialized to prevent retry loops
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist current pending questions to disk.
|
||||
*/
|
||||
function persistQuestions(): void {
|
||||
try {
|
||||
// Ensure storage directory exists
|
||||
if (!existsSync(STORAGE_DIR)) {
|
||||
mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const serialized: SerializedPendingQuestion[] = [];
|
||||
|
||||
for (const pq of pendingQuestions.values()) {
|
||||
serialized.push({
|
||||
id: pq.id,
|
||||
surfaceId: pq.surfaceId,
|
||||
question: {
|
||||
id: pq.question.id,
|
||||
type: pq.question.type,
|
||||
title: pq.question.title,
|
||||
message: pq.question.message,
|
||||
description: pq.question.description,
|
||||
options: pq.question.options,
|
||||
defaultValue: pq.question.defaultValue,
|
||||
required: pq.question.required,
|
||||
placeholder: pq.question.placeholder,
|
||||
timeout: pq.timeout,
|
||||
},
|
||||
timestamp: pq.timestamp,
|
||||
timeout: pq.timeout,
|
||||
});
|
||||
}
|
||||
|
||||
writeFileSync(STORAGE_FILE, JSON.stringify(serialized, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
console.error('[PendingQuestionService] Failed to persist questions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a pending question to storage.
|
||||
* @param pendingQ - The pending question to add
|
||||
*/
|
||||
export function addPendingQuestion(pendingQ: PendingQuestion): void {
|
||||
initialize();
|
||||
pendingQuestions.set(pendingQ.id, pendingQ);
|
||||
persistQuestions();
|
||||
console.log(`[PendingQuestionService] Added pending question: ${pendingQ.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pending question by ID.
|
||||
* @param questionId - The question ID
|
||||
* @returns The pending question or undefined
|
||||
*/
|
||||
export function getPendingQuestion(questionId: string): PendingQuestion | undefined {
|
||||
initialize();
|
||||
return pendingQuestions.get(questionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing pending question (e.g., to attach new resolve/reject).
|
||||
* @param questionId - The question ID
|
||||
* @param pendingQ - The updated pending question
|
||||
*/
|
||||
export function updatePendingQuestion(questionId: string, pendingQ: PendingQuestion): boolean {
|
||||
initialize();
|
||||
if (pendingQuestions.has(questionId)) {
|
||||
pendingQuestions.set(questionId, pendingQ);
|
||||
// Don't persist here - resolve/reject functions aren't serializable
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending question from storage.
|
||||
* @param questionId - The question ID to remove
|
||||
* @returns True if the question was found and removed
|
||||
*/
|
||||
export function removePendingQuestion(questionId: string): boolean {
|
||||
initialize();
|
||||
const existed = pendingQuestions.delete(questionId);
|
||||
if (existed) {
|
||||
persistQuestions();
|
||||
console.log(`[PendingQuestionService] Removed pending question: ${questionId}`);
|
||||
}
|
||||
return existed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending questions.
|
||||
* @returns Array of all pending questions
|
||||
*/
|
||||
export function getAllPendingQuestions(): PendingQuestion[] {
|
||||
initialize();
|
||||
return Array.from(pendingQuestions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pending question exists.
|
||||
* @param questionId - The question ID to check
|
||||
* @returns True if the question exists
|
||||
*/
|
||||
export function hasPendingQuestion(questionId: string): boolean {
|
||||
initialize();
|
||||
return pendingQuestions.has(questionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of pending questions.
|
||||
* @returns Number of pending questions
|
||||
*/
|
||||
export function getPendingQuestionCount(): number {
|
||||
initialize();
|
||||
return pendingQuestions.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending questions from storage.
|
||||
*/
|
||||
export function clearAllPendingQuestions(): void {
|
||||
initialize();
|
||||
pendingQuestions.clear();
|
||||
persistQuestions();
|
||||
console.log(`[PendingQuestionService] Cleared all pending questions`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired questions (older than their timeout).
|
||||
* This can be called periodically to prevent stale data accumulation.
|
||||
* @returns Number of questions removed
|
||||
*/
|
||||
export function cleanupExpiredQuestions(): number {
|
||||
initialize();
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
|
||||
for (const [id, pq] of pendingQuestions) {
|
||||
if (now - pq.timestamp > pq.timeout) {
|
||||
pendingQuestions.delete(id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
persistQuestions();
|
||||
console.log(`[PendingQuestionService] Cleaned up ${removed} expired questions`);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
// Initialize on module load
|
||||
initialize();
|
||||
@@ -18,6 +18,18 @@ import type {
|
||||
import http from 'http';
|
||||
import { a2uiWebSocketHandler } from '../core/a2ui/A2UIWebSocketHandler.js';
|
||||
import { remoteNotificationService } from '../core/services/remote-notification-service.js';
|
||||
import {
|
||||
addPendingQuestion,
|
||||
getPendingQuestion,
|
||||
removePendingQuestion,
|
||||
getAllPendingQuestions,
|
||||
clearAllPendingQuestions,
|
||||
hasPendingQuestion,
|
||||
} from '../core/services/pending-question-service.js';
|
||||
import {
|
||||
isDashboardServerRunning,
|
||||
startCcwServeProcess,
|
||||
} from '../utils/dashboard-launcher.js';
|
||||
|
||||
const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
@@ -32,9 +44,6 @@ a2uiWebSocketHandler.registerMultiAnswerCallback(
|
||||
/** Default question timeout (5 minutes) */
|
||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Map of pending questions waiting for responses */
|
||||
const pendingQuestions = new Map<string, PendingQuestion>();
|
||||
|
||||
// ========== Validation ==========
|
||||
|
||||
/**
|
||||
@@ -454,12 +463,13 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
pendingQuestions.set(question.id, pendingQuestion);
|
||||
addPendingQuestion(pendingQuestion);
|
||||
|
||||
// Set timeout
|
||||
setTimeout(() => {
|
||||
if (pendingQuestions.has(question.id)) {
|
||||
pendingQuestions.delete(question.id);
|
||||
const timedOutQuestion = getPendingQuestion(question.id);
|
||||
if (timedOutQuestion) {
|
||||
removePendingQuestion(question.id);
|
||||
if (question.defaultValue !== undefined) {
|
||||
resolve({
|
||||
success: true,
|
||||
@@ -495,8 +505,17 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
|
||||
});
|
||||
}
|
||||
|
||||
// If no local WS clients, start HTTP polling for answer from Dashboard
|
||||
// If no local WS clients, check Dashboard status and start HTTP polling
|
||||
if (sentCount === 0) {
|
||||
// Check if Dashboard server is running, attempt to start if not
|
||||
const dashboardRunning = await isDashboardServerRunning();
|
||||
if (!dashboardRunning) {
|
||||
console.warn(`[AskQuestion] Dashboard server not running. Attempting to start...`);
|
||||
const started = await startCcwServeProcess();
|
||||
if (!started) {
|
||||
console.error(`[AskQuestion] Failed to automatically start Dashboard server.`);
|
||||
}
|
||||
}
|
||||
startAnswerPolling(question.id);
|
||||
}
|
||||
|
||||
@@ -523,7 +542,7 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
|
||||
* @returns True if answer was processed
|
||||
*/
|
||||
export function handleAnswer(answer: QuestionAnswer): boolean {
|
||||
const pending = pendingQuestions.get(answer.questionId);
|
||||
const pending = getPendingQuestion(answer.questionId);
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
@@ -543,7 +562,7 @@ export function handleAnswer(answer: QuestionAnswer): boolean {
|
||||
});
|
||||
|
||||
// Remove from pending
|
||||
pendingQuestions.delete(answer.questionId);
|
||||
removePendingQuestion(answer.questionId);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -555,7 +574,7 @@ export function handleAnswer(answer: QuestionAnswer): boolean {
|
||||
* @returns True if answer was processed
|
||||
*/
|
||||
export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]): boolean {
|
||||
const pending = pendingQuestions.get(compositeId);
|
||||
const pending = getPendingQuestion(compositeId);
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
@@ -568,7 +587,7 @@ export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
pendingQuestions.delete(compositeId);
|
||||
removePendingQuestion(compositeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -577,7 +596,7 @@ export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]
|
||||
/**
|
||||
* 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).
|
||||
* Automatically stops when the questionId is no longer in pending questions (timeout cleanup).
|
||||
*/
|
||||
function startAnswerPolling(questionId: string, isComposite: boolean = false): void {
|
||||
const pollPath = `/api/a2ui/answer?questionId=${encodeURIComponent(questionId)}&composite=${isComposite}`;
|
||||
@@ -586,7 +605,7 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
|
||||
|
||||
const poll = () => {
|
||||
// Stop if the question was already resolved or timed out
|
||||
if (!pendingQuestions.has(questionId)) {
|
||||
if (!hasPendingQuestion(questionId)) {
|
||||
console.error(`[A2UI-Poll] Stopping: questionId=${questionId} no longer pending`);
|
||||
return;
|
||||
}
|
||||
@@ -614,14 +633,14 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
|
||||
if (isComposite && Array.isArray(parsed.answers)) {
|
||||
const ok = handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]);
|
||||
console.error(`[A2UI-Poll] handleMultiAnswer result: ${ok}`);
|
||||
if (!ok && pendingQuestions.has(questionId)) {
|
||||
if (!ok && hasPendingQuestion(questionId)) {
|
||||
// Answer consumed but delivery failed; keep polling for a new answer
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
} else if (!isComposite && parsed.answer) {
|
||||
const ok = handleAnswer(parsed.answer as QuestionAnswer);
|
||||
console.error(`[A2UI-Poll] handleAnswer result: ${ok}`);
|
||||
if (!ok && pendingQuestions.has(questionId)) {
|
||||
if (!ok && hasPendingQuestion(questionId)) {
|
||||
// Answer consumed but validation/delivery failed; keep polling for a new answer
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
@@ -638,7 +657,7 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error(`[A2UI-Poll] Network error: ${err.message}`);
|
||||
if (pendingQuestions.has(questionId)) {
|
||||
if (hasPendingQuestion(questionId)) {
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
});
|
||||
@@ -660,7 +679,7 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
|
||||
* @returns True if question was cancelled
|
||||
*/
|
||||
export function cancelQuestion(questionId: string): boolean {
|
||||
const pending = pendingQuestions.get(questionId);
|
||||
const pending = getPendingQuestion(questionId);
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
@@ -674,7 +693,7 @@ export function cancelQuestion(questionId: string): boolean {
|
||||
error: 'Question cancelled',
|
||||
});
|
||||
|
||||
pendingQuestions.delete(questionId);
|
||||
removePendingQuestion(questionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -683,17 +702,17 @@ export function cancelQuestion(questionId: string): boolean {
|
||||
* @returns Array of pending questions
|
||||
*/
|
||||
export function getPendingQuestions(): PendingQuestion[] {
|
||||
return Array.from(pendingQuestions.values());
|
||||
return getAllPendingQuestions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending questions
|
||||
*/
|
||||
export function clearPendingQuestions(): void {
|
||||
for (const pending of pendingQuestions.values()) {
|
||||
for (const pending of getAllPendingQuestions()) {
|
||||
pending.reject(new Error('Question cleared'));
|
||||
}
|
||||
pendingQuestions.clear();
|
||||
clearAllPendingQuestions();
|
||||
}
|
||||
|
||||
// ========== Tool Schema ==========
|
||||
@@ -1076,7 +1095,7 @@ async function executeSimpleFormat(
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
pendingQuestions.set(compositeId, pendingQuestion);
|
||||
addPendingQuestion(pendingQuestion);
|
||||
|
||||
// Also register each sub-question's questionId pointing to the same pending entry
|
||||
// so that select/toggle actions on individual questions get tracked
|
||||
@@ -1090,8 +1109,9 @@ async function executeSimpleFormat(
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (pendingQuestions.has(compositeId)) {
|
||||
pendingQuestions.delete(compositeId);
|
||||
const timedOutQuestion = getPendingQuestion(compositeId);
|
||||
if (timedOutQuestion) {
|
||||
removePendingQuestion(compositeId);
|
||||
// Collect default values from each sub-question
|
||||
const defaultAnswers: QuestionAnswer[] = [];
|
||||
for (const simpleQ of questions) {
|
||||
|
||||
201
ccw/src/utils/dashboard-launcher.ts
Normal file
201
ccw/src/utils/dashboard-launcher.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// ========================================
|
||||
// Dashboard Launcher Utility
|
||||
// ========================================
|
||||
// Detects Dashboard server status and auto-starts if needed
|
||||
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import http from 'http';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Constants
|
||||
const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
|
||||
const DASHBOARD_HOST = process.env.CCW_HOST || '127.0.0.1';
|
||||
const DASHBOARD_CHECK_TIMEOUT_MS = 500;
|
||||
const DASHBOARD_STARTUP_TIMEOUT_MS = 30000;
|
||||
const DASHBOARD_STARTUP_POLL_INTERVAL_MS = 500;
|
||||
|
||||
// Path to CCW CLI (adjust based on build output location)
|
||||
const CCW_CLI_PATH = join(__dirname, '../../bin/ccw.js');
|
||||
|
||||
// Track spawned dashboard process
|
||||
let dashboardProcess: ChildProcess | null = null;
|
||||
|
||||
/**
|
||||
* Check if the Dashboard server is running by attempting to connect to its health endpoint.
|
||||
* @returns Promise that resolves to true if server is reachable
|
||||
*/
|
||||
export async function isDashboardServerRunning(
|
||||
port: number = DASHBOARD_PORT,
|
||||
host: string = DASHBOARD_HOST
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(
|
||||
{
|
||||
hostname: host,
|
||||
port,
|
||||
path: '/api/health',
|
||||
timeout: DASHBOARD_CHECK_TIMEOUT_MS,
|
||||
},
|
||||
(res) => {
|
||||
res.resume(); // Consume response data
|
||||
res.on('end', () => {
|
||||
resolve(res.statusCode === 200);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Dashboard server to become available.
|
||||
* Polls the health endpoint until it responds or timeout is reached.
|
||||
* @param port - Port to check
|
||||
* @param host - Host to check
|
||||
* @param timeoutMs - Maximum time to wait
|
||||
* @returns Promise that resolves to true if server became available
|
||||
*/
|
||||
export async function waitForDashboardReady(
|
||||
port: number = DASHBOARD_PORT,
|
||||
host: string = DASHBOARD_HOST,
|
||||
timeoutMs: number = DASHBOARD_STARTUP_TIMEOUT_MS
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const isRunning = await isDashboardServerRunning(port, host);
|
||||
if (isRunning) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, DASHBOARD_STARTUP_POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to start the CCW Dashboard server in a detached child process.
|
||||
* @param port - Port to start the server on
|
||||
* @param host - Host to bind the server to
|
||||
* @param openBrowser - Whether to open browser (default: false)
|
||||
* @returns Promise that resolves to true if process was successfully spawned and became ready
|
||||
*/
|
||||
export async function startCcwServeProcess(
|
||||
port: number = DASHBOARD_PORT,
|
||||
host: string = DASHBOARD_HOST,
|
||||
openBrowser: boolean = false
|
||||
): Promise<boolean> {
|
||||
// Don't start if already running
|
||||
const alreadyRunning = await isDashboardServerRunning(port, host);
|
||||
if (alreadyRunning) {
|
||||
console.log(`[DashboardLauncher] Dashboard already running at ${host}:${port}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Don't spawn duplicate process
|
||||
if (dashboardProcess && !dashboardProcess.killed) {
|
||||
console.log(`[DashboardLauncher] Dashboard process already spawned, waiting for ready...`);
|
||||
return waitForDashboardReady(port, host);
|
||||
}
|
||||
|
||||
console.log(`[DashboardLauncher] Starting Dashboard server at ${host}:${port}...`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const args = ['serve', '--port', port.toString(), '--host', host];
|
||||
if (!openBrowser) {
|
||||
args.push('--no-browser');
|
||||
}
|
||||
|
||||
dashboardProcess = spawn('node', [CCW_CLI_PATH, ...args], {
|
||||
detached: true,
|
||||
stdio: 'ignore', // Detach stdio from parent
|
||||
shell: process.platform === 'win32', // Use shell on Windows for better compatibility
|
||||
env: {
|
||||
...process.env,
|
||||
CCW_PORT: port.toString(),
|
||||
CCW_HOST: host,
|
||||
},
|
||||
});
|
||||
|
||||
dashboardProcess.unref(); // Allow parent to exit independently
|
||||
|
||||
dashboardProcess.on('error', (err) => {
|
||||
console.error(`[DashboardLauncher] Failed to start Dashboard: ${err.message}`);
|
||||
dashboardProcess = null;
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
// Wait for server to become ready
|
||||
waitForDashboardReady(port, host)
|
||||
.then((ready) => {
|
||||
if (ready) {
|
||||
console.log(`[DashboardLauncher] Dashboard started successfully (PID: ${dashboardProcess?.pid})`);
|
||||
} else {
|
||||
console.error(`[DashboardLauncher] Dashboard failed to start within timeout`);
|
||||
dashboardProcess = null;
|
||||
}
|
||||
resolve(ready);
|
||||
})
|
||||
.catch(() => {
|
||||
resolve(false);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[DashboardLauncher] Exception while starting Dashboard: ${(err as Error).message}`);
|
||||
dashboardProcess = null;
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current dashboard process info.
|
||||
* @returns Object with process status and PID
|
||||
*/
|
||||
export function getDashboardProcessStatus(): { running: boolean; pid: number | null } {
|
||||
return {
|
||||
running: dashboardProcess !== null && !dashboardProcess.killed,
|
||||
pid: dashboardProcess?.pid ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the spawned dashboard process (if any).
|
||||
* Note: This only stops the process we spawned, not externally started servers.
|
||||
*/
|
||||
export async function stopSpawnedDashboard(): Promise<void> {
|
||||
if (dashboardProcess && !dashboardProcess.killed) {
|
||||
console.log(`[DashboardLauncher] Stopping spawned Dashboard process (PID: ${dashboardProcess.pid})...`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (dashboardProcess && !dashboardProcess.killed) {
|
||||
dashboardProcess.kill('SIGKILL');
|
||||
}
|
||||
dashboardProcess = null;
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
dashboardProcess!.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
dashboardProcess = null;
|
||||
console.log(`[DashboardLauncher] Dashboard process stopped`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
dashboardProcess!.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user