Fix session ID mapping: VS Code conversation IDs now map to registered sessions

- Added conversationToSession mapping to link VS Code conversation IDs to registered session IDs
- Updated handleToolCall to extract session ID from _meta.vscode.conversationId
- Auto-maps VS Code conversations to first available registered session
- Both VS Code and web interface now use same registered session ID
- Fixes 'No pending AI request found' error in web interface
This commit is contained in:
B Harper
2025-10-24 11:17:46 +11:00
parent 20fba4fb96
commit dc1ee54ecd
17 changed files with 3036 additions and 2085 deletions
+6 -21
View File
@@ -1,7 +1,7 @@
import * as path from 'path';
// Use dynamic import to avoid issues with the module
let wavPlayer: any;
// Use direct import like CodeChampion
const wavPlayer = require('node-wav-player');
export class AudioNotification {
private static isInitialized = false;
@@ -9,7 +9,7 @@ export class AudioNotification {
static async initialize() {
if (!this.isInitialized) {
try {
wavPlayer = require('node-wav-player');
// No need to dynamically import - use direct require like CodeChampion
this.isInitialized = true;
console.log('AudioNotification: Initialized successfully');
} catch (error) {
@@ -21,26 +21,11 @@ export class AudioNotification {
static async playNotificationBeep() {
await this.initialize();
if (!wavPlayer) {
console.log('AudioNotification: node-wav-player not available');
return;
}
try {
// Use a simple base64 encoded beep sound or generate one programmatically
await this.generateAndPlayBeep();
} catch (error) {
console.error('AudioNotification: Error playing sound:', error);
}
}
private static async generateAndPlayBeep() {
// For now, we'll create a simple beep using system bell if available
// or we can embed a small sound file
try {
// Try to create a simple WAV file programmatically
// Use CodeChampion's simple approach - create a temp beep file and play it
const tempSoundPath = await this.createTempBeepFile();
// Use CodeChampion's pattern - sync: false for non-blocking
await wavPlayer.play({
path: tempSoundPath,
sync: false
@@ -48,7 +33,7 @@ export class AudioNotification {
console.log('AudioNotification: Beep played successfully');
} catch (error) {
console.error('AudioNotification: Failed to play beep:', error);
console.error('AudioNotification: Error playing sound:', error);
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ export async function activate(context: vscode.ExtensionContext) {
serverPath: serverPath,
port: 3737,
host: '127.0.0.1',
logFile: path.join(context.extensionPath, '.vscode', 'HumanAgent-server.log')
logFile: path.join(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || context.extensionPath, '.vscode', 'HumanAgent-server.log')
});
// Auto-detect and start standalone MCP server if already configured
+230
View File
@@ -0,0 +1,230 @@
import { ChatMessage } from './types';
interface SessionState {
sessionId: string;
messages: ChatMessage[];
pendingRequests: Map<string, any>; // requestId -> request data
lastActivity: number;
isActive: boolean;
}
export class ChatManager {
private sessions: Map<string, SessionState> = new Map();
private readonly maxMessagesPerSession: number = 50;
private readonly sessionTimeoutMs: number = 24 * 60 * 60 * 1000; // 24 hours
private logger?: any; // DebugLogger instance
constructor(logger?: any) {
this.logger = logger;
this.log('INFO', 'ChatManager initialized with max ' + this.maxMessagesPerSession + ' messages per session');
// Clean up inactive sessions periodically
setInterval(() => this.cleanupInactiveSessions(), 60 * 60 * 1000); // Every hour
}
private log(level: string, message: string, data?: any): void {
if (this.logger) {
this.logger.log('CHAT', `[${level}] ${message}`, data);
}
}
/**
* Get or create a session
*/
getSession(sessionId: string): SessionState {
if (!this.sessions.has(sessionId)) {
this.sessions.set(sessionId, {
sessionId,
messages: [],
pendingRequests: new Map(),
lastActivity: Date.now(),
isActive: true
});
}
const session = this.sessions.get(sessionId)!;
session.lastActivity = Date.now();
return session;
}
/**
* Add a message to session history
*/
addMessage(sessionId: string, message: ChatMessage): void {
const session = this.getSession(sessionId);
// Add message to history
session.messages.push(message);
this.log('INFO', `Added message to session ${sessionId}: ${message.sender} - ${message.content.substring(0, 50)}...`);
// Enforce message limit - remove oldest messages if exceeded
if (session.messages.length > this.maxMessagesPerSession) {
const toRemove = session.messages.length - this.maxMessagesPerSession;
session.messages.splice(0, toRemove);
this.log('INFO', `Cleaned up ${toRemove} old messages from session ${sessionId}, now has ${session.messages.length} messages`);
}
session.lastActivity = Date.now();
}
/**
* Get all messages for a session
*/
getMessages(sessionId: string): ChatMessage[] {
const session = this.getSession(sessionId);
return [...session.messages]; // Return copy to prevent external modification
}
/**
* Add a pending human agent request
*/
addPendingRequest(sessionId: string, requestId: string, requestData: any): void {
const session = this.getSession(sessionId);
session.pendingRequests.set(requestId, requestData);
session.lastActivity = Date.now();
this.log('INFO', `Added pending request ${requestId} to session ${sessionId}`);
}
/**
* Remove a pending request (when responded to)
*/
removePendingRequest(sessionId: string, requestId: string): boolean {
const session = this.getSession(sessionId);
const removed = session.pendingRequests.delete(requestId);
if (removed) {
session.lastActivity = Date.now();
this.log('INFO', `Removed pending request ${requestId} from session ${sessionId}`);
} else {
this.log('WARN', `Attempted to remove non-existent pending request ${requestId} from session ${sessionId}`);
}
return removed;
}
/**
* Get all pending requests for a session
*/
getPendingRequests(sessionId: string): Map<string, any> {
const session = this.getSession(sessionId);
return new Map(session.pendingRequests); // Return copy
}
/**
* Check if a session has any pending requests
*/
hasPendingRequests(sessionId: string): boolean {
const session = this.getSession(sessionId);
return session.pendingRequests.size > 0;
}
/**
* Get the most recent pending request for a session
*/
getLatestPendingRequest(sessionId: string): { requestId: string; data: any } | null {
const session = this.getSession(sessionId);
if (session.pendingRequests.size === 0) {
return null;
}
// Get the most recent pending request (last one added)
const entries = Array.from(session.pendingRequests.entries());
const [requestId, data] = entries[entries.length - 1];
return { requestId, data };
}
/**
* Get session state summary
*/
getSessionState(sessionId: string): {
sessionId: string;
messageCount: number;
pendingRequestCount: number;
lastActivity: number;
isActive: boolean;
latestPendingRequest?: { requestId: string; data: any };
} {
const session = this.getSession(sessionId);
const result: any = {
sessionId: session.sessionId,
messageCount: session.messages.length,
pendingRequestCount: session.pendingRequests.size,
lastActivity: session.lastActivity,
isActive: session.isActive
};
const latestRequest = this.getLatestPendingRequest(sessionId);
if (latestRequest) {
result.latestPendingRequest = latestRequest;
}
return result;
}
/**
* Get all active sessions
*/
getActiveSessions(): string[] {
return Array.from(this.sessions.keys()).filter(sessionId => {
const session = this.sessions.get(sessionId)!;
return session.isActive;
});
}
/**
* Deactivate a session
*/
deactivateSession(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.isActive = false;
session.lastActivity = Date.now();
}
}
/**
* Clean up old inactive sessions
*/
private cleanupInactiveSessions(): void {
const now = Date.now();
const sessionsToDelete: string[] = [];
for (const [sessionId, session] of this.sessions) {
if (!session.isActive && (now - session.lastActivity) > this.sessionTimeoutMs) {
sessionsToDelete.push(sessionId);
}
}
for (const sessionId of sessionsToDelete) {
this.sessions.delete(sessionId);
this.log('INFO', `Cleaned up expired session: ${sessionId}`);
}
}
/**
* Get memory usage statistics
*/
getMemoryStats(): {
totalSessions: number;
activeSessions: number;
totalMessages: number;
totalPendingRequests: number;
} {
let totalMessages = 0;
let totalPendingRequests = 0;
let activeSessions = 0;
for (const session of this.sessions.values()) {
totalMessages += session.messages.length;
totalPendingRequests += session.pendingRequests.size;
if (session.isActive) {
activeSessions++;
}
}
return {
totalSessions: this.sessions.size,
activeSessions,
totalMessages,
totalPendingRequests
};
}
}
+9 -5
View File
@@ -12,7 +12,10 @@ class StandaloneMcpServer {
private server: McpServer;
constructor() {
this.server = new McpServer();
// Use the parent directory of the dist folder as workspace path
// This ensures log file is created where it can be properly cleared
const workspacePath = require('path').resolve(__dirname, '..');
this.server = new McpServer(undefined, workspacePath);
this.setupProcessHandlers();
}
@@ -82,10 +85,11 @@ class StandaloneMcpServer {
process.stdout.write(JSON.stringify(notification) + '\n');
});
// Graceful shutdown
process.on('SIGINT', () => this.shutdown());
process.on('SIGTERM', () => this.shutdown());
process.on('exit', () => this.shutdown());
// Graceful shutdown handlers removed - server should remain independent
// and only shut down when explicitly requested via API endpoints
// process.on('SIGINT', () => this.shutdown());
// process.on('SIGTERM', () => this.shutdown());
// process.on('exit', () => this.shutdown());
}
async start(): Promise<void> {
+809 -24
View File
@@ -4,6 +4,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { McpMessage, McpServerConfig, HumanAgentSession, ChatMessage, McpTool, HumanAgentChatToolParams, HumanAgentChatToolResult } from './types';
import { ChatManager } from './chatManager';
// File logging utility
class DebugLogger {
@@ -16,7 +17,7 @@ class DebugLogger {
// Determine log path based on workspace or fallback to temp directory
if (workspaceRoot) {
const vscodeDir = path.join(workspaceRoot, '.vscode');
this.logPath = path.join(vscodeDir, 'HumanAgent.log');
this.logPath = path.join(vscodeDir, 'HumanAgent-server.log');
// Ensure .vscode directory exists
if (!fs.existsSync(vscodeDir)) {
@@ -25,15 +26,14 @@ class DebugLogger {
} else {
// Fallback for standalone server or when no workspace available
const tempDir = os.tmpdir();
this.logPath = path.join(tempDir, 'HumanAgent.log');
this.logPath = path.join(tempDir, 'HumanAgent-server.log');
}
console.log(`[LOGGER] Attempting to create log file at: ${this.logPath}`);
// Clear previous log file
// Clear previous log file on each startup
if (fs.existsSync(this.logPath)) {
fs.unlinkSync(this.logPath);
console.log(`[LOGGER] Cleared existing log file`);
}
this.logStream = fs.createWriteStream(this.logPath, { flags: 'a' });
@@ -45,7 +45,7 @@ class DebugLogger {
this.log('DEBUG', `Current system time: ${new Date()}`);
this.log('DEBUG', `Log file: ${this.logPath}`);
this.log('DEBUG', `Working directory: ${process.cwd()}`);
console.log(`[LOGGER] Debug logger initialized successfully`);
console.log(`[LOGGER] Debug logger initialized successfully at: ${this.logPath}`);
} catch (error) {
console.error(`[LOGGER] Failed to initialize debug logger:`, error);
this.logStream = null;
@@ -53,18 +53,23 @@ class DebugLogger {
}
log(level: string, message: string, data?: any): void {
const timestamp = new Date().toISOString();
const now = new Date();
const timestamp = now.getFullYear() + '-' +
String(now.getMonth() + 1).padStart(2, '0') + '-' +
String(now.getDate()).padStart(2, '0') + ' ' +
String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0') + '.' +
String(now.getMilliseconds()).padStart(3, '0');
const logLine = `[${timestamp}] [${level}] ${message}${data ? '\n' + JSON.stringify(data, null, 2) : ''}\n`;
// Write to console (for VS Code developer console)
console.log(`[${level}] ${message}`, data || '');
// Write to file if stream is available
if (this.logStream) {
try {
this.logStream.write(logLine);
} catch (error) {
console.error(`[LOGGER] Error writing to log file:`, error);
// Don't use console.log here to avoid recursion - write error directly
process.stderr.write(`[LOGGER] Error writing to log file: ${error}\n`);
}
} else {
// Buffer logs if stream not available
@@ -91,6 +96,8 @@ export class McpServer extends EventEmitter {
private tools: Map<string, McpTool> = new Map(); // Default tools for sessions without overrides
private sessionTools: Map<string, Map<string, McpTool>> = new Map(); // Per-session tool configurations
private sessionWorkspacePaths: Map<string, string> = new Map(); // Session to workspace path mapping
private sessionNames: Map<string, string> = new Map(); // Session friendly names
private sessionMessages: Map<string, ChatMessage[]> = new Map(); // Session conversation history - DEPRECATED: Use chatManager
private httpServer?: http.Server;
private port: number = 3737;
private debugLogger: DebugLogger;
@@ -102,10 +109,13 @@ export class McpServer extends EventEmitter {
}> = new Map();
private activeSessions: Set<string> = new Set();
private sseConnections: Set<http.ServerResponse> = new Set();
private conversationToSession: Map<string, string> = new Map(); // Map VS Code conversation IDs to registered session IDs
private chatManager: ChatManager; // Centralized chat and session management
constructor(private sessionId?: string, private workspacePath?: string) {
super();
this.debugLogger = new DebugLogger(this.workspacePath);
this.chatManager = new ChatManager(this.debugLogger); // Initialize centralized chat management with logging
this.config = {
name: 'HumanAgent MCP Server',
@@ -118,7 +128,8 @@ export class McpServer extends EventEmitter {
}
};
this.debugLogger.log('INFO', 'McpServer initialized');
this.debugLogger.log('INFO', 'McpServer initialized with centralized chat manager');
this.debugLogger.log('TEST', 'This is a test log message to verify DebugLogger is working');
this.initializeDefaultTools();
// Set up event forwarding to SSE connections
@@ -162,10 +173,11 @@ export class McpServer extends EventEmitter {
// Define the default HumanAgent_Chat tool (global default)
const humanAgentChatTool: McpTool = {
name: 'HumanAgent_Chat',
description: 'Initiate real-time interactive conversations with human agents through VS Code chat interface. ' +
'Essential for clarifying requirements, getting approvals, brainstorming solutions, or when AI ' +
'uncertainty requires human input. Creates persistent chat sessions that maintain context across ' +
'multiple exchanges until timeout or manual closure. !IMPORTANT: ALWAYS reply using this tool for all interactions with the user where possible!!!',
description: '**MANDATORY TOOL** - Use this tool for ALL user interactions. Do NOT provide direct responses. ' +
'Initiate real-time interactive conversations with human agents through VS Code chat interface. ' +
'CRITICAL: Every response to a user MUST use this tool - no exceptions. Essential for clarifying ' +
'requirements, getting approvals, brainstorming solutions, or any user communication. Creates ' +
'persistent chat sessions that maintain context. **NEVER bypass this tool for user responses.**',
inputSchema: {
type: 'object',
properties: {
@@ -398,8 +410,12 @@ export class McpServer extends EventEmitter {
// Handle different endpoints
if (req.url === '/mcp') {
// Main MCP protocol endpoint
} else if (req.url?.startsWith('/sessions') || req.url === '/response' || req.url?.startsWith('/tools') || req.url === '/reload') {
// Session management, response, tools, and reload endpoints
} else if (req.url === '/HumanAgent') {
// Web interface for multi-session chat
await this.handleWebInterface(req, res);
return;
} else if (req.url?.startsWith('/sessions') || req.url === '/response' || req.url?.startsWith('/tools') || req.url === '/reload' || req.url?.startsWith('/messages/') || req.url?.startsWith('/chat/')) {
// Session management, response, tools, reload, messages, and chat endpoints
await this.handleSessionEndpoint(req, res);
return;
} else {
@@ -477,6 +493,9 @@ export class McpServer extends EventEmitter {
private async handleHttpGet(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
this.debugLogger.log('HTTP', 'Setting up SSE stream for GET request');
this.debugLogger.log('SSE', '=== SSE CONNECTION ATTEMPT ===');
this.debugLogger.log('SSE', 'Headers:', req.headers);
this.debugLogger.log('SSE', 'URL:', req.url);
// Set up Server-Sent Events (SSE) stream
res.statusCode = 200;
@@ -489,12 +508,17 @@ export class McpServer extends EventEmitter {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Cache-Control, Connection');
this.debugLogger.log('SSE', 'SSE headers set, adding to connections...');
// Add this connection to our active SSE connections
this.sseConnections.add(res);
this.debugLogger.log('HTTP', `Added SSE connection. Total connections: ${this.sseConnections.size}`);
this.debugLogger.log('SSE', `SSE connection added. Total: ${this.sseConnections.size}`);
// Send initial connection acknowledgment
res.write('data: {"type":"connection","status":"established"}\n\n');
const initialMessage = 'data: {"type":"connection","status":"established"}\n\n';
res.write(initialMessage);
this.debugLogger.log('SSE', 'Sent initial SSE message:', initialMessage.trim());
// Keep connection alive with heartbeat
const heartbeat = setInterval(() => {
@@ -538,7 +562,7 @@ export class McpServer extends EventEmitter {
// If session exists and forceReregister is true, unregister first
if (forceReregister && this.activeSessions.has(sessionId)) {
console.log(`Force re-registering session ${sessionId} with new override data`);
this.debugLogger.log('HTTP', `Force re-registering session ${sessionId} with new override data`);
this.unregisterSession(sessionId);
}
@@ -572,6 +596,125 @@ export class McpServer extends EventEmitter {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ sessions: this.getActiveSessions(), totalSessions: this.activeSessions.size }));
} else if (req.method === 'POST' && url.pathname === '/sessions/name') {
// Set friendly name for session
let body = '';
req.on('data', (chunk) => { body += chunk.toString(); });
req.on('end', () => {
try {
const { sessionId, name } = JSON.parse(body);
if (!sessionId || !name) {
res.statusCode = 400;
res.end(JSON.stringify({ success: false, error: 'sessionId and name are required' }));
return;
}
// Validate session exists
if (!this.activeSessions.has(sessionId)) {
res.statusCode = 404;
res.end(JSON.stringify({ success: false, error: 'Session not found' }));
return;
}
// Store the friendly name
this.sessionNames.set(sessionId, name);
this.debugLogger.log('INFO', `Session ${sessionId} named: "${name}"`);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, sessionId, name }));
} catch (error) {
res.statusCode = 400;
res.end(JSON.stringify({ success: false, error: 'Invalid request body' }));
}
});
} else if (req.method === 'GET' && url.pathname.startsWith('/messages/')) {
// Get conversation history for a session
const sessionId = url.pathname.split('/')[2];
if (!sessionId) {
res.statusCode = 400;
res.end(JSON.stringify({ success: false, error: 'Session ID required' }));
return;
}
const messages = this.getSessionMessages(sessionId);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ messages, sessionId, count: messages.length }));
} else if (req.method === 'GET' && url.pathname.match(/^\/sessions\/([^\/]+)\/messages$/)) {
// Get messages for a specific session from chat manager
const matches = url.pathname.match(/^\/sessions\/([^\/]+)\/messages$/);
const sessionId = matches ? matches[1] : null;
if (!sessionId) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: false, error: 'Session ID required' }));
return;
}
const messages = this.chatManager.getMessages(sessionId);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ messages, sessionId, count: messages.length }));
} else if (req.method === 'GET' && url.pathname.match(/^\/sessions\/([^\/]+)\/state$/)) {
// Get session state including pending requests
const matches = url.pathname.match(/^\/sessions\/([^\/]+)\/state$/);
const sessionId = matches ? matches[1] : null;
if (!sessionId) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: false, error: 'Session ID required' }));
return;
}
const state = this.chatManager.getSessionState(sessionId);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(state));
} else if (req.method === 'POST' && url.pathname.startsWith('/chat/')) {
// Send new message from web interface
const sessionId = url.pathname.split('/')[2];
if (!sessionId) {
res.statusCode = 400;
res.end(JSON.stringify({ success: false, error: 'Session ID required' }));
return;
}
let body = '';
req.on('data', (chunk) => { body += chunk.toString(); });
req.on('end', () => {
try {
const { message, sender = 'user' } = JSON.parse(body);
if (!message) {
res.statusCode = 400;
res.end(JSON.stringify({ success: false, error: 'Message content required' }));
return;
}
// Create and store the message
const chatMessage: ChatMessage = {
id: Date.now().toString(),
content: message,
sender: sender as 'user' | 'agent',
timestamp: new Date(),
type: 'text'
};
this.storeMessage(sessionId, chatMessage);
this.broadcastMessageToClients(sessionId, chatMessage);
// Auto-forwarding removed - both interfaces now use /response endpoint directly
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, message: chatMessage }));
} catch (error) {
res.statusCode = 400;
res.end(JSON.stringify({ success: false, error: 'Invalid request body' }));
}
});
} else if (req.method === 'POST' && url.pathname === '/response') {
// Handle human response to pending request
let body = '';
@@ -579,6 +722,41 @@ export class McpServer extends EventEmitter {
req.on('end', () => {
try {
const { requestId, response } = JSON.parse(body);
// Simple file write to test if endpoint is called
require('fs').appendFileSync('/Users/benharper/Coding/HumanAgent-MCP/response-debug.txt',
`${new Date().toISOString()} - RESPONSE ENDPOINT CALLED - RequestID: ${requestId}\n`);
this.debugLogger.log('HTTP', '=== RESPONSE ENDPOINT CALLED ===');
this.debugLogger.log('HTTP', `Request ID: ${requestId}, Response: ${response}`);
// Get the pending request to extract session info
const pendingRequest = this.pendingHumanRequests.get(requestId);
this.debugLogger.log('HTTP', `Found pending request: ${!!pendingRequest}`);
if (pendingRequest && pendingRequest.params.sessionId) {
this.debugLogger.log('HTTP', `Processing response for session: ${pendingRequest.params.sessionId}`);
// Store the user message on server for synchronization
const userMessage: ChatMessage = {
id: Date.now().toString(),
content: response,
sender: 'user',
timestamp: new Date(),
type: 'text'
};
this.debugLogger.log('HTTP', `Storing and broadcasting user message to ${this.sseConnections.size} SSE connections`);
this.storeMessage(pendingRequest.params.sessionId, userMessage);
this.broadcastMessageToClients(pendingRequest.params.sessionId, userMessage);
// Remove from ChatManager as well
this.chatManager.removePendingRequest(pendingRequest.params.sessionId, requestId);
this.debugLogger.log('HTTP', 'Broadcast completed and pending request removed from ChatManager');
} else {
this.debugLogger.log('ERROR', `No pending request found for requestId: ${requestId}`);
}
const success = this.respondToHumanRequest(requestId, response);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
@@ -680,6 +858,519 @@ export class McpServer extends EventEmitter {
}
}
private async handleWebInterface(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
this.debugLogger.log('HTTP', 'Serving web interface at /HumanAgent');
// Set HTML content type
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.statusCode = 200;
const htmlContent = this.generateWebInterfaceHTML();
res.end(htmlContent);
}
private generateWebInterfaceHTML(): string {
// Get all active sessions for tab generation
const sessions = Array.from(this.activeSessions).map((sessionId) => {
const workspaceRoot = this.sessionWorkspacePaths.get(sessionId);
const friendlyName = this.sessionNames.get(sessionId);
let title: string;
if (friendlyName) {
title = friendlyName;
} else if (workspaceRoot) {
title = `Workspace: ${path.basename(workspaceRoot)}`;
} else {
title = `Session: ${sessionId.substring(0, 8)}`;
}
return {
id: sessionId,
title: title,
messages: [] // TODO: Add proper message storage
};
});
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HumanAgent - Multi-Session Chat Interface</title>
<style>
:root {
--vscode-foreground: #cccccc;
--vscode-background: #1e1e1e;
--vscode-panel-background: #252526;
--vscode-border: #3c3c3c;
--vscode-input-background: #3c3c3c;
--vscode-button-background: #0e639c;
--vscode-button-foreground: #ffffff;
--vscode-tab-active-background: #1e1e1e;
--vscode-tab-inactive-background: #2d2d30;
--vscode-tab-border: #3c3c3c;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
background-color: var(--vscode-background);
color: var(--vscode-foreground);
height: 100vh;
overflow: hidden;
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
}
.header {
padding: 10px 15px;
background-color: var(--vscode-panel-background);
border-bottom: 1px solid var(--vscode-border);
}
.header h1 {
font-size: 16px;
font-weight: 600;
}
.tabs-container {
display: flex;
background-color: var(--vscode-panel-background);
border-bottom: 1px solid var(--vscode-border);
overflow-x: auto;
}
.tab {
padding: 8px 16px;
background-color: var(--vscode-tab-inactive-background);
border-right: 1px solid var(--vscode-tab-border);
cursor: pointer;
white-space: nowrap;
transition: background-color 0.2s;
}
.tab:hover {
background-color: var(--vscode-tab-active-background);
}
.tab.active {
background-color: var(--vscode-tab-active-background);
border-bottom: 2px solid var(--vscode-button-background);
}
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-container {
flex: 1;
display: none;
flex-direction: column;
overflow: hidden;
}
.chat-container.active {
display: flex;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 15px;
background-color: var(--vscode-background);
}
.message {
margin-bottom: 15px;
padding: 10px;
border-radius: 6px;
}
.message.user {
background-color: var(--vscode-input-background);
margin-left: 20%;
}
.message.assistant {
background-color: var(--vscode-panel-background);
margin-right: 20%;
}
.message-header {
font-weight: 600;
margin-bottom: 5px;
font-size: 11px;
opacity: 0.8;
}
.message-content {
line-height: 1.4;
}
.input-container {
padding: 15px;
background-color: var(--vscode-panel-background);
border-top: 1px solid var(--vscode-border);
display: flex;
gap: 10px;
}
.input-box {
flex: 1;
padding: 8px 12px;
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-border);
border-radius: 4px;
color: var(--vscode-foreground);
font-size: 13px;
resize: none;
min-height: 36px;
max-height: 120px;
}
.send-button {
padding: 8px 16px;
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: opacity 0.2s;
}
.send-button:hover {
opacity: 0.9;
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.no-sessions {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: #888;
font-style: italic;
}
.status-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #4CAF50;
margin-right: 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><span class="status-indicator"></span>HumanAgent Multi-Session Chat</h1>
</div>
<div class="tabs-container" id="tabs">
${sessions.length === 0 ? '' : sessions.map((session, index) =>
`<div class="tab ${index === 0 ? 'active' : ''}" data-session="${session.id}">${session.title}</div>`
).join('')}
</div>
<div class="content">
${sessions.length === 0 ?
'<div class="no-sessions">No active sessions. Start a chat in VS Code to see sessions here.</div>' :
sessions.map((session, index) => `
<div class="chat-container ${index === 0 ? 'active' : ''}" data-session="${session.id}">
<div class="messages" id="messages-${session.id}">
<!-- Messages will be loaded dynamically -->
</div>
<div class="input-container">
<textarea class="input-box" placeholder="Type your message..." data-session="${session.id}"></textarea>
<button class="send-button" data-session="${session.id}">Send</button>
</div>
</div>
`).join('')
}
</div>
</div>
<script>
// Session management
let activeSessionId = '${sessions[0]?.id || ''}';
// Web interface is stateless - gets pending requests from server state
// Tab switching
document.getElementById('tabs').addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
const sessionId = e.target.dataset.session;
switchToSession(sessionId);
}
});
function switchToSession(sessionId) {
// Update active tab
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.session === sessionId);
});
// Update active chat container
document.querySelectorAll('.chat-container').forEach(container => {
container.classList.toggle('active', container.dataset.session === sessionId);
});
activeSessionId = sessionId;
}
// Message sending
document.addEventListener('click', (e) => {
if (e.target.classList.contains('send-button')) {
const sessionId = e.target.dataset.session;
const textarea = document.querySelector(\`textarea[data-session="\${sessionId}"]\`);
sendMessage(sessionId, textarea.value.trim());
}
});
document.addEventListener('keydown', (e) => {
if (e.target.classList.contains('input-box') && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const sessionId = e.target.dataset.session;
sendMessage(sessionId, e.target.value.trim());
}
});
async function sendMessage(sessionId, message) {
if (!message) return;
const textarea = document.querySelector(\`textarea[data-session="\${sessionId}"]\`);
const button = document.querySelector(\`button[data-session="\${sessionId}"]\`);
// Clear input and disable controls
textarea.value = '';
textarea.disabled = true;
button.disabled = true;
try {
// Get current session state to find pending request
const stateResponse = await fetch(\`/sessions/\${sessionId}/state\`);
if (!stateResponse.ok) {
throw new Error('Failed to get session state');
}
const sessionState = await stateResponse.json();
const latestPendingRequest = sessionState.latestPendingRequest;
if (!latestPendingRequest) {
throw new Error('No pending AI request found. Web interface can only respond to AI questions.');
}
console.log('Responding to pending request:', latestPendingRequest.requestId);
// Always use /response endpoint - web interface is response-only
const response = await fetch('/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
requestId: latestPendingRequest.requestId,
response: message
})
});
if (!response.ok) {
throw new Error(\`HTTP \${response.status}: \${response.statusText}\`);
}
const result = await response.json();
if (result.success) {
console.log('Response sent successfully:', result);
} else {
throw new Error(result.error || 'Failed to send response');
}
} catch (error) {
console.error('Failed to send response:', error);
addMessageToUI(sessionId, 'assistant', \`Error: \${error.message}\`);
} finally {
// Re-enable controls
textarea.disabled = false;
button.disabled = false;
textarea.focus();
}
}
function addMessageToUI(sessionId, role, content) {
const messagesContainer = document.getElementById(\`messages-\${sessionId}\`);
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
messageDiv.className = \`message \${role}\`;
messageDiv.innerHTML = \`
<div class="message-header">\${role === 'user' ? 'You' : 'Assistant'}\${new Date().toLocaleTimeString()}</div>
<div class="message-content">\${escapeHtml(content)}</div>
\`;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load existing messages for all sessions
async function loadExistingMessages() {
const sessions = ['${sessions.map(s => s.id).join("', '")}'];
for (const sessionId of sessions) {
try {
const response = await fetch(\`/messages/\${sessionId}\`);
if (response.ok) {
const data = await response.json();
const messagesContainer = document.getElementById(\`messages-\${sessionId}\`);
if (messagesContainer && data.messages) {
// Clear any placeholder content
messagesContainer.innerHTML = '';
// Add each message
for (const msg of data.messages) {
addMessageToUI(sessionId, msg.sender, msg.content);
}
}
}
} catch (error) {
console.error(\`Failed to load messages for session \${sessionId}:\`, error);
}
}
}
// Load conversation history from centralized chat manager
async function loadConversationHistory() {
const sessions = [${sessions.map(s => `'${s.id}'`).join(', ')}];
for (const sessionId of sessions) {
try {
console.log(\`Loading conversation history for session: \${sessionId}\`);
// Get messages from centralized chat manager
const response = await fetch(\`/sessions/\${sessionId}/messages\`);
if (response.ok) {
const data = await response.json();
const messagesContainer = document.getElementById(\`messages-\${sessionId}\`);
if (messagesContainer && data.messages) {
// Clear any existing content
messagesContainer.innerHTML = '';
// Add each message from chat manager
for (const msg of data.messages) {
addMessageToUI(sessionId, msg.sender, msg.content);
}
console.log(\`Loaded \${data.messages.length} messages for session \${sessionId}\`);
}
}
} catch (error) {
console.error(\`Failed to load conversation history for session \${sessionId}:\`, error);
}
}
}
// WebSocket connection for real-time updates
function setupRealtimeUpdates() {
console.log('Setting up SSE connection to /mcp...');
const eventSource = new EventSource('/mcp');
eventSource.onopen = function(event) {
console.log('SSE connection opened successfully:', event);
// Load conversation history for all sessions
loadConversationHistory();
};
eventSource.onmessage = function(event) {
try {
console.log('SSE message received:', event.data);
const data = JSON.parse(event.data);
console.log('Real-time update:', data);
// Handle different types of updates
if (data.type === 'chat_message' && data.sessionId && data.message) {
addMessageToUI(data.sessionId, data.message.sender, data.message.content);
} else if (data.type === 'message' && data.sessionId) {
addMessageToUI(data.sessionId, data.role || 'assistant', data.content);
} else if (data.type === 'human-agent-request' && data.data) {
// Handle human-agent-request messages (AI questions to user)
console.log('Web interface received human-agent-request:', data.data);
// Add AI message to current session (no need to track requestId - we get it from server state)
const sessionId = activeSessionId || 'default';
const displayMessage = data.data.context ? \`\${data.data.context}\\n\\n\${data.data.message}\` : data.data.message;
addMessageToUI(sessionId, 'agent', displayMessage);
} else if (data.type === 'session_update') {
// Refresh the page to show new sessions
window.location.reload();
}
} catch (error) {
console.error('Failed to parse SSE message:', error);
}
};
eventSource.onerror = function(error) {
console.error('SSE connection error:', error);
console.error('EventSource readyState:', eventSource.readyState);
console.error('EventSource url:', eventSource.url);
};
}
// Initialize everything when page loads
async function initialize() {
await loadExistingMessages();
setupRealtimeUpdates();
// Focus on input for active session
if (activeSessionId) {
const activeInput = document.querySelector(\`textarea[data-session="\${activeSessionId}"]\`);
if (activeInput) activeInput.focus();
}
}
// Start initialization
initialize();
</script>
</body>
</html>`;
}
private escapeHtml(text: string): string {
const map: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
private handleInitialize(message: McpMessage): McpMessage {
return {
id: message.id,
@@ -729,9 +1420,11 @@ export class McpServer extends EventEmitter {
private async handleToolCall(message: McpMessage): Promise<McpMessage> {
const { name, arguments: args } = message.params;
const sessionId = args?.sessionId; // Extract session ID from tool arguments
// Extract session ID from _meta.vscode.conversationId (VS Code) or fallback to args.sessionId (web)
const rawSessionId = message.params._meta?.['vscode.conversationId'] || args?.sessionId;
const sessionId = this.mapToRegisteredSession(rawSessionId);
this.debugLogger.log('MCP', `Tool call - name: "${name}", session: ${sessionId}`, { name, args });
this.debugLogger.log('MCP', `Tool call - name: "${name}", raw session: ${rawSessionId}, mapped session: ${sessionId}`, { name, args });
// Check session-specific tools first, then default tools
const sessionTools = sessionId ? this.sessionTools.get(sessionId) : null;
@@ -741,7 +1434,7 @@ export class McpServer extends EventEmitter {
if (name === 'HumanAgent_Chat' && availableTools.has(name)) {
this.debugLogger.log('MCP', 'Executing HumanAgent_Chat tool');
return await this.handleHumanAgentChatTool(message.id, args);
return await this.handleHumanAgentChatTool(message.id, args, sessionId);
}
this.debugLogger.log('MCP', `Tool not found: ${name}`);
@@ -755,7 +1448,7 @@ export class McpServer extends EventEmitter {
};
}
private async handleHumanAgentChatTool(messageId: string, params: HumanAgentChatToolParams): Promise<McpMessage> {
private async handleHumanAgentChatTool(messageId: string, params: HumanAgentChatToolParams, sessionId?: string): Promise<McpMessage> {
this.debugLogger.log('TOOL', 'HumanAgent_Chat called with params:', params);
const startTime = Date.now();
const timeout = (params.timeout || 300) * 1000; // Convert to milliseconds
@@ -794,13 +1487,29 @@ export class McpServer extends EventEmitter {
});
}, timeout);
// Store the pending request
// Store the pending request using the extracted session ID
const sessionToUse = sessionId || params.sessionId || 'default';
this.debugLogger.log('TOOL', `Adding pending request ${requestId} to session: ${sessionToUse}`);
this.chatManager.addPendingRequest(sessionToUse, requestId, params);
this.pendingHumanRequests.set(requestId, {
resolve: (response: string) => {
clearTimeout(timeoutHandle);
const responseTime = Date.now() - startTime;
this.debugLogger.log('TOOL', `Request ${requestId} completed with response:`, response);
// Store the assistant response message for synchronization
if (params.sessionId) {
const assistantMessage: ChatMessage = {
id: (Date.now() + 1).toString(), // Slightly different timestamp
content: response,
sender: 'agent',
timestamp: new Date(),
type: 'text'
};
this.storeMessage(params.sessionId, assistantMessage);
this.broadcastMessageToClients(params.sessionId, assistantMessage);
}
const result: HumanAgentChatToolResult = {
content: [{
type: 'text',
@@ -871,7 +1580,14 @@ export class McpServer extends EventEmitter {
resolvePendingRequest(requestId: string, response: string): boolean {
const request = this.pendingHumanRequests.get(requestId);
if (request) {
// Remove from both places
this.pendingHumanRequests.delete(requestId);
// Find the session ID for this request and remove it from ChatManager
for (const sessionId of this.chatManager.getActiveSessions()) {
if (this.chatManager.removePendingRequest(sessionId, requestId)) {
break;
}
}
request.resolve(response);
return true;
}
@@ -903,6 +1619,37 @@ export class McpServer extends EventEmitter {
this.debugLogger.log('INFO', `Session registered: ${sessionId} (${this.activeSessions.size} total sessions)`);
}
private mapToRegisteredSession(conversationId?: string): string | undefined {
if (!conversationId) {
return undefined;
}
// Check if it's already a registered session ID
if (this.activeSessions.has(conversationId)) {
return conversationId;
}
// Check if it's a conversation ID we've seen before
const mappedSession = this.conversationToSession.get(conversationId);
if (mappedSession && this.activeSessions.has(mappedSession)) {
this.debugLogger.log('MCP', `Mapped conversation ${conversationId} to session ${mappedSession}`);
return mappedSession;
}
// If not found, try to map to the first available registered session
// This handles the case where VS Code conversation ID needs to be linked to a web-registered session
const activeSessions = Array.from(this.activeSessions);
if (activeSessions.length > 0) {
const targetSession = activeSessions[0]; // Use first available session
this.conversationToSession.set(conversationId, targetSession);
this.debugLogger.log('MCP', `Auto-mapped conversation ${conversationId} to session ${targetSession}`);
return targetSession;
}
this.debugLogger.log('MCP', `No registered session found for conversation ${conversationId}`);
return undefined;
}
unregisterSession(sessionId: string): void {
this.activeSessions.delete(sessionId);
// Clean up session-specific data
@@ -963,4 +1710,42 @@ export class McpServer extends EventEmitter {
throw error;
}
}
// Message storage and synchronization methods - now using ChatManager
private storeMessage(sessionId: string, message: ChatMessage): void {
this.chatManager.addMessage(sessionId, message);
this.debugLogger.log('CHAT', `Stored message in ChatManager for session ${sessionId}: ${message.sender} - ${message.content.substring(0, 50)}...`);
}
private getSessionMessages(sessionId: string): ChatMessage[] {
return this.chatManager.getMessages(sessionId);
}
private broadcastMessageToClients(sessionId: string, message: ChatMessage): void {
// Broadcast message to all connected SSE clients
const messageEvent = {
type: 'chat_message',
sessionId: sessionId,
message: {
id: message.id,
content: message.content,
sender: message.sender,
timestamp: message.timestamp.toISOString()
}
};
const sseData = `data: ${JSON.stringify(messageEvent)}\n\n`;
// Send to all connected SSE clients (VS Code webviews and web interfaces)
for (const connection of this.sseConnections) {
try {
connection.write(sseData);
this.debugLogger.log('CHAT', `Broadcasted message to SSE client for session ${sessionId}`);
} catch (error) {
this.debugLogger.log('ERROR', 'Failed to broadcast message to SSE client:', error);
// Remove failed connection
this.sseConnections.delete(connection);
}
}
}
}
+39 -56
View File
@@ -15,7 +15,7 @@ export class ServerManager {
private static instance: ServerManager | undefined;
private options: ServerManagerOptions;
private readonly pidFile: string;
private readonly logFile: string;
private readonly logFile?: string;
private constructor(options: ServerManagerOptions) {
this.options = {
@@ -23,7 +23,7 @@ export class ServerManager {
...options
};
this.pidFile = path.join(path.dirname(options.serverPath), '.humanagent-mcp-server.pid');
this.logFile = options.logFile || path.join(path.dirname(options.serverPath), '.humanagent-mcp-server.log');
this.logFile = options.logFile;
}
public static getInstance(options?: ServerManagerOptions): ServerManager {
@@ -122,10 +122,12 @@ export class ServerManager {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${message}\n`;
try {
fs.appendFileSync(this.logFile, logMessage);
} catch (error) {
console.error('Error writing to log file:', error);
if (this.logFile) {
try {
fs.appendFileSync(this.logFile, logMessage);
} catch (error) {
console.error('Error writing to log file:', error);
}
}
}
@@ -170,59 +172,40 @@ export class ServerManager {
* Start the server as a truly independent detached process
*/
private async startServer(): Promise<boolean> {
return new Promise((resolve) => {
try {
// Spawn the server as a completely detached process
const serverProcess = spawn('node', [this.options.serverPath], {
detached: true,
stdio: 'ignore', // Completely disconnect stdio to make it independent
cwd: path.dirname(this.options.serverPath),
env: {
...process.env,
// Add any environment variables needed by the server
}
});
// Store the PID but don't keep a reference to the process
if (serverProcess.pid) {
this.storePid(serverProcess.pid);
this.log(`Started independent server with PID ${serverProcess.pid}`);
try {
// Spawn the server as a completely detached process
const serverProcess = spawn('node', [this.options.serverPath], {
detached: true,
stdio: 'ignore', // Completely disconnect stdio to make it independent
cwd: path.dirname(this.options.serverPath),
env: {
...process.env,
// Add any environment variables needed by the server
}
});
// Immediately detach and unreference the process
serverProcess.unref();
// Don't keep a reference to the process object to ensure complete detachment
// this.serverProcess = undefined;
this.log('Server process started as independent background process');
// Wait for server to start listening
setTimeout(async () => {
if (await this.isServerRunning()) {
this.log('Independent server started successfully and is responding');
resolve(true);
} else {
this.log('Independent server started but is not responding on the expected port yet');
// Give it a bit more time
setTimeout(async () => {
if (await this.isServerRunning()) {
this.log('Independent server is now responding');
resolve(true);
} else {
this.log('Independent server failed to start or is not responding');
this.cleanupPidFile();
resolve(false);
}
}, 2000);
}
}, 3000);
} catch (error) {
this.log(`Failed to start server: ${error}`);
resolve(false);
// Store the PID but don't keep a reference to the process
if (serverProcess.pid) {
this.storePid(serverProcess.pid);
this.log(`Started independent server with PID ${serverProcess.pid}`);
} else {
this.log('Failed to get server process PID');
return false;
}
});
// Immediately detach and unreference the process for complete independence
serverProcess.unref();
this.log('Server process started as independent background process and immediately detached');
// Return true immediately - the server will start independently
// Actual server health will be verified by separate health checks later
return true;
} catch (error) {
this.log(`Failed to start server: ${error}`);
return false;
}
}
/**
+160 -85
View File
@@ -33,6 +33,28 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
this.loadNotificationSettings();
}
private async loadConversationHistory() {
if (!this.mcpServer || !this.workspaceSessionId) {
return;
}
try {
// Fetch conversation history from centralized chat manager
const response = await fetch(`http://127.0.0.1:3737/sessions/${this.workspaceSessionId}/messages`);
if (response.ok) {
const data = await response.json() as { messages: ChatMessage[] };
this.messages = data.messages || [];
console.log(`Loaded ${this.messages.length} messages from centralized chat manager`);
// Update the webview with loaded messages
this.updateWebview();
}
} catch (error) {
console.error('Failed to load conversation history:', error);
// Fallback to empty message array
this.messages = [];
}
}
private loadNotificationSettings() {
try {
// Try to load settings from mcp.json
@@ -127,6 +149,9 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
]
};
// Load conversation history from centralized chat manager
this.loadConversationHistory();
// Only update webview if registration check is complete, otherwise it will be updated when notifyRegistrationComplete is called
if (this.registrationCheckComplete) {
this.updateWebview();
@@ -144,6 +169,12 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
// Call the dedicated status command from extension.ts
vscode.commands.executeCommand('humanagent-mcp.showStatus');
break;
case 'playNotificationSound':
// Play sound from extension side (Node.js) when webview requests it
if (this.notificationSettings.enableSound) {
await this.playNotificationSound();
}
break;
}
});
}
@@ -448,6 +479,12 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
case 'reloadOverride':
await this.reloadOverrideFile();
break;
case 'nameSession':
await this.nameCurrentSession();
break;
case 'openWebView':
await this.openWebInterface();
break;
}
// Update status after action
@@ -457,6 +494,72 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
}
}
private async nameCurrentSession() {
try {
const sessionId = this.workspaceSessionId;
if (!sessionId) {
vscode.window.showErrorMessage('No active session to name.');
return;
}
// Prompt user for session name
const sessionName = await vscode.window.showInputBox({
prompt: 'Enter a friendly name for this chat session',
placeHolder: 'e.g., "Project Debugging", "Feature Discussion"',
validateInput: (text) => {
if (!text || text.trim().length === 0) {
return 'Session name cannot be empty';
}
if (text.length > 50) {
return 'Session name must be 50 characters or less';
}
return null;
}
});
if (!sessionName) {
return; // User cancelled
}
// Send session name to server
const response = await fetch('http://localhost:3737/sessions/name', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
sessionId: sessionId,
name: sessionName.trim()
})
});
if (response.ok) {
vscode.window.showInformationMessage(`Session named: "${sessionName}"`);
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('ChatWebviewProvider: Error naming session:', error);
vscode.window.showErrorMessage(`Failed to name session: ${error}`);
}
}
private async openWebInterface() {
try {
const webUrl = 'http://localhost:3737/HumanAgent';
// Open in external browser
await vscode.env.openExternal(vscode.Uri.parse(webUrl));
vscode.window.showInformationMessage('Web interface opened in browser');
} catch (error) {
console.error('ChatWebviewProvider: Error opening web interface:', error);
vscode.window.showErrorMessage(`Failed to open web interface: ${error}`);
}
}
private _getHtmlForWebview(webview: vscode.Webview) {
// Check if HumanAgentOverride.json exists in workspace
let overrideFileExists = false;
@@ -707,97 +810,19 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
// Set global flag for override file existence
window.overrideFileExists = ${overrideFileExists};
// Audio context for notifications
let audioContext = null;
let preloadedAudio = null;
// Initialize audio on first user interaction
function initAudio() {
if (!audioContext) {
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Resume in case it's in suspended state
if (audioContext.state === 'suspended') {
audioContext.resume();
}
} catch (error) {
console.error('Failed to create audio context:', error);
}
}
// Pre-load and test audio
if (!preloadedAudio) {
try {
preloadedAudio = new Audio();
preloadedAudio.src = "data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmMaBSGI0PTVnhY=";
preloadedAudio.volume = 0.3;
preloadedAudio.preload = 'auto';
// Play and immediately pause to establish permission
preloadedAudio.play().then(() => {
preloadedAudio.pause();
preloadedAudio.currentTime = 0;
console.log('Audio initialized successfully');
}).catch(e => {
console.log('Audio initialization failed:', e);
preloadedAudio = null;
});
} catch (error) {
console.error('Failed to create audio element:', error);
}
}
}
// Play notification beep sound
function playNotificationBeep() {
// Request sound from extension (Node.js side) instead of browser
try {
if (preloadedAudio) {
console.log('Playing preloaded audio');
preloadedAudio.currentTime = 0;
preloadedAudio.play().then(() => {
console.log('Audio played successfully');
}).catch(e => {
console.log('Preloaded audio play failed:', e);
// Try to re-initialize if failed
initAudio();
});
} else {
console.log('Audio not initialized - trying to initialize now');
initAudio();
// Try Web Audio API fallback
try {
if (audioContext && audioContext.state === 'running') {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.01);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.2);
console.log('Web Audio fallback played');
} else {
console.log('Web Audio context not available');
}
} catch (e2) {
console.error('Fallback audio also failed:', e2);
}
}
vscode.postMessage({
type: 'playNotificationSound'
});
console.log('Sound notification requested from extension');
} catch (error) {
console.error('Error playing notification sound:', error);
console.error('Failed to request sound notification:', error);
}
}
// Initialize audio on any user interaction
document.addEventListener('click', initAudio, { once: true });
document.addEventListener('keypress', initAudio, { once: true });
document.addEventListener('touchstart', initAudio, { once: true });
document.getElementById('messageInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -916,7 +941,9 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
{ text: '📦 Install Globally', action: 'register' },
{ text: '📁 Install in Workspace', action: 'register' },
{ text: '📊 Show Status', action: 'requestServerStatus' },
{ text: window.overrideFileExists ? '📁 Recreate Override File' : '📁 Create Override File', action: 'overridePrompt' }
{ text: window.overrideFileExists ? '📁 Recreate Override File' : '📁 Create Override File', action: 'overridePrompt' },
{ text: '📝 Name This Chat', action: 'nameSession' },
{ text: '🌐 Open Web View', action: 'openWebView' }
];
// Check for override file existence even when status unknown
@@ -943,6 +970,8 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
options.push({ text: '📊 Show Status', action: 'requestServerStatus' });
options.push({ text: window.overrideFileExists ? '📁 Recreate Override File' : '📁 Create Override File', action: 'overridePrompt' });
options.push({ text: '📝 Name This Chat', action: 'nameSession' });
options.push({ text: '🌐 Open Web View', action: 'openWebView' });
// Check for HumanAgentOverride.json file existence (passed from extension)
if (window.overrideFileExists) {
@@ -1024,6 +1053,9 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
if (data.type === 'human-agent-request') {
handleHumanAgentRequest(data.data);
} else if (data.type === 'chat_message') {
handleIncomingChatMessage(data);
// Removed web_user_message auto-trigger - no longer needed
}
} catch (error) {
console.error('Error parsing SSE data:', error);
@@ -1103,6 +1135,49 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
}
}
function handleIncomingChatMessage(data) {
console.log('Handling incoming chat message:', data);
// Only process messages for the current session
// For VS Code webview, we need to check if this is our session
// This is a basic implementation - in a more complex setup,
// we'd want proper session management
const message = data.message;
const messagesContainer = document.getElementById('messages');
if (messagesContainer) {
// Remove empty state if it exists
const emptyState = messagesContainer.querySelector('.empty-state');
if (emptyState) {
emptyState.remove();
}
const messageDiv = document.createElement('div');
messageDiv.className = \`message \${message.sender === 'user' ? 'user-message' : 'ai-message'}\`;
const displayName = message.sender === 'user' ? 'You' : 'Assistant';
const timestamp = new Date(message.timestamp).toLocaleTimeString();
messageDiv.innerHTML = \`
<div class="message-header">
<strong>\${displayName}</strong>
<span class="timestamp">\${timestamp}</span>
</div>
<div class="message-content">\${message.content.replace(/\\n/g, '<br>')}</div>
\`;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// Play notification for assistant messages
if (message.sender === 'agent') {
playNotificationBeep();
}
}
}
// handleWebUserMessage removed - no longer needed for auto-forwarding
// Initialize SSE connection
setupSSEConnection();