@buger/probe-chat
Version:
CLI and web interface for Probe code search (formerly @buger/probe-web and @buger/probe-chat)
760 lines (655 loc) • 30.2 kB
JavaScript
import 'dotenv/config';
import { createServer } from 'http';
// import { streamText } from 'ai'; // streamText might not be suitable for the loop logic directly
import { readFileSync, existsSync } from 'fs';
import { resolve, dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { randomUUID } from 'crypto';
import { ProbeChat } from './probeChat.js';
import { TokenUsageDisplay } from './tokenUsageDisplay.js';
import { authMiddleware, withAuth } from './auth.js';
import {
// probeTool, // This is the compatibility layer, less critical now
searchToolInstance, // Keep direct instances for API endpoints
queryToolInstance,
extractToolInstance,
implementToolInstance,
toolCallEmitter,
cancelToolExecutions,
clearToolExecutionData,
isSessionCancelled
} from './probeTool.js';
import { registerRequest, cancelRequest, clearRequest, isRequestActive } from './cancelRequest.js';
// Get the directory name of the current module
const __dirname = dirname(fileURLToPath(import.meta.url));
// Map to store chat instances by session ID
const chatSessions = new Map();
/**
* Retrieve or create a ProbeChat instance keyed by sessionId.
*/
function getOrCreateChat(sessionId, apiCredentials = null) {
if (!sessionId) {
// Safety fallback: generate a random ID if missing
sessionId = randomUUID(); // Use crypto.randomUUID() if available/preferred
console.warn(`[WARN] Missing sessionId, generated fallback: ${sessionId}`);
}
if (chatSessions.has(sessionId)) {
return chatSessions.get(sessionId);
}
// Create options object with sessionId and API credentials if provided
const options = { sessionId };
if (apiCredentials) {
options.apiProvider = apiCredentials.apiProvider;
options.apiKey = apiCredentials.apiKey;
options.apiUrl = apiCredentials.apiUrl;
}
const newChat = new ProbeChat(options);
chatSessions.set(sessionId, newChat);
if (process.env.DEBUG_CHAT === '1') {
console.log(`[DEBUG] Created and stored new chat instance for session: ${sessionId}. Total sessions: ${chatSessions.size}`);
if (apiCredentials && apiCredentials.apiKey) {
console.log(`[DEBUG] Chat instance created with client-provided API credentials (provider: ${apiCredentials.apiProvider})`);
}
}
return newChat;
}
/**
* Start the web server
* @param {string} version - The version of the application
* @param {boolean} hasApiKeys - Whether any API keys are configured
* @param {Object} options - Additional options
* @param {boolean} options.allowEdit - Whether to allow editing files via the implement tool
*/
export function startWebServer(version, hasApiKeys = true, options = {}) {
const allowEdit = options?.allowEdit || false;
if (allowEdit) {
console.log('Edit mode enabled: implement tool is available');
}
// Authentication configuration
const AUTH_ENABLED = process.env.AUTH_ENABLED === '1';
const AUTH_USERNAME = process.env.AUTH_USERNAME || 'admin';
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || 'password';
if (AUTH_ENABLED) {
console.log(`Authentication enabled (username: ${AUTH_USERNAME})`);
} else {
console.log('Authentication disabled');
}
// Map to store SSE clients by session ID
const sseClients = new Map();
// Initialize a default ProbeChat instance for /folders endpoint? Or make folders static?
// Let's make /folders rely on environment variables directly or a static config
// to avoid needing a default chat instance just for that.
const staticAllowedFolders = process.env.ALLOWED_FOLDERS
? process.env.ALLOWED_FOLDERS.split(',').map(folder => folder.trim()).filter(Boolean)
: [];
let noApiKeysMode = !hasApiKeys;
if (noApiKeysMode) {
console.log('Running in No API Keys mode - will show setup instructions to users');
} else {
console.log('API keys detected. Chat functionality enabled.');
}
// Define the tools available for direct API calls (bypassing LLM loop)
// Note: probeTool is the backward compatibility wrapper
const directApiTools = {
search: searchToolInstance,
query: queryToolInstance,
extract: extractToolInstance
};
// Add implement tool if edit mode is enabled
if (allowEdit) {
directApiTools.implement = implementToolInstance;
}
// Helper function to send SSE data
function sendSSEData(res, data, eventType = 'message') {
const DEBUG = process.env.DEBUG_CHAT === '1';
try {
// Check if the response stream is still writable
if (!res.writable || res.writableEnded) {
if (DEBUG) console.log(`[DEBUG] SSE stream closed for event type ${eventType}, cannot send.`);
return;
}
if (DEBUG) {
// console.log(`[DEBUG] Sending SSE data, event type: ${eventType}`); // Can be noisy
}
res.write(`event: ${eventType}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
if (DEBUG) {
// console.log(`[DEBUG] SSE data sent successfully for event: ${eventType}`);
// const preview = JSON.stringify(data).substring(0, 100);
// console.log(`[DEBUG] SSE data content preview: ${preview}...`);
}
} catch (error) {
console.error(`[ERROR] Error sending SSE data:`, error);
// Attempt to close the connection gracefully on error?
try {
if (res.writable && !res.writableEnded) res.end();
} catch (closeError) {
console.error(`[ERROR] Error closing SSE stream after send error:`, closeError);
}
}
}
// Map to store active chat instances by session ID for cancellation purposes
const activeChatInstances = new Map();
const server = createServer(async (req, res) => {
// Apply authentication middleware to all requests first
const processRequest = (routeHandler) => {
// First apply authentication middleware
authMiddleware(req, res, () => {
// Then process the route if authentication passes
routeHandler(req, res);
});
};
// Define route handlers
const routes = {
// Handle OPTIONS requests for CORS preflight (Common)
'OPTIONS /api/token-usage': (req, res) => handleOptions(res),
'OPTIONS /chat': (req, res) => handleOptions(res),
'OPTIONS /api/search': (req, res) => handleOptions(res),
'OPTIONS /api/query': (req, res) => handleOptions(res),
'OPTIONS /api/extract': (req, res) => handleOptions(res),
'OPTIONS /api/implement': (req, res) => handleOptions(res),
'OPTIONS /cancel-request': (req, res) => handleOptions(res),
'OPTIONS /folders': (req, res) => handleOptions(res), // Added for /folders
// Token usage API endpoint
'GET /api/token-usage': (req, res) => {
const sessionId = getSessionIdFromUrl(req);
if (!sessionId) return sendError(res, 400, 'Missing sessionId parameter');
const chatInstance = chatSessions.get(sessionId);
if (!chatInstance) return sendError(res, 404, 'Session not found');
const DEBUG = process.env.DEBUG_CHAT === '1';
// Update the tokenCounter's history with the chat history
if (chatInstance.tokenCounter && typeof chatInstance.tokenCounter.updateHistory === 'function' &&
chatInstance.history) {
chatInstance.tokenCounter.updateHistory(chatInstance.history);
if (DEBUG) {
console.log(`[DEBUG] Updated tokenCounter history with ${chatInstance.history.length} messages for token usage request`);
}
}
// Get raw token usage data from the chat instance
const tokenUsage = chatInstance.getTokenUsage();
if (DEBUG) {
console.log(`[DEBUG] Token usage request - Context window size: ${tokenUsage.contextWindow}`);
console.log(`[DEBUG] Token usage request - Cache metrics - Read: ${tokenUsage.current.cacheRead}, Write: ${tokenUsage.current.cacheWrite}`);
}
// Send the raw token usage data to the client
// The client-side JavaScript will handle formatting
sendJson(res, 200, tokenUsage);
},
// Static file routes
'GET /logo.png': (req, res) => serveStatic(res, join(__dirname, 'logo.png'), 'image/png'),
// UI Routes
'GET /': (req, res) => {
const htmlPath = join(__dirname, 'index.html');
serveHtml(res, htmlPath, { 'data-no-api-keys': noApiKeysMode ? 'true' : 'false' });
},
'GET /folders': (req, res) => {
const currentWorkingDir = process.cwd();
// Use static config or environment variables directly
const folders = staticAllowedFolders.length > 0 ? staticAllowedFolders : [currentWorkingDir];
sendJson(res, 200, {
folders: folders,
currentDir: currentWorkingDir,
noApiKeysMode: noApiKeysMode
});
},
'GET /openapi.yaml': (req, res) => serveStatic(res, join(__dirname, 'openapi.yaml'), 'text/yaml'),
// SSE endpoint for tool calls - NO AUTH for easier client implementation
'GET /api/tool-events': (req, res) => {
const DEBUG = process.env.DEBUG_CHAT === '1';
const sessionId = getSessionIdFromUrl(req);
if (!sessionId) {
if (DEBUG) console.error(`[DEBUG] SSE: No sessionId found in URL: ${req.url}`);
return sendError(res, 400, 'Missing sessionId parameter');
}
if (DEBUG) console.log(`[DEBUG] SSE: Setting up connection for session: ${sessionId}`);
// Set headers for SSE
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*' // Allow all origins for SSE
});
if (DEBUG) console.log(`[DEBUG] SSE: Headers set for session: ${sessionId}`);
// Send initial connection established event
const connectionData = { type: 'connection', message: 'SSE Connection Established', sessionId, timestamp: new Date().toISOString() };
sendSSEData(res, connectionData, 'connection');
if (DEBUG) console.log(`[DEBUG] SSE: Sent connection event for session: ${sessionId}`);
// Send a test event (optional, for debugging)
// setTimeout(() => sendSSEData(res, { type: 'test', message: 'Test event', sessionId }, 'test'), 1000);
// Function to handle tool call events for this session
const handleToolCall = (toolCall) => {
if (DEBUG) {
// console.log(`[DEBUG] SSE: Handling tool call event for session ${sessionId}: ${toolCall.name}`); // Noisy
}
// Ensure data is serializable and add timestamp if missing
const serializableCall = {
...toolCall,
timestamp: toolCall.timestamp || new Date().toISOString(),
_sse_sent_at: new Date().toISOString()
};
sendSSEData(res, serializableCall, 'toolCall'); // Event type 'toolCall'
};
// Register event listener for this specific session
const eventName = `toolCall:${sessionId}`;
// Remove previous listener for this exact session ID if any (safety measure)
const existingHandler = sseClients.get(sessionId)?.handler;
if (existingHandler) {
toolCallEmitter.removeListener(eventName, existingHandler);
}
toolCallEmitter.on(eventName, handleToolCall);
if (DEBUG) console.log(`[DEBUG] SSE: Registered listener for ${eventName}`);
// Store client and handler for cleanup
sseClients.set(sessionId, { res, handler: handleToolCall });
if (DEBUG) console.log(`[DEBUG] SSE: Client added for session ${sessionId}. Total clients: ${sseClients.size}`);
// Handle client disconnect
req.on('close', () => {
if (DEBUG) console.log(`[DEBUG] SSE: Client disconnecting: ${sessionId}`);
toolCallEmitter.removeListener(eventName, handleToolCall);
sseClients.delete(sessionId);
if (DEBUG) console.log(`[DEBUG] SSE: Client removed for session ${sessionId}. Remaining clients: ${sseClients.size}`);
});
},
// Cancellation endpoint
'POST /cancel-request': async (req, res) => {
handlePostRequest(req, res, async (body) => {
const { sessionId } = body;
if (!sessionId) return sendError(res, 400, 'Missing required parameter: sessionId');
const DEBUG = process.env.DEBUG_CHAT === '1';
if (DEBUG) console.log(`\n[DEBUG] ===== Cancel Request for Session: ${sessionId} =====`);
// 1. Cancel Tool Executions (via probeTool.js)
const toolExecutionsCancelled = cancelToolExecutions(sessionId);
// 2. Cancel Active Chat Request (via probeChat instance)
const chatInstance = activeChatInstances.get(sessionId);
let chatInstanceAborted = false;
if (chatInstance && typeof chatInstance.abort === 'function') {
try {
chatInstance.abort(); // This sets chatInstance.cancelled = true and aborts controller
chatInstanceAborted = true;
if (DEBUG) console.log(`[DEBUG] Aborted chat instance processing for session: ${sessionId}`);
} catch (error) {
console.error(`Error aborting chat instance for session ${sessionId}:`, error);
}
} else {
if (DEBUG) console.log(`[DEBUG] No active chat instance found in map for session ${sessionId} to abort.`);
}
// 3. Cancel the request tracking entry (via cancelRequest.js - might be redundant if chatInstance.abort works)
const requestCancelled = cancelRequest(sessionId); // This calls the registered abort function
// Clean up map entry (might be done in finally block of chat endpoint too)
activeChatInstances.delete(sessionId);
console.log(`Cancellation processed for session ${sessionId}: Tools=${toolExecutionsCancelled}, Chat=${chatInstanceAborted}, RequestTracking=${requestCancelled}`);
sendJson(res, 200, {
success: true,
message: 'Cancellation request processed',
details: { toolExecutionsCancelled, chatInstanceAborted, requestCancelled },
timestamp: new Date().toISOString()
});
});
},
// --- Direct API Tool Endpoints (Bypass LLM Loop) ---
'POST /api/search': async (req, res) => {
handlePostRequest(req, res, async (body) => {
const { query, path, allow_tests, maxResults, maxTokens, sessionId: reqSessionId } = body; // Renamed params
if (!query) return sendError(res, 400, 'Missing required parameter: query');
const sessionId = reqSessionId || randomUUID(); // Use provided or generate new for direct call
const toolParams = { query, path, allow_tests, maxResults, maxTokens, sessionId };
await executeDirectTool(res, directApiTools.search, 'search', toolParams, sessionId);
});
},
'POST /api/query': async (req, res) => {
handlePostRequest(req, res, async (body) => {
const { pattern, path, language, allow_tests, sessionId: reqSessionId } = body;
if (!pattern) return sendError(res, 400, 'Missing required parameter: pattern');
const sessionId = reqSessionId || randomUUID();
const toolParams = { pattern, path, language, allow_tests, sessionId };
await executeDirectTool(res, directApiTools.query, 'query', toolParams, sessionId);
});
},
'POST /api/extract': async (req, res) => {
handlePostRequest(req, res, async (body) => {
const { file_path, line, end_line, allow_tests, context_lines, format, input_content, sessionId: reqSessionId } = body;
// file_path or input_content is required by the underlying tool implementation usually
if (!file_path && !input_content) return sendError(res, 400, 'Missing required parameter: file_path or input_content');
const sessionId = reqSessionId || randomUUID();
const toolParams = { file_path, line, end_line, allow_tests, context_lines, format, input_content, sessionId };
await executeDirectTool(res, directApiTools.extract, 'extract', toolParams, sessionId);
});
},
// Implement tool endpoint (only available if allowEdit is true)
'POST /api/implement': async (req, res) => {
// Check if edit mode is enabled
if (!directApiTools.implement) {
return sendError(res, 403, 'Implement tool is not enabled. Start server with --allow-edit to enable.');
}
handlePostRequest(req, res, async (body) => {
const { task, sessionId: reqSessionId } = body;
if (!task) return sendError(res, 400, 'Missing required parameter: task');
const sessionId = reqSessionId || randomUUID();
const toolParams = { task, sessionId };
await executeDirectTool(res, directApiTools.implement, 'implement', toolParams, sessionId);
});
},
// --- Main Chat Endpoint (Handles the Loop) ---
'POST /chat': (req, res) => { // This is the route used by the frontend UI
handlePostRequest(req, res, async (requestData) => {
const {
message,
sessionId: reqSessionId,
clearHistory,
apiProvider,
apiKey,
apiUrl
} = requestData;
const DEBUG = process.env.DEBUG_CHAT === '1';
if (DEBUG) {
console.log(`\n[DEBUG] ===== UI Chat Request =====`);
console.log(`[DEBUG] Request Data:`, { ...requestData, apiKey: requestData.apiKey ? '******' : undefined });
}
// --- Session and Instance Management ---
const chatSessionId = reqSessionId || randomUUID(); // Ensure we always have a session ID
if (!reqSessionId && DEBUG) console.log(`[DEBUG] No session ID from UI, generated: ${chatSessionId}`);
else if (DEBUG) console.log(`[DEBUG] Using session ID from UI: ${chatSessionId}`);
// Get or create the chat instance *without* API key overrides here.
// API keys from request are ignored for existing sessions to preserve history consistency.
// If a *new* session is created AND keys are provided, the ProbeChat constructor *should* handle them.
// Extract API credentials from request if available
const apiCredentials = apiKey ? { apiProvider, apiKey, apiUrl } : null;
// Get or create chat instance with API credentials
const chatInstance = getOrCreateChat(chatSessionId, apiCredentials);
// Check if API keys are needed but missing
if (chatInstance.noApiKeysMode) {
console.warn(`[WARN] Chat request for session ${chatSessionId} cannot proceed: No API keys configured.`);
return sendError(res, 503, 'Chat service unavailable: API key not configured on server.');
}
// Register this request as active for cancellation
registerRequest(chatSessionId, { abort: () => chatInstance.abort() });
if (DEBUG) console.log(`[DEBUG] Registered cancellable request for session: ${chatSessionId}`);
activeChatInstances.set(chatSessionId, chatInstance); // Store for direct access during cancellation
// --- Handle Clear History ---
if (message === '__clear_history__' || clearHistory) {
console.log(`Clearing chat history for session: ${chatSessionId}`);
const newSessionId = chatInstance.clearHistory(); // clearHistory now returns the *new* session ID
// Remove old session state
clearRequest(chatSessionId);
activeChatInstances.delete(chatSessionId);
clearToolExecutionData(chatSessionId);
chatSessions.delete(chatSessionId); // Remove old instance from map
// We don't create the *new* instance here, it will be created on the *next* message request
// Create a new empty token usage object for the cleared history
const emptyTokenUsage = {
contextWindow: 0,
current: {
request: 0,
response: 0,
total: 0,
cacheRead: 0,
cacheWrite: 0,
cacheTotal: 0
},
total: {
request: 0,
response: 0,
total: 0,
cacheRead: 0,
cacheWrite: 0,
cacheTotal: 0
}
};
sendJson(res, 200, {
response: 'Chat history cleared',
tokenUsage: emptyTokenUsage, // Include empty token usage data
newSessionId: newSessionId, // Inform UI about the new ID
timestamp: new Date().toISOString()
});
return; // Stop processing
}
// --- Execute Chat Loop (Non-Streaming Response) ---
// The loop is inside chatInstance.chat now.
// We expect the *final* result string back.
try {
// Pass API credentials to the chat method if provided
const apiCredentials = apiKey ? { apiProvider, apiKey, apiUrl } : null;
const result = await chatInstance.chat(message, chatSessionId, apiCredentials); // Pass session ID and API credentials
// Check if cancelled *during* the chat call (ProbeChat throws error)
// Error handled in catch block
// Handle the new structured response format
let responseText;
let tokenUsage;
if (result && typeof result === 'object' && 'response' in result) {
// New format: { response: string, tokenUsage: object }
responseText = result.response;
tokenUsage = result.tokenUsage;
if (process.env.DEBUG_CHAT === '1') {
console.log(`[DEBUG] Received structured response with token usage data`);
console.log(`[DEBUG] Context window size: ${tokenUsage.contextWindow}`);
console.log(`[DEBUG] Cache metrics - Read: ${tokenUsage.current.cacheRead}, Write: ${tokenUsage.current.cacheWrite}`);
}
} else {
// Legacy format: string response
responseText = result;
tokenUsage = chatInstance.getTokenUsage(); // Get token usage separately
if (process.env.DEBUG_CHAT === '1') {
console.log(`[DEBUG] Received legacy response format, fetched token usage separately`);
}
}
// Create the response object with the response text and token usage data
const responseObject = {
response: responseText,
tokenUsage: tokenUsage,
sessionId: chatSessionId,
timestamp: new Date().toISOString()
};
// Send the response with token usage in both the body and header
sendJson(res, 200, responseObject, { 'X-Token-Usage': JSON.stringify(tokenUsage) });
console.log(`Finished chat request for session: ${chatSessionId}`);
} catch (error) {
// Check if the error is actually a structured response with token usage
let errorResponse = error;
let tokenUsage;
if (error && typeof error === 'object' && error.response && error.tokenUsage) {
// This is a structured error response from probeChat
errorResponse = error.response;
tokenUsage = error.tokenUsage;
if (process.env.DEBUG_CHAT === '1') {
console.log(`[DEBUG] Received structured error response with token usage data`);
console.log(`[DEBUG] Context window size: ${tokenUsage.contextWindow}`);
console.log(`[DEBUG] Cache metrics - Read: ${tokenUsage.current.cacheRead}, Write: ${tokenUsage.current.cacheWrite}`);
}
} else {
// Get token usage separately for regular errors
// First update the tokenCounter's history with the chat history
if (chatInstance.tokenCounter && typeof chatInstance.tokenCounter.updateHistory === 'function' &&
chatInstance.history) {
chatInstance.tokenCounter.updateHistory(chatInstance.history);
if (DEBUG) {
console.log(`[DEBUG] Updated tokenCounter history with ${chatInstance.history.length} messages for error case`);
}
}
// Force recalculation of context window size
if (chatInstance.tokenCounter && typeof chatInstance.tokenCounter.calculateContextSize === 'function') {
chatInstance.tokenCounter.calculateContextSize(chatInstance.history);
if (DEBUG) {
console.log(`[DEBUG] Forced recalculation of context window size for error case`);
}
}
// Get updated token usage after history update and recalculation
tokenUsage = chatInstance.getTokenUsage();
if (DEBUG) {
console.log(`[DEBUG] Error case - Final context window size: ${tokenUsage.contextWindow}`);
console.log(`[DEBUG] Error case - Cache metrics - Read: ${tokenUsage.current.cacheRead}, Write: ${tokenUsage.current.cacheWrite}`);
}
}
// Handle errors, including cancellation
if (errorResponse.message && errorResponse.message.includes('cancelled') ||
(typeof errorResponse === 'string' && errorResponse.includes('cancelled'))) {
console.log(`Chat request processing was cancelled for session: ${chatSessionId}`);
// Send structured error response with token usage
sendJson(res, 499, {
error: 'Request cancelled by user',
tokenUsage: tokenUsage,
sessionId: chatSessionId,
timestamp: new Date().toISOString()
}); // 499 Client Closed Request
} else {
console.error(`Error processing chat for session ${chatSessionId}:`, error);
// Send structured error response with token usage
sendJson(res, 500, {
error: `Chat processing error: ${typeof errorResponse === 'string' ? errorResponse : errorResponse.message || 'Unknown error'}`,
tokenUsage: tokenUsage,
sessionId: chatSessionId,
timestamp: new Date().toISOString()
});
}
} finally {
// Cleanup regardless of success, error, or cancellation
clearRequest(chatSessionId);
activeChatInstances.delete(chatSessionId);
// Don't clear tool execution data here, it might be needed if user retries
// clearToolExecutionData(chatSessionId);
if (DEBUG) console.log(`[DEBUG] Cleaned up active request tracking for session: ${chatSessionId}`);
}
}); // End handlePostRequest for /chat
} // End /chat route
}; // End routes object
// --- Request Routing ---
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const routeKey = `${req.method} ${parsedUrl.pathname}`;
const handler = routes[routeKey];
if (handler) {
// Skip auth for specific public routes
const publicRoutes = ['GET /openapi.yaml', 'GET /api/tool-events', 'GET /logo.png', 'GET /', 'GET /folders', 'OPTIONS']; // Add OPTIONS
if (publicRoutes.includes(routeKey) || req.method === 'OPTIONS') {
handler(req, res);
} else {
processRequest(handler); // Apply auth middleware
}
} else {
// No route match, return 404
sendError(res, 404, 'Not Found');
}
}); // End createServer
// Start the server
const PORT = process.env.PORT || 8080;
server.listen(PORT, () => {
console.log(`Probe Web Interface v${version}`);
console.log(`Server running on http://localhost:${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
if (noApiKeysMode) {
console.log('*** Running in NO API KEYS mode. Chat functionality disabled. ***');
}
});
}
// --- Helper Functions ---
function handleOptions(res) {
res.writeHead(200, {
'Access-Control-Allow-Origin': '*', // Or specific origin
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Session-ID', // Add any custom headers needed
'Access-Control-Max-Age': '86400' // 24 hours
});
res.end();
}
function sendJson(res, statusCode, data, headers = {}) {
if (res.headersSent) return;
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*', // Adjust as needed
'Access-Control-Expose-Headers': 'X-Token-Usage', // Expose custom headers
...headers
});
res.end(JSON.stringify(data));
}
function sendError(res, statusCode, message) {
if (res.headersSent) return;
console.error(`Sending error (${statusCode}): ${message}`);
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
});
res.end(JSON.stringify({ error: message, status: statusCode }));
}
function serveStatic(res, filePath, contentType) {
if (res.headersSent) return;
if (existsSync(filePath)) {
res.writeHead(200, { 'Content-Type': contentType });
const fileData = readFileSync(filePath);
res.end(fileData);
} else {
sendError(res, 404, `${contentType} not found`);
}
}
function serveHtml(res, filePath, bodyAttributes = {}) {
if (res.headersSent) return;
if (existsSync(filePath)) {
res.writeHead(200, { 'Content-Type': 'text/html' });
let html = readFileSync(filePath, 'utf8');
// Inject attributes into body tag
const attributesString = Object.entries(bodyAttributes)
.map(([key, value]) => `${key}="${String(value).replace(/"/g, '"')}"`)
.join(' ');
if (attributesString) {
html = html.replace('<body', `<body ${attributesString}`);
}
res.end(html);
} else {
sendError(res, 404, 'HTML file not found');
}
}
function getSessionIdFromUrl(req) {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
return url.searchParams.get('sessionId');
} catch (error) {
console.error(`Error parsing URL for sessionId: ${error.message}`);
// Fallback: manual parsing (less reliable)
const match = req.url.match(/[?&]sessionId=([^&]+)/);
return match ? match[1] : null;
}
}
async function handlePostRequest(req, res, callback) {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', async () => {
try {
const parsedBody = JSON.parse(body);
await callback(parsedBody);
} catch (error) {
if (error instanceof SyntaxError) {
sendError(res, 400, 'Invalid JSON in request body');
} else {
console.error('Error handling POST request:', error);
sendError(res, 500, `Internal Server Error: ${error.message}`);
}
}
});
req.on('error', (err) => {
console.error('Request error:', err);
sendError(res, 500, 'Request error');
});
}
async function executeDirectTool(res, toolInstance, toolName, toolParams, sessionId) {
const DEBUG = process.env.DEBUG_CHAT === '1';
if (DEBUG) {
console.log(`\n[DEBUG] ===== Direct API Tool Call: ${toolName} =====`);
console.log(`[DEBUG] Session ID: ${sessionId}`);
console.log(`[DEBUG] Params:`, toolParams);
}
try {
// Execute the tool instance directly (it handles events/cancellation)
const result = await toolInstance.execute(toolParams);
sendJson(res, 200, { results: result, timestamp: new Date().toISOString() });
} catch (error) {
console.error(`Error executing direct tool ${toolName}:`, error);
let statusCode = 500;
let errorMessage = `Error executing ${toolName}`;
if (error.message.includes('cancelled')) {
statusCode = 499; // Client Closed Request
errorMessage = 'Operation cancelled';
} else if (error.code === 'ENOENT') {
statusCode = 404; errorMessage = 'File or path not found';
} else if (error.code === 'EACCES') {
statusCode = 403; errorMessage = 'Permission denied';
}
// Add more specific error handling if needed
sendError(res, statusCode, `${errorMessage}: ${error.message}`);
}
}