import 'dotenv/config';
import { createServer } from 'http';
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 {
searchToolInstance, queryToolInstance,
extractToolInstance,
implementToolInstance,
toolCallEmitter,
cancelToolExecutions,
clearToolExecutionData,
isSessionCancelled
} from './probeTool.js';
import { registerRequest, cancelRequest, clearRequest, isRequestActive } from './cancelRequest.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const chatSessions = new Map();
function getOrCreateChat(sessionId, apiCredentials = null) {
if (!sessionId) {
sessionId = randomUUID(); console.warn(`[WARN] Missing sessionId, generated fallback: ${sessionId}`);
}
if (chatSessions.has(sessionId)) {
return chatSessions.get(sessionId);
}
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;
}
export function startWebServer(version, hasApiKeys = true, options = {}) {
const allowEdit = options?.allowEdit || false;
if (allowEdit) {
console.log('Edit mode enabled: implement tool is available');
}
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');
}
const sseClients = new Map();
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.');
}
const directApiTools = {
search: searchToolInstance,
query: queryToolInstance,
extract: extractToolInstance
};
if (allowEdit) {
directApiTools.implement = implementToolInstance;
}
function sendSSEData(res, data, eventType = 'message') {
const DEBUG = process.env.DEBUG_CHAT === '1';
try {
if (!res.writable || res.writableEnded) {
if (DEBUG) console.log(`[DEBUG] SSE stream closed for event type ${eventType}, cannot send.`);
return;
}
if (DEBUG) {
}
res.write(`event: ${eventType}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
if (DEBUG) {
}
} catch (error) {
console.error(`[ERROR] Error sending SSE data:`, error);
try {
if (res.writable && !res.writableEnded) res.end();
} catch (closeError) {
console.error(`[ERROR] Error closing SSE stream after send error:`, closeError);
}
}
}
const activeChatInstances = new Map();
const server = createServer(async (req, res) => {
const processRequest = (routeHandler) => {
authMiddleware(req, res, () => {
routeHandler(req, res);
});
};
const routes = {
'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),
'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';
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`);
}
}
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}`);
}
sendJson(res, 200, tokenUsage);
},
'GET /logo.png': (req, res) => serveStatic(res, join(__dirname, 'logo.png'), 'image/png'),
'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();
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'),
'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}`);
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*' });
if (DEBUG) console.log(`[DEBUG] SSE: Headers set for session: ${sessionId}`);
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}`);
const handleToolCall = (toolCall) => {
if (DEBUG) {
}
const serializableCall = {
...toolCall,
timestamp: toolCall.timestamp || new Date().toISOString(),
_sse_sent_at: new Date().toISOString()
};
sendSSEData(res, serializableCall, 'toolCall'); };
const eventName = `toolCall:${sessionId}`;
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}`);
sseClients.set(sessionId, { res, handler: handleToolCall });
if (DEBUG) console.log(`[DEBUG] SSE: Client added for session ${sessionId}. Total clients: ${sseClients.size}`);
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}`);
});
},
'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} =====`);
const toolExecutionsCancelled = cancelToolExecutions(sessionId);
const chatInstance = activeChatInstances.get(sessionId);
let chatInstanceAborted = false;
if (chatInstance && typeof chatInstance.abort === 'function') {
try {
chatInstance.abort(); 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.`);
}
const requestCancelled = cancelRequest(sessionId);
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()
});
});
},
'POST /api/search': async (req, res) => {
handlePostRequest(req, res, async (body) => {
const { query, path, allow_tests, maxResults, maxTokens, sessionId: reqSessionId } = body; if (!query) return sendError(res, 400, 'Missing required parameter: query');
const sessionId = reqSessionId || randomUUID(); 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;
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);
});
},
'POST /api/implement': async (req, res) => {
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);
});
},
'POST /chat': (req, res) => { 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 });
}
const chatSessionId = reqSessionId || randomUUID(); 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}`);
const apiCredentials = apiKey ? { apiProvider, apiKey, apiUrl } : null;
const chatInstance = getOrCreateChat(chatSessionId, apiCredentials);
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.');
}
registerRequest(chatSessionId, { abort: () => chatInstance.abort() });
if (DEBUG) console.log(`[DEBUG] Registered cancellable request for session: ${chatSessionId}`);
activeChatInstances.set(chatSessionId, chatInstance);
if (message === '__clear_history__' || clearHistory) {
console.log(`Clearing chat history for session: ${chatSessionId}`);
const newSessionId = chatInstance.clearHistory(); clearRequest(chatSessionId);
activeChatInstances.delete(chatSessionId);
clearToolExecutionData(chatSessionId);
chatSessions.delete(chatSessionId);
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, newSessionId: newSessionId, timestamp: new Date().toISOString()
});
return; }
try {
const apiCredentials = apiKey ? { apiProvider, apiKey, apiUrl } : null;
const result = await chatInstance.chat(message, chatSessionId, apiCredentials);
let responseText;
let tokenUsage;
if (result && typeof result === 'object' && 'response' in result) {
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 {
responseText = result;
tokenUsage = chatInstance.getTokenUsage();
if (process.env.DEBUG_CHAT === '1') {
console.log(`[DEBUG] Received legacy response format, fetched token usage separately`);
}
}
const responseObject = {
response: responseText,
tokenUsage: tokenUsage,
sessionId: chatSessionId,
timestamp: new Date().toISOString()
};
sendJson(res, 200, responseObject, { 'X-Token-Usage': JSON.stringify(tokenUsage) });
console.log(`Finished chat request for session: ${chatSessionId}`);
} catch (error) {
let errorResponse = error;
let tokenUsage;
if (error && typeof error === 'object' && error.response && error.tokenUsage) {
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 {
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`);
}
}
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`);
}
}
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}`);
}
}
if (errorResponse.message && errorResponse.message.includes('cancelled') ||
(typeof errorResponse === 'string' && errorResponse.includes('cancelled'))) {
console.log(`Chat request processing was cancelled for session: ${chatSessionId}`);
sendJson(res, 499, {
error: 'Request cancelled by user',
tokenUsage: tokenUsage,
sessionId: chatSessionId,
timestamp: new Date().toISOString()
}); } else {
console.error(`Error processing chat for session ${chatSessionId}:`, error);
sendJson(res, 500, {
error: `Chat processing error: ${typeof errorResponse === 'string' ? errorResponse : errorResponse.message || 'Unknown error'}`,
tokenUsage: tokenUsage,
sessionId: chatSessionId,
timestamp: new Date().toISOString()
});
}
} finally {
clearRequest(chatSessionId);
activeChatInstances.delete(chatSessionId);
if (DEBUG) console.log(`[DEBUG] Cleaned up active request tracking for session: ${chatSessionId}`);
}
}); } };
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const routeKey = `${req.method} ${parsedUrl.pathname}`;
const handler = routes[routeKey];
if (handler) {
const publicRoutes = ['GET /openapi.yaml', 'GET /api/tool-events', 'GET /logo.png', 'GET /', 'GET /folders', 'OPTIONS']; if (publicRoutes.includes(routeKey) || req.method === 'OPTIONS') {
handler(req, res);
} else {
processRequest(handler); }
} else {
sendError(res, 404, 'Not Found');
}
});
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. ***');
}
});
}
function handleOptions(res) {
res.writeHead(200, {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Session-ID', 'Access-Control-Max-Age': '86400' });
res.end();
}
function sendJson(res, statusCode, data, headers = {}) {
if (res.headersSent) return;
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*', 'Access-Control-Expose-Headers': 'X-Token-Usage', ...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');
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}`);
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 {
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; 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';
}
sendError(res, statusCode, `${errorMessage}: ${error.message}`);
}
}