This is page 4 of 8. Use http://codebase.md/sammcj/bybit-mcp?lines=true&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 -------------------------------------------------------------------------------- /src/tools/BaseTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Tool, TextContent, CallToolResult } from "@modelcontextprotocol/sdk/types.js" 2 | import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" 3 | import { z } from "zod" 4 | import { RestClientV5, APIResponseV3WithTime } from "bybit-api" 5 | import { getEnvConfig } from "../env.js" 6 | 7 | // Error categories for better error handling 8 | export enum ErrorCategory { 9 | VALIDATION = "VALIDATION", 10 | API_ERROR = "API_ERROR", 11 | RATE_LIMIT = "RATE_LIMIT", 12 | NETWORK = "NETWORK", 13 | AUTHENTICATION = "AUTHENTICATION", 14 | PERMISSION = "PERMISSION", 15 | INTERNAL = "INTERNAL" 16 | } 17 | 18 | // Structured error interface 19 | export interface ToolError { 20 | category: ErrorCategory 21 | code?: string | number 22 | message: string 23 | details?: any 24 | timestamp: string 25 | tool: string 26 | } 27 | 28 | // Standard error codes 29 | export const ERROR_CODES = { 30 | INVALID_INPUT: "INVALID_INPUT", 31 | MISSING_REQUIRED_FIELD: "MISSING_REQUIRED_FIELD", 32 | INVALID_SYMBOL: "INVALID_SYMBOL", 33 | INVALID_CATEGORY: "INVALID_CATEGORY", 34 | API_KEY_REQUIRED: "API_KEY_REQUIRED", 35 | RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED", 36 | BYBIT_API_ERROR: "BYBIT_API_ERROR", 37 | NETWORK_ERROR: "NETWORK_ERROR", 38 | TIMEOUT: "TIMEOUT", 39 | UNKNOWN_ERROR: "UNKNOWN_ERROR" 40 | } as const 41 | 42 | // Rate limit configuration (as per Bybit docs) 43 | const RATE_LIMIT = { 44 | maxRequestsPerSecond: 10, 45 | maxRequestsPerMinute: 120, 46 | retryAfter: 2000, // ms 47 | maxRetries: 3 48 | } 49 | 50 | interface QueuedRequest { 51 | execute: () => Promise<any> 52 | resolve: (value: any) => void 53 | reject: (error: any) => void 54 | } 55 | 56 | export abstract class BaseToolImplementation { 57 | abstract name: string 58 | abstract toolDefinition: Tool 59 | abstract toolCall(request: z.infer<typeof CallToolRequestSchema>): Promise<CallToolResult> 60 | 61 | protected client: RestClientV5 62 | protected isDevMode: boolean 63 | protected isTestMode: boolean = false 64 | private requestQueue: QueuedRequest[] = [] 65 | private processingQueue = false 66 | private requestCount = 0 67 | private lastRequestTime = 0 68 | private requestHistory: number[] = [] // Timestamps of requests within the last minute 69 | private initialized = false 70 | private activeTimeouts: NodeJS.Timeout[] = [] 71 | 72 | constructor(mockClient?: RestClientV5) { 73 | if (mockClient) { 74 | // Use provided mock client for testing 75 | this.client = mockClient 76 | this.isDevMode = true 77 | this.isTestMode = true 78 | } else { 79 | // Normal production/development initialization 80 | const config = getEnvConfig() 81 | this.isDevMode = !config.apiKey || !config.apiSecret 82 | 83 | if (this.isDevMode) { 84 | this.client = new RestClientV5({ 85 | testnet: true, 86 | }) 87 | } else { 88 | this.client = new RestClientV5({ 89 | key: config.apiKey, 90 | secret: config.apiSecret, 91 | testnet: config.useTestnet, 92 | recv_window: 5000, // 5 second receive window 93 | }) 94 | } 95 | } 96 | } 97 | 98 | protected ensureInitialized() { 99 | if (!this.initialized) { 100 | if (this.isDevMode) { 101 | this.logWarning("Running in development mode with limited functionality") 102 | } 103 | this.initialized = true 104 | } 105 | } 106 | 107 | /** 108 | * Enqueues a request with rate limiting and retry logic 109 | */ 110 | protected async executeRequest<T>( 111 | operation: () => Promise<APIResponseV3WithTime<T>>, 112 | retryCount = 0 113 | ): Promise<T> { 114 | this.ensureInitialized() 115 | return new Promise((resolve, reject) => { 116 | this.requestQueue.push({ 117 | execute: async () => { 118 | try { 119 | // Check rate limits 120 | if (!this.canMakeRequest() && !this.isTestMode) { 121 | const waitTime = this.getWaitTime() 122 | this.logInfo(`Rate limit reached. Waiting ${waitTime}ms`) 123 | await new Promise(resolve => setTimeout(resolve, waitTime)) 124 | } 125 | 126 | // Execute request with timeout 127 | let response: APIResponseV3WithTime<T> 128 | 129 | if (this.isTestMode) { 130 | // In test mode, don't create timeout promises to avoid open handles 131 | response = await operation() as APIResponseV3WithTime<T> 132 | } else { 133 | // In production mode, use timeout for real API calls 134 | response = await Promise.race([ 135 | operation(), 136 | new Promise<never>((_, reject) => 137 | setTimeout(() => reject(new Error("Request timeout")), 10000) 138 | ) 139 | ]) as APIResponseV3WithTime<T> 140 | } 141 | 142 | // Update rate limit tracking 143 | this.updateRequestHistory() 144 | 145 | // Handle Bybit API errors 146 | if (response.retCode !== 0) { 147 | throw this.createBybitError(response.retCode, response.retMsg) 148 | } 149 | 150 | return response.result 151 | } catch (error) { 152 | // Retry logic for specific errors 153 | if ( 154 | retryCount < RATE_LIMIT.maxRetries && 155 | this.shouldRetry(error) 156 | ) { 157 | this.logWarning(`Retrying request (attempt ${retryCount + 1})`) 158 | if (!this.isTestMode) { 159 | await new Promise(resolve => 160 | setTimeout(resolve, RATE_LIMIT.retryAfter) 161 | ) 162 | } 163 | return this.executeRequest(operation, retryCount + 1) 164 | } 165 | throw error 166 | } 167 | }, 168 | resolve, 169 | reject 170 | }) 171 | 172 | if (!this.processingQueue) { 173 | this.processQueue() 174 | } 175 | }) 176 | } 177 | 178 | private async processQueue() { 179 | if (this.requestQueue.length === 0) { 180 | this.processingQueue = false 181 | return 182 | } 183 | 184 | this.processingQueue = true 185 | const request = this.requestQueue.shift() 186 | 187 | if (request) { 188 | try { 189 | const result = await request.execute() 190 | request.resolve(result) 191 | } catch (error) { 192 | request.reject(error) 193 | } 194 | } 195 | 196 | // Process next request 197 | setImmediate(() => this.processQueue()) 198 | } 199 | 200 | private canMakeRequest(): boolean { 201 | const now = Date.now() 202 | // Clean up old requests 203 | this.requestHistory = this.requestHistory.filter( 204 | time => now - time < 60000 205 | ) 206 | 207 | return ( 208 | this.requestHistory.length < RATE_LIMIT.maxRequestsPerMinute && 209 | now - this.lastRequestTime >= (1000 / RATE_LIMIT.maxRequestsPerSecond) 210 | ) 211 | } 212 | 213 | private getWaitTime(): number { 214 | const now = Date.now() 215 | const timeToWaitForSecondLimit = Math.max( 216 | 0, 217 | this.lastRequestTime + (1000 / RATE_LIMIT.maxRequestsPerSecond) - now 218 | ) 219 | 220 | if (this.requestHistory.length >= RATE_LIMIT.maxRequestsPerMinute) { 221 | const timeToWaitForMinuteLimit = Math.max( 222 | 0, 223 | this.requestHistory[0] + 60000 - now 224 | ) 225 | return Math.max(timeToWaitForSecondLimit, timeToWaitForMinuteLimit) 226 | } 227 | 228 | return timeToWaitForSecondLimit 229 | } 230 | 231 | private updateRequestHistory() { 232 | const now = Date.now() 233 | this.requestHistory.push(now) 234 | this.lastRequestTime = now 235 | } 236 | 237 | private shouldRetry(error: any): boolean { 238 | // Retry on network errors or specific Bybit error codes 239 | return ( 240 | error.name === "NetworkError" || 241 | error.code === 10002 || // Rate limit 242 | error.code === 10006 || // System busy 243 | error.code === -1 // Unknown error 244 | ) 245 | } 246 | 247 | private createBybitError(code: number, message: string): Error { 248 | const errorMap: Record<number, string> = { 249 | 10001: "Parameter error", 250 | 10002: "Rate limit exceeded", 251 | 10003: "Invalid API key", 252 | 10004: "Invalid sign", 253 | 10005: "Permission denied", 254 | 10006: "System busy", 255 | 10009: "Order not found", 256 | 10010: "Insufficient balance", 257 | } 258 | 259 | const errorMessage = errorMap[code] || message 260 | const error = new Error(`Bybit API Error ${code}: ${errorMessage}`) 261 | ; (error as any).code = code 262 | ; (error as any).bybitCode = code 263 | ; (error as any).category = this.categoriseBybitError(code) 264 | return error 265 | } 266 | 267 | /** 268 | * Creates a standardised ToolError object 269 | */ 270 | protected createToolError( 271 | category: ErrorCategory, 272 | message: string, 273 | code?: string | number, 274 | details?: any 275 | ): ToolError { 276 | return { 277 | category, 278 | code, 279 | message, 280 | details, 281 | timestamp: new Date().toISOString(), 282 | tool: this.name 283 | } 284 | } 285 | 286 | /** 287 | * Creates a validation error for invalid input 288 | */ 289 | protected createValidationError(message: string, details?: any): ToolError { 290 | return this.createToolError( 291 | ErrorCategory.VALIDATION, 292 | message, 293 | ERROR_CODES.INVALID_INPUT, 294 | details 295 | ) 296 | } 297 | 298 | /** 299 | * Creates an API error from Bybit response 300 | */ 301 | protected createApiError(code: number, message: string): ToolError { 302 | const category = this.categoriseBybitError(code) 303 | return this.createToolError( 304 | category, 305 | `Bybit API Error ${code}: ${message}`, 306 | code 307 | ) 308 | } 309 | 310 | /** 311 | * Categorises Bybit API errors 312 | */ 313 | private categoriseBybitError(code: number): ErrorCategory { 314 | switch (code) { 315 | case 10002: 316 | return ErrorCategory.RATE_LIMIT 317 | case 10003: 318 | case 10004: 319 | return ErrorCategory.AUTHENTICATION 320 | case 10005: 321 | return ErrorCategory.PERMISSION 322 | case 10001: 323 | return ErrorCategory.VALIDATION 324 | default: 325 | return ErrorCategory.API_ERROR 326 | } 327 | } 328 | 329 | /** 330 | * Handles errors and returns MCP-compliant CallToolResult 331 | */ 332 | protected handleError(error: any): CallToolResult { 333 | let toolError: ToolError 334 | 335 | if (error instanceof Error) { 336 | // Check if it's a Bybit API error (has bybitCode property) 337 | if ((error as any).bybitCode) { 338 | toolError = this.createApiError((error as any).bybitCode, error.message) 339 | } 340 | // Check if it's a validation error (from Zod) 341 | else if (error.message.includes("Invalid input")) { 342 | toolError = this.createValidationError(error.message) 343 | } 344 | // Check for specific error patterns 345 | else if (error.message.includes("API credentials required") || error.message.includes("development mode")) { 346 | toolError = this.createToolError( 347 | ErrorCategory.AUTHENTICATION, 348 | error.message, 349 | ERROR_CODES.API_KEY_REQUIRED 350 | ) 351 | } else if (error.message.includes("Rate limit")) { 352 | toolError = this.createToolError( 353 | ErrorCategory.RATE_LIMIT, 354 | error.message, 355 | ERROR_CODES.RATE_LIMIT_EXCEEDED 356 | ) 357 | } else if (error.message.includes("timeout") || error.message.includes("Request timeout")) { 358 | toolError = this.createToolError( 359 | ErrorCategory.NETWORK, 360 | error.message, 361 | ERROR_CODES.TIMEOUT 362 | ) 363 | } else if (error.name === "NetworkError") { 364 | toolError = this.createToolError( 365 | ErrorCategory.NETWORK, 366 | error.message, 367 | ERROR_CODES.NETWORK_ERROR 368 | ) 369 | } else { 370 | toolError = this.createToolError( 371 | ErrorCategory.INTERNAL, 372 | error.message, 373 | ERROR_CODES.UNKNOWN_ERROR 374 | ) 375 | } 376 | } else { 377 | toolError = this.createToolError( 378 | ErrorCategory.INTERNAL, 379 | String(error), 380 | ERROR_CODES.UNKNOWN_ERROR 381 | ) 382 | } 383 | 384 | // Log the error 385 | console.error(JSON.stringify({ 386 | jsonrpc: "2.0", 387 | method: "notify", 388 | params: { 389 | level: "error", 390 | message: `${this.name} tool error: ${toolError.message}` 391 | } 392 | })) 393 | 394 | // Create MCP-compliant error response 395 | const content: TextContent = { 396 | type: "text", 397 | text: JSON.stringify(toolError, null, 2), 398 | annotations: { 399 | audience: ["assistant", "user"], 400 | priority: 1 401 | } 402 | } 403 | 404 | return { 405 | content: [content], 406 | isError: true 407 | } 408 | } 409 | 410 | // Reference ID counter for generating unique IDs 411 | private static referenceIdCounter = 0 412 | 413 | /** 414 | * Generate a unique reference ID 415 | */ 416 | protected generateReferenceId(): string { 417 | BaseToolImplementation.referenceIdCounter += 1 418 | return `REF${String(BaseToolImplementation.referenceIdCounter).padStart(3, '0')}` 419 | } 420 | 421 | /** 422 | * Add reference ID metadata to response if requested 423 | */ 424 | protected addReferenceMetadata(data: any, includeReferenceId: boolean, toolName: string, endpoint?: string): any { 425 | if (!includeReferenceId) { 426 | return data 427 | } 428 | 429 | return { 430 | ...data, 431 | _referenceId: this.generateReferenceId(), 432 | _timestamp: new Date().toISOString(), 433 | _toolName: toolName, 434 | _endpoint: endpoint 435 | } 436 | } 437 | 438 | protected formatResponse(data: any): CallToolResult { 439 | this.ensureInitialized() 440 | const content: TextContent = { 441 | type: "text", 442 | text: JSON.stringify(data, null, 2), 443 | annotations: { 444 | audience: ["assistant", "user"], 445 | priority: 1 446 | } 447 | } 448 | 449 | return { 450 | content: [content] 451 | } 452 | } 453 | 454 | protected logInfo(message: string) { 455 | console.info(JSON.stringify({ 456 | jsonrpc: "2.0", 457 | method: "notify", 458 | params: { 459 | level: "info", 460 | message: `${this.name}: ${message}` 461 | } 462 | })) 463 | } 464 | 465 | protected logWarning(message: string) { 466 | console.warn(JSON.stringify({ 467 | jsonrpc: "2.0", 468 | method: "notify", 469 | params: { 470 | level: "warning", 471 | message: `${this.name}: ${message}` 472 | } 473 | })) 474 | } 475 | 476 | /** 477 | * Cleanup method for tests to clear any remaining timeouts 478 | */ 479 | public cleanup() { 480 | this.activeTimeouts.forEach(timeout => clearTimeout(timeout)) 481 | this.activeTimeouts = [] 482 | this.requestQueue = [] 483 | this.processingQueue = false 484 | } 485 | } 486 | ``` -------------------------------------------------------------------------------- /webui/src/services/mcpClient.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * MCP (Model Context Protocol) client for communicating with the Bybit MCP server 3 | * Uses the official MCP SDK with StreamableHTTPClientTransport for browser compatibility 4 | */ 5 | 6 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 7 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; 8 | import { citationStore } from './citationStore'; 9 | import type { 10 | MCPTool, 11 | MCPToolCall, 12 | MCPToolResult, 13 | MCPToolName, 14 | MCPToolParams, 15 | MCPToolResponse, 16 | } from '@/types/mcp'; 17 | 18 | export class MCPClient { 19 | private baseUrl: string; 20 | private timeout: number; 21 | private client: Client | null = null; 22 | private transport: StreamableHTTPClientTransport | null = null; 23 | private tools: MCPTool[] = []; 24 | private connected: boolean = false; 25 | 26 | constructor(baseUrl: string = '', timeout: number = 30000) { 27 | // Determine the correct base URL based on environment 28 | if (typeof window !== 'undefined') { 29 | if (window.location.hostname === 'localhost' && window.location.port === '3000') { 30 | // Development mode with Vite dev server 31 | this.baseUrl = '/api/mcp'; // Use Vite proxy in development 32 | } else if (baseUrl && baseUrl !== '' && baseUrl !== 'auto') { 33 | // Explicit base URL provided (not empty or 'auto') 34 | this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash 35 | } else { 36 | // Production mode or Docker - use current origin 37 | this.baseUrl = window.location.origin; 38 | } 39 | } else { 40 | // Server-side or fallback 41 | this.baseUrl = baseUrl || 'http://localhost:8080'; 42 | this.baseUrl = this.baseUrl.replace(/\/$/, ''); // Remove trailing slash 43 | } 44 | this.timeout = timeout; 45 | 46 | console.log('🔧 MCP Client initialised with baseUrl:', this.baseUrl); 47 | console.log('🔧 Environment check:', { 48 | hostname: typeof window !== 'undefined' ? window.location.hostname : 'server-side', 49 | port: typeof window !== 'undefined' ? window.location.port : 'server-side', 50 | origin: typeof window !== 'undefined' ? window.location.origin : 'server-side', 51 | providedBaseUrl: baseUrl, 52 | finalBaseUrl: this.baseUrl 53 | }); 54 | } 55 | 56 | /** 57 | * Initialize the client and connect to the MCP server 58 | */ 59 | async initialize(): Promise<void> { 60 | try { 61 | console.log('🔌 Initialising MCP client...'); 62 | console.log('🔗 MCP endpoint:', this.baseUrl); 63 | 64 | // For now, skip the complex MCP client setup and just load tools 65 | // This allows the WebUI to work while we debug the MCP protocol issues 66 | console.log('🔄 Loading tools via HTTP...'); 67 | await this.listTools(); 68 | 69 | // Mark as connected if we successfully loaded tools 70 | this.connected = this.tools.length > 0; 71 | 72 | if (this.connected) { 73 | console.log('✅ MCP client initialised via HTTP'); 74 | } else { 75 | console.warn('⚠️ No tools loaded, but continuing...'); 76 | } 77 | } catch (error) { 78 | console.error('❌ Failed to initialise MCP client:', error); 79 | console.error('❌ MCP Error details:', { 80 | name: error instanceof Error ? error.name : 'Unknown', 81 | message: error instanceof Error ? error.message : String(error), 82 | stack: error instanceof Error ? error.stack : undefined 83 | }); 84 | this.connected = false; 85 | // Don't throw error, allow WebUI to continue 86 | console.log('💡 Continuing without MCP tools...'); 87 | } 88 | } 89 | 90 | /** 91 | * Check if the MCP server is reachable 92 | */ 93 | async isConnected(): Promise<boolean> { 94 | try { 95 | // Simple health check to the HTTP server 96 | const response = await fetch(`${this.baseUrl}/health`); 97 | return response.ok; 98 | } catch (error) { 99 | console.warn('🔍 MCP health check failed:', error); 100 | return false; 101 | } 102 | } 103 | 104 | /** 105 | * List all available tools from the MCP server using direct HTTP 106 | */ 107 | async listTools(): Promise<MCPTool[]> { 108 | try { 109 | // Use direct HTTP request to get tools 110 | const response = await fetch(`${this.baseUrl}/tools`, { 111 | method: 'GET', 112 | headers: { 113 | 'Content-Type': 'application/json', 114 | }, 115 | }); 116 | 117 | if (!response.ok) { 118 | throw new Error(`HTTP ${response.status}: ${response.statusText}`); 119 | } 120 | 121 | const data = await response.json(); 122 | 123 | // Handle different response formats 124 | let tools = []; 125 | if (Array.isArray(data)) { 126 | tools = data; 127 | } else if (data.tools && Array.isArray(data.tools)) { 128 | tools = data.tools; 129 | } else { 130 | console.warn('Unexpected tools response format:', data); 131 | return []; 132 | } 133 | 134 | this.tools = tools.map((tool: any) => ({ 135 | name: tool.name, 136 | description: tool.description || '', 137 | inputSchema: tool.inputSchema || { type: 'object', properties: {} }, 138 | })); 139 | 140 | console.log('🔧 Loaded tools via HTTP:', this.tools.length); 141 | return this.tools; 142 | } catch (error) { 143 | console.error('Failed to list tools via HTTP:', error); 144 | // Fallback: return empty array instead of throwing 145 | this.tools = []; 146 | return this.tools; 147 | } 148 | } 149 | 150 | /** 151 | * Get information about a specific tool 152 | */ 153 | getTool(name: string): MCPTool | undefined { 154 | return this.tools.find(tool => tool.name === name); 155 | } 156 | 157 | /** 158 | * Get all available tools 159 | */ 160 | getTools(): MCPTool[] { 161 | return [...this.tools]; 162 | } 163 | 164 | /** 165 | * Validate and convert parameters based on tool schema 166 | */ 167 | private validateAndConvertParams(toolName: string, params: Record<string, any>): Record<string, any> { 168 | const tool = this.getTool(toolName); 169 | if (!tool || !tool.inputSchema || !tool.inputSchema.properties) { 170 | return params; 171 | } 172 | 173 | const convertedParams: Record<string, any> = {}; 174 | const schema = tool.inputSchema.properties; 175 | 176 | for (const [key, value] of Object.entries(params)) { 177 | if (value === undefined || value === null) { 178 | continue; 179 | } 180 | 181 | const propertySchema = schema[key] as any; 182 | if (!propertySchema) { 183 | convertedParams[key] = value; 184 | continue; 185 | } 186 | 187 | // Convert based on schema type 188 | if (propertySchema.type === 'number') { 189 | const numValue = typeof value === 'string' ? parseFloat(value) : value; 190 | if (!isNaN(numValue)) { 191 | convertedParams[key] = numValue; 192 | } else { 193 | console.warn(`⚠️ Invalid number value for ${key}: ${value}`); 194 | convertedParams[key] = value; // Keep original value 195 | } 196 | } else if (propertySchema.type === 'integer') { 197 | const intValue = typeof value === 'string' ? parseInt(value, 10) : value; 198 | if (!isNaN(intValue)) { 199 | convertedParams[key] = intValue; 200 | } else { 201 | console.warn(`⚠️ Invalid integer value for ${key}: ${value}`); 202 | convertedParams[key] = value; // Keep original value 203 | } 204 | } else if (propertySchema.type === 'boolean') { 205 | if (typeof value === 'string') { 206 | convertedParams[key] = value.toLowerCase() === 'true'; 207 | } else { 208 | convertedParams[key] = Boolean(value); 209 | } 210 | } else { 211 | // String or other types - keep as is 212 | convertedParams[key] = value; 213 | } 214 | } 215 | 216 | return convertedParams; 217 | } 218 | 219 | /** 220 | * Call a specific MCP tool using HTTP 221 | */ 222 | async callTool<T extends MCPToolName>( 223 | name: T, 224 | params: MCPToolParams<T> 225 | ): Promise<MCPToolResponse<T>> { 226 | try { 227 | console.log(`🔧 Calling tool ${name} with params:`, params); 228 | 229 | // Validate and convert parameters 230 | const convertedParams = this.validateAndConvertParams(name as string, params as Record<string, any>); 231 | console.log(`🔧 Converted params:`, convertedParams); 232 | 233 | const response = await fetch(`${this.baseUrl}/call-tool`, { 234 | method: 'POST', 235 | headers: { 236 | 'Content-Type': 'application/json', 237 | }, 238 | body: JSON.stringify({ 239 | name: name as string, 240 | arguments: convertedParams, 241 | }), 242 | }); 243 | 244 | if (!response.ok) { 245 | const errorText = await response.text(); 246 | throw new Error(`HTTP ${response.status}: ${errorText}`); 247 | } 248 | 249 | const result = await response.json(); 250 | console.log(`✅ Tool ${name} result:`, result); 251 | 252 | // Process tool response for citation storage 253 | console.log(`🔍 About to process tool response for citations...`); 254 | citationStore.processToolResponse(result); 255 | 256 | return result as MCPToolResponse<T>; 257 | } catch (error) { 258 | console.error(`❌ Failed to call tool ${name}:`, error); 259 | throw error; 260 | } 261 | } 262 | 263 | /** 264 | * Call multiple tools in sequence 265 | */ 266 | async callTools(toolCalls: MCPToolCall[]): Promise<MCPToolResult[]> { 267 | const results: MCPToolResult[] = []; 268 | 269 | for (const toolCall of toolCalls) { 270 | try { 271 | const result = await this.callTool( 272 | toolCall.name as MCPToolName, 273 | toolCall.arguments as any 274 | ); 275 | 276 | results.push({ 277 | content: [{ 278 | type: 'text', 279 | text: JSON.stringify(result, null, 2), 280 | }], 281 | isError: false, 282 | }); 283 | } catch (error) { 284 | results.push({ 285 | content: [{ 286 | type: 'text', 287 | text: `Error calling ${toolCall.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, 288 | }], 289 | isError: true, 290 | }); 291 | } 292 | } 293 | 294 | return results; 295 | } 296 | 297 | /** 298 | * Disconnect from the MCP server 299 | */ 300 | async disconnect(): Promise<void> { 301 | if (this.client && this.transport) { 302 | try { 303 | await this.client.close(); 304 | } catch (error) { 305 | console.error('Error disconnecting from MCP server:', error); 306 | } 307 | } 308 | 309 | this.client = null; 310 | this.transport = null; 311 | this.connected = false; 312 | this.tools = []; 313 | } 314 | 315 | /** 316 | * Update the base URL for the MCP server 317 | */ 318 | setBaseUrl(url: string): void { 319 | // Handle empty string or 'auto' to use current origin 320 | if (!url || url === '' || url === 'auto') { 321 | if (typeof window !== 'undefined') { 322 | this.baseUrl = window.location.origin; 323 | console.log('🔧 setBaseUrl: Using current origin:', this.baseUrl); 324 | } else { 325 | this.baseUrl = 'http://localhost:8080'; 326 | console.log('🔧 setBaseUrl: Using server-side fallback:', this.baseUrl); 327 | } 328 | } else { 329 | this.baseUrl = url.replace(/\/$/, ''); 330 | console.log('🔧 setBaseUrl: Using explicit URL:', this.baseUrl); 331 | } 332 | 333 | // If connected, disconnect and reconnect with new URL 334 | if (this.connected) { 335 | this.disconnect().then(() => { 336 | this.initialize().catch(console.error); 337 | }); 338 | } 339 | } 340 | 341 | /** 342 | * Update the request timeout 343 | */ 344 | setTimeout(timeout: number): void { 345 | this.timeout = timeout; 346 | } 347 | 348 | /** 349 | * Get current configuration 350 | */ 351 | getConfig(): { baseUrl: string; timeout: number; isConnected: boolean } { 352 | return { 353 | baseUrl: this.baseUrl, 354 | timeout: this.timeout, 355 | isConnected: this.connected, 356 | }; 357 | } 358 | } 359 | 360 | // Create a singleton instance with environment-aware defaults 361 | const getDefaultMCPUrl = (): string => { 362 | // Check for build-time injected environment variable 363 | const envEndpoint = (typeof window !== 'undefined' && (window as any).__MCP_ENDPOINT__) || ''; 364 | 365 | console.log('🔧 MCP URL Detection:', { 366 | envEndpoint, 367 | isWindow: typeof window !== 'undefined', 368 | windowMcpEndpoint: typeof window !== 'undefined' ? (window as any).__MCP_ENDPOINT__ : 'N/A', 369 | hostname: typeof window !== 'undefined' ? window.location.hostname : 'N/A', 370 | origin: typeof window !== 'undefined' ? window.location.origin : 'N/A' 371 | }); 372 | 373 | // If we have an explicit endpoint from build-time injection, use it 374 | if (envEndpoint && envEndpoint !== '' && envEndpoint !== 'auto') { 375 | console.log('🔧 Using explicit MCP endpoint:', envEndpoint); 376 | return envEndpoint; 377 | } 378 | 379 | // In browser, always use empty string to trigger current origin logic 380 | if (typeof window !== 'undefined') { 381 | console.log('🔧 Using current origin for MCP endpoint (empty string)'); 382 | return ''; // Empty string means use current origin 383 | } 384 | 385 | // Server-side fallback 386 | console.log('🔧 Using server-side fallback for MCP endpoint'); 387 | return 'http://localhost:8080'; 388 | }; 389 | 390 | export const mcpClient = new MCPClient(getDefaultMCPUrl()); 391 | 392 | // Convenience functions for common operations 393 | export async function getTicker(symbol: string, category?: 'spot' | 'linear' | 'inverse' | 'option') { 394 | return mcpClient.callTool('get_ticker', { symbol, category }); 395 | } 396 | 397 | export async function getKlineData(symbol: string, interval?: string, limit?: number) { 398 | return mcpClient.callTool('get_kline', { symbol, interval, limit }); 399 | } 400 | 401 | export async function getOrderbook(symbol: string, category?: 'spot' | 'linear' | 'inverse' | 'option', limit?: number) { 402 | return mcpClient.callTool('get_orderbook', { symbol, category, limit }); 403 | } 404 | 405 | export async function getMLRSI(symbol: string, category: 'spot' | 'linear' | 'inverse' | 'option', interval: string, options?: Partial<MCPToolParams<'get_ml_rsi'>>) { 406 | return mcpClient.callTool('get_ml_rsi', { symbol, category, interval, ...options }); 407 | } 408 | 409 | export async function getOrderBlocks(symbol: string, category: 'spot' | 'linear' | 'inverse' | 'option', interval: string, options?: Partial<MCPToolParams<'get_order_blocks'>>) { 410 | return mcpClient.callTool('get_order_blocks', { symbol, category, interval, ...options }); 411 | } 412 | 413 | export async function getMarketStructure(symbol: string, category: 'spot' | 'linear' | 'inverse' | 'option', interval: string, options?: Partial<MCPToolParams<'get_market_structure'>>) { 414 | return mcpClient.callTool('get_market_structure', { symbol, category, interval, ...options }); 415 | } 416 | ``` -------------------------------------------------------------------------------- /webui/src/services/multiStepAgent.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Multi-step agent service for enhanced agentic capabilities 3 | * Implements multi-step tool calling and workflow orchestration 4 | */ 5 | 6 | import type { AgentConfig, AgentState } from '@/types/agent'; 7 | import type { WorkflowEvent } from '@/types/workflow'; 8 | import { WorkflowEventEmitter, createWorkflowEvent } from '@/types/workflow'; 9 | import { agentConfigService } from './agentConfig'; 10 | import { aiClient } from './aiClient'; 11 | import { mcpClient } from './mcpClient'; 12 | import { agentMemory } from './agentMemory'; 13 | import { performanceOptimiser } from './performanceOptimiser'; 14 | import { systemPromptService } from './systemPrompt'; 15 | import type { ChatMessage } from '@/types/ai'; 16 | 17 | export class MultiStepAgentService { 18 | private availableTools: any[] = []; 19 | private isInitialized = false; 20 | private eventEmitter: WorkflowEventEmitter; 21 | private currentConfig: AgentConfig; 22 | private conversationHistory: ChatMessage[] = []; 23 | private currentConversationId?: string; 24 | 25 | constructor() { 26 | this.eventEmitter = new WorkflowEventEmitter(); 27 | this.currentConfig = agentConfigService.getConfig(); 28 | 29 | // Subscribe to config changes 30 | agentConfigService.subscribe((config) => { 31 | this.currentConfig = config; 32 | this.reinitializeAgents(); 33 | }); 34 | } 35 | 36 | /** 37 | * Initialize the agent service 38 | */ 39 | async initialize(): Promise<void> { 40 | if (this.isInitialized) return; 41 | 42 | try { 43 | console.log('🤖 Initializing Multi-Step Agent Service...'); 44 | 45 | // Load MCP tools 46 | await this.loadMCPTools(); 47 | 48 | // Initialize agents based on configuration 49 | await this.initializeAgents(); 50 | 51 | // Update state 52 | agentConfigService.updateState({ 53 | isProcessing: false 54 | }); 55 | 56 | this.isInitialized = true; 57 | console.log('✅ Multi-Step Agent Service initialized successfully'); 58 | 59 | } catch (error) { 60 | console.error('❌ Failed to initialize Multi-Step Agent Service:', error); 61 | 62 | agentConfigService.updateState({ 63 | isProcessing: false 64 | }); 65 | 66 | throw error; 67 | } 68 | } 69 | 70 | /** 71 | * Load MCP tools from the server 72 | */ 73 | private async loadMCPTools(): Promise<void> { 74 | try { 75 | console.log('🔧 Loading MCP tools...'); 76 | 77 | // Get available tools from MCP client 78 | const tools = await mcpClient.listTools(); 79 | this.availableTools = tools; 80 | 81 | console.log(`🔧 Loaded ${this.availableTools.length} MCP tools:`, this.availableTools.map(t => t.name)); 82 | 83 | } catch (error) { 84 | console.error('❌ Failed to load MCP tools:', error); 85 | // Continue with empty tools array for now 86 | this.availableTools = []; 87 | } 88 | } 89 | 90 | /** 91 | * Initialize agent system 92 | */ 93 | private async initializeAgents(): Promise<void> { 94 | console.log('🤖 Initializing multi-step agent system...'); 95 | 96 | // Agent system is ready - we'll use the existing AI client with multi-step logic 97 | console.log('✅ Multi-step agent system initialized'); 98 | } 99 | 100 | /** 101 | * Build system prompt based on configuration and memory context 102 | */ 103 | private async buildSystemPrompt(symbol?: string): Promise<string> { 104 | // Get base system prompt from centralized service 105 | const basePrompt = await systemPromptService.generateSystemPrompt({ 106 | includeTimestamp: true, 107 | includeTools: true, 108 | includeMemoryContext: false 109 | }); 110 | 111 | // Add memory context if available 112 | const memoryContext = agentMemory.buildContextSummary(symbol); 113 | const finalPrompt = basePrompt + memoryContext; 114 | 115 | return finalPrompt; 116 | } 117 | 118 | /** 119 | * Process a chat message with the agent using multi-step reasoning 120 | */ 121 | async chat(message: string): Promise<string> { 122 | if (!this.isInitialized) { 123 | await this.initialize(); 124 | } 125 | 126 | const startTime = Date.now(); 127 | const toolCallsCount = 0; 128 | 129 | try { 130 | agentConfigService.updateState({ isProcessing: true }); 131 | 132 | console.log('💬 Processing chat message with multi-step agent...'); 133 | 134 | // Start new conversation if needed 135 | if (!this.currentConversationId) { 136 | this.currentConversationId = agentMemory.startConversation(); 137 | } 138 | 139 | // Add user message to conversation history 140 | const userMessage: ChatMessage = { 141 | role: 'user', 142 | content: message 143 | }; 144 | 145 | this.conversationHistory.push(userMessage); 146 | agentMemory.addMessage(this.currentConversationId, userMessage); 147 | 148 | // Extract symbol from message for context 149 | const symbolMatch = message.match(/\b([A-Z]{2,5})(?:USD|USDT)?\b/); 150 | const symbol = symbolMatch ? symbolMatch[1] : undefined; 151 | 152 | // Run multi-step agent loop 153 | const result = await this.runAgentLoop(symbol); 154 | 155 | // Add assistant response to memory 156 | if (this.currentConversationId) { 157 | const assistantMessage: ChatMessage = { 158 | role: 'assistant', 159 | content: result 160 | }; 161 | agentMemory.addMessage(this.currentConversationId, assistantMessage); 162 | } 163 | 164 | // Record analysis in memory 165 | if (symbol) { 166 | const duration = Date.now() - startTime; 167 | agentMemory.recordAnalysis({ 168 | symbol, 169 | analysisType: this.determineAnalysisType(), 170 | query: message, 171 | response: result, 172 | toolsUsed: [], // Will be populated by runAgentLoop 173 | duration 174 | }); 175 | } 176 | 177 | // Record successful query 178 | const duration = Date.now() - startTime; 179 | agentConfigService.recordQuery(duration, toolCallsCount); 180 | 181 | return result; 182 | 183 | } catch (error) { 184 | console.error('❌ Multi-step agent chat failed:', error); 185 | agentConfigService.recordFailure(); 186 | 187 | agentConfigService.updateState({ 188 | isProcessing: false 189 | }); 190 | 191 | throw error; 192 | 193 | } finally { 194 | agentConfigService.updateState({ isProcessing: false }); 195 | } 196 | } 197 | 198 | /** 199 | * Run the multi-step agent reasoning loop 200 | */ 201 | private async runAgentLoop(symbol?: string): Promise<string> { 202 | const maxIterations = this.currentConfig.maxIterations; 203 | let iteration = 0; 204 | 205 | // Build system prompt once and cache it for this conversation 206 | const systemPrompt = await this.buildSystemPrompt(symbol); 207 | console.log('🎯 System prompt generated once for conversation'); 208 | 209 | const messages: ChatMessage[] = [ 210 | { role: 'system', content: systemPrompt }, 211 | ...this.conversationHistory 212 | ]; 213 | 214 | while (iteration < maxIterations) { 215 | iteration++; 216 | console.log(`🔄 Agent iteration ${iteration}/${maxIterations}`); 217 | 218 | // Emit workflow step event 219 | this.emitEvent(createWorkflowEvent('workflow_step', { 220 | stepName: `Iteration ${iteration}`, 221 | stepDescription: 'Agent reasoning and tool execution', 222 | progress: iteration, 223 | totalSteps: maxIterations 224 | })); 225 | 226 | // Get AI response with tool calling 227 | const response = await aiClient.chatWithTools(messages); 228 | 229 | // Find the latest assistant message 230 | const assistantMessages = response.filter(msg => msg.role === 'assistant'); 231 | const latestAssistant = assistantMessages[assistantMessages.length - 1]; 232 | 233 | if (!latestAssistant) { 234 | throw new Error('No assistant response received'); 235 | } 236 | 237 | // Check if there are tool calls 238 | if (latestAssistant.tool_calls && latestAssistant.tool_calls.length > 0) { 239 | console.log(`🔧 Processing ${latestAssistant.tool_calls.length} tool calls`); 240 | 241 | // Update conversation history with the complete response 242 | this.conversationHistory = response.slice(1); // Remove system message 243 | 244 | // Continue the loop for next iteration - rebuild messages with cached system prompt 245 | messages.length = 1; // Keep only system message (already cached) 246 | messages.push(...this.conversationHistory); 247 | 248 | continue; 249 | } 250 | 251 | // No more tool calls - we have the final response 252 | if (latestAssistant.content) { 253 | // Check if content is meaningful (not just placeholder text) 254 | const trimmedContent = latestAssistant.content.trim(); 255 | const isPlaceholder = trimmedContent === '...' || 256 | trimmedContent === '' || 257 | trimmedContent.length < 3; 258 | 259 | if (!isPlaceholder) { 260 | // Add final response to conversation history 261 | this.conversationHistory.push({ 262 | role: 'assistant', 263 | content: latestAssistant.content 264 | }); 265 | 266 | console.log(`✅ Multi-step agent completed in ${iteration} iterations`); 267 | return latestAssistant.content; 268 | } else { 269 | console.log(`⚠️ Received placeholder content: "${trimmedContent}", continuing iteration...`); 270 | // Continue to next iteration - treat as if no meaningful response 271 | } 272 | } 273 | 274 | // If we get here and it's not the last iteration, continue 275 | if (iteration < maxIterations) { 276 | console.log(`🔄 No meaningful response in iteration ${iteration}, continuing...`); 277 | continue; 278 | } 279 | 280 | // If we get here on the last iteration, something went wrong 281 | throw new Error('Assistant response has no meaningful content and no tool calls'); 282 | } 283 | 284 | // Max iterations reached 285 | 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.'; 286 | 287 | this.conversationHistory.push({ 288 | role: 'assistant', 289 | content: fallbackResponse 290 | }); 291 | 292 | return fallbackResponse; 293 | } 294 | 295 | /** 296 | * Stream chat with real-time events 297 | */ 298 | async streamChat( 299 | message: string, 300 | onChunk: (chunk: string) => void, 301 | onEvent?: (event: WorkflowEvent) => void 302 | ): Promise<void> { 303 | if (!this.isInitialized) { 304 | await this.initialize(); 305 | } 306 | 307 | const startTime = Date.now(); 308 | const toolCallsCount = 0; 309 | 310 | try { 311 | agentConfigService.updateState({ isProcessing: true }); 312 | 313 | console.log('💬 Streaming chat with multi-step agent...'); 314 | 315 | // Subscribe to events if callback provided 316 | let unsubscribe: (() => void) | undefined; 317 | if (onEvent) { 318 | unsubscribe = this.onEvent(onEvent); 319 | } 320 | 321 | // Add user message to conversation history 322 | this.conversationHistory.push({ 323 | role: 'user', 324 | content: message 325 | }); 326 | 327 | // Run multi-step agent loop and stream the final response 328 | const result = await this.runAgentLoop(); 329 | 330 | // Stream the final result 331 | const words = result.split(' '); 332 | for (let i = 0; i < words.length; i++) { 333 | const chunk = (i === 0 ? '' : ' ') + words[i]; 334 | onChunk(chunk); 335 | 336 | // Small delay for streaming effect 337 | await new Promise(resolve => setTimeout(resolve, 30)); 338 | } 339 | 340 | // Clean up event subscription 341 | if (unsubscribe) { 342 | unsubscribe(); 343 | } 344 | 345 | // Record successful query 346 | const duration = Date.now() - startTime; 347 | agentConfigService.recordQuery(duration, toolCallsCount); 348 | 349 | } catch (error) { 350 | console.error('❌ Multi-step agent stream chat failed:', error); 351 | agentConfigService.recordFailure(); 352 | 353 | agentConfigService.updateState({ 354 | isProcessing: false 355 | }); 356 | 357 | throw error; 358 | 359 | } finally { 360 | agentConfigService.updateState({ isProcessing: false }); 361 | } 362 | } 363 | 364 | /** 365 | * Emit a workflow event 366 | */ 367 | private emitEvent(event: WorkflowEvent): void { 368 | this.eventEmitter.emit(event); 369 | } 370 | 371 | /** 372 | * Check if the service is connected and ready 373 | */ 374 | async isConnected(): Promise<boolean> { 375 | return this.isInitialized && this.availableTools.length > 0; 376 | } 377 | 378 | /** 379 | * Get current agent state 380 | */ 381 | getState(): AgentState { 382 | return agentConfigService.getState(); 383 | } 384 | 385 | /** 386 | * Subscribe to workflow events 387 | */ 388 | onEvent(listener: (event: WorkflowEvent) => void): () => void { 389 | this.eventEmitter.on('all', listener); 390 | return () => this.eventEmitter.off('all', listener); 391 | } 392 | 393 | /** 394 | * Determine analysis type based on current configuration 395 | */ 396 | private determineAnalysisType(): 'quick' | 'standard' | 'comprehensive' { 397 | const maxIterations = this.currentConfig.maxIterations; 398 | if (maxIterations <= 2) return 'quick'; 399 | if (maxIterations <= 5) return 'standard'; 400 | return 'comprehensive'; 401 | } 402 | 403 | // Note: Market context updating will be implemented in future iterations 404 | // when tool response interception is added to the agent loop 405 | 406 | /** 407 | * Reinitialise agents when configuration changes 408 | */ 409 | private async reinitializeAgents(): Promise<void> { 410 | if (!this.isInitialized) return; 411 | 412 | console.log('🔄 Reinitialising multi-step agents due to configuration change...'); 413 | 414 | try { 415 | await this.initializeAgents(); 416 | console.log('✅ Multi-step agents reinitialised successfully'); 417 | } catch (error) { 418 | console.error('❌ Failed to reinitialise multi-step agents:', error); 419 | } 420 | } 421 | 422 | /** 423 | * Get memory statistics 424 | */ 425 | getMemoryStats() { 426 | return agentMemory.getMemoryStats(); 427 | } 428 | 429 | /** 430 | * Get performance statistics 431 | */ 432 | getPerformanceStats() { 433 | return performanceOptimiser.getPerformanceStats(); 434 | } 435 | 436 | /** 437 | * Get conversation history for a symbol 438 | */ 439 | getSymbolHistory(symbol: string, limit: number = 5) { 440 | return agentMemory.getSymbolContext(symbol, limit); 441 | } 442 | 443 | /** 444 | * Get recent analysis history 445 | */ 446 | getAnalysisHistory(symbol?: string, limit: number = 10) { 447 | if (symbol) { 448 | return agentMemory.getSymbolAnalysisHistory(symbol, limit); 449 | } 450 | return agentMemory.getRecentAnalysisHistory(limit); 451 | } 452 | 453 | /** 454 | * Clear all memory data 455 | */ 456 | clearMemory(): void { 457 | agentMemory.clearAllMemory(); 458 | this.conversationHistory = []; 459 | this.currentConversationId = undefined; 460 | console.log('🧹 Multi-step agent memory cleared'); 461 | } 462 | 463 | /** 464 | * Start a new conversation session 465 | */ 466 | startNewConversation(): void { 467 | this.conversationHistory = []; 468 | this.currentConversationId = undefined; 469 | console.log('🆕 New conversation session started'); 470 | } 471 | } 472 | 473 | // Singleton instance 474 | export const multiStepAgent = new MultiStepAgentService(); 475 | ``` -------------------------------------------------------------------------------- /webui/src/services/aiClient.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * AI client for OpenAI-compatible API integration (Ollama, etc.) 3 | */ 4 | 5 | import type { 6 | AIService, 7 | ChatMessage, 8 | ChatCompletionRequest, 9 | ChatCompletionResponse, 10 | ChatCompletionStreamResponse, 11 | AIConfig, 12 | AIError, 13 | ModelInfo, 14 | } from '@/types/ai'; 15 | import { mcpClient } from './mcpClient'; 16 | import { systemPromptService } from './systemPrompt'; 17 | 18 | export class AIClient implements AIService { 19 | private config: AIConfig; 20 | private controller?: AbortController; 21 | 22 | constructor(config: AIConfig) { 23 | this.config = { ...config }; 24 | } 25 | 26 | /** 27 | * Send a chat completion request with tool calling support 28 | */ 29 | async chat( 30 | messages: ChatMessage[], 31 | options?: Partial<ChatCompletionRequest> 32 | ): Promise<ChatCompletionResponse> { 33 | // First, try to get available tools from MCP 34 | let tools: any[] = []; 35 | try { 36 | const mcpTools = await mcpClient.getTools(); 37 | tools = mcpTools.map(tool => ({ 38 | type: 'function', 39 | function: { 40 | name: tool.name, 41 | description: tool.description, 42 | parameters: tool.inputSchema, 43 | }, 44 | })); 45 | console.log('🔧 Available MCP tools:', tools.length); 46 | console.log('🔧 Tool definitions:', tools); 47 | } catch (error) { 48 | console.warn('Failed to get MCP tools:', error); 49 | } 50 | 51 | const request: ChatCompletionRequest = { 52 | model: this.config.model, 53 | messages, 54 | temperature: this.config.temperature, 55 | max_tokens: this.config.maxTokens, 56 | stream: false, 57 | tools: tools.length > 0 ? tools : undefined, 58 | tool_choice: tools.length > 0 ? 'auto' : undefined, 59 | ...options, 60 | }; 61 | 62 | console.log('🚀 Sending request to AI:', { 63 | model: request.model, 64 | toolsCount: tools.length, 65 | hasTools: !!request.tools, 66 | toolChoice: request.tool_choice 67 | }); 68 | 69 | try { 70 | console.log('🌐 Making request to:', `${this.config.endpoint}/v1/chat/completions`); 71 | console.log('📤 Request body:', JSON.stringify(request, null, 2)); 72 | 73 | const response = await fetch(`${this.config.endpoint}/v1/chat/completions`, { 74 | method: 'POST', 75 | headers: { 76 | 'Content-Type': 'application/json', 77 | }, 78 | body: JSON.stringify(request), 79 | }); 80 | 81 | console.log('📡 Response status:', response.status, response.statusText); 82 | 83 | if (!response.ok) { 84 | const errorText = await response.text(); 85 | console.error('❌ Error response body:', errorText); 86 | 87 | let errorData; 88 | try { 89 | errorData = JSON.parse(errorText); 90 | } catch { 91 | errorData = { message: errorText }; 92 | } 93 | 94 | throw this.createError( 95 | 'API_ERROR', 96 | `HTTP ${response.status}: ${response.statusText}`, 97 | errorData 98 | ); 99 | } 100 | 101 | const data = await response.json(); 102 | console.log('📥 AI Response:', { 103 | choices: data.choices?.length, 104 | hasToolCalls: !!data.choices?.[0]?.message?.tool_calls, 105 | toolCallsCount: data.choices?.[0]?.message?.tool_calls?.length || 0, 106 | content: data.choices?.[0]?.message?.content?.substring(0, 100) + '...' 107 | }); 108 | return data as ChatCompletionResponse; 109 | } catch (error) { 110 | if (error instanceof Error && error.name === 'AbortError') { 111 | throw this.createError('REQUEST_CANCELLED', 'Request was cancelled'); 112 | } 113 | 114 | if (error instanceof Error) { 115 | throw error; 116 | } 117 | 118 | throw this.createError('UNKNOWN_ERROR', 'An unknown error occurred'); 119 | } 120 | } 121 | 122 | /** 123 | * Execute tool calls and return results 124 | */ 125 | async executeToolCalls(toolCalls: any[]): Promise<any[]> { 126 | console.log('🔧 Executing tool calls:', toolCalls.length); 127 | const results = []; 128 | 129 | for (const toolCall of toolCalls) { 130 | try { 131 | console.log('🔧 Processing tool call:', toolCall); 132 | const { function: func } = toolCall; 133 | 134 | // Parse arguments if they're a string (from Ollama format) 135 | const args = typeof func.arguments === 'string' 136 | ? JSON.parse(func.arguments) 137 | : func.arguments; 138 | 139 | console.log(`🔧 Calling tool ${func.name} with args:`, args); 140 | const result = await mcpClient.callTool(func.name, args); 141 | console.log(`✅ Tool ${func.name} result:`, result); 142 | 143 | // Extract reference ID from the result to include in AI context 144 | let referenceId: string | null = null; 145 | let actualData: any = result; 146 | 147 | // Check if response has content array (MCP format) 148 | if ((result as any).content && Array.isArray((result as any).content) && (result as any).content.length > 0) { 149 | const contentItem = (result as any).content[0]; 150 | if (contentItem.type === 'text' && contentItem.text) { 151 | try { 152 | actualData = JSON.parse(contentItem.text); 153 | referenceId = actualData._referenceId; 154 | } catch (e) { 155 | // If parsing fails, just use the original result 156 | } 157 | } 158 | } else if ((result as any)._referenceId) { 159 | referenceId = (result as any)._referenceId; 160 | } 161 | 162 | // Prepare content for AI with reference ID hint 163 | let toolContent = JSON.stringify(result, null, 2); 164 | if (referenceId) { 165 | 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.`; 166 | } 167 | 168 | results.push({ 169 | tool_call_id: toolCall.id, 170 | role: 'tool', 171 | content: toolContent, 172 | }); 173 | } catch (error) { 174 | console.error(`❌ Tool execution failed for ${toolCall.function?.name}:`, error); 175 | results.push({ 176 | tool_call_id: toolCall.id, 177 | role: 'tool', 178 | content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, 179 | }); 180 | } 181 | } 182 | 183 | console.log('🔧 Tool execution results:', results); 184 | return results; 185 | } 186 | 187 | /** 188 | * Parse tool calls from text content (fallback for models that don't support function calling) 189 | */ 190 | private parseToolCallsFromText(content: string): { toolCalls: any[], cleanContent: string } { 191 | // Match both single and triple backticks 192 | const toolCallPattern = /(`{1,3})tool_code\s*\n?([^`]+)\1/g; 193 | const toolCalls: any[] = []; 194 | let cleanContent = content; 195 | 196 | let match; 197 | while ((match = toolCallPattern.exec(content)) !== null) { 198 | const toolCallText = match[2].trim(); // match[2] is the content, match[1] is the backticks 199 | 200 | // Parse function call like: get_ticker(symbol="BTCUSDT") 201 | const functionCallPattern = /(\w+)\s*\(\s*([^)]*)\s*\)/; 202 | const funcMatch = functionCallPattern.exec(toolCallText); 203 | 204 | if (funcMatch) { 205 | const functionName = funcMatch[1]; 206 | const argsString = funcMatch[2]; 207 | 208 | // Parse arguments (simple key=value parsing) 209 | const args: Record<string, any> = {}; 210 | if (argsString) { 211 | const argPattern = /(\w+)\s*=\s*"([^"]+)"/g; 212 | let argMatch; 213 | while ((argMatch = argPattern.exec(argsString)) !== null) { 214 | args[argMatch[1]] = argMatch[2]; 215 | } 216 | } 217 | 218 | toolCalls.push({ 219 | id: `call_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`, 220 | type: 'function', 221 | function: { 222 | name: functionName, 223 | arguments: JSON.stringify(args) 224 | } 225 | }); 226 | 227 | // Remove the tool call from content 228 | cleanContent = cleanContent.replace(match[0], '').trim(); 229 | } 230 | } 231 | 232 | return { toolCalls, cleanContent }; 233 | } 234 | 235 | /** 236 | * Send a chat completion with automatic tool calling 237 | */ 238 | async chatWithTools(messages: ChatMessage[]): Promise<ChatMessage[]> { 239 | const conversationMessages = [...messages]; 240 | let response = await this.chat(conversationMessages); 241 | 242 | // Check if the response contains tool calls 243 | const choice = response.choices[0]; 244 | let toolCalls = choice?.message?.tool_calls; 245 | let content = choice?.message?.content || ''; 246 | 247 | // If no native tool calls, try to parse from text content 248 | if (!toolCalls && content) { 249 | const parsed = this.parseToolCallsFromText(content); 250 | if (parsed.toolCalls.length > 0) { 251 | toolCalls = parsed.toolCalls; 252 | content = parsed.cleanContent; 253 | console.log('🔍 Parsed tool calls from text:', toolCalls); 254 | } 255 | } 256 | 257 | if (toolCalls && toolCalls.length > 0) { 258 | // Add the assistant's message with tool calls 259 | conversationMessages.push({ 260 | role: 'assistant', 261 | content: content, 262 | tool_calls: toolCalls, 263 | }); 264 | 265 | // Execute tool calls 266 | const toolResults = await this.executeToolCalls(toolCalls); 267 | 268 | // Add tool results to conversation 269 | conversationMessages.push(...toolResults); 270 | 271 | // Get final response with tool results - reuse the same conversation context 272 | console.log('🔄 Getting final response with tool results (system prompt already included)'); 273 | response = await this.chat(conversationMessages); 274 | } 275 | 276 | return conversationMessages.concat({ 277 | role: 'assistant', 278 | content: response.choices[0]?.message?.content || '', 279 | }); 280 | } 281 | 282 | /** 283 | * Send a streaming chat completion request 284 | */ 285 | async streamChat( 286 | messages: ChatMessage[], 287 | onChunk: (chunk: ChatCompletionStreamResponse) => void, 288 | options?: Partial<ChatCompletionRequest> 289 | ): Promise<void> { 290 | // Cancel any existing stream 291 | this.cancelStream(); 292 | 293 | this.controller = new AbortController(); 294 | 295 | const request: ChatCompletionRequest = { 296 | model: this.config.model, 297 | messages, 298 | temperature: this.config.temperature, 299 | max_tokens: this.config.maxTokens, 300 | stream: true, 301 | ...options, 302 | }; 303 | 304 | try { 305 | const response = await fetch(`${this.config.endpoint}/v1/chat/completions`, { 306 | method: 'POST', 307 | headers: { 308 | 'Content-Type': 'application/json', 309 | }, 310 | body: JSON.stringify(request), 311 | signal: this.controller.signal, 312 | }); 313 | 314 | if (!response.ok) { 315 | const errorData = await response.json().catch(() => ({})); 316 | throw this.createError( 317 | 'API_ERROR', 318 | `HTTP ${response.status}: ${response.statusText}`, 319 | errorData 320 | ); 321 | } 322 | 323 | if (!response.body) { 324 | throw this.createError('STREAM_ERROR', 'No response body received'); 325 | } 326 | 327 | const reader = response.body.getReader(); 328 | const decoder = new TextDecoder(); 329 | 330 | try { 331 | while (true) { 332 | const { done, value } = await reader.read(); 333 | 334 | if (done) break; 335 | 336 | const chunk = decoder.decode(value, { stream: true }); 337 | const lines = chunk.split('\n').filter(line => line.trim()); 338 | 339 | for (const line of lines) { 340 | if (line.startsWith('data: ')) { 341 | const data = line.slice(6); 342 | 343 | if (data === '[DONE]') { 344 | return; 345 | } 346 | 347 | try { 348 | const parsed = JSON.parse(data) as ChatCompletionStreamResponse; 349 | onChunk(parsed); 350 | } catch (parseError) { 351 | console.warn('Failed to parse streaming chunk:', parseError); 352 | } 353 | } 354 | } 355 | } 356 | } finally { 357 | reader.releaseLock(); 358 | } 359 | } catch (error) { 360 | if (error instanceof Error && error.name === 'AbortError') { 361 | throw this.createError('REQUEST_CANCELLED', 'Stream was cancelled'); 362 | } 363 | 364 | if (error instanceof Error) { 365 | throw error; 366 | } 367 | 368 | throw this.createError('STREAM_ERROR', 'Streaming failed'); 369 | } finally { 370 | this.controller = undefined; 371 | } 372 | } 373 | 374 | /** 375 | * Cancel the current streaming request 376 | */ 377 | cancelStream(): void { 378 | if (this.controller) { 379 | this.controller.abort(); 380 | this.controller = undefined; 381 | } 382 | } 383 | 384 | /** 385 | * Check if the AI service is connected and available 386 | */ 387 | async isConnected(): Promise<boolean> { 388 | try { 389 | const response = await fetch(`${this.config.endpoint}/v1/models`, { 390 | method: 'GET', 391 | signal: AbortSignal.timeout(5000), // 5 second timeout 392 | }); 393 | 394 | return response.ok; 395 | } catch { 396 | return false; 397 | } 398 | } 399 | 400 | /** 401 | * Get available models from the AI service 402 | */ 403 | async getModels(): Promise<ModelInfo[]> { 404 | try { 405 | const response = await fetch(`${this.config.endpoint}/v1/models`); 406 | 407 | if (!response.ok) { 408 | throw this.createError('API_ERROR', 'Failed to fetch models'); 409 | } 410 | 411 | const data = await response.json(); 412 | 413 | if (data.data && Array.isArray(data.data)) { 414 | return data.data.map((model: any) => ({ 415 | id: model.id, 416 | name: model.id, 417 | description: model.description, 418 | contextLength: model.context_length, 419 | capabilities: model.capabilities, 420 | })); 421 | } 422 | 423 | return []; 424 | } catch (error) { 425 | console.error('Failed to fetch models:', error); 426 | return []; 427 | } 428 | } 429 | 430 | /** 431 | * Update the AI configuration 432 | */ 433 | updateConfig(newConfig: Partial<AIConfig>): void { 434 | this.config = { ...this.config, ...newConfig }; 435 | } 436 | 437 | /** 438 | * Get current configuration 439 | */ 440 | getConfig(): AIConfig { 441 | return { ...this.config }; 442 | } 443 | 444 | 445 | 446 | /** 447 | * Create a standardised error object 448 | */ 449 | private createError(code: string, message: string, details?: unknown): AIError { 450 | const error = new Error(message) as Error & AIError; 451 | error.code = code; 452 | error.message = message; 453 | error.details = details; 454 | return error; 455 | } 456 | } 457 | 458 | // Function to generate system prompt with current timestamp 459 | export function generateSystemPrompt(): string { 460 | // Use the centralized system prompt service for legacy compatibility 461 | return systemPromptService.generateLegacySystemPrompt(); 462 | } 463 | 464 | // Async function to generate system prompt with dynamic tools 465 | export async function generateDynamicSystemPrompt(): Promise<string> { 466 | return await systemPromptService.generateSystemPrompt({ 467 | includeTimestamp: true, 468 | includeTools: true, 469 | includeMemoryContext: false 470 | }); 471 | } 472 | 473 | // Default system prompt for Bybit MCP integration (for backward compatibility) 474 | export const DEFAULT_SYSTEM_PROMPT = generateSystemPrompt(); 475 | 476 | // Create default AI client instance 477 | export function createAIClient(config?: Partial<AIConfig>): AIClient { 478 | const defaultConfig: AIConfig = { 479 | endpoint: 'http://localhost:11434', 480 | model: 'qwen3-30b-a3b-ud-nothink-128k:q4_k_xl', 481 | temperature: 0.7, 482 | maxTokens: 2048, 483 | systemPrompt: DEFAULT_SYSTEM_PROMPT, 484 | ...config, 485 | }; 486 | 487 | return new AIClient(defaultConfig); 488 | } 489 | 490 | // Singleton instance 491 | export const aiClient = createAIClient(); 492 | ``` -------------------------------------------------------------------------------- /webui/src/components/AgentDashboard.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Agent Dashboard - Shows memory, performance, and analysis statistics 3 | */ 4 | 5 | import { multiStepAgent } from '@/services/multiStepAgent'; 6 | import type { ChatApp } from './ChatApp'; 7 | 8 | // Interface for memory statistics 9 | interface MemoryStats { 10 | conversations: number; 11 | marketContexts: number; 12 | analysisHistory: number; 13 | totalSymbols: number; 14 | } 15 | 16 | export class AgentDashboard { 17 | private container: HTMLElement; 18 | private isVisible: boolean = false; 19 | private refreshInterval: NodeJS.Timeout | null = null; 20 | private chatApp?: ChatApp; 21 | 22 | constructor(containerId: string, chatApp?: ChatApp) { 23 | this.container = document.getElementById(containerId)!; 24 | if (!this.container) { 25 | throw new Error(`Container element with id "${containerId}" not found`); 26 | } 27 | 28 | this.chatApp = chatApp; 29 | this.initialize(); 30 | } 31 | 32 | private initialize(): void { 33 | this.createDashboardStructure(); 34 | this.setupEventListeners(); 35 | this.startAutoRefresh(); 36 | } 37 | 38 | private createDashboardStructure(): void { 39 | this.container.innerHTML = ` 40 | <div class="agent-dashboard ${this.isVisible ? 'visible' : 'hidden'}"> 41 | <div class="dashboard-header"> 42 | <h3>Agent Dashboard</h3> 43 | <div class="dashboard-controls"> 44 | <button class="refresh-btn" id="dashboard-refresh" aria-label="Refresh dashboard"> 45 | 🔄 46 | </button> 47 | <button class="toggle-btn" id="dashboard-toggle" aria-label="Toggle dashboard"> 48 | 📊 49 | </button> 50 | </div> 51 | </div> 52 | 53 | <div class="dashboard-content"> 54 | <!-- Empty State Notice --> 55 | <div class="dashboard-notice" id="dashboard-notice" style="display: none;"> 56 | <div class="notice-content"> 57 | <h4>🤖 Agent Dashboard</h4> 58 | <p>This dashboard will show agent performance metrics, memory usage, and analysis history once you start using the agent mode.</p> 59 | <p><strong>To get started:</strong></p> 60 | <ol> 61 | <li>Enable Agent Mode in Settings (⚙️)</li> 62 | <li>Ask questions about cryptocurrency markets</li> 63 | <li>Watch the dashboard populate with data!</li> 64 | </ol> 65 | </div> 66 | </div> 67 | 68 | <!-- Memory Statistics --> 69 | <div class="dashboard-section"> 70 | <h4>Memory Statistics</h4> 71 | <div class="stats-grid" id="memory-stats"> 72 | <div class="stat-item"> 73 | <span class="stat-label">Conversations:</span> 74 | <span class="stat-value" id="memory-conversations">0</span> 75 | </div> 76 | <div class="stat-item"> 77 | <span class="stat-label">Market Contexts:</span> 78 | <span class="stat-value" id="memory-contexts">0</span> 79 | </div> 80 | <div class="stat-item"> 81 | <span class="stat-label">Analysis History:</span> 82 | <span class="stat-value" id="memory-analyses">0</span> 83 | </div> 84 | <div class="stat-item"> 85 | <span class="stat-label">Tracked Symbols:</span> 86 | <span class="stat-value" id="memory-symbols">0</span> 87 | </div> 88 | </div> 89 | </div> 90 | 91 | <!-- Performance Statistics --> 92 | <div class="dashboard-section"> 93 | <h4>Performance Statistics</h4> 94 | <div class="stats-grid" id="performance-stats"> 95 | <div class="stat-item"> 96 | <span class="stat-label">Success Rate:</span> 97 | <span class="stat-value" id="perf-success-rate">0%</span> 98 | </div> 99 | <div class="stat-item"> 100 | <span class="stat-label">Avg Tool Time:</span> 101 | <span class="stat-value" id="perf-avg-time">0ms</span> 102 | </div> 103 | <div class="stat-item"> 104 | <span class="stat-label">Parallel Savings:</span> 105 | <span class="stat-value" id="perf-savings">0ms</span> 106 | </div> 107 | <div class="stat-item"> 108 | <span class="stat-label">Total Tools:</span> 109 | <span class="stat-value" id="perf-tool-count">0</span> 110 | </div> 111 | </div> 112 | </div> 113 | 114 | <!-- Recent Analysis --> 115 | <div class="dashboard-section"> 116 | <h4>Recent Analysis</h4> 117 | <div class="analysis-list" id="recent-analysis"> 118 | <div class="empty-state"> 119 | <p>No recent analysis available.</p> 120 | </div> 121 | </div> 122 | </div> 123 | 124 | <!-- Actions --> 125 | <div class="dashboard-section"> 126 | <h4>Actions</h4> 127 | <div class="action-buttons"> 128 | <button class="action-btn" id="clear-memory">Clear Memory</button> 129 | <button class="action-btn" id="new-conversation">New Conversation</button> 130 | <button class="action-btn" id="export-data">Export Data</button> 131 | </div> 132 | </div> 133 | </div> 134 | </div> 135 | `; 136 | } 137 | 138 | private setupEventListeners(): void { 139 | // Toggle dashboard visibility 140 | const toggleBtn = this.container.querySelector('#dashboard-toggle') as HTMLButtonElement; 141 | toggleBtn?.addEventListener('click', () => { 142 | this.toggleVisibility(); 143 | }); 144 | 145 | // Refresh dashboard 146 | const refreshBtn = this.container.querySelector('#dashboard-refresh') as HTMLButtonElement; 147 | refreshBtn?.addEventListener('click', () => { 148 | this.refreshDashboard(); 149 | }); 150 | 151 | // Clear memory 152 | const clearMemoryBtn = this.container.querySelector('#clear-memory') as HTMLButtonElement; 153 | clearMemoryBtn?.addEventListener('click', () => { 154 | this.clearMemory(); 155 | }); 156 | 157 | // New conversation 158 | const newConversationBtn = this.container.querySelector('#new-conversation') as HTMLButtonElement; 159 | newConversationBtn?.addEventListener('click', () => { 160 | this.startNewConversation(); 161 | }); 162 | 163 | // Export data 164 | const exportDataBtn = this.container.querySelector('#export-data') as HTMLButtonElement; 165 | exportDataBtn?.addEventListener('click', () => { 166 | this.exportData(); 167 | }); 168 | 169 | // Keyboard shortcut to toggle dashboard (Ctrl/Cmd + M) 170 | document.addEventListener('keydown', (e) => { 171 | if ((e.ctrlKey || e.metaKey) && e.key === 'm') { 172 | e.preventDefault(); 173 | this.toggleVisibility(); 174 | } 175 | }); 176 | } 177 | 178 | private startAutoRefresh(): void { 179 | // Refresh every 5 seconds when dashboard is visible 180 | this.refreshInterval = setInterval(() => { 181 | if (this.isVisible) { 182 | this.refreshDashboard(); 183 | } 184 | }, 5000); 185 | } 186 | 187 | public toggleVisibility(): void { 188 | this.isVisible = !this.isVisible; 189 | const dashboard = this.container.querySelector('.agent-dashboard'); 190 | 191 | if (this.isVisible) { 192 | dashboard?.classList.remove('hidden'); 193 | dashboard?.classList.add('visible'); 194 | this.refreshDashboard(); 195 | } else { 196 | dashboard?.classList.remove('visible'); 197 | dashboard?.classList.add('hidden'); 198 | } 199 | } 200 | 201 | public show(): void { 202 | if (!this.isVisible) { 203 | this.toggleVisibility(); 204 | } 205 | } 206 | 207 | public hide(): void { 208 | if (this.isVisible) { 209 | this.toggleVisibility(); 210 | } 211 | } 212 | 213 | public get visible(): boolean { 214 | return this.isVisible; 215 | } 216 | 217 | private refreshDashboard(): void { 218 | this.updateMemoryStats(); 219 | this.updatePerformanceStats(); 220 | this.updateRecentAnalysis(); 221 | } 222 | 223 | private updateMemoryStats(): void { 224 | try { 225 | const memoryStats = multiStepAgent.getMemoryStats(); 226 | 227 | const conversationsEl = this.container.querySelector('#memory-conversations'); 228 | const contextsEl = this.container.querySelector('#memory-contexts'); 229 | const analysesEl = this.container.querySelector('#memory-analyses'); 230 | const symbolsEl = this.container.querySelector('#memory-symbols'); 231 | 232 | if (conversationsEl) conversationsEl.textContent = memoryStats.conversations.toString(); 233 | if (contextsEl) contextsEl.textContent = memoryStats.marketContexts.toString(); 234 | if (analysesEl) analysesEl.textContent = memoryStats.analysisHistory.toString(); 235 | if (symbolsEl) symbolsEl.textContent = memoryStats.totalSymbols.toString(); 236 | 237 | // Show helpful message if no data yet 238 | this.updateEmptyStateMessage(memoryStats); 239 | 240 | } catch (error) { 241 | console.warn('Failed to update memory stats:', error); 242 | } 243 | } 244 | 245 | private updatePerformanceStats(): void { 246 | try { 247 | const perfStats = multiStepAgent.getPerformanceStats(); 248 | 249 | const successRateEl = this.container.querySelector('#perf-success-rate'); 250 | const avgTimeEl = this.container.querySelector('#perf-avg-time'); 251 | const savingsEl = this.container.querySelector('#perf-savings'); 252 | const toolCountEl = this.container.querySelector('#perf-tool-count'); 253 | 254 | if (successRateEl) { 255 | successRateEl.textContent = `${(perfStats.successRate * 100).toFixed(1)}%`; 256 | } 257 | if (avgTimeEl) { 258 | avgTimeEl.textContent = `${Math.round(perfStats.averageToolTime)}ms`; 259 | } 260 | if (savingsEl) { 261 | savingsEl.textContent = `${Math.round(perfStats.parallelSavings)}ms`; 262 | } 263 | if (toolCountEl) { 264 | toolCountEl.textContent = perfStats.toolCount.toString(); 265 | } 266 | 267 | } catch (error) { 268 | console.warn('Failed to update performance stats:', error); 269 | } 270 | } 271 | 272 | private updateRecentAnalysis(): void { 273 | try { 274 | const recentAnalysis = multiStepAgent.getAnalysisHistory(undefined, 5); 275 | const listContainer = this.container.querySelector('#recent-analysis'); 276 | 277 | if (!listContainer) return; 278 | 279 | if (recentAnalysis.length === 0) { 280 | listContainer.innerHTML = ` 281 | <div class="empty-state"> 282 | <p>No recent analysis available.</p> 283 | </div> 284 | `; 285 | return; 286 | } 287 | 288 | const analysisHtml = recentAnalysis.map(analysis => ` 289 | <div class="analysis-item"> 290 | <div class="analysis-header"> 291 | <span class="analysis-symbol">${analysis.symbol}</span> 292 | <span class="analysis-type">${analysis.analysisType}</span> 293 | <span class="analysis-time">${this.getTimeAgo(analysis.timestamp)}</span> 294 | </div> 295 | <div class="analysis-query">${this.truncateText(analysis.query, 60)}</div> 296 | <div class="analysis-metrics"> 297 | <span class="metric">Duration: ${analysis.duration}ms</span> 298 | <span class="metric">Tools: ${analysis.toolsUsed.length}</span> 299 | ${analysis.accuracy ? `<span class="metric">Accuracy: ${(analysis.accuracy * 100).toFixed(0)}%</span>` : ''} 300 | </div> 301 | </div> 302 | `).join(''); 303 | 304 | listContainer.innerHTML = analysisHtml; 305 | 306 | } catch (error) { 307 | console.warn('Failed to update recent analysis:', error); 308 | } 309 | } 310 | 311 | private clearMemory(): void { 312 | if (confirm('Are you sure you want to clear all agent memory? This action cannot be undone.')) { 313 | multiStepAgent.clearMemory(); 314 | this.refreshDashboard(); 315 | this.showToast('Memory cleared successfully!'); 316 | } 317 | } 318 | 319 | private startNewConversation(): void { 320 | // Clear agent memory 321 | multiStepAgent.startNewConversation(); 322 | 323 | // Clear chat UI if available 324 | if (this.chatApp) { 325 | this.chatApp.clearMessages(); 326 | } 327 | 328 | this.showToast('New conversation started!'); 329 | } 330 | 331 | private exportData(): void { 332 | try { 333 | const data = { 334 | memoryStats: multiStepAgent.getMemoryStats(), 335 | performanceStats: multiStepAgent.getPerformanceStats(), 336 | recentAnalysis: multiStepAgent.getAnalysisHistory(undefined, 20), 337 | exportedAt: new Date().toISOString() 338 | }; 339 | 340 | const dataStr = JSON.stringify(data, null, 2); 341 | const blob = new Blob([dataStr], { type: 'application/json' }); 342 | const url = URL.createObjectURL(blob); 343 | 344 | const a = document.createElement('a'); 345 | a.href = url; 346 | a.download = `agent-data-${new Date().toISOString().split('T')[0]}.json`; 347 | document.body.appendChild(a); 348 | a.click(); 349 | document.body.removeChild(a); 350 | URL.revokeObjectURL(url); 351 | 352 | this.showToast('Data exported successfully!'); 353 | 354 | } catch (error) { 355 | console.error('Failed to export data:', error); 356 | this.showToast('Failed to export data', 'error'); 357 | } 358 | } 359 | 360 | private showToast(message: string, type: 'success' | 'error' = 'success'): void { 361 | const toast = document.createElement('div'); 362 | toast.className = `dashboard-toast toast-${type}`; 363 | toast.textContent = message; 364 | 365 | document.body.appendChild(toast); 366 | 367 | // Animate in 368 | setTimeout(() => toast.classList.add('show'), 10); 369 | 370 | // Remove after 3 seconds 371 | setTimeout(() => { 372 | toast.classList.remove('show'); 373 | setTimeout(() => toast.remove(), 300); 374 | }, 3000); 375 | } 376 | 377 | private getTimeAgo(timestamp: number): string { 378 | const now = Date.now(); 379 | const diff = now - timestamp; 380 | 381 | const seconds = Math.floor(diff / 1000); 382 | const minutes = Math.floor(seconds / 60); 383 | const hours = Math.floor(minutes / 60); 384 | 385 | if (seconds < 60) return `${seconds}s ago`; 386 | if (minutes < 60) return `${minutes}m ago`; 387 | if (hours < 24) return `${hours}h ago`; 388 | 389 | return new Date(timestamp).toLocaleDateString(); 390 | } 391 | 392 | private truncateText(text: string, maxLength: number): string { 393 | if (text.length <= maxLength) return text; 394 | return text.substring(0, maxLength) + '...'; 395 | } 396 | 397 | private updateEmptyStateMessage(memoryStats: MemoryStats): void { 398 | const hasData = memoryStats.conversations > 0 || memoryStats.analysisHistory > 0; 399 | 400 | if (!hasData) { 401 | // Add notice to dashboard if no data 402 | const dashboardContent = this.container.querySelector('.dashboard-content'); 403 | if (dashboardContent && !dashboardContent.querySelector('.dashboard-notice')) { 404 | const notice = document.createElement('div'); 405 | notice.className = 'dashboard-notice'; 406 | notice.innerHTML = ` 407 | <div class="notice-content"> 408 | <h4>🤖 Agent Dashboard</h4> 409 | <p>This dashboard will show agent performance metrics, memory usage, and analysis history once you start using the agent mode.</p> 410 | <p><strong>To get started:</strong></p> 411 | <ol> 412 | <li>Enable Agent Mode in Settings (⚙️)</li> 413 | <li>Ask questions about cryptocurrency markets</li> 414 | <li>Watch the dashboard populate with data!</li> 415 | </ol> 416 | </div> 417 | `; 418 | dashboardContent.insertBefore(notice, dashboardContent.firstChild); 419 | } 420 | } else { 421 | // Remove notice if data exists 422 | const notice = this.container.querySelector('.dashboard-notice'); 423 | if (notice) { 424 | notice.remove(); 425 | } 426 | } 427 | } 428 | 429 | public destroy(): void { 430 | if (this.refreshInterval) { 431 | clearInterval(this.refreshInterval); 432 | this.refreshInterval = null; 433 | } 434 | } 435 | } 436 | ``` -------------------------------------------------------------------------------- /webui/src/components/chat/DataCard.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * DataCard Component - Expandable cards for visualising tool response data 3 | * 4 | * Provides a clean, collapsible interface for displaying structured data 5 | * with embedded visualisations when expanded. 6 | */ 7 | 8 | export interface DataCardConfig { 9 | title: string; 10 | summary: string; 11 | data: any; 12 | dataType: 'kline' | 'rsi' | 'orderBlocks' | 'price' | 'volume' | 'unknown'; 13 | expanded?: boolean; 14 | showChart?: boolean; 15 | } 16 | 17 | export class DataCard { 18 | private container: HTMLElement; 19 | private config: DataCardConfig; 20 | private isExpanded: boolean = false; 21 | private chartContainer?: HTMLElement; 22 | 23 | constructor(container: HTMLElement, config: DataCardConfig) { 24 | this.container = container; 25 | this.config = config; 26 | this.isExpanded = config.expanded || false; 27 | 28 | this.render(); 29 | this.setupEventListeners(); 30 | 31 | // If expanded by default, render chart after DOM is ready 32 | if (this.isExpanded) { 33 | setTimeout(() => { 34 | this.renderChart(); 35 | }, 150); 36 | } 37 | } 38 | 39 | /** 40 | * Render the data card structure 41 | */ 42 | private render(): void { 43 | this.container.innerHTML = ` 44 | <div class="data-card ${this.isExpanded ? 'expanded' : 'collapsed'}" data-type="${this.config.dataType}"> 45 | <div class="data-card-header" role="button" tabindex="0" aria-expanded="${this.isExpanded}"> 46 | <div class="data-card-title"> 47 | <span class="data-card-icon">${this.getDataTypeIcon()}</span> 48 | <h4>${this.config.title}</h4> 49 | </div> 50 | <div class="data-card-controls"> 51 | <span class="data-card-summary">${this.config.summary}</span> 52 | <button class="expand-toggle" aria-label="${this.isExpanded ? 'Collapse' : 'Expand'} data card"> 53 | <span class="expand-icon">${this.isExpanded ? '▼' : '▶'}</span> 54 | </button> 55 | </div> 56 | </div> 57 | <div class="data-card-content" ${this.isExpanded ? '' : 'style="display: none;"'}> 58 | <div class="data-card-details"> 59 | ${this.renderDataSummary()} 60 | </div> 61 | ${this.config.showChart !== false ? '<div class="data-card-chart" id="chart-' + this.generateId() + '"></div>' : ''} 62 | </div> 63 | </div> 64 | `; 65 | 66 | // Store reference to chart container if it exists 67 | const chartElement = this.container.querySelector('.data-card-chart') as HTMLElement; 68 | if (chartElement) { 69 | this.chartContainer = chartElement; 70 | } 71 | } 72 | 73 | /** 74 | * Set up event listeners for card interactions 75 | */ 76 | private setupEventListeners(): void { 77 | const header = this.container.querySelector('.data-card-header') as HTMLElement; 78 | const toggleButton = this.container.querySelector('.expand-toggle') as HTMLElement; 79 | 80 | if (header) { 81 | header.addEventListener('click', () => this.toggle()); 82 | header.addEventListener('keydown', (e) => { 83 | if (e.key === 'Enter' || e.key === ' ') { 84 | e.preventDefault(); 85 | this.toggle(); 86 | } 87 | }); 88 | } 89 | 90 | if (toggleButton) { 91 | toggleButton.addEventListener('click', (e) => { 92 | e.stopPropagation(); 93 | this.toggle(); 94 | }); 95 | } 96 | } 97 | 98 | /** 99 | * Toggle card expanded/collapsed state 100 | */ 101 | public toggle(): void { 102 | this.isExpanded = !this.isExpanded; 103 | this.updateExpandedState(); 104 | } 105 | 106 | /** 107 | * Expand the card 108 | */ 109 | public expand(): void { 110 | if (!this.isExpanded) { 111 | this.isExpanded = true; 112 | this.updateExpandedState(); 113 | } 114 | } 115 | 116 | /** 117 | * Collapse the card 118 | */ 119 | public collapse(): void { 120 | if (this.isExpanded) { 121 | this.isExpanded = false; 122 | this.updateExpandedState(); 123 | } 124 | } 125 | 126 | /** 127 | * Update the visual state when expanded/collapsed 128 | */ 129 | private updateExpandedState(): void { 130 | const card = this.container.querySelector('.data-card') as HTMLElement; 131 | const content = this.container.querySelector('.data-card-content') as HTMLElement; 132 | const header = this.container.querySelector('.data-card-header') as HTMLElement; 133 | const expandIcon = this.container.querySelector('.expand-icon') as HTMLElement; 134 | 135 | if (card && content && header && expandIcon) { 136 | // Update classes 137 | card.classList.toggle('expanded', this.isExpanded); 138 | card.classList.toggle('collapsed', !this.isExpanded); 139 | 140 | // Update ARIA attributes 141 | header.setAttribute('aria-expanded', this.isExpanded.toString()); 142 | 143 | // Update expand icon 144 | expandIcon.textContent = this.isExpanded ? '▼' : '▶'; 145 | 146 | // Update button label 147 | const toggleButton = this.container.querySelector('.expand-toggle') as HTMLElement; 148 | if (toggleButton) { 149 | toggleButton.setAttribute('aria-label', `${this.isExpanded ? 'Collapse' : 'Expand'} data card`); 150 | } 151 | 152 | // Animate content visibility 153 | if (this.isExpanded) { 154 | content.style.display = 'block'; 155 | // Trigger chart rendering with a small delay to ensure DOM is ready 156 | setTimeout(() => { 157 | this.renderChart(); 158 | }, 100); 159 | } else { 160 | // Add a small delay to allow animation 161 | setTimeout(() => { 162 | if (!this.isExpanded) { 163 | content.style.display = 'none'; 164 | } 165 | }, 250); 166 | } 167 | } 168 | } 169 | 170 | /** 171 | * Get appropriate icon for data type 172 | */ 173 | private getDataTypeIcon(): string { 174 | switch (this.config.dataType) { 175 | case 'kline': return '📈'; 176 | case 'rsi': return '📊'; 177 | case 'orderBlocks': return '🧱'; 178 | case 'price': return '💰'; 179 | case 'volume': return '📊'; 180 | default: return '📋'; 181 | } 182 | } 183 | 184 | /** 185 | * Render data summary in the expanded view 186 | */ 187 | private renderDataSummary(): string { 188 | // This will be enhanced based on data type 189 | if (typeof this.config.data === 'object') { 190 | return `<pre class="data-preview">${JSON.stringify(this.config.data, null, 2)}</pre>`; 191 | } 192 | return `<div class="data-preview">${this.config.data}</div>`; 193 | } 194 | 195 | /** 196 | * Render chart when card is expanded 197 | */ 198 | private renderChart(): void { 199 | if (!this.chartContainer || this.config.showChart === false) { 200 | return; 201 | } 202 | 203 | // Render different chart types based on data type 204 | switch (this.config.dataType) { 205 | case 'kline': 206 | this.renderCandlestickChart(); 207 | break; 208 | case 'rsi': 209 | this.renderLineChart(); 210 | break; 211 | case 'price': 212 | this.renderPriceChart(); 213 | break; 214 | default: 215 | this.renderPlaceholder(); 216 | break; 217 | } 218 | } 219 | 220 | /** 221 | * Format timestamp for X-axis labels 222 | */ 223 | private formatTimestamp(timestamp: number, interval: string): string { 224 | const date = new Date(timestamp); 225 | 226 | // For different intervals, show different levels of detail 227 | switch (interval) { 228 | case '1': // 1 minute 229 | case '5': // 5 minutes 230 | case '15': // 15 minutes 231 | case '30': // 30 minutes 232 | return date.toLocaleTimeString('en-US', { 233 | hour: '2-digit', 234 | minute: '2-digit', 235 | hour12: false 236 | }); 237 | case '60': // 1 hour 238 | case '240': // 4 hours 239 | return date.toLocaleDateString('en-US', { 240 | month: 'short', 241 | day: 'numeric', 242 | hour: '2-digit' 243 | }); 244 | case 'D': // Daily 245 | case 'W': // Weekly 246 | default: 247 | return date.toLocaleDateString('en-US', { 248 | month: 'short', 249 | day: 'numeric' 250 | }); 251 | } 252 | } 253 | 254 | /** 255 | * Render candlestick chart for kline data 256 | */ 257 | private renderCandlestickChart(): void { 258 | if (!this.chartContainer) return; 259 | 260 | // Extract kline data 261 | let klineData = this.config.data; 262 | if (this.config.data.data && Array.isArray(this.config.data.data)) { 263 | klineData = this.config.data.data; 264 | } 265 | 266 | if (!Array.isArray(klineData) || klineData.length === 0) { 267 | this.renderPlaceholder(); 268 | return; 269 | } 270 | 271 | // Create canvas element with responsive sizing 272 | const canvas = document.createElement('canvas'); 273 | const maxWidth = Math.min(this.chartContainer.clientWidth || 600, 800); // Cap at 800px 274 | const containerWidth = Math.max(maxWidth - 40, 400); // Ensure minimum 400px with padding 275 | canvas.width = containerWidth; 276 | canvas.height = 350; // Increased height for X-axis labels 277 | canvas.style.width = '100%'; 278 | canvas.style.maxWidth = `${containerWidth}px`; 279 | canvas.style.height = '350px'; 280 | canvas.style.border = '1px solid #ddd'; 281 | canvas.style.display = 'block'; 282 | canvas.style.margin = '0 auto'; 283 | 284 | this.chartContainer.innerHTML = ''; 285 | this.chartContainer.appendChild(canvas); 286 | 287 | const ctx = canvas.getContext('2d'); 288 | if (!ctx) return; 289 | 290 | // Parse and normalize data 291 | const candles = klineData.slice(-50).map((item: any) => { 292 | if (Array.isArray(item)) { 293 | return { 294 | timestamp: parseInt(item[0]), 295 | open: parseFloat(item[1]), 296 | high: parseFloat(item[2]), 297 | low: parseFloat(item[3]), 298 | close: parseFloat(item[4]), 299 | volume: parseFloat(item[5] || 0) 300 | }; 301 | } else if (typeof item === 'object') { 302 | return { 303 | timestamp: parseInt(item.timestamp || item.time || item.openTime || 0), 304 | open: parseFloat(item.open || 0), 305 | high: parseFloat(item.high || 0), 306 | low: parseFloat(item.low || 0), 307 | close: parseFloat(item.close || 0), 308 | volume: parseFloat(item.volume || 0) 309 | }; 310 | } 311 | return null; 312 | }).filter(Boolean); 313 | 314 | if (candles.length === 0) { 315 | this.renderPlaceholder(); 316 | return; 317 | } 318 | 319 | // Calculate price range 320 | const prices = candles.flatMap(c => c ? [c.high, c.low] : []); 321 | const minPrice = Math.min(...prices); 322 | const maxPrice = Math.max(...prices); 323 | const priceRange = maxPrice - minPrice; 324 | const padding = priceRange * 0.1; 325 | 326 | // Chart dimensions - adjusted for X-axis labels 327 | const chartWidth = canvas.width - 80; 328 | const chartHeight = canvas.height - 90; // More space for X-axis 329 | const chartX = 60; 330 | const chartY = 20; 331 | 332 | // Clear canvas 333 | ctx.fillStyle = '#ffffff'; 334 | ctx.fillRect(0, 0, canvas.width, canvas.height); 335 | 336 | // Draw background grid 337 | ctx.strokeStyle = '#f0f0f0'; 338 | ctx.lineWidth = 1; 339 | for (let i = 0; i <= 5; i++) { 340 | const y = chartY + (chartHeight * i) / 5; 341 | ctx.beginPath(); 342 | ctx.moveTo(chartX, y); 343 | ctx.lineTo(chartX + chartWidth, y); 344 | ctx.stroke(); 345 | } 346 | 347 | // Draw vertical grid lines for time 348 | const timeSteps = Math.min(candles.length, 6); 349 | for (let i = 0; i <= timeSteps; i++) { 350 | const x = chartX + (chartWidth * i) / timeSteps; 351 | ctx.beginPath(); 352 | ctx.moveTo(x, chartY); 353 | ctx.lineTo(x, chartY + chartHeight); 354 | ctx.stroke(); 355 | } 356 | 357 | // Draw price labels (Y-axis) 358 | ctx.fillStyle = '#666'; 359 | ctx.font = '12px Arial'; 360 | ctx.textAlign = 'right'; 361 | for (let i = 0; i <= 5; i++) { 362 | const price = maxPrice + padding - ((maxPrice + padding - (minPrice - padding)) * i) / 5; 363 | const y = chartY + (chartHeight * i) / 5; 364 | ctx.fillText(price.toFixed(4), chartX - 10, y + 4); 365 | } 366 | 367 | // Draw time labels (X-axis) 368 | ctx.textAlign = 'center'; 369 | const interval = this.config.data.interval || 'D'; 370 | for (let i = 0; i <= timeSteps; i++) { 371 | const candleIndex = Math.floor((candles.length - 1) * i / timeSteps); 372 | if (candles[candleIndex]) { 373 | const x = chartX + (chartWidth * i) / timeSteps; 374 | const timeLabel = this.formatTimestamp(candles[candleIndex].timestamp, interval); 375 | ctx.fillText(timeLabel, x, chartY + chartHeight + 20); 376 | } 377 | } 378 | 379 | // Draw candlesticks 380 | const candleWidth = Math.max(2, chartWidth / candles.length - 2); 381 | candles.forEach((candle, index) => { 382 | if (!candle) return; 383 | 384 | const x = chartX + (index * chartWidth) / candles.length + (chartWidth / candles.length - candleWidth) / 2; 385 | 386 | // Calculate y positions 387 | const highY = chartY + ((maxPrice + padding - candle.high) / (maxPrice + padding - (minPrice - padding))) * chartHeight; 388 | const lowY = chartY + ((maxPrice + padding - candle.low) / (maxPrice + padding - (minPrice - padding))) * chartHeight; 389 | const openY = chartY + ((maxPrice + padding - candle.open) / (maxPrice + padding - (minPrice - padding))) * chartHeight; 390 | const closeY = chartY + ((maxPrice + padding - candle.close) / (maxPrice + padding - (minPrice - padding))) * chartHeight; 391 | 392 | // Determine candle color 393 | const isGreen = candle.close >= candle.open; 394 | const bodyColor = isGreen ? '#22c55e' : '#ef4444'; 395 | const wickColor = '#666'; 396 | 397 | // Draw wick (high-low line) 398 | ctx.strokeStyle = wickColor; 399 | ctx.lineWidth = 1; 400 | ctx.beginPath(); 401 | ctx.moveTo(x + candleWidth / 2, highY); 402 | ctx.lineTo(x + candleWidth / 2, lowY); 403 | ctx.stroke(); 404 | 405 | // Draw candle body 406 | ctx.fillStyle = bodyColor; 407 | const bodyTop = Math.min(openY, closeY); 408 | const bodyHeight = Math.abs(closeY - openY) || 1; 409 | ctx.fillRect(x, bodyTop, candleWidth, bodyHeight); 410 | }); 411 | 412 | // Add title 413 | ctx.fillStyle = '#333'; 414 | ctx.font = 'bold 14px Arial'; 415 | ctx.textAlign = 'left'; 416 | const symbol = this.config.data.symbol || 'Symbol'; 417 | const intervalLabel = this.config.data.interval || ''; 418 | ctx.fillText(`${symbol} ${intervalLabel} Candlestick Chart`, chartX, 15); 419 | 420 | // Add current price info 421 | const lastCandle = candles[candles.length - 1]; 422 | if (lastCandle) { 423 | ctx.font = '12px Arial'; 424 | ctx.fillStyle = lastCandle.close >= lastCandle.open ? '#22c55e' : '#ef4444'; 425 | ctx.textAlign = 'right'; 426 | ctx.fillText(`Last: $${lastCandle.close.toFixed(4)}`, canvas.width - 10, 15); 427 | } 428 | } 429 | 430 | /** 431 | * Render line chart for RSI and other indicators 432 | */ 433 | private renderLineChart(): void { 434 | if (!this.chartContainer) return; 435 | 436 | this.chartContainer.innerHTML = ` 437 | <div class="chart-placeholder"> 438 | <p>📊 Line chart for ${this.config.dataType} data</p> 439 | <small>Line chart implementation coming soon</small> 440 | </div> 441 | `; 442 | } 443 | 444 | /** 445 | * Render simple price chart 446 | */ 447 | private renderPriceChart(): void { 448 | if (!this.chartContainer) return; 449 | 450 | this.chartContainer.innerHTML = ` 451 | <div class="chart-placeholder"> 452 | <p>💰 Price chart rendering</p> 453 | <small>Price chart implementation coming soon</small> 454 | </div> 455 | `; 456 | } 457 | 458 | /** 459 | * Render placeholder for unsupported chart types 460 | */ 461 | private renderPlaceholder(): void { 462 | if (!this.chartContainer) return; 463 | 464 | this.chartContainer.innerHTML = ` 465 | <div class="chart-placeholder"> 466 | <p>Chart rendering for ${this.config.dataType} data</p> 467 | <small>Chart component will be implemented next</small> 468 | </div> 469 | `; 470 | } 471 | 472 | /** 473 | * Generate unique ID for chart container 474 | */ 475 | private generateId(): string { 476 | return Math.random().toString(36).substr(2, 9); 477 | } 478 | 479 | /** 480 | * Update card configuration 481 | */ 482 | public updateConfig(newConfig: Partial<DataCardConfig>): void { 483 | this.config = { ...this.config, ...newConfig }; 484 | this.render(); 485 | this.setupEventListeners(); 486 | } 487 | 488 | /** 489 | * Get current card state 490 | */ 491 | public getState(): { expanded: boolean; dataType: string } { 492 | return { 493 | expanded: this.isExpanded, 494 | dataType: this.config.dataType 495 | }; 496 | } 497 | 498 | /** 499 | * Destroy the card and clean up event listeners 500 | */ 501 | public destroy(): void { 502 | // Event listeners will be automatically removed when innerHTML is cleared 503 | this.container.innerHTML = ''; 504 | } 505 | } 506 | ``` -------------------------------------------------------------------------------- /src/tools/GetMarketStructure.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js" 2 | import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" 3 | import { z } from "zod" 4 | import { BaseToolImplementation } from "./BaseTool.js" 5 | import { KlineData, calculateRSI, calculateVolatility } from "../utils/mathUtils.js" 6 | import { 7 | detectOrderBlocks, 8 | getActiveLevels, 9 | calculateOrderBlockStats, 10 | VolumeAnalysisConfig 11 | } from "../utils/volumeAnalysis.js" 12 | import { 13 | applyKNNToRSI, 14 | KNNConfig 15 | } from "../utils/knnAlgorithm.js" 16 | import { GetKlineParamsV5, KlineIntervalV3 } from "bybit-api" 17 | 18 | // Zod schema for input validation 19 | const inputSchema = z.object({ 20 | symbol: z.string() 21 | .min(1, "Symbol is required") 22 | .regex(/^[A-Z0-9]+$/, "Symbol must contain only uppercase letters and numbers"), 23 | category: z.enum(["spot", "linear", "inverse"]), 24 | interval: z.enum(["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"]), 25 | analysisDepth: z.number().min(100).max(500).optional().default(200), 26 | includeOrderBlocks: z.boolean().optional().default(true), 27 | includeMLRSI: z.boolean().optional().default(true), 28 | includeLiquidityZones: z.boolean().optional().default(true) 29 | }) 30 | 31 | type ToolArguments = z.infer<typeof inputSchema> 32 | 33 | interface LiquidityZone { 34 | price: number; 35 | strength: number; 36 | type: "support" | "resistance"; 37 | } 38 | 39 | interface MarketStructureResponse { 40 | symbol: string; 41 | interval: string; 42 | marketRegime: "trending_up" | "trending_down" | "ranging" | "volatile"; 43 | trendStrength: number; 44 | volatilityLevel: "low" | "medium" | "high"; 45 | keyLevels: { 46 | support: number[]; 47 | resistance: number[]; 48 | liquidityZones: LiquidityZone[]; 49 | }; 50 | orderBlocks?: { 51 | bullishBlocks: any[]; 52 | bearishBlocks: any[]; 53 | activeBullishBlocks: number; 54 | activeBearishBlocks: number; 55 | }; 56 | mlRsi?: { 57 | currentRsi: number; 58 | mlRsi: number; 59 | adaptiveOverbought: number; 60 | adaptiveOversold: number; 61 | trend: string; 62 | confidence: number; 63 | }; 64 | recommendations: string[]; 65 | metadata: { 66 | analysisDepth: number; 67 | calculationTime: number; 68 | confidence: number; 69 | dataQuality: "excellent" | "good" | "fair" | "poor"; 70 | }; 71 | } 72 | 73 | class GetMarketStructure extends BaseToolImplementation { 74 | name = "get_market_structure" 75 | toolDefinition: Tool = { 76 | name: this.name, 77 | description: "Advanced market structure analysis combining ML-RSI, order blocks, and liquidity zones. Provides comprehensive market regime detection and trading recommendations.", 78 | inputSchema: { 79 | type: "object", 80 | properties: { 81 | symbol: { 82 | type: "string", 83 | description: "Trading pair symbol (e.g., 'BTCUSDT')", 84 | pattern: "^[A-Z0-9]+$" 85 | }, 86 | category: { 87 | type: "string", 88 | description: "Category of the instrument", 89 | enum: ["spot", "linear", "inverse"] 90 | }, 91 | interval: { 92 | type: "string", 93 | description: "Kline interval", 94 | enum: ["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"] 95 | }, 96 | analysisDepth: { 97 | type: "number", 98 | description: "How far back to analyse (default: 200)", 99 | minimum: 100, 100 | maximum: 500 101 | }, 102 | includeOrderBlocks: { 103 | type: "boolean", 104 | description: "Include order block analysis (default: true)" 105 | }, 106 | includeMLRSI: { 107 | type: "boolean", 108 | description: "Include ML-RSI analysis (default: true)" 109 | }, 110 | includeLiquidityZones: { 111 | type: "boolean", 112 | description: "Include liquidity analysis (default: true)" 113 | } 114 | }, 115 | required: ["symbol", "category", "interval"] 116 | } 117 | } 118 | 119 | async toolCall(request: z.infer<typeof CallToolRequestSchema>): Promise<CallToolResult> { 120 | const startTime = Date.now() 121 | 122 | try { 123 | this.logInfo("Starting get_market_structure tool call") 124 | 125 | // Parse and validate input 126 | const validationResult = inputSchema.safeParse(request.params.arguments) 127 | if (!validationResult.success) { 128 | const errorDetails = validationResult.error.errors.map(err => ({ 129 | field: err.path.join('.'), 130 | message: err.message, 131 | code: err.code 132 | })) 133 | throw new Error(`Invalid input: ${JSON.stringify(errorDetails)}`) 134 | } 135 | 136 | const args = validationResult.data 137 | 138 | // Fetch kline data 139 | const klineData = await this.fetchKlineData(args) 140 | 141 | if (klineData.length < 50) { 142 | throw new Error(`Insufficient data. Need at least 50 data points, got ${klineData.length}`) 143 | } 144 | 145 | // Analyze market structure 146 | const analysis = await this.analyzeMarketStructure(klineData, args) 147 | 148 | const calculationTime = Date.now() - startTime 149 | 150 | const response: MarketStructureResponse = { 151 | symbol: args.symbol, 152 | interval: args.interval, 153 | marketRegime: analysis.marketRegime, 154 | trendStrength: analysis.trendStrength, 155 | volatilityLevel: analysis.volatilityLevel, 156 | keyLevels: analysis.keyLevels, 157 | orderBlocks: args.includeOrderBlocks ? analysis.orderBlocks : undefined, 158 | mlRsi: args.includeMLRSI ? analysis.mlRsi : undefined, 159 | recommendations: analysis.recommendations, 160 | metadata: { 161 | analysisDepth: args.analysisDepth, 162 | calculationTime, 163 | confidence: analysis.confidence, 164 | dataQuality: analysis.dataQuality 165 | } 166 | } 167 | 168 | this.logInfo(`Market structure analysis completed in ${calculationTime}ms. Regime: ${analysis.marketRegime}, Confidence: ${analysis.confidence}%`) 169 | return this.formatResponse(response) 170 | 171 | } catch (error) { 172 | this.logInfo(`Market structure analysis failed: ${error instanceof Error ? error.message : String(error)}`) 173 | return this.handleError(error) 174 | } 175 | } 176 | 177 | private async fetchKlineData(args: ToolArguments): Promise<KlineData[]> { 178 | const params: GetKlineParamsV5 = { 179 | category: args.category, 180 | symbol: args.symbol, 181 | interval: args.interval as KlineIntervalV3, 182 | limit: args.analysisDepth 183 | } 184 | 185 | const response = await this.executeRequest(() => this.client.getKline(params)) 186 | 187 | if (!response.list || response.list.length === 0) { 188 | throw new Error("No kline data received from API") 189 | } 190 | 191 | // Convert API response to KlineData format 192 | return response.list.map(kline => ({ 193 | timestamp: parseInt(kline[0]), 194 | open: parseFloat(kline[1]), 195 | high: parseFloat(kline[2]), 196 | low: parseFloat(kline[3]), 197 | close: parseFloat(kline[4]), 198 | volume: parseFloat(kline[5]) 199 | })).reverse() // Reverse to get chronological order 200 | } 201 | 202 | private async analyzeMarketStructure(klineData: KlineData[], args: ToolArguments) { 203 | const closePrices = klineData.map(k => k.close) 204 | const highs = klineData.map(k => k.high) 205 | const lows = klineData.map(k => k.low) 206 | 207 | // Calculate basic indicators 208 | const rsiValues = calculateRSI(closePrices, 14) 209 | const volatility = calculateVolatility(closePrices, 20) 210 | 211 | // Determine market regime 212 | const marketRegime = this.determineMarketRegime(klineData, rsiValues, volatility) 213 | 214 | // Calculate trend strength 215 | const trendStrength = this.calculateTrendStrength(closePrices) 216 | 217 | // Determine volatility level 218 | const volatilityLevel = this.determineVolatilityLevel(volatility) 219 | 220 | // Analyze order blocks if requested 221 | let orderBlocks: any = undefined 222 | if (args.includeOrderBlocks) { 223 | const config: VolumeAnalysisConfig = { 224 | volumePivotLength: 3, // Reduced for better detection 225 | bullishBlocks: 5, 226 | bearishBlocks: 5, 227 | mitigationMethod: 'wick' 228 | } 229 | 230 | const { bullishBlocks, bearishBlocks } = detectOrderBlocks(klineData, config) 231 | const stats = calculateOrderBlockStats(bullishBlocks, bearishBlocks) 232 | 233 | orderBlocks = { 234 | bullishBlocks, 235 | bearishBlocks, 236 | activeBullishBlocks: stats.activeBullishBlocks, 237 | activeBearishBlocks: stats.activeBearishBlocks 238 | } 239 | } 240 | 241 | // Analyze ML-RSI if requested 242 | let mlRsi: any = undefined 243 | if (args.includeMLRSI && rsiValues.length > 0) { 244 | const currentRsi = rsiValues[rsiValues.length - 1] 245 | // Simplified ML-RSI for market structure analysis 246 | mlRsi = { 247 | currentRsi, 248 | mlRsi: currentRsi, // Simplified for now 249 | adaptiveOverbought: 70, 250 | adaptiveOversold: 30, 251 | trend: currentRsi > 50 ? "bullish" : "bearish", 252 | confidence: 75 253 | } 254 | } 255 | 256 | // Identify key levels 257 | const keyLevels = this.identifyKeyLevels(klineData, args.includeLiquidityZones) 258 | 259 | // Generate recommendations 260 | const recommendations = this.generateRecommendations(marketRegime, trendStrength, volatilityLevel, mlRsi, orderBlocks) 261 | 262 | // Calculate overall confidence 263 | const confidence = this.calculateConfidence(klineData.length, volatility) 264 | 265 | // Assess data quality 266 | const dataQuality = this.assessDataQuality(klineData) 267 | 268 | return { 269 | marketRegime, 270 | trendStrength, 271 | volatilityLevel, 272 | keyLevels, 273 | orderBlocks, 274 | mlRsi, 275 | recommendations, 276 | confidence, 277 | dataQuality 278 | } 279 | } 280 | 281 | private determineMarketRegime(klineData: KlineData[], rsiValues: number[], volatility: number[]): "trending_up" | "trending_down" | "ranging" | "volatile" { 282 | const closePrices = klineData.map(k => k.close) 283 | const recentPrices = closePrices.slice(-20) // Last 20 periods 284 | 285 | if (recentPrices.length < 10) return "ranging" 286 | 287 | const firstPrice = recentPrices[0] 288 | const lastPrice = recentPrices[recentPrices.length - 1] 289 | const priceChange = (lastPrice - firstPrice) / firstPrice 290 | 291 | const avgVolatility = volatility.length > 0 292 | ? volatility.slice(-10).reduce((sum, v) => sum + v, 0) / Math.min(10, volatility.length) 293 | : 0 294 | 295 | const avgRsi = rsiValues.length > 0 296 | ? rsiValues.slice(-10).reduce((sum, r) => sum + r, 0) / Math.min(10, rsiValues.length) 297 | : 50 298 | 299 | // High volatility threshold 300 | const highVolatilityThreshold = lastPrice * 0.02 // 2% of price 301 | 302 | if (avgVolatility > highVolatilityThreshold) { 303 | return "volatile" 304 | } 305 | 306 | if (priceChange > 0.03 && avgRsi > 45) { // 3% up move 307 | return "trending_up" 308 | } else if (priceChange < -0.03 && avgRsi < 55) { // 3% down move 309 | return "trending_down" 310 | } else { 311 | return "ranging" 312 | } 313 | } 314 | 315 | private calculateTrendStrength(prices: number[]): number { 316 | if (prices.length < 20) return 50 317 | 318 | const recent = prices.slice(-20) 319 | const slope = this.calculateLinearRegressionSlope(recent) 320 | const correlation = this.calculateCorrelation(recent) 321 | 322 | // Normalize slope and correlation to 0-100 scale 323 | const normalizedSlope = Math.min(100, Math.max(0, (Math.abs(slope) * 1000) + 50)) 324 | const normalizedCorrelation = Math.abs(correlation) * 100 325 | 326 | return Math.round((normalizedSlope + normalizedCorrelation) / 2) 327 | } 328 | 329 | private calculateLinearRegressionSlope(values: number[]): number { 330 | const n = values.length 331 | const x = Array.from({ length: n }, (_, i) => i) 332 | 333 | const sumX = x.reduce((sum, val) => sum + val, 0) 334 | const sumY = values.reduce((sum, val) => sum + val, 0) 335 | const sumXY = x.reduce((sum, val, idx) => sum + val * values[idx], 0) 336 | const sumXX = x.reduce((sum, val) => sum + val * val, 0) 337 | 338 | return (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX) 339 | } 340 | 341 | private calculateCorrelation(values: number[]): number { 342 | const n = values.length 343 | const x = Array.from({ length: n }, (_, i) => i) 344 | 345 | const meanX = x.reduce((sum, val) => sum + val, 0) / n 346 | const meanY = values.reduce((sum, val) => sum + val, 0) / n 347 | 348 | const numerator = x.reduce((sum, val, idx) => sum + (val - meanX) * (values[idx] - meanY), 0) 349 | const denomX = Math.sqrt(x.reduce((sum, val) => sum + Math.pow(val - meanX, 2), 0)) 350 | const denomY = Math.sqrt(values.reduce((sum, val) => sum + Math.pow(val - meanY, 2), 0)) 351 | 352 | return denomX * denomY !== 0 ? numerator / (denomX * denomY) : 0 353 | } 354 | 355 | private determineVolatilityLevel(volatility: number[]): "low" | "medium" | "high" { 356 | if (volatility.length === 0) return "medium" 357 | 358 | const avgVolatility = volatility.slice(-10).reduce((sum, v) => sum + v, 0) / Math.min(10, volatility.length) 359 | const maxVolatility = Math.max(...volatility.slice(-20)) 360 | 361 | const relativeVolatility = avgVolatility / maxVolatility 362 | 363 | if (relativeVolatility < 0.3) return "low" 364 | if (relativeVolatility > 0.7) return "high" 365 | return "medium" 366 | } 367 | 368 | private identifyKeyLevels(klineData: KlineData[], includeLiquidityZones: boolean) { 369 | const highs = klineData.map(k => k.high) 370 | const lows = klineData.map(k => k.low) 371 | 372 | // Find significant highs and lows 373 | const resistance = this.findSignificantLevels(highs, 'high').slice(0, 5) 374 | const support = this.findSignificantLevels(lows, 'low').slice(0, 5) 375 | 376 | const liquidityZones: LiquidityZone[] = [] 377 | 378 | if (includeLiquidityZones) { 379 | // Create liquidity zones around key levels 380 | resistance.forEach(level => { 381 | liquidityZones.push({ 382 | price: level, 383 | strength: 75, 384 | type: "resistance" 385 | }) 386 | }) 387 | 388 | support.forEach(level => { 389 | liquidityZones.push({ 390 | price: level, 391 | strength: 75, 392 | type: "support" 393 | }) 394 | }) 395 | } 396 | 397 | return { 398 | support: support.sort((a, b) => b - a), // Descending 399 | resistance: resistance.sort((a, b) => a - b), // Ascending 400 | liquidityZones 401 | } 402 | } 403 | 404 | private findSignificantLevels(values: number[], type: 'high' | 'low'): number[] { 405 | const levels: number[] = [] 406 | const lookback = 5 407 | 408 | for (let i = lookback; i < values.length - lookback; i++) { 409 | const current = values[i] 410 | let isSignificant = true 411 | 412 | // Check if current value is a local extreme 413 | for (let j = i - lookback; j <= i + lookback; j++) { 414 | if (j !== i) { 415 | if (type === 'high' && values[j] >= current) { 416 | isSignificant = false 417 | break 418 | } else if (type === 'low' && values[j] <= current) { 419 | isSignificant = false 420 | break 421 | } 422 | } 423 | } 424 | 425 | if (isSignificant) { 426 | levels.push(current) 427 | } 428 | } 429 | 430 | return levels 431 | } 432 | 433 | private generateRecommendations( 434 | marketRegime: string, 435 | trendStrength: number, 436 | volatilityLevel: string, 437 | mlRsi: any, 438 | orderBlocks: any 439 | ): string[] { 440 | const recommendations: string[] = [] 441 | 442 | // Market regime recommendations 443 | switch (marketRegime) { 444 | case "trending_up": 445 | recommendations.push("Market is in an uptrend - consider long positions on pullbacks") 446 | if (trendStrength > 70) { 447 | recommendations.push("Strong uptrend detected - momentum strategies may be effective") 448 | } 449 | break 450 | case "trending_down": 451 | recommendations.push("Market is in a downtrend - consider short positions on rallies") 452 | if (trendStrength > 70) { 453 | recommendations.push("Strong downtrend detected - avoid catching falling knives") 454 | } 455 | break 456 | case "ranging": 457 | recommendations.push("Market is ranging - consider mean reversion strategies") 458 | recommendations.push("Look for support and resistance bounces") 459 | break 460 | case "volatile": 461 | recommendations.push("High volatility detected - use smaller position sizes") 462 | recommendations.push("Consider volatility-based strategies or wait for calmer conditions") 463 | break 464 | } 465 | 466 | // Volatility recommendations 467 | if (volatilityLevel === "high") { 468 | recommendations.push("High volatility - use wider stops and smaller positions") 469 | } else if (volatilityLevel === "low") { 470 | recommendations.push("Low volatility - potential for breakout moves") 471 | } 472 | 473 | // RSI recommendations 474 | if (mlRsi) { 475 | if (mlRsi.currentRsi > 70) { 476 | recommendations.push("RSI indicates overbought conditions - watch for potential reversal") 477 | } else if (mlRsi.currentRsi < 30) { 478 | recommendations.push("RSI indicates oversold conditions - potential buying opportunity") 479 | } 480 | } 481 | 482 | // Order block recommendations 483 | if (orderBlocks && (orderBlocks.activeBullishBlocks > 0 || orderBlocks.activeBearishBlocks > 0)) { 484 | recommendations.push("Active order blocks detected - watch for reactions at these levels") 485 | } 486 | 487 | return recommendations 488 | } 489 | 490 | private calculateConfidence(dataPoints: number, volatility: number[]): number { 491 | let confidence = 50 // Base confidence 492 | 493 | // More data points = higher confidence 494 | if (dataPoints > 150) confidence += 20 495 | else if (dataPoints > 100) confidence += 10 496 | 497 | // Lower volatility = higher confidence in analysis 498 | if (volatility.length > 0) { 499 | const avgVolatility = volatility.slice(-10).reduce((sum, v) => sum + v, 0) / Math.min(10, volatility.length) 500 | const maxVolatility = Math.max(...volatility) 501 | const relativeVolatility = avgVolatility / maxVolatility 502 | 503 | if (relativeVolatility < 0.3) confidence += 15 504 | else if (relativeVolatility > 0.7) confidence -= 10 505 | } 506 | 507 | return Math.min(100, Math.max(0, confidence)) 508 | } 509 | 510 | private assessDataQuality(klineData: KlineData[]): "excellent" | "good" | "fair" | "poor" { 511 | if (klineData.length > 200) return "excellent" 512 | if (klineData.length > 150) return "good" 513 | if (klineData.length > 100) return "fair" 514 | return "poor" 515 | } 516 | } 517 | 518 | export default GetMarketStructure 519 | ``` -------------------------------------------------------------------------------- /webui/src/components/ToolsManager.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tools Manager - Handles the MCP Tools tab functionality 3 | * Displays available tools, allows manual testing, and shows execution history 4 | */ 5 | 6 | import { mcpClient } from '@/services/mcpClient'; 7 | import type { MCPTool } from '@/types/mcp'; 8 | import { DataCard, type DataCardConfig } from './chat/DataCard'; 9 | import { detectDataType } from '../utils/dataDetection'; 10 | 11 | export class ToolsManager { 12 | private tools: MCPTool[] = []; 13 | private isInitialized = false; 14 | private executionHistory: Array<{ 15 | id: string; 16 | tool: string; 17 | params: any; 18 | result: any; 19 | timestamp: number; 20 | success: boolean; 21 | }> = []; 22 | private dataCards: Map<string, DataCard> = new Map(); // Track DataCards by tool name 23 | 24 | constructor() {} 25 | 26 | /** 27 | * Initialize the tools manager 28 | */ 29 | async initialize(): Promise<void> { 30 | if (this.isInitialized) return; 31 | 32 | try { 33 | console.log('🔧 Initializing Tools Manager...'); 34 | 35 | // Load available tools 36 | await this.loadTools(); 37 | 38 | // Render tools interface 39 | this.renderToolsInterface(); 40 | 41 | this.isInitialized = true; 42 | console.log('✅ Tools Manager initialized'); 43 | } catch (error) { 44 | console.error('❌ Failed to initialize Tools Manager:', error); 45 | this.showError('Failed to initialize tools'); 46 | } 47 | } 48 | 49 | /** 50 | * Load available tools from MCP server 51 | */ 52 | private async loadTools(): Promise<void> { 53 | try { 54 | this.tools = await mcpClient.listTools(); 55 | console.log(`🔧 Loaded ${this.tools.length} tools`); 56 | } catch (error) { 57 | console.error('Failed to load tools:', error); 58 | this.tools = []; 59 | } 60 | } 61 | 62 | /** 63 | * Render the tools interface 64 | */ 65 | private renderToolsInterface(): void { 66 | const container = document.getElementById('tools-grid'); 67 | if (!container) return; 68 | 69 | if (this.tools.length === 0) { 70 | container.innerHTML = ` 71 | <div class="tools-empty"> 72 | <h3>No Tools Available</h3> 73 | <p>Unable to load MCP tools. Please check your connection.</p> 74 | <button onclick="location.reload()" class="retry-btn">Retry</button> 75 | </div> 76 | `; 77 | return; 78 | } 79 | 80 | // Create tools grid 81 | container.innerHTML = ` 82 | <div class="tools-header"> 83 | <h3>Available MCP Tools (${this.tools.length})</h3> 84 | <div class="tools-actions"> 85 | <button id="refresh-tools" class="refresh-btn">Refresh</button> 86 | <button id="clear-history" class="clear-btn">Clear History</button> 87 | </div> 88 | </div> 89 | <div class="tools-list"> 90 | ${this.tools.map(tool => this.renderToolCard(tool)).join('')} 91 | </div> 92 | <div class="execution-history"> 93 | <h3>Execution History</h3> 94 | <div id="history-list" class="history-list"> 95 | ${this.renderExecutionHistory()} 96 | </div> 97 | </div> 98 | `; 99 | 100 | // Set up event listeners 101 | this.setupEventListeners(); 102 | } 103 | 104 | /** 105 | * Render a single tool card 106 | */ 107 | private renderToolCard(tool: MCPTool): string { 108 | const requiredParams = tool.inputSchema?.required || []; 109 | const properties = tool.inputSchema?.properties || {}; 110 | 111 | const html = ` 112 | <div class="tool-card" data-tool="${tool.name}"> 113 | <div class="tool-header"> 114 | <h4>${tool.name}</h4> 115 | <button class="test-tool-btn" data-tool="${tool.name}">Test</button> 116 | </div> 117 | <p class="tool-description">${tool.description}</p> 118 | <div class="tool-params"> 119 | <h5>Parameters:</h5> 120 | ${Object.entries(properties).map(([key, param]: [string, any]) => { 121 | // Determine default value 122 | let defaultValue = ''; 123 | if (key === 'symbol') { 124 | defaultValue = 'XRPUSDT'; 125 | } else if (key === 'category') { 126 | defaultValue = 'spot'; 127 | } else if (key === 'interval') { 128 | defaultValue = '15'; 129 | } else if (key === 'limit') { 130 | defaultValue = '100'; 131 | } 132 | 133 | // Check if this field has enum options 134 | if (param.enum && Array.isArray(param.enum)) { 135 | // Use dropdown for enum fields 136 | return ` 137 | <div class="param-item"> 138 | <label for="${tool.name}-${key}"> 139 | ${key}${requiredParams.includes(key) ? ' *' : ''} 140 | </label> 141 | <select id="${tool.name}-${key}" class="param-select"> 142 | ${param.enum.map((value: string) => ` 143 | <option value="${value}" ${value === defaultValue ? 'selected' : ''}> 144 | ${value} 145 | </option> 146 | `).join('')} 147 | </select> 148 | ${param.description ? `<small class="param-description">${param.description}</small>` : ''} 149 | </div> 150 | `; 151 | } else { 152 | // Use input for non-enum fields 153 | return ` 154 | <div class="param-item"> 155 | <label for="${tool.name}-${key}"> 156 | ${key}${requiredParams.includes(key) ? ' *' : ''} 157 | </label> 158 | <input 159 | type="text" 160 | id="${tool.name}-${key}" 161 | placeholder="${param.description || ''}" 162 | value="${defaultValue}" 163 | class="param-input" 164 | /> 165 | ${param.description ? `<small class="param-description">${param.description}</small>` : ''} 166 | </div> 167 | `; 168 | } 169 | }).join('')} 170 | </div> 171 | <div class="tool-result" id="result-${tool.name}" style="display: none;"> 172 | <div class="result-header"> 173 | <h5>Result</h5> 174 | <button class="result-close" data-tool="${tool.name}" title="Close result">×</button> 175 | </div> 176 | <div class="result-content" id="result-content-${tool.name}"></div> 177 | </div> 178 | </div> 179 | `; 180 | 181 | return html; 182 | } 183 | 184 | /** 185 | * Render execution history 186 | */ 187 | private renderExecutionHistory(): string { 188 | if (this.executionHistory.length === 0) { 189 | return '<p class="history-empty">No executions yet</p>'; 190 | } 191 | 192 | return this.executionHistory 193 | .slice(-10) // Show last 10 executions 194 | .reverse() 195 | .map(execution => ` 196 | <div class="history-item ${execution.success ? 'success' : 'error'}"> 197 | <div class="history-header"> 198 | <span class="tool-name">${execution.tool}</span> 199 | <span class="timestamp">${new Date(execution.timestamp).toLocaleTimeString()}</span> 200 | </div> 201 | <div class="history-params"> 202 | <strong>Params:</strong> ${JSON.stringify(execution.params, null, 2)} 203 | </div> 204 | <div class="history-result"> 205 | <strong>Result:</strong> 206 | <pre>${JSON.stringify(execution.result, null, 2)}</pre> 207 | </div> 208 | </div> 209 | `).join(''); 210 | } 211 | 212 | /** 213 | * Set up event listeners 214 | */ 215 | private setupEventListeners(): void { 216 | // Refresh tools button 217 | const refreshBtn = document.getElementById('refresh-tools'); 218 | if (refreshBtn) { 219 | refreshBtn.addEventListener('click', () => { 220 | this.refreshTools(); 221 | }); 222 | } 223 | 224 | // Clear history button 225 | const clearBtn = document.getElementById('clear-history'); 226 | if (clearBtn) { 227 | clearBtn.addEventListener('click', () => { 228 | this.clearHistory(); 229 | }); 230 | } 231 | 232 | // Test tool buttons 233 | document.querySelectorAll('.test-tool-btn').forEach(btn => { 234 | btn.addEventListener('click', (event) => { 235 | const target = event.target as HTMLElement; 236 | const toolName = target.dataset.tool; 237 | if (toolName) { 238 | this.testTool(toolName); 239 | } 240 | }); 241 | }); 242 | 243 | // Result close buttons 244 | document.querySelectorAll('.result-close').forEach(btn => { 245 | btn.addEventListener('click', (event) => { 246 | const target = event.target as HTMLElement; 247 | const toolName = target.dataset.tool; 248 | if (toolName) { 249 | this.hideToolResult(toolName); 250 | } 251 | }); 252 | }); 253 | } 254 | 255 | /** 256 | * Test a specific tool 257 | */ 258 | private async testTool(toolName: string): Promise<void> { 259 | const tool = this.tools.find(t => t.name === toolName); 260 | if (!tool) return; 261 | 262 | // Collect parameters from form 263 | const params: any = {}; 264 | const properties = tool.inputSchema?.properties || {}; 265 | 266 | for (const [key] of Object.entries(properties)) { 267 | const element = document.getElementById(`${toolName}-${key}`) as HTMLInputElement | HTMLSelectElement; 268 | if (element && element.value) { 269 | params[key] = element.value; 270 | } 271 | } 272 | 273 | try { 274 | console.log(`🔧 Testing tool ${toolName} with params:`, params); 275 | 276 | // Show loading state 277 | this.showToolLoading(toolName); 278 | 279 | // Execute tool 280 | const result = await mcpClient.callTool(toolName as any, params); 281 | 282 | // Record execution 283 | this.recordExecution(toolName, params, result, true); 284 | 285 | // Show result in tool card 286 | this.showToolResult(toolName, result, true); 287 | 288 | // Update UI 289 | this.hideToolLoading(toolName); 290 | this.updateHistoryDisplay(); 291 | 292 | console.log(`✅ Tool ${toolName} executed successfully:`, result); 293 | 294 | } catch (error) { 295 | console.error(`❌ Tool ${toolName} execution failed:`, error); 296 | 297 | // Record failed execution 298 | this.recordExecution(toolName, params, error, false); 299 | 300 | // Show error in tool card 301 | this.showToolResult(toolName, error, false); 302 | 303 | this.hideToolLoading(toolName); 304 | this.updateHistoryDisplay(); 305 | } 306 | } 307 | 308 | /** 309 | * Record tool execution 310 | */ 311 | private recordExecution(tool: string, params: any, result: any, success: boolean): void { 312 | this.executionHistory.push({ 313 | id: Date.now().toString(), 314 | tool, 315 | params, 316 | result, 317 | timestamp: Date.now(), 318 | success, 319 | }); 320 | 321 | // Keep only last 50 executions 322 | if (this.executionHistory.length > 50) { 323 | this.executionHistory = this.executionHistory.slice(-50); 324 | } 325 | } 326 | 327 | /** 328 | * Update history display 329 | */ 330 | private updateHistoryDisplay(): void { 331 | const historyContainer = document.getElementById('history-list'); 332 | if (historyContainer) { 333 | historyContainer.innerHTML = this.renderExecutionHistory(); 334 | } 335 | } 336 | 337 | /** 338 | * Show tool loading state 339 | */ 340 | private showToolLoading(toolName: string): void { 341 | const btn = document.querySelector(`.test-tool-btn[data-tool="${toolName}"]`) as HTMLElement; 342 | if (btn) { 343 | btn.textContent = 'Testing...'; 344 | btn.setAttribute('disabled', 'true'); 345 | } 346 | } 347 | 348 | /** 349 | * Hide tool loading state 350 | */ 351 | private hideToolLoading(toolName: string): void { 352 | const btn = document.querySelector(`.test-tool-btn[data-tool="${toolName}"]`) as HTMLElement; 353 | if (btn) { 354 | btn.textContent = 'Test'; 355 | btn.removeAttribute('disabled'); 356 | } 357 | } 358 | 359 | /** 360 | * Show tool result in the tool card 361 | */ 362 | private showToolResult(toolName: string, result: any, success: boolean): void { 363 | const resultContainer = document.getElementById(`result-${toolName}`); 364 | const resultContent = document.getElementById(`result-content-${toolName}`); 365 | 366 | if (!resultContainer || !resultContent) { 367 | console.error(`❌ Could not find result DOM elements for ${toolName}`); 368 | return; 369 | } 370 | 371 | this.displayResult(resultContainer, resultContent, result, success); 372 | } 373 | 374 | private displayResult(resultContainer: HTMLElement, resultContent: HTMLElement, result: any, success: boolean): void { 375 | if (!success) { 376 | // Handle error case with existing logic 377 | this.displayErrorResult(resultContent, result); 378 | resultContainer.style.display = 'block'; 379 | return; 380 | } 381 | 382 | // Extract actual data from MCP content structure 383 | const actualData = this.extractActualData(result); 384 | 385 | // Try to create a DataCard for visualisable data 386 | const dataCardCreated = this.tryCreateDataCard(resultContainer, resultContent, actualData); 387 | 388 | if (!dataCardCreated) { 389 | // Fall back to traditional JSON display 390 | this.displayTraditionalResult(resultContent, actualData); 391 | } 392 | 393 | // Show the result container 394 | resultContainer.style.display = 'block'; 395 | 396 | // Scroll result into view 397 | resultContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 398 | } 399 | 400 | /** 401 | * Extract actual data from MCP content structure 402 | */ 403 | private extractActualData(result: any): any { 404 | let actualData = result; 405 | 406 | // Check if this is an MCP content response 407 | if (result && result.content && Array.isArray(result.content) && result.content.length > 0) { 408 | const firstContent = result.content[0]; 409 | if (firstContent.type === 'text' && firstContent.text) { 410 | try { 411 | // Try to parse the text as JSON 412 | actualData = JSON.parse(firstContent.text); 413 | } catch { 414 | // If parsing fails, use the text as-is 415 | actualData = firstContent.text; 416 | } 417 | } 418 | } 419 | 420 | return actualData; 421 | } 422 | 423 | /** 424 | * Try to create a DataCard for visualisable data 425 | */ 426 | private tryCreateDataCard(resultContainer: HTMLElement, resultContent: HTMLElement, actualData: any): boolean { 427 | try { 428 | // Detect if the data is visualisable 429 | const detection = detectDataType(actualData); 430 | 431 | if (!detection.visualisable || detection.confidence < 0.6) { 432 | return false; 433 | } 434 | 435 | // Get tool name from container 436 | const toolName = this.getToolNameFromContainer(resultContainer); 437 | if (!toolName) { 438 | return false; 439 | } 440 | 441 | // Clean up any existing DataCard for this tool 442 | const existingCard = this.dataCards.get(toolName); 443 | if (existingCard) { 444 | existingCard.destroy(); 445 | this.dataCards.delete(toolName); 446 | } 447 | 448 | // Create DataCard configuration 449 | const cardConfig: DataCardConfig = { 450 | title: this.generateToolCardTitle(toolName, detection), 451 | summary: detection.summary, 452 | data: actualData, 453 | dataType: detection.dataType, 454 | expanded: true, // Start expanded in tools tab for immediate visibility 455 | showChart: true 456 | }; 457 | 458 | // Create container for DataCard 459 | const cardContainer = document.createElement('div'); 460 | cardContainer.className = 'tool-result-datacard'; 461 | 462 | // Create and store the DataCard 463 | const dataCard = new DataCard(cardContainer, cardConfig); 464 | this.dataCards.set(toolName, dataCard); 465 | 466 | // Add status and actions above the card 467 | resultContent.innerHTML = ` 468 | <div class="result-status result-success"> 469 | ✅ Success - Data Visualisation Available 470 | </div> 471 | <div class="result-actions"> 472 | <button class="copy-result-btn" data-result="${encodeURIComponent(JSON.stringify(actualData, null, 2))}"> 473 | 📋 Copy Raw Data 474 | </button> 475 | <button class="toggle-raw-btn"> 476 | 📊 Show Raw JSON 477 | </button> 478 | </div> 479 | `; 480 | 481 | // Append the DataCard 482 | resultContent.appendChild(cardContainer); 483 | 484 | // Add toggle functionality for raw data 485 | this.setupDataCardActions(resultContent, actualData); 486 | 487 | return true; 488 | } catch (error) { 489 | console.warn('Failed to create DataCard for tool result:', error); 490 | return false; 491 | } 492 | } 493 | 494 | /** 495 | * Set up actions for DataCard (copy, toggle raw data) 496 | */ 497 | private setupDataCardActions(resultContent: HTMLElement, actualData: any): void { 498 | const copyBtn = resultContent.querySelector('.copy-result-btn') as HTMLElement; 499 | const toggleBtn = resultContent.querySelector('.toggle-raw-btn') as HTMLElement; 500 | 501 | if (copyBtn) { 502 | copyBtn.addEventListener('click', () => { 503 | const resultData = decodeURIComponent(copyBtn.dataset.result || ''); 504 | navigator.clipboard.writeText(resultData).then(() => { 505 | copyBtn.textContent = '✅ Copied!'; 506 | setTimeout(() => { 507 | copyBtn.textContent = '📋 Copy Raw Data'; 508 | }, 2000); 509 | }).catch(() => { 510 | copyBtn.textContent = '❌ Failed'; 511 | setTimeout(() => { 512 | copyBtn.textContent = '📋 Copy Raw Data'; 513 | }, 2000); 514 | }); 515 | }); 516 | } 517 | 518 | if (toggleBtn) { 519 | let showingRaw = false; 520 | toggleBtn.addEventListener('click', () => { 521 | const cardContainer = resultContent.querySelector('.tool-result-datacard') as HTMLElement; 522 | if (!cardContainer) return; 523 | 524 | if (showingRaw) { 525 | // Show DataCard 526 | cardContainer.style.display = 'block'; 527 | const rawDataDiv = resultContent.querySelector('.raw-data-display'); 528 | if (rawDataDiv) rawDataDiv.remove(); 529 | toggleBtn.textContent = '📊 Show Raw JSON'; 530 | showingRaw = false; 531 | } else { 532 | // Show raw JSON 533 | cardContainer.style.display = 'none'; 534 | const rawDataDiv = document.createElement('div'); 535 | rawDataDiv.className = 'raw-data-display'; 536 | rawDataDiv.innerHTML = `<pre class="result-data">${JSON.stringify(actualData, null, 2)}</pre>`; 537 | resultContent.appendChild(rawDataDiv); 538 | toggleBtn.textContent = '🎴 Show DataCard'; 539 | showingRaw = true; 540 | } 541 | }); 542 | } 543 | } 544 | 545 | /** 546 | * Display traditional JSON result (fallback) 547 | */ 548 | private displayTraditionalResult(resultContent: HTMLElement, actualData: any): void { 549 | let formattedResult: string; 550 | 551 | if (typeof actualData === 'object') { 552 | formattedResult = JSON.stringify(actualData, null, 2); 553 | } else { 554 | formattedResult = String(actualData); 555 | } 556 | 557 | resultContent.innerHTML = ` 558 | <div class="result-status result-success"> 559 | ✅ Success 560 | </div> 561 | <pre class="result-data">${formattedResult}</pre> 562 | <div class="result-actions"> 563 | <button class="copy-result-btn" data-result="${encodeURIComponent(formattedResult)}"> 564 | 📋 Copy 565 | </button> 566 | </div> 567 | `; 568 | 569 | // Add copy functionality 570 | const copyBtn = resultContent.querySelector('.copy-result-btn') as HTMLElement; 571 | if (copyBtn) { 572 | copyBtn.addEventListener('click', () => { 573 | const resultData = decodeURIComponent(copyBtn.dataset.result || ''); 574 | navigator.clipboard.writeText(resultData).then(() => { 575 | copyBtn.textContent = '✅ Copied!'; 576 | setTimeout(() => { 577 | copyBtn.textContent = '📋 Copy'; 578 | }, 2000); 579 | }).catch(() => { 580 | copyBtn.textContent = '❌ Failed'; 581 | setTimeout(() => { 582 | copyBtn.textContent = '📋 Copy'; 583 | }, 2000); 584 | }); 585 | }); 586 | } 587 | } 588 | 589 | /** 590 | * Display error result 591 | */ 592 | private displayErrorResult(resultContent: HTMLElement, result: any): void { 593 | let formattedResult: string; 594 | 595 | if (result instanceof Error) { 596 | formattedResult = `Error: ${result.message}`; 597 | } else { 598 | formattedResult = `Error: ${String(result)}`; 599 | } 600 | 601 | resultContent.innerHTML = ` 602 | <div class="result-status result-error"> 603 | ❌ Error 604 | </div> 605 | <pre class="result-data">${formattedResult}</pre> 606 | `; 607 | } 608 | 609 | /** 610 | * Get tool name from result container 611 | */ 612 | private getToolNameFromContainer(resultContainer: HTMLElement): string | null { 613 | const id = resultContainer.id; 614 | if (id && id.startsWith('result-')) { 615 | return id.substring(7); // Remove 'result-' prefix 616 | } 617 | return null; 618 | } 619 | 620 | /** 621 | * Generate appropriate title for tool DataCard 622 | */ 623 | private generateToolCardTitle(toolName: string, _detection: any): string { 624 | const toolDisplayNames: Record<string, string> = { 625 | 'get_ticker': 'Ticker Data', 626 | 'get_kline_data': 'Kline Data', 627 | 'get_ml_rsi': 'ML-RSI Analysis', 628 | 'get_order_blocks': 'Order Blocks', 629 | 'get_market_structure': 'Market Structure' 630 | }; 631 | 632 | return toolDisplayNames[toolName] || toolName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); 633 | } 634 | 635 | /** 636 | * Hide tool result 637 | */ 638 | private hideToolResult(toolName: string): void { 639 | const resultContainer = document.getElementById(`result-${toolName}`); 640 | if (resultContainer) { 641 | resultContainer.style.display = 'none'; 642 | } 643 | 644 | // Clean up associated DataCard 645 | const dataCard = this.dataCards.get(toolName); 646 | if (dataCard) { 647 | dataCard.destroy(); 648 | this.dataCards.delete(toolName); 649 | } 650 | } 651 | 652 | /** 653 | * Refresh tools 654 | */ 655 | private async refreshTools(): Promise<void> { 656 | console.log('🔄 Refreshing tools...'); 657 | await this.loadTools(); 658 | this.renderToolsInterface(); 659 | } 660 | 661 | /** 662 | * Clear execution history 663 | */ 664 | private clearHistory(): void { 665 | this.executionHistory = []; 666 | this.updateHistoryDisplay(); 667 | } 668 | 669 | /** 670 | * Show error message 671 | */ 672 | private showError(message: string): void { 673 | const container = document.getElementById('tools-grid'); 674 | if (container) { 675 | container.innerHTML = ` 676 | <div class="tools-error"> 677 | <h3>❌ Error</h3> 678 | <p>${message}</p> 679 | <button onclick="location.reload()">Retry</button> 680 | </div> 681 | `; 682 | } 683 | } 684 | 685 | /** 686 | * Get current state 687 | */ 688 | getState(): { tools: MCPTool[]; history: any[] } { 689 | return { 690 | tools: [...this.tools], 691 | history: [...this.executionHistory], 692 | }; 693 | } 694 | 695 | /** 696 | * Destroy tools manager 697 | */ 698 | destroy(): void { 699 | // Clean up all DataCards 700 | this.dataCards.forEach(card => card.destroy()); 701 | this.dataCards.clear(); 702 | 703 | this.isInitialized = false; 704 | console.log('🗑️ Tools Manager destroyed'); 705 | } 706 | } 707 | 708 | // Create singleton instance 709 | export const toolsManager = new ToolsManager(); 710 | ``` -------------------------------------------------------------------------------- /webui/src/main.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Main application entry point 3 | */ 4 | 5 | console.log('🚀 Main.ts loading...'); 6 | 7 | import './styles/main.css'; 8 | 9 | import { ChatApp } from './components/ChatApp'; 10 | import { DebugConsole } from './components/DebugConsole'; 11 | import { DataVerificationPanel } from './components/DataVerificationPanel'; 12 | import { AgentDashboard } from './components/AgentDashboard'; 13 | import { toolsManager } from './components/ToolsManager'; 14 | import { configService } from './services/configService'; 15 | import { agentConfigService } from './services/agentConfig'; 16 | import { mcpClient } from './services/mcpClient'; 17 | import { aiClient } from './services/aiClient'; 18 | import { multiStepAgent } from './services/multiStepAgent'; 19 | // Import logService to initialize console interception 20 | import './services/logService'; 21 | 22 | class App { 23 | private chatApp?: ChatApp; 24 | private debugConsole?: DebugConsole; 25 | private verificationPanel?: DataVerificationPanel; 26 | private agentDashboard?: AgentDashboard; 27 | private isInitialized = false; 28 | private toolsInitialized = false; 29 | 30 | async initialize(): Promise<void> { 31 | if (this.isInitialized) return; 32 | 33 | try { 34 | // Show loading state 35 | this.showLoading(); 36 | 37 | // Initialize services 38 | await this.initializeServices(); 39 | 40 | // Initialize UI components 41 | this.initializeUI(); 42 | 43 | // Initialize debug console 44 | this.initializeDebugConsole(); 45 | 46 | // Initialize data verification panel 47 | this.initializeVerificationPanel(); 48 | 49 | // Initialize agent dashboard 50 | this.initializeAgentDashboard(); 51 | 52 | // Hide loading and show main app 53 | this.hideLoading(); 54 | 55 | this.isInitialized = true; 56 | console.log('✅ Bybit MCP WebUI initialized successfully'); 57 | } catch (error) { 58 | console.error('❌ Failed to initialize application:', error); 59 | this.showError('Failed to initialize application. Please check your configuration.'); 60 | } 61 | } 62 | 63 | private async initializeServices(): Promise<void> { 64 | console.log('🚀 Initializing services...'); 65 | 66 | // Get current configuration 67 | const aiConfig = configService.getAIConfig(); 68 | const mcpConfig = configService.getMCPConfig(); 69 | 70 | console.log('⚙️ AI Config:', { 71 | endpoint: aiConfig.endpoint, 72 | model: aiConfig.model, 73 | temperature: aiConfig.temperature, 74 | maxTokens: aiConfig.maxTokens 75 | }); 76 | console.log('⚙️ MCP Config:', mcpConfig); 77 | 78 | // Note: MCP server should be started automatically with 'pnpm dev:full' 79 | console.log('💡 If MCP server is not running, use "pnpm dev:full" to start both services'); 80 | 81 | // Update clients with current config 82 | aiClient.updateConfig(aiConfig); 83 | mcpClient.setBaseUrl(mcpConfig.endpoint); 84 | mcpClient.setTimeout(mcpConfig.timeout); 85 | 86 | // Test connections 87 | console.log('🔄 Testing connections...'); 88 | const [aiConnected, mcpConnected] = await Promise.allSettled([ 89 | aiClient.isConnected(), 90 | mcpClient.isConnected(), 91 | ]); 92 | 93 | console.log('📊 Connection results:', { 94 | ai: aiConnected.status === 'fulfilled' ? aiConnected.value : aiConnected.reason, 95 | mcp: mcpConnected.status === 'fulfilled' ? mcpConnected.value : mcpConnected.reason 96 | }); 97 | 98 | // Initialize MCP client (fetch available tools) 99 | if (mcpConnected.status === 'fulfilled' && mcpConnected.value) { 100 | try { 101 | await mcpClient.initialize(); 102 | console.log('✅ MCP client initialized'); 103 | } catch (error) { 104 | console.warn('⚠️ MCP client initialization failed:', error); 105 | } 106 | } else { 107 | console.warn('⚠️ MCP server not reachable'); 108 | } 109 | 110 | // Log connection status 111 | if (aiConnected.status === 'fulfilled' && aiConnected.value) { 112 | console.log('✅ AI service connected'); 113 | } else { 114 | console.warn('⚠️ AI service not reachable'); 115 | } 116 | 117 | // Initialize multi-step agent 118 | try { 119 | console.log('🤖 Initializing multi-step agent...'); 120 | await multiStepAgent.initialize(); 121 | console.log('✅ Multi-step agent initialized'); 122 | } catch (error) { 123 | console.warn('⚠️ Multi-step agent initialization failed:', error); 124 | console.log('💡 Falling back to legacy AI client'); 125 | } 126 | 127 | console.log('✅ Service initialization complete'); 128 | } 129 | 130 | private initializeUI(): void { 131 | // Initialize chat application 132 | this.chatApp = new ChatApp(); 133 | 134 | // Set up global event listeners 135 | this.setupGlobalEventListeners(); 136 | 137 | // Set up theme toggle 138 | this.setupThemeToggle(); 139 | 140 | // Set up settings modal 141 | this.setupSettingsModal(); 142 | } 143 | 144 | private setupGlobalEventListeners(): void { 145 | // Handle keyboard shortcuts 146 | document.addEventListener('keydown', (event) => { 147 | // Ctrl/Cmd + K to focus chat input 148 | if ((event.ctrlKey || event.metaKey) && event.key === 'k') { 149 | event.preventDefault(); 150 | const chatInput = document.getElementById('chat-input') as HTMLTextAreaElement; 151 | if (chatInput) { 152 | chatInput.focus(); 153 | } 154 | } 155 | 156 | // Escape to close modals 157 | if (event.key === 'Escape') { 158 | this.closeAllModals(); 159 | } 160 | }); 161 | 162 | // Handle navigation 163 | document.querySelectorAll('.nav-item').forEach(item => { 164 | item.addEventListener('click', (event) => { 165 | const target = event.currentTarget as HTMLElement; 166 | const view = target.dataset.view; 167 | if (view) { 168 | this.switchView(view); 169 | } 170 | }); 171 | }); 172 | 173 | // Handle example queries 174 | document.querySelectorAll('.example-query').forEach(button => { 175 | button.addEventListener('click', (event) => { 176 | const target = event.currentTarget as HTMLElement; 177 | const query = target.textContent?.trim(); 178 | if (query && this.chatApp) { 179 | this.chatApp.sendMessage(query); 180 | } 181 | }); 182 | }); 183 | 184 | // Agent settings button removed - now integrated into main settings modal 185 | 186 | // Handle agent mode toggle 187 | const agentToggleBtn = document.getElementById('agent-toggle-btn'); 188 | if (agentToggleBtn && this.chatApp) { 189 | agentToggleBtn.addEventListener('click', () => { 190 | const isUsingAgent = this.chatApp!.isUsingAgent(); 191 | this.chatApp!.toggleAgentMode(!isUsingAgent); 192 | agentToggleBtn.textContent = !isUsingAgent ? '🤖 Agent Mode' : '🔄 Legacy Mode'; 193 | }); 194 | } 195 | } 196 | 197 | private setupThemeToggle(): void { 198 | const themeToggle = document.getElementById('theme-toggle'); 199 | if (themeToggle) { 200 | themeToggle.addEventListener('click', () => { 201 | const settings = configService.getSettings(); 202 | const currentTheme = settings.ui.theme; 203 | 204 | let newTheme: 'light' | 'dark' | 'auto'; 205 | let icon: string; 206 | 207 | if (currentTheme === 'light') { 208 | newTheme = 'dark'; 209 | icon = '☀️'; 210 | } else if (currentTheme === 'dark') { 211 | newTheme = 'auto'; 212 | icon = '🌓'; 213 | } else { 214 | newTheme = 'light'; 215 | icon = '🌙'; 216 | } 217 | 218 | configService.updateSettings({ 219 | ui: { ...settings.ui, theme: newTheme }, 220 | }); 221 | 222 | // Update icon 223 | const iconElement = themeToggle.querySelector('.theme-icon'); 224 | if (iconElement) { 225 | iconElement.textContent = icon; 226 | } 227 | }); 228 | } 229 | } 230 | 231 | private setupAgentDashboardButton(): void { 232 | const agentDashboardBtn = document.getElementById('agent-dashboard-btn'); 233 | if (agentDashboardBtn && this.agentDashboard) { 234 | agentDashboardBtn.addEventListener('click', () => { 235 | this.agentDashboard!.toggleVisibility(); 236 | 237 | // Update button appearance based on dashboard visibility 238 | const isVisible = this.agentDashboard!.visible; 239 | if (isVisible) { 240 | agentDashboardBtn.classList.add('active'); 241 | } else { 242 | agentDashboardBtn.classList.remove('active'); 243 | } 244 | }); 245 | } 246 | } 247 | 248 | private setupSettingsModal(): void { 249 | const settingsBtn = document.getElementById('settings-btn'); 250 | const settingsModal = document.getElementById('settings-modal'); 251 | const closeSettings = document.getElementById('close-settings'); 252 | const saveSettings = document.getElementById('save-settings'); 253 | 254 | if (settingsBtn && settingsModal) { 255 | settingsBtn.addEventListener('click', () => { 256 | this.openSettingsModal(); 257 | }); 258 | } 259 | 260 | if (closeSettings && settingsModal) { 261 | closeSettings.addEventListener('click', () => { 262 | settingsModal.classList.add('hidden'); 263 | settingsModal.classList.remove('active'); 264 | }); 265 | } 266 | 267 | if (saveSettings) { 268 | saveSettings.addEventListener('click', () => { 269 | this.saveSettingsFromModal(); 270 | }); 271 | } 272 | 273 | // Close modal when clicking backdrop 274 | if (settingsModal) { 275 | settingsModal.addEventListener('click', (event) => { 276 | if (event.target === settingsModal) { 277 | settingsModal.classList.add('hidden'); 278 | settingsModal.classList.remove('active'); 279 | } 280 | }); 281 | } 282 | } 283 | 284 | private initializeDebugConsole(): void { 285 | // Create debug console container 286 | const debugContainer = document.createElement('div'); 287 | debugContainer.id = 'debug-console-container'; 288 | document.body.appendChild(debugContainer); 289 | 290 | // Initialize debug console 291 | this.debugConsole = new DebugConsole(debugContainer); 292 | 293 | // Add keyboard shortcut to toggle debug console (Ctrl+` or Cmd+`) 294 | document.addEventListener('keydown', (e) => { 295 | if ((e.ctrlKey || e.metaKey) && e.key === '`') { 296 | e.preventDefault(); 297 | this.debugConsole?.toggle(); 298 | } 299 | }); 300 | 301 | console.log('🔍 Debug console initialized (Ctrl+` to toggle)'); 302 | } 303 | 304 | private initializeVerificationPanel(): void { 305 | try { 306 | // Initialize data verification panel 307 | this.verificationPanel = new DataVerificationPanel('verification-panel-container'); 308 | console.log('📊 Data verification panel initialized (Ctrl+D to toggle)'); 309 | 310 | // Make panel accessible for debugging 311 | (window as any).verificationPanel = this.verificationPanel; 312 | } catch (error) { 313 | console.warn('⚠️ Failed to initialize verification panel:', error); 314 | } 315 | } 316 | 317 | private initializeAgentDashboard(): void { 318 | try { 319 | // Initialize agent dashboard with ChatApp reference 320 | this.agentDashboard = new AgentDashboard('agent-dashboard-container', this.chatApp); 321 | console.log('🤖 Agent dashboard initialized (Ctrl+M to toggle)'); 322 | 323 | // Set up agent dashboard button now that dashboard is initialized 324 | this.setupAgentDashboardButton(); 325 | 326 | // Make dashboard accessible for debugging 327 | (window as any).agentDashboard = this.agentDashboard; 328 | } catch (error) { 329 | console.warn('⚠️ Failed to initialize agent dashboard:', error); 330 | } 331 | } 332 | 333 | private openSettingsModal(): void { 334 | const modal = document.getElementById('settings-modal'); 335 | if (!modal) return; 336 | 337 | // Populate current settings 338 | const settings = configService.getSettings(); 339 | const agentConfig = agentConfigService.getConfig(); 340 | console.log('🔧 Opening settings modal with current settings:', settings, agentConfig); 341 | 342 | // AI Configuration 343 | const aiEndpoint = document.getElementById('ai-endpoint') as HTMLInputElement; 344 | const aiModel = document.getElementById('ai-model') as HTMLInputElement; 345 | const mcpEndpoint = document.getElementById('mcp-endpoint') as HTMLInputElement; 346 | 347 | if (aiEndpoint) { 348 | aiEndpoint.value = settings.ai.endpoint; 349 | console.log('📝 Set AI endpoint field to:', settings.ai.endpoint); 350 | } 351 | if (aiModel) { 352 | aiModel.value = settings.ai.model; 353 | console.log('📝 Set AI model field to:', settings.ai.model); 354 | } 355 | if (mcpEndpoint) { 356 | mcpEndpoint.value = settings.mcp.endpoint; 357 | console.log('📝 Set MCP endpoint field to:', settings.mcp.endpoint); 358 | } 359 | 360 | // Agent Configuration 361 | const agentModeEnabled = document.getElementById('agent-mode-enabled') as HTMLInputElement; 362 | const maxIterations = document.getElementById('max-iterations') as HTMLInputElement; 363 | const toolTimeout = document.getElementById('tool-timeout') as HTMLInputElement; 364 | const showWorkflowSteps = document.getElementById('show-workflow-steps') as HTMLInputElement; 365 | const showToolCalls = document.getElementById('show-tool-calls') as HTMLInputElement; 366 | const enableDebugMode = document.getElementById('enable-debug-mode') as HTMLInputElement; 367 | 368 | if (agentModeEnabled) { 369 | agentModeEnabled.checked = this.chatApp?.isAgentModeEnabled() || false; 370 | } 371 | if (maxIterations) { 372 | maxIterations.value = agentConfig.maxIterations.toString(); 373 | } 374 | if (toolTimeout) { 375 | toolTimeout.value = agentConfig.toolTimeout.toString(); 376 | } 377 | if (showWorkflowSteps) { 378 | showWorkflowSteps.checked = agentConfig.showWorkflowSteps; 379 | } 380 | if (showToolCalls) { 381 | showToolCalls.checked = agentConfig.showToolCalls; 382 | } 383 | if (enableDebugMode) { 384 | enableDebugMode.checked = agentConfig.enableDebugMode; 385 | } 386 | 387 | modal.classList.remove('hidden'); 388 | modal.classList.add('active'); 389 | } 390 | 391 | private saveSettingsFromModal(): void { 392 | const aiEndpoint = document.getElementById('ai-endpoint') as HTMLInputElement; 393 | const aiModel = document.getElementById('ai-model') as HTMLInputElement; 394 | const mcpEndpoint = document.getElementById('mcp-endpoint') as HTMLInputElement; 395 | 396 | // Agent Configuration elements 397 | const agentModeEnabled = document.getElementById('agent-mode-enabled') as HTMLInputElement; 398 | const maxIterations = document.getElementById('max-iterations') as HTMLInputElement; 399 | const toolTimeout = document.getElementById('tool-timeout') as HTMLInputElement; 400 | const showWorkflowSteps = document.getElementById('show-workflow-steps') as HTMLInputElement; 401 | const showToolCalls = document.getElementById('show-tool-calls') as HTMLInputElement; 402 | const enableDebugMode = document.getElementById('enable-debug-mode') as HTMLInputElement; 403 | 404 | console.log('💾 Saving settings from modal...'); 405 | console.log('AI Endpoint:', aiEndpoint?.value); 406 | console.log('AI Model:', aiModel?.value); 407 | console.log('MCP Endpoint:', mcpEndpoint?.value); 408 | console.log('Agent Mode:', agentModeEnabled?.checked); 409 | 410 | const currentSettings = configService.getSettings(); 411 | const updates: Partial<typeof currentSettings> = {}; 412 | 413 | // Build AI config updates 414 | const aiUpdates: Partial<typeof currentSettings.ai> = {}; 415 | let hasAIUpdates = false; 416 | 417 | if (aiEndpoint?.value && aiEndpoint.value.trim() !== '') { 418 | aiUpdates.endpoint = aiEndpoint.value.trim(); 419 | hasAIUpdates = true; 420 | } 421 | 422 | if (aiModel?.value && aiModel.value.trim() !== '') { 423 | aiUpdates.model = aiModel.value.trim(); 424 | hasAIUpdates = true; 425 | } 426 | 427 | if (hasAIUpdates) { 428 | updates.ai = { ...currentSettings.ai, ...aiUpdates }; 429 | } 430 | 431 | // Build MCP config updates 432 | if (mcpEndpoint?.value && mcpEndpoint.value.trim() !== '') { 433 | updates.mcp = { ...currentSettings.mcp, endpoint: mcpEndpoint.value.trim() }; 434 | } 435 | 436 | console.log('📝 Settings updates:', updates); 437 | 438 | if (Object.keys(updates).length > 0) { 439 | configService.updateSettings(updates); 440 | console.log('✅ Settings saved successfully'); 441 | 442 | // Reinitialize services with new config 443 | this.initializeServices().catch(console.error); 444 | } else { 445 | console.log('ℹ️ No settings changes to save'); 446 | } 447 | 448 | // Save agent configuration 449 | const agentConfig = { 450 | maxIterations: parseInt(maxIterations?.value || '5'), 451 | toolTimeout: parseInt(toolTimeout?.value || '30000'), 452 | showWorkflowSteps: showWorkflowSteps?.checked || false, 453 | showToolCalls: showToolCalls?.checked || false, 454 | enableDebugMode: enableDebugMode?.checked || false, 455 | streamingEnabled: true // Always enabled 456 | }; 457 | 458 | console.log('🤖 Saving agent config:', agentConfig); 459 | agentConfigService.updateConfig(agentConfig); 460 | 461 | // Update agent mode in chat app 462 | if (this.chatApp && agentModeEnabled) { 463 | this.chatApp.toggleAgentMode(agentModeEnabled.checked); 464 | } 465 | 466 | // Close modal 467 | const modal = document.getElementById('settings-modal'); 468 | if (modal) { 469 | modal.classList.add('hidden'); 470 | modal.classList.remove('active'); 471 | } 472 | } 473 | 474 | private switchView(viewName: string): void { 475 | // Update navigation 476 | document.querySelectorAll('.nav-item').forEach(item => { 477 | item.classList.remove('active'); 478 | }); 479 | 480 | const activeNavItem = document.querySelector(`[data-view="${viewName}"]`); 481 | if (activeNavItem) { 482 | activeNavItem.classList.add('active'); 483 | } 484 | 485 | // Update views 486 | document.querySelectorAll('.view').forEach(view => { 487 | view.classList.remove('active'); 488 | }); 489 | 490 | const activeView = document.getElementById(`${viewName}-view`); 491 | if (activeView) { 492 | activeView.classList.add('active'); 493 | } 494 | 495 | // Initialize components when their views are accessed 496 | if (viewName === 'tools' && !this.toolsInitialized) { 497 | this.initializeTools(); 498 | } 499 | 500 | // Handle dashboard view - embed agent dashboard into the tab 501 | if (viewName === 'dashboard' && this.agentDashboard) { 502 | this.embedDashboardInTab(); 503 | } 504 | } 505 | 506 | /** 507 | * Initialize tools when tools tab is first accessed 508 | */ 509 | private async initializeTools(): Promise<void> { 510 | if (this.toolsInitialized) return; 511 | 512 | try { 513 | console.log('🔧 Initializing tools...'); 514 | await toolsManager.initialize(); 515 | this.toolsInitialized = true; 516 | console.log('✅ Tools initialized successfully'); 517 | } catch (error) { 518 | console.error('❌ Failed to initialize tools:', error); 519 | } 520 | } 521 | 522 | /** 523 | * Embed agent dashboard into the dashboard tab view 524 | */ 525 | private embedDashboardInTab(): void { 526 | if (!this.agentDashboard) return; 527 | 528 | const dashboardWrapper = document.getElementById('dashboard-content-wrapper'); 529 | const agentDashboardContainer = document.getElementById('agent-dashboard-container'); 530 | 531 | if (dashboardWrapper && agentDashboardContainer) { 532 | // Check if dashboard content already exists in the tab 533 | if (dashboardWrapper.querySelector('.agent-dashboard')) { 534 | return; // Already embedded 535 | } 536 | 537 | // Get the dashboard content from the original container 538 | const dashboardContent = agentDashboardContainer.querySelector('.agent-dashboard'); 539 | 540 | if (dashboardContent) { 541 | // Clone the dashboard content for the tab view 542 | const clonedContent = dashboardContent.cloneNode(true) as HTMLElement; 543 | 544 | // Remove overlay-specific classes and styles 545 | clonedContent.classList.remove('hidden'); 546 | clonedContent.classList.add('visible'); 547 | clonedContent.style.position = 'static'; 548 | clonedContent.style.zIndex = 'auto'; 549 | clonedContent.style.background = 'transparent'; 550 | clonedContent.style.boxShadow = 'none'; 551 | clonedContent.style.border = 'none'; 552 | clonedContent.style.borderRadius = '0'; 553 | clonedContent.style.width = '100%'; 554 | clonedContent.style.height = '100%'; 555 | clonedContent.style.maxWidth = 'none'; 556 | clonedContent.style.maxHeight = 'none'; 557 | clonedContent.style.transform = 'none'; 558 | clonedContent.style.top = 'auto'; 559 | clonedContent.style.left = 'auto'; 560 | clonedContent.style.right = 'auto'; 561 | clonedContent.style.bottom = 'auto'; 562 | 563 | // Add the cloned content to the tab view 564 | dashboardWrapper.innerHTML = ''; 565 | dashboardWrapper.appendChild(clonedContent); 566 | 567 | // Set up event listeners for the cloned content 568 | this.setupTabDashboardEventListeners(clonedContent); 569 | 570 | // Debug: Check what data is available 571 | console.log('🔍 Dashboard data check:'); 572 | console.log('Memory stats:', multiStepAgent.getMemoryStats()); 573 | console.log('Performance stats:', multiStepAgent.getPerformanceStats()); 574 | console.log('Analysis history:', multiStepAgent.getAnalysisHistory(undefined, 5)); 575 | 576 | // Refresh the dashboard data 577 | this.agentDashboard.show(); // This will trigger a refresh 578 | this.agentDashboard.hide(); // Hide the overlay version 579 | } 580 | } 581 | } 582 | 583 | /** 584 | * Set up event listeners for the dashboard in tab view 585 | */ 586 | private setupTabDashboardEventListeners(dashboardElement: HTMLElement): void { 587 | // Refresh button 588 | const refreshBtn = dashboardElement.querySelector('#dashboard-refresh') as HTMLButtonElement; 589 | refreshBtn?.addEventListener('click', () => { 590 | if (this.agentDashboard) { 591 | // Trigger refresh and then update the tab view 592 | this.agentDashboard.show(); 593 | this.agentDashboard.hide(); 594 | setTimeout(() => this.embedDashboardInTab(), 100); 595 | } 596 | }); 597 | 598 | // Clear memory button 599 | const clearMemoryBtn = dashboardElement.querySelector('#clear-memory') as HTMLButtonElement; 600 | clearMemoryBtn?.addEventListener('click', () => { 601 | if (confirm('Are you sure you want to clear all agent memory? This action cannot be undone.')) { 602 | multiStepAgent.clearMemory(); 603 | // Refresh the tab view 604 | setTimeout(() => this.embedDashboardInTab(), 100); 605 | this.showToast('Memory cleared successfully!'); 606 | } 607 | }); 608 | 609 | // New conversation button 610 | const newConversationBtn = dashboardElement.querySelector('#new-conversation') as HTMLButtonElement; 611 | newConversationBtn?.addEventListener('click', () => { 612 | // Clear agent memory 613 | multiStepAgent.startNewConversation(); 614 | 615 | // Clear chat UI if available 616 | if (this.chatApp) { 617 | this.chatApp.clearMessages(); 618 | } 619 | 620 | this.showToast('New conversation started!'); 621 | }); 622 | 623 | // Export data button 624 | const exportDataBtn = dashboardElement.querySelector('#export-data') as HTMLButtonElement; 625 | exportDataBtn?.addEventListener('click', () => { 626 | try { 627 | const data = { 628 | memoryStats: multiStepAgent.getMemoryStats(), 629 | performanceStats: multiStepAgent.getPerformanceStats(), 630 | recentAnalysis: multiStepAgent.getAnalysisHistory(undefined, 20), 631 | exportedAt: new Date().toISOString() 632 | }; 633 | 634 | const dataStr = JSON.stringify(data, null, 2); 635 | const blob = new Blob([dataStr], { type: 'application/json' }); 636 | const url = URL.createObjectURL(blob); 637 | 638 | const a = document.createElement('a'); 639 | a.href = url; 640 | a.download = `agent-data-${new Date().toISOString().split('T')[0]}.json`; 641 | document.body.appendChild(a); 642 | a.click(); 643 | document.body.removeChild(a); 644 | URL.revokeObjectURL(url); 645 | 646 | this.showToast('Data exported successfully!'); 647 | 648 | } catch (error) { 649 | console.error('Failed to export data:', error); 650 | this.showToast('Failed to export data', 'error'); 651 | } 652 | }); 653 | } 654 | 655 | private showToast(message: string, type: 'success' | 'error' = 'success'): void { 656 | const toast = document.createElement('div'); 657 | toast.className = `dashboard-toast toast-${type}`; 658 | toast.textContent = message; 659 | 660 | document.body.appendChild(toast); 661 | 662 | // Animate in 663 | setTimeout(() => toast.classList.add('show'), 10); 664 | 665 | // Remove after 3 seconds 666 | setTimeout(() => { 667 | toast.classList.remove('show'); 668 | setTimeout(() => toast.remove(), 300); 669 | }, 3000); 670 | } 671 | 672 | private closeAllModals(): void { 673 | document.querySelectorAll('.modal').forEach(modal => { 674 | modal.classList.add('hidden'); 675 | modal.classList.remove('active'); 676 | }); 677 | } 678 | 679 | private showLoading(): void { 680 | const loading = document.getElementById('loading'); 681 | const mainContainer = document.getElementById('main-container'); 682 | 683 | if (loading) loading.classList.remove('hidden'); 684 | if (mainContainer) mainContainer.classList.add('hidden'); 685 | } 686 | 687 | private hideLoading(): void { 688 | const loading = document.getElementById('loading'); 689 | const mainContainer = document.getElementById('main-container'); 690 | 691 | if (loading) loading.classList.add('hidden'); 692 | if (mainContainer) mainContainer.classList.remove('hidden'); 693 | } 694 | 695 | private showError(message: string): void { 696 | const loading = document.getElementById('loading'); 697 | if (loading) { 698 | loading.innerHTML = ` 699 | <div class="loading-container"> 700 | <div style="color: var(--color-danger); text-align: center;"> 701 | <h2>❌ Error</h2> 702 | <p>${message}</p> 703 | <button onclick="location.reload()" style=" 704 | margin-top: 1rem; 705 | padding: 0.5rem 1rem; 706 | background: var(--color-primary); 707 | color: white; 708 | border: none; 709 | border-radius: 0.5rem; 710 | cursor: pointer; 711 | ">Reload Page</button> 712 | </div> 713 | </div> 714 | `; 715 | } 716 | } 717 | } 718 | 719 | // Initialize application when DOM is ready 720 | document.addEventListener('DOMContentLoaded', () => { 721 | const app = new App(); 722 | app.initialize().catch(console.error); 723 | }); 724 | 725 | // Handle unhandled errors 726 | window.addEventListener('error', (event) => { 727 | console.error('Unhandled error:', event.error); 728 | }); 729 | 730 | window.addEventListener('unhandledrejection', (event) => { 731 | console.error('Unhandled promise rejection:', event.reason); 732 | }); 733 | ```