This is page 3 of 6. Use http://codebase.md/sammcj/bybit-mcp?page={x} to view the full context. # Directory Structure ``` ├── .env.example ├── .gitignore ├── client │ ├── .env.example │ ├── .gitignore │ ├── package.json │ ├── pnpm-lock.yaml │ ├── README.md │ ├── src │ │ ├── cli.ts │ │ ├── client.ts │ │ ├── config.ts │ │ ├── env.ts │ │ ├── index.ts │ │ └── launch.ts │ └── tsconfig.json ├── DEV_PLAN.md ├── docs │ └── HTTP_SERVER.md ├── eslint.config.js ├── jest.config.js ├── LICENSE ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── README.md ├── specs │ ├── bybit │ │ ├── bybit-api-v5-openapi.yaml │ │ └── bybit-api-v5-postman-collection.json │ ├── mcp │ │ ├── mcp-schema.json │ │ └── mcp-schema.ts │ └── README.md ├── src │ ├── __tests__ │ │ ├── GetMLRSI.test.ts │ │ ├── integration.test.ts │ │ ├── test-setup.ts │ │ └── tools.test.ts │ ├── constants.ts │ ├── env.ts │ ├── httpServer.ts │ ├── index.ts │ ├── tools │ │ ├── BaseTool.ts │ │ ├── GetInstrumentInfo.ts │ │ ├── GetKline.ts │ │ ├── GetMarketInfo.ts │ │ ├── GetMarketStructure.ts │ │ ├── GetMLRSI.ts │ │ ├── GetOrderBlocks.ts │ │ ├── GetOrderbook.ts │ │ ├── GetOrderHistory.ts │ │ ├── GetPositions.ts │ │ ├── GetTicker.ts │ │ ├── GetTrades.ts │ │ └── GetWalletBalance.ts │ └── utils │ ├── knnAlgorithm.ts │ ├── mathUtils.ts │ ├── toolLoader.ts │ └── volumeAnalysis.ts ├── tsconfig.json └── webui ├── .dockerignore ├── .env.example ├── build-docker.sh ├── docker-compose.yml ├── docker-entrypoint.sh ├── docker-healthcheck.sh ├── DOCKER.md ├── Dockerfile ├── index.html ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── public │ ├── favicon.svg │ └── inter.woff2 ├── README.md ├── screenshot.png ├── src │ ├── assets │ │ └── fonts │ │ └── fonts.css │ ├── components │ │ ├── AgentDashboard.ts │ │ ├── chat │ │ │ ├── DataCard.ts │ │ │ └── MessageRenderer.ts │ │ ├── ChatApp.ts │ │ ├── DataVerificationPanel.ts │ │ ├── DebugConsole.ts │ │ └── ToolsManager.ts │ ├── main.ts │ ├── services │ │ ├── agentConfig.ts │ │ ├── agentMemory.ts │ │ ├── aiClient.ts │ │ ├── citationProcessor.ts │ │ ├── citationStore.ts │ │ ├── configService.ts │ │ ├── logService.ts │ │ ├── mcpClient.ts │ │ ├── multiStepAgent.ts │ │ ├── performanceOptimiser.ts │ │ └── systemPrompt.ts │ ├── styles │ │ ├── agent-dashboard.css │ │ ├── base.css │ │ ├── citations.css │ │ ├── components.css │ │ ├── data-cards.css │ │ ├── main.css │ │ ├── processing.css │ │ ├── variables.css │ │ └── verification-panel.css │ ├── types │ │ ├── agent.ts │ │ ├── ai.ts │ │ ├── citation.ts │ │ ├── mcp.ts │ │ └── workflow.ts │ └── utils │ ├── dataDetection.ts │ └── formatters.ts ├── tsconfig.json └── vite.config.ts ``` # Files -------------------------------------------------------------------------------- /webui/src/services/performanceOptimiser.ts: -------------------------------------------------------------------------------- ```typescript /** * Performance Optimiser Service - Handles parallel tool execution and workflow optimisation */ import type { ToolCall } from '@/types/ai'; import { mcpClient } from './mcpClient'; export interface ToolExecutionPlan { parallel: ToolCall[][]; // Groups of tools that can run in parallel sequential: ToolCall[]; // Tools that must run sequentially dependencies: Map<string, string[]>; // Tool dependencies } export interface ToolExecutionResult { toolCall: ToolCall; result?: any; error?: string; duration: number; startTime: number; endTime: number; } export interface ExecutionMetrics { totalDuration: number; parallelSavings: number; // Time saved by parallel execution toolCount: number; successRate: number; averageToolTime: number; } export class PerformanceOptimiserService { private static readonly TOOL_TIMEOUT = 30000; // 30 seconds private static readonly MAX_PARALLEL_TOOLS = 5; private toolDependencies: Map<string, string[]> = new Map(); private toolPerformanceCache: Map<string, number> = new Map(); // Average execution times private executionHistory: ToolExecutionResult[] = []; constructor() { this.initializeToolDependencies(); } /** * Execute tools with optimal parallelisation */ async executeToolsOptimised(toolCalls: ToolCall[]): Promise<ToolExecutionResult[]> { if (toolCalls.length === 0) return []; const startTime = Date.now(); console.log(`🚀 Optimising execution of ${toolCalls.length} tools...`); // Create execution plan const plan = this.createExecutionPlan(toolCalls); console.log(`📋 Execution plan: ${plan.parallel.length} parallel groups, ${plan.sequential.length} sequential tools`); const results: ToolExecutionResult[] = []; try { // Execute parallel groups first for (let i = 0; i < plan.parallel.length; i++) { const group = plan.parallel[i]; console.log(`⚡ Executing parallel group ${i + 1}/${plan.parallel.length} with ${group.length} tools`); const groupResults = await this.executeToolsInParallel(group); results.push(...groupResults); } // Execute sequential tools if (plan.sequential.length > 0) { console.log(`🔄 Executing ${plan.sequential.length} sequential tools`); for (const toolCall of plan.sequential) { const result = await this.executeSingleTool(toolCall); results.push(result); } } // Update performance metrics this.updatePerformanceMetrics(results); const totalDuration = Date.now() - startTime; console.log(`✅ Optimised execution completed in ${totalDuration}ms`); return results; } catch (error) { console.error('❌ Optimised execution failed:', error); throw error; } } /** * Create execution plan based on tool dependencies and characteristics */ private createExecutionPlan(toolCalls: ToolCall[]): ToolExecutionPlan { const plan: ToolExecutionPlan = { parallel: [], sequential: [], dependencies: new Map() }; // Analyse tool dependencies const dependencyGraph = this.buildDependencyGraph(toolCalls); // Group tools by dependency levels const processed = new Set<string>(); const remaining = [...toolCalls]; while (remaining.length > 0) { // Find tools with no unprocessed dependencies const readyTools = remaining.filter(tool => { const deps = dependencyGraph.get(tool.function.name) || []; return deps.every(dep => processed.has(dep)); }); if (readyTools.length === 0) { // No tools ready - add remaining to sequential (fallback) plan.sequential.push(...remaining); break; } // Group ready tools for parallel execution const parallelGroup = this.groupForParallelExecution(readyTools); if (parallelGroup.length > 1) { plan.parallel.push(parallelGroup); } else { plan.sequential.push(...parallelGroup); } // Mark tools as processed parallelGroup.forEach(tool => { processed.add(tool.function.name); const index = remaining.findIndex(t => t.id === tool.id); if (index >= 0) remaining.splice(index, 1); }); } return plan; } /** * Build dependency graph for tools */ private buildDependencyGraph(toolCalls: ToolCall[]): Map<string, string[]> { const graph = new Map<string, string[]>(); for (const toolCall of toolCalls) { const toolName = toolCall.function.name; const dependencies = this.toolDependencies.get(toolName) || []; // Filter dependencies to only include tools in current execution const relevantDeps = dependencies.filter(dep => toolCalls.some(tc => tc.function.name === dep) ); graph.set(toolName, relevantDeps); } return graph; } /** * Group tools for optimal parallel execution */ private groupForParallelExecution(tools: ToolCall[]): ToolCall[] { // Prioritise by estimated execution time (faster tools first) const sortedTools = tools.sort((a, b) => { const timeA = this.getEstimatedExecutionTime(a.function.name); const timeB = this.getEstimatedExecutionTime(b.function.name); return timeA - timeB; }); // Limit parallel execution to avoid overwhelming the system return sortedTools.slice(0, PerformanceOptimiserService.MAX_PARALLEL_TOOLS); } /** * Execute multiple tools in parallel */ private async executeToolsInParallel(toolCalls: ToolCall[]): Promise<ToolExecutionResult[]> { const promises = toolCalls.map(toolCall => this.executeSingleTool(toolCall)); try { const results = await Promise.allSettled(promises); return results.map((result, index) => { if (result.status === 'fulfilled') { return result.value; } else { // Handle rejected promise const toolCall = toolCalls[index]; return { toolCall, error: result.reason?.message || 'Unknown error', duration: 0, startTime: Date.now(), endTime: Date.now() }; } }); } catch (error) { console.error('Parallel execution error:', error); throw error; } } /** * Execute a single tool with timing */ private async executeSingleTool(toolCall: ToolCall): Promise<ToolExecutionResult> { const startTime = Date.now(); try { console.log(`🔧 Executing tool: ${toolCall.function.name}`); // Create timeout promise const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Tool execution timeout')), PerformanceOptimiserService.TOOL_TIMEOUT); }); // Execute tool with timeout const resultPromise = mcpClient.callTool(toolCall.function.name as any, toolCall.function.arguments as any); const result = await Promise.race([resultPromise, timeoutPromise]); const endTime = Date.now(); const duration = endTime - startTime; console.log(`✅ Tool ${toolCall.function.name} completed in ${duration}ms`); const executionResult: ToolExecutionResult = { toolCall, result, duration, startTime, endTime }; // Cache performance data this.updateToolPerformanceCache(toolCall.function.name, duration); this.executionHistory.push(executionResult); return executionResult; } catch (error) { const endTime = Date.now(); const duration = endTime - startTime; console.error(`❌ Tool ${toolCall.function.name} failed after ${duration}ms:`, error); const executionResult: ToolExecutionResult = { toolCall, error: error instanceof Error ? error.message : 'Unknown error', duration, startTime, endTime }; this.executionHistory.push(executionResult); return executionResult; } } /** * Get estimated execution time for a tool */ private getEstimatedExecutionTime(toolName: string): number { // Return cached average or default estimate return this.toolPerformanceCache.get(toolName) || 5000; // Default 5 seconds } /** * Update tool performance cache */ private updateToolPerformanceCache(toolName: string, duration: number): void { const existing = this.toolPerformanceCache.get(toolName); if (existing) { // Calculate moving average (weight recent executions more) const newAverage = (existing * 0.7) + (duration * 0.3); this.toolPerformanceCache.set(toolName, newAverage); } else { this.toolPerformanceCache.set(toolName, duration); } } /** * Update performance metrics */ private updatePerformanceMetrics(results: ToolExecutionResult[]): void { // Keep only recent history (last 100 executions) this.executionHistory = this.executionHistory.slice(-100); // Log performance summary const totalDuration = results.reduce((sum, r) => sum + r.duration, 0); const successCount = results.filter(r => !r.error).length; const successRate = results.length > 0 ? successCount / results.length : 0; console.log(`📊 Execution metrics: ${totalDuration}ms total, ${(successRate * 100).toFixed(1)}% success rate`); } /** * Initialize tool dependencies based on domain knowledge */ private initializeToolDependencies(): void { // Define tool dependencies for Bybit tools this.toolDependencies.set('get_kline', []); // Independent this.toolDependencies.set('get_ticker', []); // Independent this.toolDependencies.set('get_orderbook', []); // Independent this.toolDependencies.set('get_recent_trades', []); // Independent this.toolDependencies.set('get_funding_rate', []); // Independent this.toolDependencies.set('get_open_interest', []); // Independent // Technical analysis tools might depend on price data this.toolDependencies.set('calculate_rsi', ['get_kline']); // Needs price data this.toolDependencies.set('calculate_macd', ['get_kline']); // Needs price data this.toolDependencies.set('detect_order_blocks', ['get_kline']); // Needs price data // Risk analysis might depend on multiple data sources this.toolDependencies.set('calculate_position_size', ['get_ticker', 'get_funding_rate']); } /** * Get performance statistics */ getPerformanceStats(): ExecutionMetrics { if (this.executionHistory.length === 0) { return { totalDuration: 0, parallelSavings: 0, toolCount: 0, successRate: 0, averageToolTime: 0 }; } const recentResults = this.executionHistory.slice(-50); // Last 50 executions const totalDuration = recentResults.reduce((sum, r) => sum + r.duration, 0); const successCount = recentResults.filter(r => !r.error).length; const successRate = successCount / recentResults.length; const averageToolTime = totalDuration / recentResults.length; // Estimate parallel savings (rough calculation) const sequentialTime = recentResults.reduce((sum, r) => sum + r.duration, 0); const actualTime = Math.max(...recentResults.map(r => r.endTime)) - Math.min(...recentResults.map(r => r.startTime)); const parallelSavings = Math.max(0, sequentialTime - actualTime); return { totalDuration, parallelSavings, toolCount: recentResults.length, successRate, averageToolTime }; } /** * Clear performance cache and history */ clearPerformanceData(): void { this.toolPerformanceCache.clear(); this.executionHistory = []; console.log('🧹 Performance data cleared'); } /** * Get tool performance summary */ getToolPerformanceSummary(): Array<{tool: string, avgTime: number, executions: number}> { const toolStats = new Map<string, {totalTime: number, count: number}>(); for (const result of this.executionHistory) { const toolName = result.toolCall.function.name; const existing = toolStats.get(toolName) || {totalTime: 0, count: 0}; toolStats.set(toolName, { totalTime: existing.totalTime + result.duration, count: existing.count + 1 }); } return Array.from(toolStats.entries()).map(([tool, stats]) => ({ tool, avgTime: stats.totalTime / stats.count, executions: stats.count })).sort((a, b) => b.executions - a.executions); } } // Singleton instance export const performanceOptimiser = new PerformanceOptimiserService(); ``` -------------------------------------------------------------------------------- /src/httpServer.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node /** * HTTP/SSE server for Bybit MCP server * Provides both modern Streamable HTTP and legacy SSE transport support */ import express from "express"; import cors from "cors"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { CONSTANTS } from "./constants.js"; import { loadTools, createToolsMap } from "./utils/toolLoader.js"; import { validateEnv } from "./env.js"; const { PROJECT_NAME, PROJECT_VERSION } = CONSTANTS; // Server configuration const PORT = process.env.MCP_HTTP_PORT ? parseInt(process.env.MCP_HTTP_PORT) : 8080; const HOST = process.env.MCP_HTTP_HOST || "0.0.0.0"; // Store transports for each session type const transports = { streamable: {} as Record<string, StreamableHTTPServerTransport>, sse: {} as Record<string, SSEServerTransport> }; // Create Express app const app = express(); // Middleware app.use(cors({ origin: process.env.CORS_ORIGIN || "*", methods: ["GET", "POST", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "mcp-session-id", "Authorization"], credentials: true })); app.use(express.json({ limit: "10mb" })); // Serve WebUI static files const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const webuiPath = path.join(__dirname, "..", "webui", "dist"); // Serve static files from WebUI dist directory app.use(express.static(webuiPath)); // Health check endpoint app.get("/health", (req, res) => { res.json({ status: "healthy", name: PROJECT_NAME, version: PROJECT_VERSION, timestamp: new Date().toISOString(), transports: { streamable: Object.keys(transports.streamable).length, sse: Object.keys(transports.sse).length } }); }); // Simple HTTP endpoints for WebUI integration app.get("/tools", async (req, res) => { try { const tools = await loadTools(); const toolsList = tools.map(tool => ({ name: tool.name, description: tool.toolDefinition.description, inputSchema: tool.toolDefinition.inputSchema })); res.json(toolsList); } catch (error) { console.error("Error loading tools:", error); res.status(500).json({ error: "Failed to load tools", message: error instanceof Error ? error.message : String(error) }); } }); app.post("/call-tool", async (req, res) => { try { const { name, arguments: args } = req.body; if (!name) { res.status(400).json({ error: "Tool name is required" }); return; } // Load tools and find the requested tool const tools = await loadTools(); const toolsMap = createToolsMap(tools); const tool = toolsMap.get(name); if (!tool) { res.status(404).json({ error: `Tool '${name}' not found` }); return; } // Call the tool const mcpRequest = { method: "tools/call" as const, params: { name, arguments: args || {} } }; const result = await tool.toolCall(mcpRequest); res.json(result); } catch (error) { console.error(`Error calling tool:`, error); res.status(500).json({ error: "Tool execution failed", message: error instanceof Error ? error.message : String(error) }); } }); // Create MCP server instance function createMcpServer(toolsMap: Map<string, any>): McpServer { const server = new McpServer({ name: PROJECT_NAME, version: PROJECT_VERSION, }); // Set up tools from the loaded tools map if (toolsMap && toolsMap.size > 0) { for (const [name, tool] of toolsMap) { // Register each tool with the server using the tool definition const toolDef = tool.toolDefinition; const inputSchema = toolDef.inputSchema; // Convert JSON schema to Zod schema (simplified approach) const zodSchema: any = {}; if (inputSchema.properties) { for (const [propName, propDef] of Object.entries(inputSchema.properties as any)) { const prop = propDef as any; let zodType; switch (prop.type) { case 'string': zodType = z.string(); break; case 'number': zodType = z.number(); break; case 'boolean': zodType = z.boolean(); break; case 'array': zodType = z.array(z.any()); break; case 'object': zodType = z.object({}); break; default: zodType = z.any(); } // Make optional if not required const isRequired = inputSchema.required?.includes(propName); zodSchema[propName] = isRequired ? zodType : zodType.optional(); } } // Register the tool server.tool( name, zodSchema, async (params: any) => { // Call the original tool with MCP request format const mcpRequest = { params: { name, arguments: params } }; const result = await tool.toolCall(mcpRequest); return result; } ); } } return server; } // Modern Streamable HTTP endpoint (preferred) app.all('/mcp', async (req, res) => { try { const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; if (sessionId && transports.streamable[sessionId]) { // Reuse existing transport transport = transports.streamable[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { transports.streamable[sessionId] = transport; console.log(`[HTTP] New session initialized: ${sessionId}`); } }); // Clean up transport when closed transport.onclose = () => { if (transport.sessionId) { delete transports.streamable[transport.sessionId]; console.log(`[HTTP] Session closed: ${transport.sessionId}`); } }; // Load tools and create server const tools = await loadTools(); const toolsMap = createToolsMap(tools); const server = createMcpServer(toolsMap); // Connect to the MCP server await server.connect(transport); } else { // Invalid request res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided or not an initialize request', }, id: null, }); return; } // Handle the request await transport.handleRequest(req, res, req.body); } catch (error) { console.error("[HTTP] Error handling request:", error); res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', data: error instanceof Error ? error.message : String(error) }, id: null, }); } }); // Legacy SSE endpoint for backwards compatibility app.get('/sse', async (req, res) => { try { console.log("[SSE] New SSE connection request"); // Create SSE transport for legacy clients const transport = new SSEServerTransport('/messages', res); transports.sse[transport.sessionId] = transport; console.log(`[SSE] Session created: ${transport.sessionId}`); // Clean up on connection close res.on("close", () => { delete transports.sse[transport.sessionId]; console.log(`[SSE] Session closed: ${transport.sessionId}`); }); // Load tools and create server const tools = await loadTools(); const toolsMap = createToolsMap(tools); const server = createMcpServer(toolsMap); // Connect to the MCP server await server.connect(transport); } catch (error) { console.error("[SSE] Error setting up SSE connection:", error); res.status(500).send("Internal Server Error"); } }); // Legacy message endpoint for SSE clients app.post('/messages', async (req, res) => { try { const sessionId = req.query.sessionId as string; if (!sessionId) { res.status(400).json({ error: "Missing sessionId query parameter" }); return; } const transport = transports.sse[sessionId]; if (!transport) { res.status(404).json({ error: `No SSE transport found for sessionId: ${sessionId}` }); return; } await transport.handlePostMessage(req, res, req.body); } catch (error) { console.error("[SSE] Error handling message:", error); res.status(500).json({ error: "Internal server error", details: error instanceof Error ? error.message : String(error) }); } }); // Reusable handler for GET and DELETE requests on /mcp const handleSessionRequest = async (req: express.Request, res: express.Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports.streamable[sessionId]) { res.status(400).json({ error: 'Invalid or missing session ID' }); return; } const transport = transports.streamable[sessionId]; await transport.handleRequest(req, res); }; // Handle GET requests for server-to-client notifications via SSE app.get('/mcp', handleSessionRequest); // Handle DELETE requests for session termination app.delete('/mcp', handleSessionRequest); // Error handling middleware app.use((error: any, req: express.Request, res: express.Response, next: express.NextFunction) => { console.error("Express error:", error); res.status(500).json({ error: "Internal server error", message: error.message }); }); // 404 handler for API routes app.use((req, res, next) => { // Check if this is an API route if (req.path.startsWith('/health') || req.path.startsWith('/tools') || req.path.startsWith('/call-tool') || req.path.startsWith('/mcp') || req.path.startsWith('/sse') || req.path.startsWith('/messages')) { // This is an API route that wasn't handled, return 404 res.status(404).json({ error: "Not found", message: `Endpoint ${req.method} ${req.path} not found`, availableEndpoints: [ "GET /health - Health check", "GET /tools - List available tools", "POST /call-tool - Execute a tool", "POST /mcp - Modern Streamable HTTP transport", "GET /mcp - Server-to-client notifications", "DELETE /mcp - Session termination", "GET /sse - Legacy SSE transport", "POST /messages - Legacy SSE messages" ] }); } else { // This is not an API route, serve the SPA res.sendFile(path.join(webuiPath, 'index.html')); } }); async function startHttpServer() { try { // Validate environment configuration validateEnv(); // Test tool loading const tools = await loadTools(); console.log(`✅ Loaded ${tools.length} tools: ${tools.map(t => t.name).join(", ")}`); // Start the server const server = app.listen(PORT, HOST, () => { console.log(`🚀 Bybit MCP HTTP Server started`); console.log(`📍 Server: http://${HOST}:${PORT}`); console.log(`🔗 Modern HTTP: http://${HOST}:${PORT}/mcp`); console.log(`🔗 Legacy SSE: http://${HOST}:${PORT}/sse`); console.log(`❤️ Health check: http://${HOST}:${PORT}/health`); console.log(`📊 Project: ${PROJECT_NAME} v${PROJECT_VERSION}`); }); // Graceful shutdown process.on('SIGTERM', () => { console.log('🛑 SIGTERM received, shutting down gracefully'); server.close(() => { console.log('✅ HTTP server closed'); process.exit(0); }); }); process.on('SIGINT', () => { console.log('🛑 SIGINT received, shutting down gracefully'); server.close(() => { console.log('✅ HTTP server closed'); process.exit(0); }); }); } catch (error) { console.error("❌ Failed to start HTTP server:", error); process.exit(1); } } // Handle unhandled rejections process.on("unhandledRejection", (error) => { console.error("❌ Unhandled rejection:", error); }); // Start the server if this file is run directly if (import.meta.url === `file://${process.argv[1]}`) { startHttpServer().catch((error) => { console.error("❌ Failed to start server:", error); process.exit(1); }); } export { startHttpServer, app }; ``` -------------------------------------------------------------------------------- /webui/src/components/DataVerificationPanel.ts: -------------------------------------------------------------------------------- ```typescript /** * Data Verification Panel - Shows recent tool calls and extracted metrics */ import type { CitationData } from '@/types/citation'; import { citationStore } from '@/services/citationStore'; import { citationProcessor } from '@/services/citationProcessor'; export class DataVerificationPanel { private container: HTMLElement; private isVisible: boolean = false; private refreshInterval: NodeJS.Timeout | null = null; private currentFilter: string = 'all'; constructor(containerId: string) { this.container = document.getElementById(containerId)!; if (!this.container) { throw new Error(`Container element with id "${containerId}" not found`); } this.initialize(); } private initialize(): void { this.createPanelStructure(); this.setupEventListeners(); this.startAutoRefresh(); } private createPanelStructure(): void { this.container.innerHTML = ` <div class="verification-panel ${this.isVisible ? 'visible' : 'hidden'}"> <div class="panel-header"> <h3>Data Verification</h3> <div class="panel-controls"> <select class="filter-select" id="verification-filter"> <option value="all">All Data</option> <option value="price">Prices</option> <option value="volume">Volume</option> <option value="indicator">Indicators</option> <option value="percentage">Percentages</option> </select> <button class="toggle-btn" id="verification-toggle" aria-label="Toggle verification panel"> <span class="toggle-icon">📊</span> </button> </div> </div> <div class="panel-content"> <div class="citations-summary"> <div class="summary-item"> <span class="label">Total Citations:</span> <span class="value" id="total-citations">0</span> </div> <div class="summary-item"> <span class="label">Recent Tools:</span> <span class="value" id="recent-tools">0</span> </div> </div> <div class="citations-list" id="citations-list"> <div class="empty-state"> <p>No tool calls yet. Start a conversation to see data verification.</p> </div> </div> </div> </div> `; } private setupEventListeners(): void { // Toggle panel visibility const toggleBtn = this.container.querySelector('#verification-toggle') as HTMLButtonElement; toggleBtn?.addEventListener('click', () => { this.toggleVisibility(); }); // Filter change const filterSelect = this.container.querySelector('#verification-filter') as HTMLSelectElement; filterSelect?.addEventListener('change', (e) => { this.currentFilter = (e.target as HTMLSelectElement).value; this.refreshCitationsList(); }); // Keyboard shortcut to toggle panel (Ctrl/Cmd + D) document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'd') { e.preventDefault(); this.toggleVisibility(); } }); } private startAutoRefresh(): void { // Refresh every 2 seconds when panel is visible this.refreshInterval = setInterval(() => { if (this.isVisible) { this.refreshCitationsList(); } }, 2000); } public toggleVisibility(): void { this.isVisible = !this.isVisible; const panel = this.container.querySelector('.verification-panel'); if (this.isVisible) { panel?.classList.remove('hidden'); panel?.classList.add('visible'); this.refreshCitationsList(); } else { panel?.classList.remove('visible'); panel?.classList.add('hidden'); } } public show(): void { if (!this.isVisible) { this.toggleVisibility(); } } public hide(): void { if (this.isVisible) { this.toggleVisibility(); } } private refreshCitationsList(): void { const citations = citationStore.getAllCitations(); const filteredCitations = this.filterCitations(citations); this.updateSummary(citations); this.renderCitationsList(filteredCitations); } private filterCitations(citations: CitationData[]): CitationData[] { if (this.currentFilter === 'all') { return citations; } return citations.filter(citation => { if (!citation.extractedMetrics) return false; return citation.extractedMetrics.some(metric => metric.type === this.currentFilter ); }); } private updateSummary(citations: CitationData[]): void { const totalCitationsEl = this.container.querySelector('#total-citations'); const recentToolsEl = this.container.querySelector('#recent-tools'); if (totalCitationsEl) { totalCitationsEl.textContent = citations.length.toString(); } if (recentToolsEl) { const uniqueTools = new Set(citations.map(c => c.toolName)); recentToolsEl.textContent = uniqueTools.size.toString(); } } private renderCitationsList(citations: CitationData[]): void { const listContainer = this.container.querySelector('#citations-list'); if (!listContainer) return; if (citations.length === 0) { listContainer.innerHTML = ` <div class="empty-state"> <p>No citations found for the selected filter.</p> </div> `; return; } const citationsHtml = citations.map(citation => this.renderCitationItem(citation)).join(''); listContainer.innerHTML = citationsHtml; // Add event listeners for citation items this.addCitationItemListeners(listContainer); } private renderCitationItem(citation: CitationData): string { const timeAgo = this.getTimeAgo(citation.timestamp); const keyMetrics = citation.extractedMetrics?.slice(0, 3) || []; return ` <div class="citation-item" data-reference-id="${citation.referenceId}"> <div class="citation-header"> <span class="reference-id">${citation.referenceId}</span> <span class="tool-name">${citation.toolName}</span> <span class="timestamp">${timeAgo}</span> </div> ${keyMetrics.length > 0 ? ` <div class="key-metrics"> ${keyMetrics.map(metric => ` <div class="metric-item metric-${metric.significance}"> <span class="metric-label">${metric.label}:</span> <span class="metric-value">${metric.value}${metric.unit ? ' ' + metric.unit : ''}</span> </div> `).join('')} </div> ` : ''} <div class="citation-actions"> <button class="btn-view-details" data-reference-id="${citation.referenceId}"> View Details </button> <button class="btn-copy-data" data-reference-id="${citation.referenceId}"> Copy Data </button> </div> </div> `; } private addCitationItemListeners(container: Element): void { // View details buttons container.querySelectorAll('.btn-view-details').forEach(btn => { btn.addEventListener('click', (e) => { const referenceId = (e.target as HTMLElement).dataset.referenceId; if (referenceId) { this.showCitationDetails(referenceId); } }); }); // Copy data buttons container.querySelectorAll('.btn-copy-data').forEach(btn => { btn.addEventListener('click', (e) => { const referenceId = (e.target as HTMLElement).dataset.referenceId; if (referenceId) { this.copyCitationData(referenceId); } }); }); } private showCitationDetails(referenceId: string): void { const citation = citationStore.getCitation(referenceId); if (!citation) { console.warn(`Citation ${referenceId} not found`); return; } this.showDetailModal(citation); } private showDetailModal(citation: CitationData): void { const overlay = document.createElement('div'); overlay.className = 'verification-modal-overlay'; const modal = document.createElement('div'); modal.className = 'verification-modal'; modal.innerHTML = ` <div class="modal-header"> <h3>Citation Details: ${citation.referenceId}</h3> <button class="modal-close" aria-label="Close">×</button> </div> <div class="modal-content"> <div class="citation-metadata"> <div class="metadata-item"> <strong>Tool:</strong> ${citation.toolName} </div> <div class="metadata-item"> <strong>Timestamp:</strong> ${citationProcessor.formatTimestamp(citation.timestamp)} </div> ${citation.endpoint ? ` <div class="metadata-item"> <strong>Endpoint:</strong> ${citation.endpoint} </div> ` : ''} </div> ${citation.extractedMetrics && citation.extractedMetrics.length > 0 ? ` <div class="extracted-metrics"> <h4>Extracted Metrics</h4> <div class="metrics-grid"> ${citation.extractedMetrics.map(metric => ` <div class="metric-card metric-${metric.significance}"> <div class="metric-type">${metric.type}</div> <div class="metric-label">${metric.label}</div> <div class="metric-value">${metric.value}${metric.unit ? ' ' + metric.unit : ''}</div> </div> `).join('')} </div> </div> ` : ''} <div class="raw-data-section"> <div class="section-header"> <h4>Raw Data</h4> <button class="btn-copy-json" data-json='${JSON.stringify(citation.rawData)}'> Copy JSON </button> </div> <pre class="json-viewer"><code>${this.formatJSON(citation.rawData)}</code></pre> </div> </div> `; overlay.appendChild(modal); document.body.appendChild(overlay); // Event listeners const closeBtn = modal.querySelector('.modal-close'); const copyBtn = modal.querySelector('.btn-copy-json'); const closeModal = () => overlay.remove(); closeBtn?.addEventListener('click', closeModal); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(); }); copyBtn?.addEventListener('click', (e) => { const jsonData = (e.target as HTMLElement).dataset.json; if (jsonData) { this.copyToClipboard(jsonData); } }); // Close on Escape const handleKeydown = (e: KeyboardEvent) => { if (e.key === 'Escape') { closeModal(); document.removeEventListener('keydown', handleKeydown); } }; document.addEventListener('keydown', handleKeydown); } private copyCitationData(referenceId: string): void { const citation = citationStore.getCitation(referenceId); if (!citation) { console.warn(`Citation ${referenceId} not found`); return; } const dataToExport = { referenceId: citation.referenceId, timestamp: citation.timestamp, toolName: citation.toolName, endpoint: citation.endpoint, extractedMetrics: citation.extractedMetrics, rawData: citation.rawData }; this.copyToClipboard(JSON.stringify(dataToExport, null, 2)); } private copyToClipboard(text: string): void { navigator.clipboard.writeText(text).then(() => { this.showToast('Data copied to clipboard!'); }).catch(err => { console.error('Failed to copy to clipboard:', err); this.showToast('Failed to copy data', 'error'); }); } private showToast(message: string, type: 'success' | 'error' = 'success'): void { const toast = document.createElement('div'); toast.className = `verification-toast toast-${type}`; toast.textContent = message; document.body.appendChild(toast); // Animate in setTimeout(() => toast.classList.add('show'), 10); // Remove after 3 seconds setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 3000); } private formatJSON(data: any): string { return JSON.stringify(data, null, 2); } private getTimeAgo(timestamp: string): string { const now = Date.now(); const time = new Date(timestamp).getTime(); const diff = now - time; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (seconds < 60) return `${seconds}s ago`; if (minutes < 60) return `${minutes}m ago`; if (hours < 24) return `${hours}h ago`; return new Date(timestamp).toLocaleDateString(); } public destroy(): void { if (this.refreshInterval) { clearInterval(this.refreshInterval); this.refreshInterval = null; } } } ``` -------------------------------------------------------------------------------- /src/tools/BaseTool.ts: -------------------------------------------------------------------------------- ```typescript import { Tool, TextContent, CallToolResult } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { RestClientV5, APIResponseV3WithTime } from "bybit-api" import { getEnvConfig } from "../env.js" // Error categories for better error handling export enum ErrorCategory { VALIDATION = "VALIDATION", API_ERROR = "API_ERROR", RATE_LIMIT = "RATE_LIMIT", NETWORK = "NETWORK", AUTHENTICATION = "AUTHENTICATION", PERMISSION = "PERMISSION", INTERNAL = "INTERNAL" } // Structured error interface export interface ToolError { category: ErrorCategory code?: string | number message: string details?: any timestamp: string tool: string } // Standard error codes export const ERROR_CODES = { INVALID_INPUT: "INVALID_INPUT", MISSING_REQUIRED_FIELD: "MISSING_REQUIRED_FIELD", INVALID_SYMBOL: "INVALID_SYMBOL", INVALID_CATEGORY: "INVALID_CATEGORY", API_KEY_REQUIRED: "API_KEY_REQUIRED", RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED", BYBIT_API_ERROR: "BYBIT_API_ERROR", NETWORK_ERROR: "NETWORK_ERROR", TIMEOUT: "TIMEOUT", UNKNOWN_ERROR: "UNKNOWN_ERROR" } as const // Rate limit configuration (as per Bybit docs) const RATE_LIMIT = { maxRequestsPerSecond: 10, maxRequestsPerMinute: 120, retryAfter: 2000, // ms maxRetries: 3 } interface QueuedRequest { execute: () => Promise<any> resolve: (value: any) => void reject: (error: any) => void } export abstract class BaseToolImplementation { abstract name: string abstract toolDefinition: Tool abstract toolCall(request: z.infer<typeof CallToolRequestSchema>): Promise<CallToolResult> protected client: RestClientV5 protected isDevMode: boolean protected isTestMode: boolean = false private requestQueue: QueuedRequest[] = [] private processingQueue = false private requestCount = 0 private lastRequestTime = 0 private requestHistory: number[] = [] // Timestamps of requests within the last minute private initialized = false private activeTimeouts: NodeJS.Timeout[] = [] constructor(mockClient?: RestClientV5) { if (mockClient) { // Use provided mock client for testing this.client = mockClient this.isDevMode = true this.isTestMode = true } else { // Normal production/development initialization const config = getEnvConfig() this.isDevMode = !config.apiKey || !config.apiSecret if (this.isDevMode) { this.client = new RestClientV5({ testnet: true, }) } else { this.client = new RestClientV5({ key: config.apiKey, secret: config.apiSecret, testnet: config.useTestnet, recv_window: 5000, // 5 second receive window }) } } } protected ensureInitialized() { if (!this.initialized) { if (this.isDevMode) { this.logWarning("Running in development mode with limited functionality") } this.initialized = true } } /** * Enqueues a request with rate limiting and retry logic */ protected async executeRequest<T>( operation: () => Promise<APIResponseV3WithTime<T>>, retryCount = 0 ): Promise<T> { this.ensureInitialized() return new Promise((resolve, reject) => { this.requestQueue.push({ execute: async () => { try { // Check rate limits if (!this.canMakeRequest() && !this.isTestMode) { const waitTime = this.getWaitTime() this.logInfo(`Rate limit reached. Waiting ${waitTime}ms`) await new Promise(resolve => setTimeout(resolve, waitTime)) } // Execute request with timeout let response: APIResponseV3WithTime<T> if (this.isTestMode) { // In test mode, don't create timeout promises to avoid open handles response = await operation() as APIResponseV3WithTime<T> } else { // In production mode, use timeout for real API calls response = await Promise.race([ operation(), new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Request timeout")), 10000) ) ]) as APIResponseV3WithTime<T> } // Update rate limit tracking this.updateRequestHistory() // Handle Bybit API errors if (response.retCode !== 0) { throw this.createBybitError(response.retCode, response.retMsg) } return response.result } catch (error) { // Retry logic for specific errors if ( retryCount < RATE_LIMIT.maxRetries && this.shouldRetry(error) ) { this.logWarning(`Retrying request (attempt ${retryCount + 1})`) if (!this.isTestMode) { await new Promise(resolve => setTimeout(resolve, RATE_LIMIT.retryAfter) ) } return this.executeRequest(operation, retryCount + 1) } throw error } }, resolve, reject }) if (!this.processingQueue) { this.processQueue() } }) } private async processQueue() { if (this.requestQueue.length === 0) { this.processingQueue = false return } this.processingQueue = true const request = this.requestQueue.shift() if (request) { try { const result = await request.execute() request.resolve(result) } catch (error) { request.reject(error) } } // Process next request setImmediate(() => this.processQueue()) } private canMakeRequest(): boolean { const now = Date.now() // Clean up old requests this.requestHistory = this.requestHistory.filter( time => now - time < 60000 ) return ( this.requestHistory.length < RATE_LIMIT.maxRequestsPerMinute && now - this.lastRequestTime >= (1000 / RATE_LIMIT.maxRequestsPerSecond) ) } private getWaitTime(): number { const now = Date.now() const timeToWaitForSecondLimit = Math.max( 0, this.lastRequestTime + (1000 / RATE_LIMIT.maxRequestsPerSecond) - now ) if (this.requestHistory.length >= RATE_LIMIT.maxRequestsPerMinute) { const timeToWaitForMinuteLimit = Math.max( 0, this.requestHistory[0] + 60000 - now ) return Math.max(timeToWaitForSecondLimit, timeToWaitForMinuteLimit) } return timeToWaitForSecondLimit } private updateRequestHistory() { const now = Date.now() this.requestHistory.push(now) this.lastRequestTime = now } private shouldRetry(error: any): boolean { // Retry on network errors or specific Bybit error codes return ( error.name === "NetworkError" || error.code === 10002 || // Rate limit error.code === 10006 || // System busy error.code === -1 // Unknown error ) } private createBybitError(code: number, message: string): Error { const errorMap: Record<number, string> = { 10001: "Parameter error", 10002: "Rate limit exceeded", 10003: "Invalid API key", 10004: "Invalid sign", 10005: "Permission denied", 10006: "System busy", 10009: "Order not found", 10010: "Insufficient balance", } const errorMessage = errorMap[code] || message const error = new Error(`Bybit API Error ${code}: ${errorMessage}`) ; (error as any).code = code ; (error as any).bybitCode = code ; (error as any).category = this.categoriseBybitError(code) return error } /** * Creates a standardised ToolError object */ protected createToolError( category: ErrorCategory, message: string, code?: string | number, details?: any ): ToolError { return { category, code, message, details, timestamp: new Date().toISOString(), tool: this.name } } /** * Creates a validation error for invalid input */ protected createValidationError(message: string, details?: any): ToolError { return this.createToolError( ErrorCategory.VALIDATION, message, ERROR_CODES.INVALID_INPUT, details ) } /** * Creates an API error from Bybit response */ protected createApiError(code: number, message: string): ToolError { const category = this.categoriseBybitError(code) return this.createToolError( category, `Bybit API Error ${code}: ${message}`, code ) } /** * Categorises Bybit API errors */ private categoriseBybitError(code: number): ErrorCategory { switch (code) { case 10002: return ErrorCategory.RATE_LIMIT case 10003: case 10004: return ErrorCategory.AUTHENTICATION case 10005: return ErrorCategory.PERMISSION case 10001: return ErrorCategory.VALIDATION default: return ErrorCategory.API_ERROR } } /** * Handles errors and returns MCP-compliant CallToolResult */ protected handleError(error: any): CallToolResult { let toolError: ToolError if (error instanceof Error) { // Check if it's a Bybit API error (has bybitCode property) if ((error as any).bybitCode) { toolError = this.createApiError((error as any).bybitCode, error.message) } // Check if it's a validation error (from Zod) else if (error.message.includes("Invalid input")) { toolError = this.createValidationError(error.message) } // Check for specific error patterns else if (error.message.includes("API credentials required") || error.message.includes("development mode")) { toolError = this.createToolError( ErrorCategory.AUTHENTICATION, error.message, ERROR_CODES.API_KEY_REQUIRED ) } else if (error.message.includes("Rate limit")) { toolError = this.createToolError( ErrorCategory.RATE_LIMIT, error.message, ERROR_CODES.RATE_LIMIT_EXCEEDED ) } else if (error.message.includes("timeout") || error.message.includes("Request timeout")) { toolError = this.createToolError( ErrorCategory.NETWORK, error.message, ERROR_CODES.TIMEOUT ) } else if (error.name === "NetworkError") { toolError = this.createToolError( ErrorCategory.NETWORK, error.message, ERROR_CODES.NETWORK_ERROR ) } else { toolError = this.createToolError( ErrorCategory.INTERNAL, error.message, ERROR_CODES.UNKNOWN_ERROR ) } } else { toolError = this.createToolError( ErrorCategory.INTERNAL, String(error), ERROR_CODES.UNKNOWN_ERROR ) } // Log the error console.error(JSON.stringify({ jsonrpc: "2.0", method: "notify", params: { level: "error", message: `${this.name} tool error: ${toolError.message}` } })) // Create MCP-compliant error response const content: TextContent = { type: "text", text: JSON.stringify(toolError, null, 2), annotations: { audience: ["assistant", "user"], priority: 1 } } return { content: [content], isError: true } } // Reference ID counter for generating unique IDs private static referenceIdCounter = 0 /** * Generate a unique reference ID */ protected generateReferenceId(): string { BaseToolImplementation.referenceIdCounter += 1 return `REF${String(BaseToolImplementation.referenceIdCounter).padStart(3, '0')}` } /** * Add reference ID metadata to response if requested */ protected addReferenceMetadata(data: any, includeReferenceId: boolean, toolName: string, endpoint?: string): any { if (!includeReferenceId) { return data } return { ...data, _referenceId: this.generateReferenceId(), _timestamp: new Date().toISOString(), _toolName: toolName, _endpoint: endpoint } } protected formatResponse(data: any): CallToolResult { this.ensureInitialized() const content: TextContent = { type: "text", text: JSON.stringify(data, null, 2), annotations: { audience: ["assistant", "user"], priority: 1 } } return { content: [content] } } protected logInfo(message: string) { console.info(JSON.stringify({ jsonrpc: "2.0", method: "notify", params: { level: "info", message: `${this.name}: ${message}` } })) } protected logWarning(message: string) { console.warn(JSON.stringify({ jsonrpc: "2.0", method: "notify", params: { level: "warning", message: `${this.name}: ${message}` } })) } /** * Cleanup method for tests to clear any remaining timeouts */ public cleanup() { this.activeTimeouts.forEach(timeout => clearTimeout(timeout)) this.activeTimeouts = [] this.requestQueue = [] this.processingQueue = false } } ``` -------------------------------------------------------------------------------- /webui/src/services/mcpClient.ts: -------------------------------------------------------------------------------- ```typescript /** * MCP (Model Context Protocol) client for communicating with the Bybit MCP server * Uses the official MCP SDK with StreamableHTTPClientTransport for browser compatibility */ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { citationStore } from './citationStore'; import type { MCPTool, MCPToolCall, MCPToolResult, MCPToolName, MCPToolParams, MCPToolResponse, } from '@/types/mcp'; export class MCPClient { private baseUrl: string; private timeout: number; private client: Client | null = null; private transport: StreamableHTTPClientTransport | null = null; private tools: MCPTool[] = []; private connected: boolean = false; constructor(baseUrl: string = '', timeout: number = 30000) { // Determine the correct base URL based on environment if (typeof window !== 'undefined') { if (window.location.hostname === 'localhost' && window.location.port === '3000') { // Development mode with Vite dev server this.baseUrl = '/api/mcp'; // Use Vite proxy in development } else if (baseUrl && baseUrl !== '' && baseUrl !== 'auto') { // Explicit base URL provided (not empty or 'auto') this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash } else { // Production mode or Docker - use current origin this.baseUrl = window.location.origin; } } else { // Server-side or fallback this.baseUrl = baseUrl || 'http://localhost:8080'; this.baseUrl = this.baseUrl.replace(/\/$/, ''); // Remove trailing slash } this.timeout = timeout; console.log('🔧 MCP Client initialised with baseUrl:', this.baseUrl); console.log('🔧 Environment check:', { hostname: typeof window !== 'undefined' ? window.location.hostname : 'server-side', port: typeof window !== 'undefined' ? window.location.port : 'server-side', origin: typeof window !== 'undefined' ? window.location.origin : 'server-side', providedBaseUrl: baseUrl, finalBaseUrl: this.baseUrl }); } /** * Initialize the client and connect to the MCP server */ async initialize(): Promise<void> { try { console.log('🔌 Initialising MCP client...'); console.log('🔗 MCP endpoint:', this.baseUrl); // For now, skip the complex MCP client setup and just load tools // This allows the WebUI to work while we debug the MCP protocol issues console.log('🔄 Loading tools via HTTP...'); await this.listTools(); // Mark as connected if we successfully loaded tools this.connected = this.tools.length > 0; if (this.connected) { console.log('✅ MCP client initialised via HTTP'); } else { console.warn('⚠️ No tools loaded, but continuing...'); } } catch (error) { console.error('❌ Failed to initialise MCP client:', error); console.error('❌ MCP Error details:', { name: error instanceof Error ? error.name : 'Unknown', message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); this.connected = false; // Don't throw error, allow WebUI to continue console.log('💡 Continuing without MCP tools...'); } } /** * Check if the MCP server is reachable */ async isConnected(): Promise<boolean> { try { // Simple health check to the HTTP server const response = await fetch(`${this.baseUrl}/health`); return response.ok; } catch (error) { console.warn('🔍 MCP health check failed:', error); return false; } } /** * List all available tools from the MCP server using direct HTTP */ async listTools(): Promise<MCPTool[]> { try { // Use direct HTTP request to get tools const response = await fetch(`${this.baseUrl}/tools`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); // Handle different response formats let tools = []; if (Array.isArray(data)) { tools = data; } else if (data.tools && Array.isArray(data.tools)) { tools = data.tools; } else { console.warn('Unexpected tools response format:', data); return []; } this.tools = tools.map((tool: any) => ({ name: tool.name, description: tool.description || '', inputSchema: tool.inputSchema || { type: 'object', properties: {} }, })); console.log('🔧 Loaded tools via HTTP:', this.tools.length); return this.tools; } catch (error) { console.error('Failed to list tools via HTTP:', error); // Fallback: return empty array instead of throwing this.tools = []; return this.tools; } } /** * Get information about a specific tool */ getTool(name: string): MCPTool | undefined { return this.tools.find(tool => tool.name === name); } /** * Get all available tools */ getTools(): MCPTool[] { return [...this.tools]; } /** * Validate and convert parameters based on tool schema */ private validateAndConvertParams(toolName: string, params: Record<string, any>): Record<string, any> { const tool = this.getTool(toolName); if (!tool || !tool.inputSchema || !tool.inputSchema.properties) { return params; } const convertedParams: Record<string, any> = {}; const schema = tool.inputSchema.properties; for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) { continue; } const propertySchema = schema[key] as any; if (!propertySchema) { convertedParams[key] = value; continue; } // Convert based on schema type if (propertySchema.type === 'number') { const numValue = typeof value === 'string' ? parseFloat(value) : value; if (!isNaN(numValue)) { convertedParams[key] = numValue; } else { console.warn(`⚠️ Invalid number value for ${key}: ${value}`); convertedParams[key] = value; // Keep original value } } else if (propertySchema.type === 'integer') { const intValue = typeof value === 'string' ? parseInt(value, 10) : value; if (!isNaN(intValue)) { convertedParams[key] = intValue; } else { console.warn(`⚠️ Invalid integer value for ${key}: ${value}`); convertedParams[key] = value; // Keep original value } } else if (propertySchema.type === 'boolean') { if (typeof value === 'string') { convertedParams[key] = value.toLowerCase() === 'true'; } else { convertedParams[key] = Boolean(value); } } else { // String or other types - keep as is convertedParams[key] = value; } } return convertedParams; } /** * Call a specific MCP tool using HTTP */ async callTool<T extends MCPToolName>( name: T, params: MCPToolParams<T> ): Promise<MCPToolResponse<T>> { try { console.log(`🔧 Calling tool ${name} with params:`, params); // Validate and convert parameters const convertedParams = this.validateAndConvertParams(name as string, params as Record<string, any>); console.log(`🔧 Converted params:`, convertedParams); const response = await fetch(`${this.baseUrl}/call-tool`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: name as string, arguments: convertedParams, }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`HTTP ${response.status}: ${errorText}`); } const result = await response.json(); console.log(`✅ Tool ${name} result:`, result); // Process tool response for citation storage console.log(`🔍 About to process tool response for citations...`); citationStore.processToolResponse(result); return result as MCPToolResponse<T>; } catch (error) { console.error(`❌ Failed to call tool ${name}:`, error); throw error; } } /** * Call multiple tools in sequence */ async callTools(toolCalls: MCPToolCall[]): Promise<MCPToolResult[]> { const results: MCPToolResult[] = []; for (const toolCall of toolCalls) { try { const result = await this.callTool( toolCall.name as MCPToolName, toolCall.arguments as any ); results.push({ content: [{ type: 'text', text: JSON.stringify(result, null, 2), }], isError: false, }); } catch (error) { results.push({ content: [{ type: 'text', text: `Error calling ${toolCall.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, }); } } return results; } /** * Disconnect from the MCP server */ async disconnect(): Promise<void> { if (this.client && this.transport) { try { await this.client.close(); } catch (error) { console.error('Error disconnecting from MCP server:', error); } } this.client = null; this.transport = null; this.connected = false; this.tools = []; } /** * Update the base URL for the MCP server */ setBaseUrl(url: string): void { // Handle empty string or 'auto' to use current origin if (!url || url === '' || url === 'auto') { if (typeof window !== 'undefined') { this.baseUrl = window.location.origin; console.log('🔧 setBaseUrl: Using current origin:', this.baseUrl); } else { this.baseUrl = 'http://localhost:8080'; console.log('🔧 setBaseUrl: Using server-side fallback:', this.baseUrl); } } else { this.baseUrl = url.replace(/\/$/, ''); console.log('🔧 setBaseUrl: Using explicit URL:', this.baseUrl); } // If connected, disconnect and reconnect with new URL if (this.connected) { this.disconnect().then(() => { this.initialize().catch(console.error); }); } } /** * Update the request timeout */ setTimeout(timeout: number): void { this.timeout = timeout; } /** * Get current configuration */ getConfig(): { baseUrl: string; timeout: number; isConnected: boolean } { return { baseUrl: this.baseUrl, timeout: this.timeout, isConnected: this.connected, }; } } // Create a singleton instance with environment-aware defaults const getDefaultMCPUrl = (): string => { // Check for build-time injected environment variable const envEndpoint = (typeof window !== 'undefined' && (window as any).__MCP_ENDPOINT__) || ''; console.log('🔧 MCP URL Detection:', { envEndpoint, isWindow: typeof window !== 'undefined', windowMcpEndpoint: typeof window !== 'undefined' ? (window as any).__MCP_ENDPOINT__ : 'N/A', hostname: typeof window !== 'undefined' ? window.location.hostname : 'N/A', origin: typeof window !== 'undefined' ? window.location.origin : 'N/A' }); // If we have an explicit endpoint from build-time injection, use it if (envEndpoint && envEndpoint !== '' && envEndpoint !== 'auto') { console.log('🔧 Using explicit MCP endpoint:', envEndpoint); return envEndpoint; } // In browser, always use empty string to trigger current origin logic if (typeof window !== 'undefined') { console.log('🔧 Using current origin for MCP endpoint (empty string)'); return ''; // Empty string means use current origin } // Server-side fallback console.log('🔧 Using server-side fallback for MCP endpoint'); return 'http://localhost:8080'; }; export const mcpClient = new MCPClient(getDefaultMCPUrl()); // Convenience functions for common operations export async function getTicker(symbol: string, category?: 'spot' | 'linear' | 'inverse' | 'option') { return mcpClient.callTool('get_ticker', { symbol, category }); } export async function getKlineData(symbol: string, interval?: string, limit?: number) { return mcpClient.callTool('get_kline', { symbol, interval, limit }); } export async function getOrderbook(symbol: string, category?: 'spot' | 'linear' | 'inverse' | 'option', limit?: number) { return mcpClient.callTool('get_orderbook', { symbol, category, limit }); } export async function getMLRSI(symbol: string, category: 'spot' | 'linear' | 'inverse' | 'option', interval: string, options?: Partial<MCPToolParams<'get_ml_rsi'>>) { return mcpClient.callTool('get_ml_rsi', { symbol, category, interval, ...options }); } export async function getOrderBlocks(symbol: string, category: 'spot' | 'linear' | 'inverse' | 'option', interval: string, options?: Partial<MCPToolParams<'get_order_blocks'>>) { return mcpClient.callTool('get_order_blocks', { symbol, category, interval, ...options }); } export async function getMarketStructure(symbol: string, category: 'spot' | 'linear' | 'inverse' | 'option', interval: string, options?: Partial<MCPToolParams<'get_market_structure'>>) { return mcpClient.callTool('get_market_structure', { symbol, category, interval, ...options }); } ``` -------------------------------------------------------------------------------- /webui/src/services/multiStepAgent.ts: -------------------------------------------------------------------------------- ```typescript /** * Multi-step agent service for enhanced agentic capabilities * Implements multi-step tool calling and workflow orchestration */ import type { AgentConfig, AgentState } from '@/types/agent'; import type { WorkflowEvent } from '@/types/workflow'; import { WorkflowEventEmitter, createWorkflowEvent } from '@/types/workflow'; import { agentConfigService } from './agentConfig'; import { aiClient } from './aiClient'; import { mcpClient } from './mcpClient'; import { agentMemory } from './agentMemory'; import { performanceOptimiser } from './performanceOptimiser'; import { systemPromptService } from './systemPrompt'; import type { ChatMessage } from '@/types/ai'; export class MultiStepAgentService { private availableTools: any[] = []; private isInitialized = false; private eventEmitter: WorkflowEventEmitter; private currentConfig: AgentConfig; private conversationHistory: ChatMessage[] = []; private currentConversationId?: string; constructor() { this.eventEmitter = new WorkflowEventEmitter(); this.currentConfig = agentConfigService.getConfig(); // Subscribe to config changes agentConfigService.subscribe((config) => { this.currentConfig = config; this.reinitializeAgents(); }); } /** * Initialize the agent service */ async initialize(): Promise<void> { if (this.isInitialized) return; try { console.log('🤖 Initializing Multi-Step Agent Service...'); // Load MCP tools await this.loadMCPTools(); // Initialize agents based on configuration await this.initializeAgents(); // Update state agentConfigService.updateState({ isProcessing: false }); this.isInitialized = true; console.log('✅ Multi-Step Agent Service initialized successfully'); } catch (error) { console.error('❌ Failed to initialize Multi-Step Agent Service:', error); agentConfigService.updateState({ isProcessing: false }); throw error; } } /** * Load MCP tools from the server */ private async loadMCPTools(): Promise<void> { try { console.log('🔧 Loading MCP tools...'); // Get available tools from MCP client const tools = await mcpClient.listTools(); this.availableTools = tools; console.log(`🔧 Loaded ${this.availableTools.length} MCP tools:`, this.availableTools.map(t => t.name)); } catch (error) { console.error('❌ Failed to load MCP tools:', error); // Continue with empty tools array for now this.availableTools = []; } } /** * Initialize agent system */ private async initializeAgents(): Promise<void> { console.log('🤖 Initializing multi-step agent system...'); // Agent system is ready - we'll use the existing AI client with multi-step logic console.log('✅ Multi-step agent system initialized'); } /** * Build system prompt based on configuration and memory context */ private async buildSystemPrompt(symbol?: string): Promise<string> { // Get base system prompt from centralized service const basePrompt = await systemPromptService.generateSystemPrompt({ includeTimestamp: true, includeTools: true, includeMemoryContext: false }); // Add memory context if available const memoryContext = agentMemory.buildContextSummary(symbol); const finalPrompt = basePrompt + memoryContext; return finalPrompt; } /** * Process a chat message with the agent using multi-step reasoning */ async chat(message: string): Promise<string> { if (!this.isInitialized) { await this.initialize(); } const startTime = Date.now(); const toolCallsCount = 0; try { agentConfigService.updateState({ isProcessing: true }); console.log('💬 Processing chat message with multi-step agent...'); // Start new conversation if needed if (!this.currentConversationId) { this.currentConversationId = agentMemory.startConversation(); } // Add user message to conversation history const userMessage: ChatMessage = { role: 'user', content: message }; this.conversationHistory.push(userMessage); agentMemory.addMessage(this.currentConversationId, userMessage); // Extract symbol from message for context const symbolMatch = message.match(/\b([A-Z]{2,5})(?:USD|USDT)?\b/); const symbol = symbolMatch ? symbolMatch[1] : undefined; // Run multi-step agent loop const result = await this.runAgentLoop(symbol); // Add assistant response to memory if (this.currentConversationId) { const assistantMessage: ChatMessage = { role: 'assistant', content: result }; agentMemory.addMessage(this.currentConversationId, assistantMessage); } // Record analysis in memory if (symbol) { const duration = Date.now() - startTime; agentMemory.recordAnalysis({ symbol, analysisType: this.determineAnalysisType(), query: message, response: result, toolsUsed: [], // Will be populated by runAgentLoop duration }); } // Record successful query const duration = Date.now() - startTime; agentConfigService.recordQuery(duration, toolCallsCount); return result; } catch (error) { console.error('❌ Multi-step agent chat failed:', error); agentConfigService.recordFailure(); agentConfigService.updateState({ isProcessing: false }); throw error; } finally { agentConfigService.updateState({ isProcessing: false }); } } /** * Run the multi-step agent reasoning loop */ private async runAgentLoop(symbol?: string): Promise<string> { const maxIterations = this.currentConfig.maxIterations; let iteration = 0; // Build system prompt once and cache it for this conversation const systemPrompt = await this.buildSystemPrompt(symbol); console.log('🎯 System prompt generated once for conversation'); const messages: ChatMessage[] = [ { role: 'system', content: systemPrompt }, ...this.conversationHistory ]; while (iteration < maxIterations) { iteration++; console.log(`🔄 Agent iteration ${iteration}/${maxIterations}`); // Emit workflow step event this.emitEvent(createWorkflowEvent('workflow_step', { stepName: `Iteration ${iteration}`, stepDescription: 'Agent reasoning and tool execution', progress: iteration, totalSteps: maxIterations })); // Get AI response with tool calling const response = await aiClient.chatWithTools(messages); // Find the latest assistant message const assistantMessages = response.filter(msg => msg.role === 'assistant'); const latestAssistant = assistantMessages[assistantMessages.length - 1]; if (!latestAssistant) { throw new Error('No assistant response received'); } // Check if there are tool calls if (latestAssistant.tool_calls && latestAssistant.tool_calls.length > 0) { console.log(`🔧 Processing ${latestAssistant.tool_calls.length} tool calls`); // Update conversation history with the complete response this.conversationHistory = response.slice(1); // Remove system message // Continue the loop for next iteration - rebuild messages with cached system prompt messages.length = 1; // Keep only system message (already cached) messages.push(...this.conversationHistory); continue; } // No more tool calls - we have the final response if (latestAssistant.content) { // Check if content is meaningful (not just placeholder text) const trimmedContent = latestAssistant.content.trim(); const isPlaceholder = trimmedContent === '...' || trimmedContent === '' || trimmedContent.length < 3; if (!isPlaceholder) { // Add final response to conversation history this.conversationHistory.push({ role: 'assistant', content: latestAssistant.content }); console.log(`✅ Multi-step agent completed in ${iteration} iterations`); return latestAssistant.content; } else { console.log(`⚠️ Received placeholder content: "${trimmedContent}", continuing iteration...`); // Continue to next iteration - treat as if no meaningful response } } // If we get here and it's not the last iteration, continue if (iteration < maxIterations) { console.log(`🔄 No meaningful response in iteration ${iteration}, continuing...`); continue; } // If we get here on the last iteration, something went wrong throw new Error('Assistant response has no meaningful content and no tool calls'); } // Max iterations reached const fallbackResponse = 'I apologise, but I reached the maximum number of reasoning steps. Let me provide what I can based on the analysis so far.'; this.conversationHistory.push({ role: 'assistant', content: fallbackResponse }); return fallbackResponse; } /** * Stream chat with real-time events */ async streamChat( message: string, onChunk: (chunk: string) => void, onEvent?: (event: WorkflowEvent) => void ): Promise<void> { if (!this.isInitialized) { await this.initialize(); } const startTime = Date.now(); const toolCallsCount = 0; try { agentConfigService.updateState({ isProcessing: true }); console.log('💬 Streaming chat with multi-step agent...'); // Subscribe to events if callback provided let unsubscribe: (() => void) | undefined; if (onEvent) { unsubscribe = this.onEvent(onEvent); } // Add user message to conversation history this.conversationHistory.push({ role: 'user', content: message }); // Run multi-step agent loop and stream the final response const result = await this.runAgentLoop(); // Stream the final result const words = result.split(' '); for (let i = 0; i < words.length; i++) { const chunk = (i === 0 ? '' : ' ') + words[i]; onChunk(chunk); // Small delay for streaming effect await new Promise(resolve => setTimeout(resolve, 30)); } // Clean up event subscription if (unsubscribe) { unsubscribe(); } // Record successful query const duration = Date.now() - startTime; agentConfigService.recordQuery(duration, toolCallsCount); } catch (error) { console.error('❌ Multi-step agent stream chat failed:', error); agentConfigService.recordFailure(); agentConfigService.updateState({ isProcessing: false }); throw error; } finally { agentConfigService.updateState({ isProcessing: false }); } } /** * Emit a workflow event */ private emitEvent(event: WorkflowEvent): void { this.eventEmitter.emit(event); } /** * Check if the service is connected and ready */ async isConnected(): Promise<boolean> { return this.isInitialized && this.availableTools.length > 0; } /** * Get current agent state */ getState(): AgentState { return agentConfigService.getState(); } /** * Subscribe to workflow events */ onEvent(listener: (event: WorkflowEvent) => void): () => void { this.eventEmitter.on('all', listener); return () => this.eventEmitter.off('all', listener); } /** * Determine analysis type based on current configuration */ private determineAnalysisType(): 'quick' | 'standard' | 'comprehensive' { const maxIterations = this.currentConfig.maxIterations; if (maxIterations <= 2) return 'quick'; if (maxIterations <= 5) return 'standard'; return 'comprehensive'; } // Note: Market context updating will be implemented in future iterations // when tool response interception is added to the agent loop /** * Reinitialise agents when configuration changes */ private async reinitializeAgents(): Promise<void> { if (!this.isInitialized) return; console.log('🔄 Reinitialising multi-step agents due to configuration change...'); try { await this.initializeAgents(); console.log('✅ Multi-step agents reinitialised successfully'); } catch (error) { console.error('❌ Failed to reinitialise multi-step agents:', error); } } /** * Get memory statistics */ getMemoryStats() { return agentMemory.getMemoryStats(); } /** * Get performance statistics */ getPerformanceStats() { return performanceOptimiser.getPerformanceStats(); } /** * Get conversation history for a symbol */ getSymbolHistory(symbol: string, limit: number = 5) { return agentMemory.getSymbolContext(symbol, limit); } /** * Get recent analysis history */ getAnalysisHistory(symbol?: string, limit: number = 10) { if (symbol) { return agentMemory.getSymbolAnalysisHistory(symbol, limit); } return agentMemory.getRecentAnalysisHistory(limit); } /** * Clear all memory data */ clearMemory(): void { agentMemory.clearAllMemory(); this.conversationHistory = []; this.currentConversationId = undefined; console.log('🧹 Multi-step agent memory cleared'); } /** * Start a new conversation session */ startNewConversation(): void { this.conversationHistory = []; this.currentConversationId = undefined; console.log('🆕 New conversation session started'); } } // Singleton instance export const multiStepAgent = new MultiStepAgentService(); ``` -------------------------------------------------------------------------------- /webui/src/services/aiClient.ts: -------------------------------------------------------------------------------- ```typescript /** * AI client for OpenAI-compatible API integration (Ollama, etc.) */ import type { AIService, ChatMessage, ChatCompletionRequest, ChatCompletionResponse, ChatCompletionStreamResponse, AIConfig, AIError, ModelInfo, } from '@/types/ai'; import { mcpClient } from './mcpClient'; import { systemPromptService } from './systemPrompt'; export class AIClient implements AIService { private config: AIConfig; private controller?: AbortController; constructor(config: AIConfig) { this.config = { ...config }; } /** * Send a chat completion request with tool calling support */ async chat( messages: ChatMessage[], options?: Partial<ChatCompletionRequest> ): Promise<ChatCompletionResponse> { // First, try to get available tools from MCP let tools: any[] = []; try { const mcpTools = await mcpClient.getTools(); tools = mcpTools.map(tool => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, }, })); console.log('🔧 Available MCP tools:', tools.length); console.log('🔧 Tool definitions:', tools); } catch (error) { console.warn('Failed to get MCP tools:', error); } const request: ChatCompletionRequest = { model: this.config.model, messages, temperature: this.config.temperature, max_tokens: this.config.maxTokens, stream: false, tools: tools.length > 0 ? tools : undefined, tool_choice: tools.length > 0 ? 'auto' : undefined, ...options, }; console.log('🚀 Sending request to AI:', { model: request.model, toolsCount: tools.length, hasTools: !!request.tools, toolChoice: request.tool_choice }); try { console.log('🌐 Making request to:', `${this.config.endpoint}/v1/chat/completions`); console.log('📤 Request body:', JSON.stringify(request, null, 2)); const response = await fetch(`${this.config.endpoint}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(request), }); console.log('📡 Response status:', response.status, response.statusText); if (!response.ok) { const errorText = await response.text(); console.error('❌ Error response body:', errorText); let errorData; try { errorData = JSON.parse(errorText); } catch { errorData = { message: errorText }; } throw this.createError( 'API_ERROR', `HTTP ${response.status}: ${response.statusText}`, errorData ); } const data = await response.json(); console.log('📥 AI Response:', { choices: data.choices?.length, hasToolCalls: !!data.choices?.[0]?.message?.tool_calls, toolCallsCount: data.choices?.[0]?.message?.tool_calls?.length || 0, content: data.choices?.[0]?.message?.content?.substring(0, 100) + '...' }); return data as ChatCompletionResponse; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw this.createError('REQUEST_CANCELLED', 'Request was cancelled'); } if (error instanceof Error) { throw error; } throw this.createError('UNKNOWN_ERROR', 'An unknown error occurred'); } } /** * Execute tool calls and return results */ async executeToolCalls(toolCalls: any[]): Promise<any[]> { console.log('🔧 Executing tool calls:', toolCalls.length); const results = []; for (const toolCall of toolCalls) { try { console.log('🔧 Processing tool call:', toolCall); const { function: func } = toolCall; // Parse arguments if they're a string (from Ollama format) const args = typeof func.arguments === 'string' ? JSON.parse(func.arguments) : func.arguments; console.log(`🔧 Calling tool ${func.name} with args:`, args); const result = await mcpClient.callTool(func.name, args); console.log(`✅ Tool ${func.name} result:`, result); // Extract reference ID from the result to include in AI context let referenceId: string | null = null; let actualData: any = result; // Check if response has content array (MCP format) if ((result as any).content && Array.isArray((result as any).content) && (result as any).content.length > 0) { const contentItem = (result as any).content[0]; if (contentItem.type === 'text' && contentItem.text) { try { actualData = JSON.parse(contentItem.text); referenceId = actualData._referenceId; } catch (e) { // If parsing fails, just use the original result } } } else if ((result as any)._referenceId) { referenceId = (result as any)._referenceId; } // Prepare content for AI with reference ID hint let toolContent = JSON.stringify(result, null, 2); if (referenceId) { toolContent += `\n\n📋 Reference ID: ${referenceId}\n🔗 When responding to the user, please include this reference ID in square brackets like [${referenceId}] to enable data verification and interactive features.`; } results.push({ tool_call_id: toolCall.id, role: 'tool', content: toolContent, }); } catch (error) { console.error(`❌ Tool execution failed for ${toolCall.function?.name}:`, error); results.push({ tool_call_id: toolCall.id, role: 'tool', content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, }); } } console.log('🔧 Tool execution results:', results); return results; } /** * Parse tool calls from text content (fallback for models that don't support function calling) */ private parseToolCallsFromText(content: string): { toolCalls: any[], cleanContent: string } { // Match both single and triple backticks const toolCallPattern = /(`{1,3})tool_code\s*\n?([^`]+)\1/g; const toolCalls: any[] = []; let cleanContent = content; let match; while ((match = toolCallPattern.exec(content)) !== null) { const toolCallText = match[2].trim(); // match[2] is the content, match[1] is the backticks // Parse function call like: get_ticker(symbol="BTCUSDT") const functionCallPattern = /(\w+)\s*\(\s*([^)]*)\s*\)/; const funcMatch = functionCallPattern.exec(toolCallText); if (funcMatch) { const functionName = funcMatch[1]; const argsString = funcMatch[2]; // Parse arguments (simple key=value parsing) const args: Record<string, any> = {}; if (argsString) { const argPattern = /(\w+)\s*=\s*"([^"]+)"/g; let argMatch; while ((argMatch = argPattern.exec(argsString)) !== null) { args[argMatch[1]] = argMatch[2]; } } toolCalls.push({ id: `call_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`, type: 'function', function: { name: functionName, arguments: JSON.stringify(args) } }); // Remove the tool call from content cleanContent = cleanContent.replace(match[0], '').trim(); } } return { toolCalls, cleanContent }; } /** * Send a chat completion with automatic tool calling */ async chatWithTools(messages: ChatMessage[]): Promise<ChatMessage[]> { const conversationMessages = [...messages]; let response = await this.chat(conversationMessages); // Check if the response contains tool calls const choice = response.choices[0]; let toolCalls = choice?.message?.tool_calls; let content = choice?.message?.content || ''; // If no native tool calls, try to parse from text content if (!toolCalls && content) { const parsed = this.parseToolCallsFromText(content); if (parsed.toolCalls.length > 0) { toolCalls = parsed.toolCalls; content = parsed.cleanContent; console.log('🔍 Parsed tool calls from text:', toolCalls); } } if (toolCalls && toolCalls.length > 0) { // Add the assistant's message with tool calls conversationMessages.push({ role: 'assistant', content: content, tool_calls: toolCalls, }); // Execute tool calls const toolResults = await this.executeToolCalls(toolCalls); // Add tool results to conversation conversationMessages.push(...toolResults); // Get final response with tool results - reuse the same conversation context console.log('🔄 Getting final response with tool results (system prompt already included)'); response = await this.chat(conversationMessages); } return conversationMessages.concat({ role: 'assistant', content: response.choices[0]?.message?.content || '', }); } /** * Send a streaming chat completion request */ async streamChat( messages: ChatMessage[], onChunk: (chunk: ChatCompletionStreamResponse) => void, options?: Partial<ChatCompletionRequest> ): Promise<void> { // Cancel any existing stream this.cancelStream(); this.controller = new AbortController(); const request: ChatCompletionRequest = { model: this.config.model, messages, temperature: this.config.temperature, max_tokens: this.config.maxTokens, stream: true, ...options, }; try { const response = await fetch(`${this.config.endpoint}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(request), signal: this.controller.signal, }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw this.createError( 'API_ERROR', `HTTP ${response.status}: ${response.statusText}`, errorData ); } if (!response.body) { throw this.createError('STREAM_ERROR', 'No response body received'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); try { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n').filter(line => line.trim()); for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); if (data === '[DONE]') { return; } try { const parsed = JSON.parse(data) as ChatCompletionStreamResponse; onChunk(parsed); } catch (parseError) { console.warn('Failed to parse streaming chunk:', parseError); } } } } } finally { reader.releaseLock(); } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw this.createError('REQUEST_CANCELLED', 'Stream was cancelled'); } if (error instanceof Error) { throw error; } throw this.createError('STREAM_ERROR', 'Streaming failed'); } finally { this.controller = undefined; } } /** * Cancel the current streaming request */ cancelStream(): void { if (this.controller) { this.controller.abort(); this.controller = undefined; } } /** * Check if the AI service is connected and available */ async isConnected(): Promise<boolean> { try { const response = await fetch(`${this.config.endpoint}/v1/models`, { method: 'GET', signal: AbortSignal.timeout(5000), // 5 second timeout }); return response.ok; } catch { return false; } } /** * Get available models from the AI service */ async getModels(): Promise<ModelInfo[]> { try { const response = await fetch(`${this.config.endpoint}/v1/models`); if (!response.ok) { throw this.createError('API_ERROR', 'Failed to fetch models'); } const data = await response.json(); if (data.data && Array.isArray(data.data)) { return data.data.map((model: any) => ({ id: model.id, name: model.id, description: model.description, contextLength: model.context_length, capabilities: model.capabilities, })); } return []; } catch (error) { console.error('Failed to fetch models:', error); return []; } } /** * Update the AI configuration */ updateConfig(newConfig: Partial<AIConfig>): void { this.config = { ...this.config, ...newConfig }; } /** * Get current configuration */ getConfig(): AIConfig { return { ...this.config }; } /** * Create a standardised error object */ private createError(code: string, message: string, details?: unknown): AIError { const error = new Error(message) as Error & AIError; error.code = code; error.message = message; error.details = details; return error; } } // Function to generate system prompt with current timestamp export function generateSystemPrompt(): string { // Use the centralized system prompt service for legacy compatibility return systemPromptService.generateLegacySystemPrompt(); } // Async function to generate system prompt with dynamic tools export async function generateDynamicSystemPrompt(): Promise<string> { return await systemPromptService.generateSystemPrompt({ includeTimestamp: true, includeTools: true, includeMemoryContext: false }); } // Default system prompt for Bybit MCP integration (for backward compatibility) export const DEFAULT_SYSTEM_PROMPT = generateSystemPrompt(); // Create default AI client instance export function createAIClient(config?: Partial<AIConfig>): AIClient { const defaultConfig: AIConfig = { endpoint: 'http://localhost:11434', model: 'qwen3-30b-a3b-ud-nothink-128k:q4_k_xl', temperature: 0.7, maxTokens: 2048, systemPrompt: DEFAULT_SYSTEM_PROMPT, ...config, }; return new AIClient(defaultConfig); } // Singleton instance export const aiClient = createAIClient(); ``` -------------------------------------------------------------------------------- /webui/src/components/AgentDashboard.ts: -------------------------------------------------------------------------------- ```typescript /** * Agent Dashboard - Shows memory, performance, and analysis statistics */ import { multiStepAgent } from '@/services/multiStepAgent'; import type { ChatApp } from './ChatApp'; // Interface for memory statistics interface MemoryStats { conversations: number; marketContexts: number; analysisHistory: number; totalSymbols: number; } export class AgentDashboard { private container: HTMLElement; private isVisible: boolean = false; private refreshInterval: NodeJS.Timeout | null = null; private chatApp?: ChatApp; constructor(containerId: string, chatApp?: ChatApp) { this.container = document.getElementById(containerId)!; if (!this.container) { throw new Error(`Container element with id "${containerId}" not found`); } this.chatApp = chatApp; this.initialize(); } private initialize(): void { this.createDashboardStructure(); this.setupEventListeners(); this.startAutoRefresh(); } private createDashboardStructure(): void { this.container.innerHTML = ` <div class="agent-dashboard ${this.isVisible ? 'visible' : 'hidden'}"> <div class="dashboard-header"> <h3>Agent Dashboard</h3> <div class="dashboard-controls"> <button class="refresh-btn" id="dashboard-refresh" aria-label="Refresh dashboard"> 🔄 </button> <button class="toggle-btn" id="dashboard-toggle" aria-label="Toggle dashboard"> 📊 </button> </div> </div> <div class="dashboard-content"> <!-- Empty State Notice --> <div class="dashboard-notice" id="dashboard-notice" style="display: none;"> <div class="notice-content"> <h4>🤖 Agent Dashboard</h4> <p>This dashboard will show agent performance metrics, memory usage, and analysis history once you start using the agent mode.</p> <p><strong>To get started:</strong></p> <ol> <li>Enable Agent Mode in Settings (⚙️)</li> <li>Ask questions about cryptocurrency markets</li> <li>Watch the dashboard populate with data!</li> </ol> </div> </div> <!-- Memory Statistics --> <div class="dashboard-section"> <h4>Memory Statistics</h4> <div class="stats-grid" id="memory-stats"> <div class="stat-item"> <span class="stat-label">Conversations:</span> <span class="stat-value" id="memory-conversations">0</span> </div> <div class="stat-item"> <span class="stat-label">Market Contexts:</span> <span class="stat-value" id="memory-contexts">0</span> </div> <div class="stat-item"> <span class="stat-label">Analysis History:</span> <span class="stat-value" id="memory-analyses">0</span> </div> <div class="stat-item"> <span class="stat-label">Tracked Symbols:</span> <span class="stat-value" id="memory-symbols">0</span> </div> </div> </div> <!-- Performance Statistics --> <div class="dashboard-section"> <h4>Performance Statistics</h4> <div class="stats-grid" id="performance-stats"> <div class="stat-item"> <span class="stat-label">Success Rate:</span> <span class="stat-value" id="perf-success-rate">0%</span> </div> <div class="stat-item"> <span class="stat-label">Avg Tool Time:</span> <span class="stat-value" id="perf-avg-time">0ms</span> </div> <div class="stat-item"> <span class="stat-label">Parallel Savings:</span> <span class="stat-value" id="perf-savings">0ms</span> </div> <div class="stat-item"> <span class="stat-label">Total Tools:</span> <span class="stat-value" id="perf-tool-count">0</span> </div> </div> </div> <!-- Recent Analysis --> <div class="dashboard-section"> <h4>Recent Analysis</h4> <div class="analysis-list" id="recent-analysis"> <div class="empty-state"> <p>No recent analysis available.</p> </div> </div> </div> <!-- Actions --> <div class="dashboard-section"> <h4>Actions</h4> <div class="action-buttons"> <button class="action-btn" id="clear-memory">Clear Memory</button> <button class="action-btn" id="new-conversation">New Conversation</button> <button class="action-btn" id="export-data">Export Data</button> </div> </div> </div> </div> `; } private setupEventListeners(): void { // Toggle dashboard visibility const toggleBtn = this.container.querySelector('#dashboard-toggle') as HTMLButtonElement; toggleBtn?.addEventListener('click', () => { this.toggleVisibility(); }); // Refresh dashboard const refreshBtn = this.container.querySelector('#dashboard-refresh') as HTMLButtonElement; refreshBtn?.addEventListener('click', () => { this.refreshDashboard(); }); // Clear memory const clearMemoryBtn = this.container.querySelector('#clear-memory') as HTMLButtonElement; clearMemoryBtn?.addEventListener('click', () => { this.clearMemory(); }); // New conversation const newConversationBtn = this.container.querySelector('#new-conversation') as HTMLButtonElement; newConversationBtn?.addEventListener('click', () => { this.startNewConversation(); }); // Export data const exportDataBtn = this.container.querySelector('#export-data') as HTMLButtonElement; exportDataBtn?.addEventListener('click', () => { this.exportData(); }); // Keyboard shortcut to toggle dashboard (Ctrl/Cmd + M) document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'm') { e.preventDefault(); this.toggleVisibility(); } }); } private startAutoRefresh(): void { // Refresh every 5 seconds when dashboard is visible this.refreshInterval = setInterval(() => { if (this.isVisible) { this.refreshDashboard(); } }, 5000); } public toggleVisibility(): void { this.isVisible = !this.isVisible; const dashboard = this.container.querySelector('.agent-dashboard'); if (this.isVisible) { dashboard?.classList.remove('hidden'); dashboard?.classList.add('visible'); this.refreshDashboard(); } else { dashboard?.classList.remove('visible'); dashboard?.classList.add('hidden'); } } public show(): void { if (!this.isVisible) { this.toggleVisibility(); } } public hide(): void { if (this.isVisible) { this.toggleVisibility(); } } public get visible(): boolean { return this.isVisible; } private refreshDashboard(): void { this.updateMemoryStats(); this.updatePerformanceStats(); this.updateRecentAnalysis(); } private updateMemoryStats(): void { try { const memoryStats = multiStepAgent.getMemoryStats(); const conversationsEl = this.container.querySelector('#memory-conversations'); const contextsEl = this.container.querySelector('#memory-contexts'); const analysesEl = this.container.querySelector('#memory-analyses'); const symbolsEl = this.container.querySelector('#memory-symbols'); if (conversationsEl) conversationsEl.textContent = memoryStats.conversations.toString(); if (contextsEl) contextsEl.textContent = memoryStats.marketContexts.toString(); if (analysesEl) analysesEl.textContent = memoryStats.analysisHistory.toString(); if (symbolsEl) symbolsEl.textContent = memoryStats.totalSymbols.toString(); // Show helpful message if no data yet this.updateEmptyStateMessage(memoryStats); } catch (error) { console.warn('Failed to update memory stats:', error); } } private updatePerformanceStats(): void { try { const perfStats = multiStepAgent.getPerformanceStats(); const successRateEl = this.container.querySelector('#perf-success-rate'); const avgTimeEl = this.container.querySelector('#perf-avg-time'); const savingsEl = this.container.querySelector('#perf-savings'); const toolCountEl = this.container.querySelector('#perf-tool-count'); if (successRateEl) { successRateEl.textContent = `${(perfStats.successRate * 100).toFixed(1)}%`; } if (avgTimeEl) { avgTimeEl.textContent = `${Math.round(perfStats.averageToolTime)}ms`; } if (savingsEl) { savingsEl.textContent = `${Math.round(perfStats.parallelSavings)}ms`; } if (toolCountEl) { toolCountEl.textContent = perfStats.toolCount.toString(); } } catch (error) { console.warn('Failed to update performance stats:', error); } } private updateRecentAnalysis(): void { try { const recentAnalysis = multiStepAgent.getAnalysisHistory(undefined, 5); const listContainer = this.container.querySelector('#recent-analysis'); if (!listContainer) return; if (recentAnalysis.length === 0) { listContainer.innerHTML = ` <div class="empty-state"> <p>No recent analysis available.</p> </div> `; return; } const analysisHtml = recentAnalysis.map(analysis => ` <div class="analysis-item"> <div class="analysis-header"> <span class="analysis-symbol">${analysis.symbol}</span> <span class="analysis-type">${analysis.analysisType}</span> <span class="analysis-time">${this.getTimeAgo(analysis.timestamp)}</span> </div> <div class="analysis-query">${this.truncateText(analysis.query, 60)}</div> <div class="analysis-metrics"> <span class="metric">Duration: ${analysis.duration}ms</span> <span class="metric">Tools: ${analysis.toolsUsed.length}</span> ${analysis.accuracy ? `<span class="metric">Accuracy: ${(analysis.accuracy * 100).toFixed(0)}%</span>` : ''} </div> </div> `).join(''); listContainer.innerHTML = analysisHtml; } catch (error) { console.warn('Failed to update recent analysis:', error); } } private clearMemory(): void { if (confirm('Are you sure you want to clear all agent memory? This action cannot be undone.')) { multiStepAgent.clearMemory(); this.refreshDashboard(); this.showToast('Memory cleared successfully!'); } } private startNewConversation(): void { // Clear agent memory multiStepAgent.startNewConversation(); // Clear chat UI if available if (this.chatApp) { this.chatApp.clearMessages(); } this.showToast('New conversation started!'); } private exportData(): void { try { const data = { memoryStats: multiStepAgent.getMemoryStats(), performanceStats: multiStepAgent.getPerformanceStats(), recentAnalysis: multiStepAgent.getAnalysisHistory(undefined, 20), exportedAt: new Date().toISOString() }; const dataStr = JSON.stringify(data, null, 2); const blob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `agent-data-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.showToast('Data exported successfully!'); } catch (error) { console.error('Failed to export data:', error); this.showToast('Failed to export data', 'error'); } } private showToast(message: string, type: 'success' | 'error' = 'success'): void { const toast = document.createElement('div'); toast.className = `dashboard-toast toast-${type}`; toast.textContent = message; document.body.appendChild(toast); // Animate in setTimeout(() => toast.classList.add('show'), 10); // Remove after 3 seconds setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 3000); } private getTimeAgo(timestamp: number): string { const now = Date.now(); const diff = now - timestamp; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (seconds < 60) return `${seconds}s ago`; if (minutes < 60) return `${minutes}m ago`; if (hours < 24) return `${hours}h ago`; return new Date(timestamp).toLocaleDateString(); } private truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) return text; return text.substring(0, maxLength) + '...'; } private updateEmptyStateMessage(memoryStats: MemoryStats): void { const hasData = memoryStats.conversations > 0 || memoryStats.analysisHistory > 0; if (!hasData) { // Add notice to dashboard if no data const dashboardContent = this.container.querySelector('.dashboard-content'); if (dashboardContent && !dashboardContent.querySelector('.dashboard-notice')) { const notice = document.createElement('div'); notice.className = 'dashboard-notice'; notice.innerHTML = ` <div class="notice-content"> <h4>🤖 Agent Dashboard</h4> <p>This dashboard will show agent performance metrics, memory usage, and analysis history once you start using the agent mode.</p> <p><strong>To get started:</strong></p> <ol> <li>Enable Agent Mode in Settings (⚙️)</li> <li>Ask questions about cryptocurrency markets</li> <li>Watch the dashboard populate with data!</li> </ol> </div> `; dashboardContent.insertBefore(notice, dashboardContent.firstChild); } } else { // Remove notice if data exists const notice = this.container.querySelector('.dashboard-notice'); if (notice) { notice.remove(); } } } public destroy(): void { if (this.refreshInterval) { clearInterval(this.refreshInterval); this.refreshInterval = null; } } } ``` -------------------------------------------------------------------------------- /webui/src/components/chat/DataCard.ts: -------------------------------------------------------------------------------- ```typescript /** * DataCard Component - Expandable cards for visualising tool response data * * Provides a clean, collapsible interface for displaying structured data * with embedded visualisations when expanded. */ export interface DataCardConfig { title: string; summary: string; data: any; dataType: 'kline' | 'rsi' | 'orderBlocks' | 'price' | 'volume' | 'unknown'; expanded?: boolean; showChart?: boolean; } export class DataCard { private container: HTMLElement; private config: DataCardConfig; private isExpanded: boolean = false; private chartContainer?: HTMLElement; constructor(container: HTMLElement, config: DataCardConfig) { this.container = container; this.config = config; this.isExpanded = config.expanded || false; this.render(); this.setupEventListeners(); // If expanded by default, render chart after DOM is ready if (this.isExpanded) { setTimeout(() => { this.renderChart(); }, 150); } } /** * Render the data card structure */ private render(): void { this.container.innerHTML = ` <div class="data-card ${this.isExpanded ? 'expanded' : 'collapsed'}" data-type="${this.config.dataType}"> <div class="data-card-header" role="button" tabindex="0" aria-expanded="${this.isExpanded}"> <div class="data-card-title"> <span class="data-card-icon">${this.getDataTypeIcon()}</span> <h4>${this.config.title}</h4> </div> <div class="data-card-controls"> <span class="data-card-summary">${this.config.summary}</span> <button class="expand-toggle" aria-label="${this.isExpanded ? 'Collapse' : 'Expand'} data card"> <span class="expand-icon">${this.isExpanded ? '▼' : '▶'}</span> </button> </div> </div> <div class="data-card-content" ${this.isExpanded ? '' : 'style="display: none;"'}> <div class="data-card-details"> ${this.renderDataSummary()} </div> ${this.config.showChart !== false ? '<div class="data-card-chart" id="chart-' + this.generateId() + '"></div>' : ''} </div> </div> `; // Store reference to chart container if it exists const chartElement = this.container.querySelector('.data-card-chart') as HTMLElement; if (chartElement) { this.chartContainer = chartElement; } } /** * Set up event listeners for card interactions */ private setupEventListeners(): void { const header = this.container.querySelector('.data-card-header') as HTMLElement; const toggleButton = this.container.querySelector('.expand-toggle') as HTMLElement; if (header) { header.addEventListener('click', () => this.toggle()); header.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.toggle(); } }); } if (toggleButton) { toggleButton.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(); }); } } /** * Toggle card expanded/collapsed state */ public toggle(): void { this.isExpanded = !this.isExpanded; this.updateExpandedState(); } /** * Expand the card */ public expand(): void { if (!this.isExpanded) { this.isExpanded = true; this.updateExpandedState(); } } /** * Collapse the card */ public collapse(): void { if (this.isExpanded) { this.isExpanded = false; this.updateExpandedState(); } } /** * Update the visual state when expanded/collapsed */ private updateExpandedState(): void { const card = this.container.querySelector('.data-card') as HTMLElement; const content = this.container.querySelector('.data-card-content') as HTMLElement; const header = this.container.querySelector('.data-card-header') as HTMLElement; const expandIcon = this.container.querySelector('.expand-icon') as HTMLElement; if (card && content && header && expandIcon) { // Update classes card.classList.toggle('expanded', this.isExpanded); card.classList.toggle('collapsed', !this.isExpanded); // Update ARIA attributes header.setAttribute('aria-expanded', this.isExpanded.toString()); // Update expand icon expandIcon.textContent = this.isExpanded ? '▼' : '▶'; // Update button label const toggleButton = this.container.querySelector('.expand-toggle') as HTMLElement; if (toggleButton) { toggleButton.setAttribute('aria-label', `${this.isExpanded ? 'Collapse' : 'Expand'} data card`); } // Animate content visibility if (this.isExpanded) { content.style.display = 'block'; // Trigger chart rendering with a small delay to ensure DOM is ready setTimeout(() => { this.renderChart(); }, 100); } else { // Add a small delay to allow animation setTimeout(() => { if (!this.isExpanded) { content.style.display = 'none'; } }, 250); } } } /** * Get appropriate icon for data type */ private getDataTypeIcon(): string { switch (this.config.dataType) { case 'kline': return '📈'; case 'rsi': return '📊'; case 'orderBlocks': return '🧱'; case 'price': return '💰'; case 'volume': return '📊'; default: return '📋'; } } /** * Render data summary in the expanded view */ private renderDataSummary(): string { // This will be enhanced based on data type if (typeof this.config.data === 'object') { return `<pre class="data-preview">${JSON.stringify(this.config.data, null, 2)}</pre>`; } return `<div class="data-preview">${this.config.data}</div>`; } /** * Render chart when card is expanded */ private renderChart(): void { if (!this.chartContainer || this.config.showChart === false) { return; } // Render different chart types based on data type switch (this.config.dataType) { case 'kline': this.renderCandlestickChart(); break; case 'rsi': this.renderLineChart(); break; case 'price': this.renderPriceChart(); break; default: this.renderPlaceholder(); break; } } /** * Format timestamp for X-axis labels */ private formatTimestamp(timestamp: number, interval: string): string { const date = new Date(timestamp); // For different intervals, show different levels of detail switch (interval) { case '1': // 1 minute case '5': // 5 minutes case '15': // 15 minutes case '30': // 30 minutes return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); case '60': // 1 hour case '240': // 4 hours return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit' }); case 'D': // Daily case 'W': // Weekly default: return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } } /** * Render candlestick chart for kline data */ private renderCandlestickChart(): void { if (!this.chartContainer) return; // Extract kline data let klineData = this.config.data; if (this.config.data.data && Array.isArray(this.config.data.data)) { klineData = this.config.data.data; } if (!Array.isArray(klineData) || klineData.length === 0) { this.renderPlaceholder(); return; } // Create canvas element with responsive sizing const canvas = document.createElement('canvas'); const maxWidth = Math.min(this.chartContainer.clientWidth || 600, 800); // Cap at 800px const containerWidth = Math.max(maxWidth - 40, 400); // Ensure minimum 400px with padding canvas.width = containerWidth; canvas.height = 350; // Increased height for X-axis labels canvas.style.width = '100%'; canvas.style.maxWidth = `${containerWidth}px`; canvas.style.height = '350px'; canvas.style.border = '1px solid #ddd'; canvas.style.display = 'block'; canvas.style.margin = '0 auto'; this.chartContainer.innerHTML = ''; this.chartContainer.appendChild(canvas); const ctx = canvas.getContext('2d'); if (!ctx) return; // Parse and normalize data const candles = klineData.slice(-50).map((item: any) => { if (Array.isArray(item)) { return { timestamp: parseInt(item[0]), open: parseFloat(item[1]), high: parseFloat(item[2]), low: parseFloat(item[3]), close: parseFloat(item[4]), volume: parseFloat(item[5] || 0) }; } else if (typeof item === 'object') { return { timestamp: parseInt(item.timestamp || item.time || item.openTime || 0), open: parseFloat(item.open || 0), high: parseFloat(item.high || 0), low: parseFloat(item.low || 0), close: parseFloat(item.close || 0), volume: parseFloat(item.volume || 0) }; } return null; }).filter(Boolean); if (candles.length === 0) { this.renderPlaceholder(); return; } // Calculate price range const prices = candles.flatMap(c => c ? [c.high, c.low] : []); const minPrice = Math.min(...prices); const maxPrice = Math.max(...prices); const priceRange = maxPrice - minPrice; const padding = priceRange * 0.1; // Chart dimensions - adjusted for X-axis labels const chartWidth = canvas.width - 80; const chartHeight = canvas.height - 90; // More space for X-axis const chartX = 60; const chartY = 20; // Clear canvas ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Draw background grid ctx.strokeStyle = '#f0f0f0'; ctx.lineWidth = 1; for (let i = 0; i <= 5; i++) { const y = chartY + (chartHeight * i) / 5; ctx.beginPath(); ctx.moveTo(chartX, y); ctx.lineTo(chartX + chartWidth, y); ctx.stroke(); } // Draw vertical grid lines for time const timeSteps = Math.min(candles.length, 6); for (let i = 0; i <= timeSteps; i++) { const x = chartX + (chartWidth * i) / timeSteps; ctx.beginPath(); ctx.moveTo(x, chartY); ctx.lineTo(x, chartY + chartHeight); ctx.stroke(); } // Draw price labels (Y-axis) ctx.fillStyle = '#666'; ctx.font = '12px Arial'; ctx.textAlign = 'right'; for (let i = 0; i <= 5; i++) { const price = maxPrice + padding - ((maxPrice + padding - (minPrice - padding)) * i) / 5; const y = chartY + (chartHeight * i) / 5; ctx.fillText(price.toFixed(4), chartX - 10, y + 4); } // Draw time labels (X-axis) ctx.textAlign = 'center'; const interval = this.config.data.interval || 'D'; for (let i = 0; i <= timeSteps; i++) { const candleIndex = Math.floor((candles.length - 1) * i / timeSteps); if (candles[candleIndex]) { const x = chartX + (chartWidth * i) / timeSteps; const timeLabel = this.formatTimestamp(candles[candleIndex].timestamp, interval); ctx.fillText(timeLabel, x, chartY + chartHeight + 20); } } // Draw candlesticks const candleWidth = Math.max(2, chartWidth / candles.length - 2); candles.forEach((candle, index) => { if (!candle) return; const x = chartX + (index * chartWidth) / candles.length + (chartWidth / candles.length - candleWidth) / 2; // Calculate y positions const highY = chartY + ((maxPrice + padding - candle.high) / (maxPrice + padding - (minPrice - padding))) * chartHeight; const lowY = chartY + ((maxPrice + padding - candle.low) / (maxPrice + padding - (minPrice - padding))) * chartHeight; const openY = chartY + ((maxPrice + padding - candle.open) / (maxPrice + padding - (minPrice - padding))) * chartHeight; const closeY = chartY + ((maxPrice + padding - candle.close) / (maxPrice + padding - (minPrice - padding))) * chartHeight; // Determine candle color const isGreen = candle.close >= candle.open; const bodyColor = isGreen ? '#22c55e' : '#ef4444'; const wickColor = '#666'; // Draw wick (high-low line) ctx.strokeStyle = wickColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x + candleWidth / 2, highY); ctx.lineTo(x + candleWidth / 2, lowY); ctx.stroke(); // Draw candle body ctx.fillStyle = bodyColor; const bodyTop = Math.min(openY, closeY); const bodyHeight = Math.abs(closeY - openY) || 1; ctx.fillRect(x, bodyTop, candleWidth, bodyHeight); }); // Add title ctx.fillStyle = '#333'; ctx.font = 'bold 14px Arial'; ctx.textAlign = 'left'; const symbol = this.config.data.symbol || 'Symbol'; const intervalLabel = this.config.data.interval || ''; ctx.fillText(`${symbol} ${intervalLabel} Candlestick Chart`, chartX, 15); // Add current price info const lastCandle = candles[candles.length - 1]; if (lastCandle) { ctx.font = '12px Arial'; ctx.fillStyle = lastCandle.close >= lastCandle.open ? '#22c55e' : '#ef4444'; ctx.textAlign = 'right'; ctx.fillText(`Last: $${lastCandle.close.toFixed(4)}`, canvas.width - 10, 15); } } /** * Render line chart for RSI and other indicators */ private renderLineChart(): void { if (!this.chartContainer) return; this.chartContainer.innerHTML = ` <div class="chart-placeholder"> <p>📊 Line chart for ${this.config.dataType} data</p> <small>Line chart implementation coming soon</small> </div> `; } /** * Render simple price chart */ private renderPriceChart(): void { if (!this.chartContainer) return; this.chartContainer.innerHTML = ` <div class="chart-placeholder"> <p>💰 Price chart rendering</p> <small>Price chart implementation coming soon</small> </div> `; } /** * Render placeholder for unsupported chart types */ private renderPlaceholder(): void { if (!this.chartContainer) return; this.chartContainer.innerHTML = ` <div class="chart-placeholder"> <p>Chart rendering for ${this.config.dataType} data</p> <small>Chart component will be implemented next</small> </div> `; } /** * Generate unique ID for chart container */ private generateId(): string { return Math.random().toString(36).substr(2, 9); } /** * Update card configuration */ public updateConfig(newConfig: Partial<DataCardConfig>): void { this.config = { ...this.config, ...newConfig }; this.render(); this.setupEventListeners(); } /** * Get current card state */ public getState(): { expanded: boolean; dataType: string } { return { expanded: this.isExpanded, dataType: this.config.dataType }; } /** * Destroy the card and clean up event listeners */ public destroy(): void { // Event listeners will be automatically removed when innerHTML is cleared this.container.innerHTML = ''; } } ``` -------------------------------------------------------------------------------- /src/tools/GetMarketStructure.ts: -------------------------------------------------------------------------------- ```typescript import { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { KlineData, calculateRSI, calculateVolatility } from "../utils/mathUtils.js" import { detectOrderBlocks, getActiveLevels, calculateOrderBlockStats, VolumeAnalysisConfig } from "../utils/volumeAnalysis.js" import { applyKNNToRSI, KNNConfig } from "../utils/knnAlgorithm.js" import { GetKlineParamsV5, KlineIntervalV3 } from "bybit-api" // Zod schema for input validation const inputSchema = z.object({ symbol: z.string() .min(1, "Symbol is required") .regex(/^[A-Z0-9]+$/, "Symbol must contain only uppercase letters and numbers"), category: z.enum(["spot", "linear", "inverse"]), interval: z.enum(["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"]), analysisDepth: z.number().min(100).max(500).optional().default(200), includeOrderBlocks: z.boolean().optional().default(true), includeMLRSI: z.boolean().optional().default(true), includeLiquidityZones: z.boolean().optional().default(true) }) type ToolArguments = z.infer<typeof inputSchema> interface LiquidityZone { price: number; strength: number; type: "support" | "resistance"; } interface MarketStructureResponse { symbol: string; interval: string; marketRegime: "trending_up" | "trending_down" | "ranging" | "volatile"; trendStrength: number; volatilityLevel: "low" | "medium" | "high"; keyLevels: { support: number[]; resistance: number[]; liquidityZones: LiquidityZone[]; }; orderBlocks?: { bullishBlocks: any[]; bearishBlocks: any[]; activeBullishBlocks: number; activeBearishBlocks: number; }; mlRsi?: { currentRsi: number; mlRsi: number; adaptiveOverbought: number; adaptiveOversold: number; trend: string; confidence: number; }; recommendations: string[]; metadata: { analysisDepth: number; calculationTime: number; confidence: number; dataQuality: "excellent" | "good" | "fair" | "poor"; }; } class GetMarketStructure extends BaseToolImplementation { name = "get_market_structure" toolDefinition: Tool = { name: this.name, description: "Advanced market structure analysis combining ML-RSI, order blocks, and liquidity zones. Provides comprehensive market regime detection and trading recommendations.", inputSchema: { type: "object", properties: { symbol: { type: "string", description: "Trading pair symbol (e.g., 'BTCUSDT')", pattern: "^[A-Z0-9]+$" }, category: { type: "string", description: "Category of the instrument", enum: ["spot", "linear", "inverse"] }, interval: { type: "string", description: "Kline interval", enum: ["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"] }, analysisDepth: { type: "number", description: "How far back to analyse (default: 200)", minimum: 100, maximum: 500 }, includeOrderBlocks: { type: "boolean", description: "Include order block analysis (default: true)" }, includeMLRSI: { type: "boolean", description: "Include ML-RSI analysis (default: true)" }, includeLiquidityZones: { type: "boolean", description: "Include liquidity analysis (default: true)" } }, required: ["symbol", "category", "interval"] } } async toolCall(request: z.infer<typeof CallToolRequestSchema>): Promise<CallToolResult> { const startTime = Date.now() try { this.logInfo("Starting get_market_structure tool call") // Parse and validate input const validationResult = inputSchema.safeParse(request.params.arguments) if (!validationResult.success) { const errorDetails = validationResult.error.errors.map(err => ({ field: err.path.join('.'), message: err.message, code: err.code })) throw new Error(`Invalid input: ${JSON.stringify(errorDetails)}`) } const args = validationResult.data // Fetch kline data const klineData = await this.fetchKlineData(args) if (klineData.length < 50) { throw new Error(`Insufficient data. Need at least 50 data points, got ${klineData.length}`) } // Analyze market structure const analysis = await this.analyzeMarketStructure(klineData, args) const calculationTime = Date.now() - startTime const response: MarketStructureResponse = { symbol: args.symbol, interval: args.interval, marketRegime: analysis.marketRegime, trendStrength: analysis.trendStrength, volatilityLevel: analysis.volatilityLevel, keyLevels: analysis.keyLevels, orderBlocks: args.includeOrderBlocks ? analysis.orderBlocks : undefined, mlRsi: args.includeMLRSI ? analysis.mlRsi : undefined, recommendations: analysis.recommendations, metadata: { analysisDepth: args.analysisDepth, calculationTime, confidence: analysis.confidence, dataQuality: analysis.dataQuality } } this.logInfo(`Market structure analysis completed in ${calculationTime}ms. Regime: ${analysis.marketRegime}, Confidence: ${analysis.confidence}%`) return this.formatResponse(response) } catch (error) { this.logInfo(`Market structure analysis failed: ${error instanceof Error ? error.message : String(error)}`) return this.handleError(error) } } private async fetchKlineData(args: ToolArguments): Promise<KlineData[]> { const params: GetKlineParamsV5 = { category: args.category, symbol: args.symbol, interval: args.interval as KlineIntervalV3, limit: args.analysisDepth } const response = await this.executeRequest(() => this.client.getKline(params)) if (!response.list || response.list.length === 0) { throw new Error("No kline data received from API") } // Convert API response to KlineData format return response.list.map(kline => ({ timestamp: parseInt(kline[0]), open: parseFloat(kline[1]), high: parseFloat(kline[2]), low: parseFloat(kline[3]), close: parseFloat(kline[4]), volume: parseFloat(kline[5]) })).reverse() // Reverse to get chronological order } private async analyzeMarketStructure(klineData: KlineData[], args: ToolArguments) { const closePrices = klineData.map(k => k.close) const highs = klineData.map(k => k.high) const lows = klineData.map(k => k.low) // Calculate basic indicators const rsiValues = calculateRSI(closePrices, 14) const volatility = calculateVolatility(closePrices, 20) // Determine market regime const marketRegime = this.determineMarketRegime(klineData, rsiValues, volatility) // Calculate trend strength const trendStrength = this.calculateTrendStrength(closePrices) // Determine volatility level const volatilityLevel = this.determineVolatilityLevel(volatility) // Analyze order blocks if requested let orderBlocks: any = undefined if (args.includeOrderBlocks) { const config: VolumeAnalysisConfig = { volumePivotLength: 3, // Reduced for better detection bullishBlocks: 5, bearishBlocks: 5, mitigationMethod: 'wick' } const { bullishBlocks, bearishBlocks } = detectOrderBlocks(klineData, config) const stats = calculateOrderBlockStats(bullishBlocks, bearishBlocks) orderBlocks = { bullishBlocks, bearishBlocks, activeBullishBlocks: stats.activeBullishBlocks, activeBearishBlocks: stats.activeBearishBlocks } } // Analyze ML-RSI if requested let mlRsi: any = undefined if (args.includeMLRSI && rsiValues.length > 0) { const currentRsi = rsiValues[rsiValues.length - 1] // Simplified ML-RSI for market structure analysis mlRsi = { currentRsi, mlRsi: currentRsi, // Simplified for now adaptiveOverbought: 70, adaptiveOversold: 30, trend: currentRsi > 50 ? "bullish" : "bearish", confidence: 75 } } // Identify key levels const keyLevels = this.identifyKeyLevels(klineData, args.includeLiquidityZones) // Generate recommendations const recommendations = this.generateRecommendations(marketRegime, trendStrength, volatilityLevel, mlRsi, orderBlocks) // Calculate overall confidence const confidence = this.calculateConfidence(klineData.length, volatility) // Assess data quality const dataQuality = this.assessDataQuality(klineData) return { marketRegime, trendStrength, volatilityLevel, keyLevels, orderBlocks, mlRsi, recommendations, confidence, dataQuality } } private determineMarketRegime(klineData: KlineData[], rsiValues: number[], volatility: number[]): "trending_up" | "trending_down" | "ranging" | "volatile" { const closePrices = klineData.map(k => k.close) const recentPrices = closePrices.slice(-20) // Last 20 periods if (recentPrices.length < 10) return "ranging" const firstPrice = recentPrices[0] const lastPrice = recentPrices[recentPrices.length - 1] const priceChange = (lastPrice - firstPrice) / firstPrice const avgVolatility = volatility.length > 0 ? volatility.slice(-10).reduce((sum, v) => sum + v, 0) / Math.min(10, volatility.length) : 0 const avgRsi = rsiValues.length > 0 ? rsiValues.slice(-10).reduce((sum, r) => sum + r, 0) / Math.min(10, rsiValues.length) : 50 // High volatility threshold const highVolatilityThreshold = lastPrice * 0.02 // 2% of price if (avgVolatility > highVolatilityThreshold) { return "volatile" } if (priceChange > 0.03 && avgRsi > 45) { // 3% up move return "trending_up" } else if (priceChange < -0.03 && avgRsi < 55) { // 3% down move return "trending_down" } else { return "ranging" } } private calculateTrendStrength(prices: number[]): number { if (prices.length < 20) return 50 const recent = prices.slice(-20) const slope = this.calculateLinearRegressionSlope(recent) const correlation = this.calculateCorrelation(recent) // Normalize slope and correlation to 0-100 scale const normalizedSlope = Math.min(100, Math.max(0, (Math.abs(slope) * 1000) + 50)) const normalizedCorrelation = Math.abs(correlation) * 100 return Math.round((normalizedSlope + normalizedCorrelation) / 2) } private calculateLinearRegressionSlope(values: number[]): number { const n = values.length const x = Array.from({ length: n }, (_, i) => i) const sumX = x.reduce((sum, val) => sum + val, 0) const sumY = values.reduce((sum, val) => sum + val, 0) const sumXY = x.reduce((sum, val, idx) => sum + val * values[idx], 0) const sumXX = x.reduce((sum, val) => sum + val * val, 0) return (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX) } private calculateCorrelation(values: number[]): number { const n = values.length const x = Array.from({ length: n }, (_, i) => i) const meanX = x.reduce((sum, val) => sum + val, 0) / n const meanY = values.reduce((sum, val) => sum + val, 0) / n const numerator = x.reduce((sum, val, idx) => sum + (val - meanX) * (values[idx] - meanY), 0) const denomX = Math.sqrt(x.reduce((sum, val) => sum + Math.pow(val - meanX, 2), 0)) const denomY = Math.sqrt(values.reduce((sum, val) => sum + Math.pow(val - meanY, 2), 0)) return denomX * denomY !== 0 ? numerator / (denomX * denomY) : 0 } private determineVolatilityLevel(volatility: number[]): "low" | "medium" | "high" { if (volatility.length === 0) return "medium" const avgVolatility = volatility.slice(-10).reduce((sum, v) => sum + v, 0) / Math.min(10, volatility.length) const maxVolatility = Math.max(...volatility.slice(-20)) const relativeVolatility = avgVolatility / maxVolatility if (relativeVolatility < 0.3) return "low" if (relativeVolatility > 0.7) return "high" return "medium" } private identifyKeyLevels(klineData: KlineData[], includeLiquidityZones: boolean) { const highs = klineData.map(k => k.high) const lows = klineData.map(k => k.low) // Find significant highs and lows const resistance = this.findSignificantLevels(highs, 'high').slice(0, 5) const support = this.findSignificantLevels(lows, 'low').slice(0, 5) const liquidityZones: LiquidityZone[] = [] if (includeLiquidityZones) { // Create liquidity zones around key levels resistance.forEach(level => { liquidityZones.push({ price: level, strength: 75, type: "resistance" }) }) support.forEach(level => { liquidityZones.push({ price: level, strength: 75, type: "support" }) }) } return { support: support.sort((a, b) => b - a), // Descending resistance: resistance.sort((a, b) => a - b), // Ascending liquidityZones } } private findSignificantLevels(values: number[], type: 'high' | 'low'): number[] { const levels: number[] = [] const lookback = 5 for (let i = lookback; i < values.length - lookback; i++) { const current = values[i] let isSignificant = true // Check if current value is a local extreme for (let j = i - lookback; j <= i + lookback; j++) { if (j !== i) { if (type === 'high' && values[j] >= current) { isSignificant = false break } else if (type === 'low' && values[j] <= current) { isSignificant = false break } } } if (isSignificant) { levels.push(current) } } return levels } private generateRecommendations( marketRegime: string, trendStrength: number, volatilityLevel: string, mlRsi: any, orderBlocks: any ): string[] { const recommendations: string[] = [] // Market regime recommendations switch (marketRegime) { case "trending_up": recommendations.push("Market is in an uptrend - consider long positions on pullbacks") if (trendStrength > 70) { recommendations.push("Strong uptrend detected - momentum strategies may be effective") } break case "trending_down": recommendations.push("Market is in a downtrend - consider short positions on rallies") if (trendStrength > 70) { recommendations.push("Strong downtrend detected - avoid catching falling knives") } break case "ranging": recommendations.push("Market is ranging - consider mean reversion strategies") recommendations.push("Look for support and resistance bounces") break case "volatile": recommendations.push("High volatility detected - use smaller position sizes") recommendations.push("Consider volatility-based strategies or wait for calmer conditions") break } // Volatility recommendations if (volatilityLevel === "high") { recommendations.push("High volatility - use wider stops and smaller positions") } else if (volatilityLevel === "low") { recommendations.push("Low volatility - potential for breakout moves") } // RSI recommendations if (mlRsi) { if (mlRsi.currentRsi > 70) { recommendations.push("RSI indicates overbought conditions - watch for potential reversal") } else if (mlRsi.currentRsi < 30) { recommendations.push("RSI indicates oversold conditions - potential buying opportunity") } } // Order block recommendations if (orderBlocks && (orderBlocks.activeBullishBlocks > 0 || orderBlocks.activeBearishBlocks > 0)) { recommendations.push("Active order blocks detected - watch for reactions at these levels") } return recommendations } private calculateConfidence(dataPoints: number, volatility: number[]): number { let confidence = 50 // Base confidence // More data points = higher confidence if (dataPoints > 150) confidence += 20 else if (dataPoints > 100) confidence += 10 // Lower volatility = higher confidence in analysis if (volatility.length > 0) { const avgVolatility = volatility.slice(-10).reduce((sum, v) => sum + v, 0) / Math.min(10, volatility.length) const maxVolatility = Math.max(...volatility) const relativeVolatility = avgVolatility / maxVolatility if (relativeVolatility < 0.3) confidence += 15 else if (relativeVolatility > 0.7) confidence -= 10 } return Math.min(100, Math.max(0, confidence)) } private assessDataQuality(klineData: KlineData[]): "excellent" | "good" | "fair" | "poor" { if (klineData.length > 200) return "excellent" if (klineData.length > 150) return "good" if (klineData.length > 100) return "fair" return "poor" } } export default GetMarketStructure ``` -------------------------------------------------------------------------------- /webui/src/components/ToolsManager.ts: -------------------------------------------------------------------------------- ```typescript /** * Tools Manager - Handles the MCP Tools tab functionality * Displays available tools, allows manual testing, and shows execution history */ import { mcpClient } from '@/services/mcpClient'; import type { MCPTool } from '@/types/mcp'; import { DataCard, type DataCardConfig } from './chat/DataCard'; import { detectDataType } from '../utils/dataDetection'; export class ToolsManager { private tools: MCPTool[] = []; private isInitialized = false; private executionHistory: Array<{ id: string; tool: string; params: any; result: any; timestamp: number; success: boolean; }> = []; private dataCards: Map<string, DataCard> = new Map(); // Track DataCards by tool name constructor() {} /** * Initialize the tools manager */ async initialize(): Promise<void> { if (this.isInitialized) return; try { console.log('🔧 Initializing Tools Manager...'); // Load available tools await this.loadTools(); // Render tools interface this.renderToolsInterface(); this.isInitialized = true; console.log('✅ Tools Manager initialized'); } catch (error) { console.error('❌ Failed to initialize Tools Manager:', error); this.showError('Failed to initialize tools'); } } /** * Load available tools from MCP server */ private async loadTools(): Promise<void> { try { this.tools = await mcpClient.listTools(); console.log(`🔧 Loaded ${this.tools.length} tools`); } catch (error) { console.error('Failed to load tools:', error); this.tools = []; } } /** * Render the tools interface */ private renderToolsInterface(): void { const container = document.getElementById('tools-grid'); if (!container) return; if (this.tools.length === 0) { container.innerHTML = ` <div class="tools-empty"> <h3>No Tools Available</h3> <p>Unable to load MCP tools. Please check your connection.</p> <button onclick="location.reload()" class="retry-btn">Retry</button> </div> `; return; } // Create tools grid container.innerHTML = ` <div class="tools-header"> <h3>Available MCP Tools (${this.tools.length})</h3> <div class="tools-actions"> <button id="refresh-tools" class="refresh-btn">Refresh</button> <button id="clear-history" class="clear-btn">Clear History</button> </div> </div> <div class="tools-list"> ${this.tools.map(tool => this.renderToolCard(tool)).join('')} </div> <div class="execution-history"> <h3>Execution History</h3> <div id="history-list" class="history-list"> ${this.renderExecutionHistory()} </div> </div> `; // Set up event listeners this.setupEventListeners(); } /** * Render a single tool card */ private renderToolCard(tool: MCPTool): string { const requiredParams = tool.inputSchema?.required || []; const properties = tool.inputSchema?.properties || {}; const html = ` <div class="tool-card" data-tool="${tool.name}"> <div class="tool-header"> <h4>${tool.name}</h4> <button class="test-tool-btn" data-tool="${tool.name}">Test</button> </div> <p class="tool-description">${tool.description}</p> <div class="tool-params"> <h5>Parameters:</h5> ${Object.entries(properties).map(([key, param]: [string, any]) => { // Determine default value let defaultValue = ''; if (key === 'symbol') { defaultValue = 'XRPUSDT'; } else if (key === 'category') { defaultValue = 'spot'; } else if (key === 'interval') { defaultValue = '15'; } else if (key === 'limit') { defaultValue = '100'; } // Check if this field has enum options if (param.enum && Array.isArray(param.enum)) { // Use dropdown for enum fields return ` <div class="param-item"> <label for="${tool.name}-${key}"> ${key}${requiredParams.includes(key) ? ' *' : ''} </label> <select id="${tool.name}-${key}" class="param-select"> ${param.enum.map((value: string) => ` <option value="${value}" ${value === defaultValue ? 'selected' : ''}> ${value} </option> `).join('')} </select> ${param.description ? `<small class="param-description">${param.description}</small>` : ''} </div> `; } else { // Use input for non-enum fields return ` <div class="param-item"> <label for="${tool.name}-${key}"> ${key}${requiredParams.includes(key) ? ' *' : ''} </label> <input type="text" id="${tool.name}-${key}" placeholder="${param.description || ''}" value="${defaultValue}" class="param-input" /> ${param.description ? `<small class="param-description">${param.description}</small>` : ''} </div> `; } }).join('')} </div> <div class="tool-result" id="result-${tool.name}" style="display: none;"> <div class="result-header"> <h5>Result</h5> <button class="result-close" data-tool="${tool.name}" title="Close result">×</button> </div> <div class="result-content" id="result-content-${tool.name}"></div> </div> </div> `; return html; } /** * Render execution history */ private renderExecutionHistory(): string { if (this.executionHistory.length === 0) { return '<p class="history-empty">No executions yet</p>'; } return this.executionHistory .slice(-10) // Show last 10 executions .reverse() .map(execution => ` <div class="history-item ${execution.success ? 'success' : 'error'}"> <div class="history-header"> <span class="tool-name">${execution.tool}</span> <span class="timestamp">${new Date(execution.timestamp).toLocaleTimeString()}</span> </div> <div class="history-params"> <strong>Params:</strong> ${JSON.stringify(execution.params, null, 2)} </div> <div class="history-result"> <strong>Result:</strong> <pre>${JSON.stringify(execution.result, null, 2)}</pre> </div> </div> `).join(''); } /** * Set up event listeners */ private setupEventListeners(): void { // Refresh tools button const refreshBtn = document.getElementById('refresh-tools'); if (refreshBtn) { refreshBtn.addEventListener('click', () => { this.refreshTools(); }); } // Clear history button const clearBtn = document.getElementById('clear-history'); if (clearBtn) { clearBtn.addEventListener('click', () => { this.clearHistory(); }); } // Test tool buttons document.querySelectorAll('.test-tool-btn').forEach(btn => { btn.addEventListener('click', (event) => { const target = event.target as HTMLElement; const toolName = target.dataset.tool; if (toolName) { this.testTool(toolName); } }); }); // Result close buttons document.querySelectorAll('.result-close').forEach(btn => { btn.addEventListener('click', (event) => { const target = event.target as HTMLElement; const toolName = target.dataset.tool; if (toolName) { this.hideToolResult(toolName); } }); }); } /** * Test a specific tool */ private async testTool(toolName: string): Promise<void> { const tool = this.tools.find(t => t.name === toolName); if (!tool) return; // Collect parameters from form const params: any = {}; const properties = tool.inputSchema?.properties || {}; for (const [key] of Object.entries(properties)) { const element = document.getElementById(`${toolName}-${key}`) as HTMLInputElement | HTMLSelectElement; if (element && element.value) { params[key] = element.value; } } try { console.log(`🔧 Testing tool ${toolName} with params:`, params); // Show loading state this.showToolLoading(toolName); // Execute tool const result = await mcpClient.callTool(toolName as any, params); // Record execution this.recordExecution(toolName, params, result, true); // Show result in tool card this.showToolResult(toolName, result, true); // Update UI this.hideToolLoading(toolName); this.updateHistoryDisplay(); console.log(`✅ Tool ${toolName} executed successfully:`, result); } catch (error) { console.error(`❌ Tool ${toolName} execution failed:`, error); // Record failed execution this.recordExecution(toolName, params, error, false); // Show error in tool card this.showToolResult(toolName, error, false); this.hideToolLoading(toolName); this.updateHistoryDisplay(); } } /** * Record tool execution */ private recordExecution(tool: string, params: any, result: any, success: boolean): void { this.executionHistory.push({ id: Date.now().toString(), tool, params, result, timestamp: Date.now(), success, }); // Keep only last 50 executions if (this.executionHistory.length > 50) { this.executionHistory = this.executionHistory.slice(-50); } } /** * Update history display */ private updateHistoryDisplay(): void { const historyContainer = document.getElementById('history-list'); if (historyContainer) { historyContainer.innerHTML = this.renderExecutionHistory(); } } /** * Show tool loading state */ private showToolLoading(toolName: string): void { const btn = document.querySelector(`.test-tool-btn[data-tool="${toolName}"]`) as HTMLElement; if (btn) { btn.textContent = 'Testing...'; btn.setAttribute('disabled', 'true'); } } /** * Hide tool loading state */ private hideToolLoading(toolName: string): void { const btn = document.querySelector(`.test-tool-btn[data-tool="${toolName}"]`) as HTMLElement; if (btn) { btn.textContent = 'Test'; btn.removeAttribute('disabled'); } } /** * Show tool result in the tool card */ private showToolResult(toolName: string, result: any, success: boolean): void { const resultContainer = document.getElementById(`result-${toolName}`); const resultContent = document.getElementById(`result-content-${toolName}`); if (!resultContainer || !resultContent) { console.error(`❌ Could not find result DOM elements for ${toolName}`); return; } this.displayResult(resultContainer, resultContent, result, success); } private displayResult(resultContainer: HTMLElement, resultContent: HTMLElement, result: any, success: boolean): void { if (!success) { // Handle error case with existing logic this.displayErrorResult(resultContent, result); resultContainer.style.display = 'block'; return; } // Extract actual data from MCP content structure const actualData = this.extractActualData(result); // Try to create a DataCard for visualisable data const dataCardCreated = this.tryCreateDataCard(resultContainer, resultContent, actualData); if (!dataCardCreated) { // Fall back to traditional JSON display this.displayTraditionalResult(resultContent, actualData); } // Show the result container resultContainer.style.display = 'block'; // Scroll result into view resultContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } /** * Extract actual data from MCP content structure */ private extractActualData(result: any): any { let actualData = result; // Check if this is an MCP content response if (result && result.content && Array.isArray(result.content) && result.content.length > 0) { const firstContent = result.content[0]; if (firstContent.type === 'text' && firstContent.text) { try { // Try to parse the text as JSON actualData = JSON.parse(firstContent.text); } catch { // If parsing fails, use the text as-is actualData = firstContent.text; } } } return actualData; } /** * Try to create a DataCard for visualisable data */ private tryCreateDataCard(resultContainer: HTMLElement, resultContent: HTMLElement, actualData: any): boolean { try { // Detect if the data is visualisable const detection = detectDataType(actualData); if (!detection.visualisable || detection.confidence < 0.6) { return false; } // Get tool name from container const toolName = this.getToolNameFromContainer(resultContainer); if (!toolName) { return false; } // Clean up any existing DataCard for this tool const existingCard = this.dataCards.get(toolName); if (existingCard) { existingCard.destroy(); this.dataCards.delete(toolName); } // Create DataCard configuration const cardConfig: DataCardConfig = { title: this.generateToolCardTitle(toolName, detection), summary: detection.summary, data: actualData, dataType: detection.dataType, expanded: true, // Start expanded in tools tab for immediate visibility showChart: true }; // Create container for DataCard const cardContainer = document.createElement('div'); cardContainer.className = 'tool-result-datacard'; // Create and store the DataCard const dataCard = new DataCard(cardContainer, cardConfig); this.dataCards.set(toolName, dataCard); // Add status and actions above the card resultContent.innerHTML = ` <div class="result-status result-success"> ✅ Success - Data Visualisation Available </div> <div class="result-actions"> <button class="copy-result-btn" data-result="${encodeURIComponent(JSON.stringify(actualData, null, 2))}"> 📋 Copy Raw Data </button> <button class="toggle-raw-btn"> 📊 Show Raw JSON </button> </div> `; // Append the DataCard resultContent.appendChild(cardContainer); // Add toggle functionality for raw data this.setupDataCardActions(resultContent, actualData); return true; } catch (error) { console.warn('Failed to create DataCard for tool result:', error); return false; } } /** * Set up actions for DataCard (copy, toggle raw data) */ private setupDataCardActions(resultContent: HTMLElement, actualData: any): void { const copyBtn = resultContent.querySelector('.copy-result-btn') as HTMLElement; const toggleBtn = resultContent.querySelector('.toggle-raw-btn') as HTMLElement; if (copyBtn) { copyBtn.addEventListener('click', () => { const resultData = decodeURIComponent(copyBtn.dataset.result || ''); navigator.clipboard.writeText(resultData).then(() => { copyBtn.textContent = '✅ Copied!'; setTimeout(() => { copyBtn.textContent = '📋 Copy Raw Data'; }, 2000); }).catch(() => { copyBtn.textContent = '❌ Failed'; setTimeout(() => { copyBtn.textContent = '📋 Copy Raw Data'; }, 2000); }); }); } if (toggleBtn) { let showingRaw = false; toggleBtn.addEventListener('click', () => { const cardContainer = resultContent.querySelector('.tool-result-datacard') as HTMLElement; if (!cardContainer) return; if (showingRaw) { // Show DataCard cardContainer.style.display = 'block'; const rawDataDiv = resultContent.querySelector('.raw-data-display'); if (rawDataDiv) rawDataDiv.remove(); toggleBtn.textContent = '📊 Show Raw JSON'; showingRaw = false; } else { // Show raw JSON cardContainer.style.display = 'none'; const rawDataDiv = document.createElement('div'); rawDataDiv.className = 'raw-data-display'; rawDataDiv.innerHTML = `<pre class="result-data">${JSON.stringify(actualData, null, 2)}</pre>`; resultContent.appendChild(rawDataDiv); toggleBtn.textContent = '🎴 Show DataCard'; showingRaw = true; } }); } } /** * Display traditional JSON result (fallback) */ private displayTraditionalResult(resultContent: HTMLElement, actualData: any): void { let formattedResult: string; if (typeof actualData === 'object') { formattedResult = JSON.stringify(actualData, null, 2); } else { formattedResult = String(actualData); } resultContent.innerHTML = ` <div class="result-status result-success"> ✅ Success </div> <pre class="result-data">${formattedResult}</pre> <div class="result-actions"> <button class="copy-result-btn" data-result="${encodeURIComponent(formattedResult)}"> 📋 Copy </button> </div> `; // Add copy functionality const copyBtn = resultContent.querySelector('.copy-result-btn') as HTMLElement; if (copyBtn) { copyBtn.addEventListener('click', () => { const resultData = decodeURIComponent(copyBtn.dataset.result || ''); navigator.clipboard.writeText(resultData).then(() => { copyBtn.textContent = '✅ Copied!'; setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 2000); }).catch(() => { copyBtn.textContent = '❌ Failed'; setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 2000); }); }); } } /** * Display error result */ private displayErrorResult(resultContent: HTMLElement, result: any): void { let formattedResult: string; if (result instanceof Error) { formattedResult = `Error: ${result.message}`; } else { formattedResult = `Error: ${String(result)}`; } resultContent.innerHTML = ` <div class="result-status result-error"> ❌ Error </div> <pre class="result-data">${formattedResult}</pre> `; } /** * Get tool name from result container */ private getToolNameFromContainer(resultContainer: HTMLElement): string | null { const id = resultContainer.id; if (id && id.startsWith('result-')) { return id.substring(7); // Remove 'result-' prefix } return null; } /** * Generate appropriate title for tool DataCard */ private generateToolCardTitle(toolName: string, _detection: any): string { const toolDisplayNames: Record<string, string> = { 'get_ticker': 'Ticker Data', 'get_kline_data': 'Kline Data', 'get_ml_rsi': 'ML-RSI Analysis', 'get_order_blocks': 'Order Blocks', 'get_market_structure': 'Market Structure' }; return toolDisplayNames[toolName] || toolName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); } /** * Hide tool result */ private hideToolResult(toolName: string): void { const resultContainer = document.getElementById(`result-${toolName}`); if (resultContainer) { resultContainer.style.display = 'none'; } // Clean up associated DataCard const dataCard = this.dataCards.get(toolName); if (dataCard) { dataCard.destroy(); this.dataCards.delete(toolName); } } /** * Refresh tools */ private async refreshTools(): Promise<void> { console.log('🔄 Refreshing tools...'); await this.loadTools(); this.renderToolsInterface(); } /** * Clear execution history */ private clearHistory(): void { this.executionHistory = []; this.updateHistoryDisplay(); } /** * Show error message */ private showError(message: string): void { const container = document.getElementById('tools-grid'); if (container) { container.innerHTML = ` <div class="tools-error"> <h3>❌ Error</h3> <p>${message}</p> <button onclick="location.reload()">Retry</button> </div> `; } } /** * Get current state */ getState(): { tools: MCPTool[]; history: any[] } { return { tools: [...this.tools], history: [...this.executionHistory], }; } /** * Destroy tools manager */ destroy(): void { // Clean up all DataCards this.dataCards.forEach(card => card.destroy()); this.dataCards.clear(); this.isInitialized = false; console.log('🗑️ Tools Manager destroyed'); } } // Create singleton instance export const toolsManager = new ToolsManager(); ``` -------------------------------------------------------------------------------- /webui/src/main.ts: -------------------------------------------------------------------------------- ```typescript /** * Main application entry point */ console.log('🚀 Main.ts loading...'); import './styles/main.css'; import { ChatApp } from './components/ChatApp'; import { DebugConsole } from './components/DebugConsole'; import { DataVerificationPanel } from './components/DataVerificationPanel'; import { AgentDashboard } from './components/AgentDashboard'; import { toolsManager } from './components/ToolsManager'; import { configService } from './services/configService'; import { agentConfigService } from './services/agentConfig'; import { mcpClient } from './services/mcpClient'; import { aiClient } from './services/aiClient'; import { multiStepAgent } from './services/multiStepAgent'; // Import logService to initialize console interception import './services/logService'; class App { private chatApp?: ChatApp; private debugConsole?: DebugConsole; private verificationPanel?: DataVerificationPanel; private agentDashboard?: AgentDashboard; private isInitialized = false; private toolsInitialized = false; async initialize(): Promise<void> { if (this.isInitialized) return; try { // Show loading state this.showLoading(); // Initialize services await this.initializeServices(); // Initialize UI components this.initializeUI(); // Initialize debug console this.initializeDebugConsole(); // Initialize data verification panel this.initializeVerificationPanel(); // Initialize agent dashboard this.initializeAgentDashboard(); // Hide loading and show main app this.hideLoading(); this.isInitialized = true; console.log('✅ Bybit MCP WebUI initialized successfully'); } catch (error) { console.error('❌ Failed to initialize application:', error); this.showError('Failed to initialize application. Please check your configuration.'); } } private async initializeServices(): Promise<void> { console.log('🚀 Initializing services...'); // Get current configuration const aiConfig = configService.getAIConfig(); const mcpConfig = configService.getMCPConfig(); console.log('⚙️ AI Config:', { endpoint: aiConfig.endpoint, model: aiConfig.model, temperature: aiConfig.temperature, maxTokens: aiConfig.maxTokens }); console.log('⚙️ MCP Config:', mcpConfig); // Note: MCP server should be started automatically with 'pnpm dev:full' console.log('💡 If MCP server is not running, use "pnpm dev:full" to start both services'); // Update clients with current config aiClient.updateConfig(aiConfig); mcpClient.setBaseUrl(mcpConfig.endpoint); mcpClient.setTimeout(mcpConfig.timeout); // Test connections console.log('🔄 Testing connections...'); const [aiConnected, mcpConnected] = await Promise.allSettled([ aiClient.isConnected(), mcpClient.isConnected(), ]); console.log('📊 Connection results:', { ai: aiConnected.status === 'fulfilled' ? aiConnected.value : aiConnected.reason, mcp: mcpConnected.status === 'fulfilled' ? mcpConnected.value : mcpConnected.reason }); // Initialize MCP client (fetch available tools) if (mcpConnected.status === 'fulfilled' && mcpConnected.value) { try { await mcpClient.initialize(); console.log('✅ MCP client initialized'); } catch (error) { console.warn('⚠️ MCP client initialization failed:', error); } } else { console.warn('⚠️ MCP server not reachable'); } // Log connection status if (aiConnected.status === 'fulfilled' && aiConnected.value) { console.log('✅ AI service connected'); } else { console.warn('⚠️ AI service not reachable'); } // Initialize multi-step agent try { console.log('🤖 Initializing multi-step agent...'); await multiStepAgent.initialize(); console.log('✅ Multi-step agent initialized'); } catch (error) { console.warn('⚠️ Multi-step agent initialization failed:', error); console.log('💡 Falling back to legacy AI client'); } console.log('✅ Service initialization complete'); } private initializeUI(): void { // Initialize chat application this.chatApp = new ChatApp(); // Set up global event listeners this.setupGlobalEventListeners(); // Set up theme toggle this.setupThemeToggle(); // Set up settings modal this.setupSettingsModal(); } private setupGlobalEventListeners(): void { // Handle keyboard shortcuts document.addEventListener('keydown', (event) => { // Ctrl/Cmd + K to focus chat input if ((event.ctrlKey || event.metaKey) && event.key === 'k') { event.preventDefault(); const chatInput = document.getElementById('chat-input') as HTMLTextAreaElement; if (chatInput) { chatInput.focus(); } } // Escape to close modals if (event.key === 'Escape') { this.closeAllModals(); } }); // Handle navigation document.querySelectorAll('.nav-item').forEach(item => { item.addEventListener('click', (event) => { const target = event.currentTarget as HTMLElement; const view = target.dataset.view; if (view) { this.switchView(view); } }); }); // Handle example queries document.querySelectorAll('.example-query').forEach(button => { button.addEventListener('click', (event) => { const target = event.currentTarget as HTMLElement; const query = target.textContent?.trim(); if (query && this.chatApp) { this.chatApp.sendMessage(query); } }); }); // Agent settings button removed - now integrated into main settings modal // Handle agent mode toggle const agentToggleBtn = document.getElementById('agent-toggle-btn'); if (agentToggleBtn && this.chatApp) { agentToggleBtn.addEventListener('click', () => { const isUsingAgent = this.chatApp!.isUsingAgent(); this.chatApp!.toggleAgentMode(!isUsingAgent); agentToggleBtn.textContent = !isUsingAgent ? '🤖 Agent Mode' : '🔄 Legacy Mode'; }); } } private setupThemeToggle(): void { const themeToggle = document.getElementById('theme-toggle'); if (themeToggle) { themeToggle.addEventListener('click', () => { const settings = configService.getSettings(); const currentTheme = settings.ui.theme; let newTheme: 'light' | 'dark' | 'auto'; let icon: string; if (currentTheme === 'light') { newTheme = 'dark'; icon = '☀️'; } else if (currentTheme === 'dark') { newTheme = 'auto'; icon = '🌓'; } else { newTheme = 'light'; icon = '🌙'; } configService.updateSettings({ ui: { ...settings.ui, theme: newTheme }, }); // Update icon const iconElement = themeToggle.querySelector('.theme-icon'); if (iconElement) { iconElement.textContent = icon; } }); } } private setupAgentDashboardButton(): void { const agentDashboardBtn = document.getElementById('agent-dashboard-btn'); if (agentDashboardBtn && this.agentDashboard) { agentDashboardBtn.addEventListener('click', () => { this.agentDashboard!.toggleVisibility(); // Update button appearance based on dashboard visibility const isVisible = this.agentDashboard!.visible; if (isVisible) { agentDashboardBtn.classList.add('active'); } else { agentDashboardBtn.classList.remove('active'); } }); } } private setupSettingsModal(): void { const settingsBtn = document.getElementById('settings-btn'); const settingsModal = document.getElementById('settings-modal'); const closeSettings = document.getElementById('close-settings'); const saveSettings = document.getElementById('save-settings'); if (settingsBtn && settingsModal) { settingsBtn.addEventListener('click', () => { this.openSettingsModal(); }); } if (closeSettings && settingsModal) { closeSettings.addEventListener('click', () => { settingsModal.classList.add('hidden'); settingsModal.classList.remove('active'); }); } if (saveSettings) { saveSettings.addEventListener('click', () => { this.saveSettingsFromModal(); }); } // Close modal when clicking backdrop if (settingsModal) { settingsModal.addEventListener('click', (event) => { if (event.target === settingsModal) { settingsModal.classList.add('hidden'); settingsModal.classList.remove('active'); } }); } } private initializeDebugConsole(): void { // Create debug console container const debugContainer = document.createElement('div'); debugContainer.id = 'debug-console-container'; document.body.appendChild(debugContainer); // Initialize debug console this.debugConsole = new DebugConsole(debugContainer); // Add keyboard shortcut to toggle debug console (Ctrl+` or Cmd+`) document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === '`') { e.preventDefault(); this.debugConsole?.toggle(); } }); console.log('🔍 Debug console initialized (Ctrl+` to toggle)'); } private initializeVerificationPanel(): void { try { // Initialize data verification panel this.verificationPanel = new DataVerificationPanel('verification-panel-container'); console.log('📊 Data verification panel initialized (Ctrl+D to toggle)'); // Make panel accessible for debugging (window as any).verificationPanel = this.verificationPanel; } catch (error) { console.warn('⚠️ Failed to initialize verification panel:', error); } } private initializeAgentDashboard(): void { try { // Initialize agent dashboard with ChatApp reference this.agentDashboard = new AgentDashboard('agent-dashboard-container', this.chatApp); console.log('🤖 Agent dashboard initialized (Ctrl+M to toggle)'); // Set up agent dashboard button now that dashboard is initialized this.setupAgentDashboardButton(); // Make dashboard accessible for debugging (window as any).agentDashboard = this.agentDashboard; } catch (error) { console.warn('⚠️ Failed to initialize agent dashboard:', error); } } private openSettingsModal(): void { const modal = document.getElementById('settings-modal'); if (!modal) return; // Populate current settings const settings = configService.getSettings(); const agentConfig = agentConfigService.getConfig(); console.log('🔧 Opening settings modal with current settings:', settings, agentConfig); // AI Configuration const aiEndpoint = document.getElementById('ai-endpoint') as HTMLInputElement; const aiModel = document.getElementById('ai-model') as HTMLInputElement; const mcpEndpoint = document.getElementById('mcp-endpoint') as HTMLInputElement; if (aiEndpoint) { aiEndpoint.value = settings.ai.endpoint; console.log('📝 Set AI endpoint field to:', settings.ai.endpoint); } if (aiModel) { aiModel.value = settings.ai.model; console.log('📝 Set AI model field to:', settings.ai.model); } if (mcpEndpoint) { mcpEndpoint.value = settings.mcp.endpoint; console.log('📝 Set MCP endpoint field to:', settings.mcp.endpoint); } // Agent Configuration const agentModeEnabled = document.getElementById('agent-mode-enabled') as HTMLInputElement; const maxIterations = document.getElementById('max-iterations') as HTMLInputElement; const toolTimeout = document.getElementById('tool-timeout') as HTMLInputElement; const showWorkflowSteps = document.getElementById('show-workflow-steps') as HTMLInputElement; const showToolCalls = document.getElementById('show-tool-calls') as HTMLInputElement; const enableDebugMode = document.getElementById('enable-debug-mode') as HTMLInputElement; if (agentModeEnabled) { agentModeEnabled.checked = this.chatApp?.isAgentModeEnabled() || false; } if (maxIterations) { maxIterations.value = agentConfig.maxIterations.toString(); } if (toolTimeout) { toolTimeout.value = agentConfig.toolTimeout.toString(); } if (showWorkflowSteps) { showWorkflowSteps.checked = agentConfig.showWorkflowSteps; } if (showToolCalls) { showToolCalls.checked = agentConfig.showToolCalls; } if (enableDebugMode) { enableDebugMode.checked = agentConfig.enableDebugMode; } modal.classList.remove('hidden'); modal.classList.add('active'); } private saveSettingsFromModal(): void { const aiEndpoint = document.getElementById('ai-endpoint') as HTMLInputElement; const aiModel = document.getElementById('ai-model') as HTMLInputElement; const mcpEndpoint = document.getElementById('mcp-endpoint') as HTMLInputElement; // Agent Configuration elements const agentModeEnabled = document.getElementById('agent-mode-enabled') as HTMLInputElement; const maxIterations = document.getElementById('max-iterations') as HTMLInputElement; const toolTimeout = document.getElementById('tool-timeout') as HTMLInputElement; const showWorkflowSteps = document.getElementById('show-workflow-steps') as HTMLInputElement; const showToolCalls = document.getElementById('show-tool-calls') as HTMLInputElement; const enableDebugMode = document.getElementById('enable-debug-mode') as HTMLInputElement; console.log('💾 Saving settings from modal...'); console.log('AI Endpoint:', aiEndpoint?.value); console.log('AI Model:', aiModel?.value); console.log('MCP Endpoint:', mcpEndpoint?.value); console.log('Agent Mode:', agentModeEnabled?.checked); const currentSettings = configService.getSettings(); const updates: Partial<typeof currentSettings> = {}; // Build AI config updates const aiUpdates: Partial<typeof currentSettings.ai> = {}; let hasAIUpdates = false; if (aiEndpoint?.value && aiEndpoint.value.trim() !== '') { aiUpdates.endpoint = aiEndpoint.value.trim(); hasAIUpdates = true; } if (aiModel?.value && aiModel.value.trim() !== '') { aiUpdates.model = aiModel.value.trim(); hasAIUpdates = true; } if (hasAIUpdates) { updates.ai = { ...currentSettings.ai, ...aiUpdates }; } // Build MCP config updates if (mcpEndpoint?.value && mcpEndpoint.value.trim() !== '') { updates.mcp = { ...currentSettings.mcp, endpoint: mcpEndpoint.value.trim() }; } console.log('📝 Settings updates:', updates); if (Object.keys(updates).length > 0) { configService.updateSettings(updates); console.log('✅ Settings saved successfully'); // Reinitialize services with new config this.initializeServices().catch(console.error); } else { console.log('ℹ️ No settings changes to save'); } // Save agent configuration const agentConfig = { maxIterations: parseInt(maxIterations?.value || '5'), toolTimeout: parseInt(toolTimeout?.value || '30000'), showWorkflowSteps: showWorkflowSteps?.checked || false, showToolCalls: showToolCalls?.checked || false, enableDebugMode: enableDebugMode?.checked || false, streamingEnabled: true // Always enabled }; console.log('🤖 Saving agent config:', agentConfig); agentConfigService.updateConfig(agentConfig); // Update agent mode in chat app if (this.chatApp && agentModeEnabled) { this.chatApp.toggleAgentMode(agentModeEnabled.checked); } // Close modal const modal = document.getElementById('settings-modal'); if (modal) { modal.classList.add('hidden'); modal.classList.remove('active'); } } private switchView(viewName: string): void { // Update navigation document.querySelectorAll('.nav-item').forEach(item => { item.classList.remove('active'); }); const activeNavItem = document.querySelector(`[data-view="${viewName}"]`); if (activeNavItem) { activeNavItem.classList.add('active'); } // Update views document.querySelectorAll('.view').forEach(view => { view.classList.remove('active'); }); const activeView = document.getElementById(`${viewName}-view`); if (activeView) { activeView.classList.add('active'); } // Initialize components when their views are accessed if (viewName === 'tools' && !this.toolsInitialized) { this.initializeTools(); } // Handle dashboard view - embed agent dashboard into the tab if (viewName === 'dashboard' && this.agentDashboard) { this.embedDashboardInTab(); } } /** * Initialize tools when tools tab is first accessed */ private async initializeTools(): Promise<void> { if (this.toolsInitialized) return; try { console.log('🔧 Initializing tools...'); await toolsManager.initialize(); this.toolsInitialized = true; console.log('✅ Tools initialized successfully'); } catch (error) { console.error('❌ Failed to initialize tools:', error); } } /** * Embed agent dashboard into the dashboard tab view */ private embedDashboardInTab(): void { if (!this.agentDashboard) return; const dashboardWrapper = document.getElementById('dashboard-content-wrapper'); const agentDashboardContainer = document.getElementById('agent-dashboard-container'); if (dashboardWrapper && agentDashboardContainer) { // Check if dashboard content already exists in the tab if (dashboardWrapper.querySelector('.agent-dashboard')) { return; // Already embedded } // Get the dashboard content from the original container const dashboardContent = agentDashboardContainer.querySelector('.agent-dashboard'); if (dashboardContent) { // Clone the dashboard content for the tab view const clonedContent = dashboardContent.cloneNode(true) as HTMLElement; // Remove overlay-specific classes and styles clonedContent.classList.remove('hidden'); clonedContent.classList.add('visible'); clonedContent.style.position = 'static'; clonedContent.style.zIndex = 'auto'; clonedContent.style.background = 'transparent'; clonedContent.style.boxShadow = 'none'; clonedContent.style.border = 'none'; clonedContent.style.borderRadius = '0'; clonedContent.style.width = '100%'; clonedContent.style.height = '100%'; clonedContent.style.maxWidth = 'none'; clonedContent.style.maxHeight = 'none'; clonedContent.style.transform = 'none'; clonedContent.style.top = 'auto'; clonedContent.style.left = 'auto'; clonedContent.style.right = 'auto'; clonedContent.style.bottom = 'auto'; // Add the cloned content to the tab view dashboardWrapper.innerHTML = ''; dashboardWrapper.appendChild(clonedContent); // Set up event listeners for the cloned content this.setupTabDashboardEventListeners(clonedContent); // Debug: Check what data is available console.log('🔍 Dashboard data check:'); console.log('Memory stats:', multiStepAgent.getMemoryStats()); console.log('Performance stats:', multiStepAgent.getPerformanceStats()); console.log('Analysis history:', multiStepAgent.getAnalysisHistory(undefined, 5)); // Refresh the dashboard data this.agentDashboard.show(); // This will trigger a refresh this.agentDashboard.hide(); // Hide the overlay version } } } /** * Set up event listeners for the dashboard in tab view */ private setupTabDashboardEventListeners(dashboardElement: HTMLElement): void { // Refresh button const refreshBtn = dashboardElement.querySelector('#dashboard-refresh') as HTMLButtonElement; refreshBtn?.addEventListener('click', () => { if (this.agentDashboard) { // Trigger refresh and then update the tab view this.agentDashboard.show(); this.agentDashboard.hide(); setTimeout(() => this.embedDashboardInTab(), 100); } }); // Clear memory button const clearMemoryBtn = dashboardElement.querySelector('#clear-memory') as HTMLButtonElement; clearMemoryBtn?.addEventListener('click', () => { if (confirm('Are you sure you want to clear all agent memory? This action cannot be undone.')) { multiStepAgent.clearMemory(); // Refresh the tab view setTimeout(() => this.embedDashboardInTab(), 100); this.showToast('Memory cleared successfully!'); } }); // New conversation button const newConversationBtn = dashboardElement.querySelector('#new-conversation') as HTMLButtonElement; newConversationBtn?.addEventListener('click', () => { // Clear agent memory multiStepAgent.startNewConversation(); // Clear chat UI if available if (this.chatApp) { this.chatApp.clearMessages(); } this.showToast('New conversation started!'); }); // Export data button const exportDataBtn = dashboardElement.querySelector('#export-data') as HTMLButtonElement; exportDataBtn?.addEventListener('click', () => { try { const data = { memoryStats: multiStepAgent.getMemoryStats(), performanceStats: multiStepAgent.getPerformanceStats(), recentAnalysis: multiStepAgent.getAnalysisHistory(undefined, 20), exportedAt: new Date().toISOString() }; const dataStr = JSON.stringify(data, null, 2); const blob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `agent-data-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.showToast('Data exported successfully!'); } catch (error) { console.error('Failed to export data:', error); this.showToast('Failed to export data', 'error'); } }); } private showToast(message: string, type: 'success' | 'error' = 'success'): void { const toast = document.createElement('div'); toast.className = `dashboard-toast toast-${type}`; toast.textContent = message; document.body.appendChild(toast); // Animate in setTimeout(() => toast.classList.add('show'), 10); // Remove after 3 seconds setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 3000); } private closeAllModals(): void { document.querySelectorAll('.modal').forEach(modal => { modal.classList.add('hidden'); modal.classList.remove('active'); }); } private showLoading(): void { const loading = document.getElementById('loading'); const mainContainer = document.getElementById('main-container'); if (loading) loading.classList.remove('hidden'); if (mainContainer) mainContainer.classList.add('hidden'); } private hideLoading(): void { const loading = document.getElementById('loading'); const mainContainer = document.getElementById('main-container'); if (loading) loading.classList.add('hidden'); if (mainContainer) mainContainer.classList.remove('hidden'); } private showError(message: string): void { const loading = document.getElementById('loading'); if (loading) { loading.innerHTML = ` <div class="loading-container"> <div style="color: var(--color-danger); text-align: center;"> <h2>❌ Error</h2> <p>${message}</p> <button onclick="location.reload()" style=" margin-top: 1rem; padding: 0.5rem 1rem; background: var(--color-primary); color: white; border: none; border-radius: 0.5rem; cursor: pointer; ">Reload Page</button> </div> </div> `; } } } // Initialize application when DOM is ready document.addEventListener('DOMContentLoaded', () => { const app = new App(); app.initialize().catch(console.error); }); // Handle unhandled errors window.addEventListener('error', (event) => { console.error('Unhandled error:', event.error); }); window.addEventListener('unhandledrejection', (event) => { console.error('Unhandled promise rejection:', event.reason); }); ```