This is page 2 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 -------------------------------------------------------------------------------- /webui/src/types/mcp.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * TypeScript types for MCP (Model Context Protocol) server integration 3 | */ 4 | 5 | // Base MCP types 6 | export interface MCPRequest { 7 | jsonrpc: '2.0'; 8 | id: string | number; 9 | method: string; 10 | params?: Record<string, unknown>; 11 | } 12 | 13 | export interface MCPResponse<T = unknown> { 14 | jsonrpc: '2.0'; 15 | id: string | number; 16 | result?: T; 17 | error?: MCPError; 18 | } 19 | 20 | export interface MCPError { 21 | code: number; 22 | message: string; 23 | data?: unknown; 24 | } 25 | 26 | // Tool definitions 27 | export interface MCPTool { 28 | name: string; 29 | description: string; 30 | inputSchema: { 31 | type: 'object'; 32 | properties?: Record<string, unknown>; 33 | required?: string[]; 34 | }; 35 | } 36 | 37 | export interface MCPToolCall { 38 | name: string; 39 | arguments: Record<string, unknown>; 40 | } 41 | 42 | export interface MCPToolResult { 43 | content: Array<{ 44 | type: 'text'; 45 | text: string; 46 | }>; 47 | isError?: boolean; 48 | } 49 | 50 | // Bybit-specific types 51 | export interface BybitCategory { 52 | spot: 'spot'; 53 | linear: 'linear'; 54 | inverse: 'inverse'; 55 | option: 'option'; 56 | } 57 | 58 | export interface TickerData { 59 | symbol: string; 60 | category: string; 61 | lastPrice: string; 62 | price24hPcnt: string; 63 | highPrice24h: string; 64 | lowPrice24h: string; 65 | prevPrice24h: string; 66 | volume24h: string; 67 | turnover24h: string; 68 | bid1Price: string; 69 | bid1Size: string; 70 | ask1Price: string; 71 | ask1Size: string; 72 | usdIndexPrice?: string; 73 | timestamp: string; 74 | } 75 | 76 | export interface KlineData { 77 | symbol: string; 78 | category: string; 79 | interval: string; 80 | data: Array<{ 81 | startTime: number; 82 | openPrice: string; 83 | highPrice: string; 84 | lowPrice: string; 85 | closePrice: string; 86 | volume: string; 87 | turnover: string; 88 | }>; 89 | } 90 | 91 | export interface OrderbookData { 92 | symbol: string; 93 | category: string; 94 | bids: Array<[string, string]>; // [price, size] 95 | asks: Array<[string, string]>; // [price, size] 96 | timestamp: string; 97 | } 98 | 99 | // Advanced analysis types 100 | export interface MLRSIData { 101 | symbol: string; 102 | interval: string; 103 | data: Array<{ 104 | timestamp: number; 105 | standardRsi: number; 106 | mlRsi: number; 107 | adaptiveOverbought: number; 108 | adaptiveOversold: number; 109 | knnDivergence: number; 110 | effectiveNeighbors: number; 111 | trend: 'bullish' | 'bearish' | 'neutral'; 112 | }>; 113 | metadata: { 114 | mlEnabled: boolean; 115 | featuresUsed: string[]; 116 | smoothingApplied: string; 117 | calculationTime: number; 118 | }; 119 | } 120 | 121 | export interface OrderBlock { 122 | id: string; 123 | timestamp: number; 124 | top: number; 125 | bottom: number; 126 | average: number; 127 | volume: number; 128 | mitigated: boolean; 129 | mitigationTime?: number; 130 | } 131 | 132 | export interface OrderBlocksData { 133 | symbol: string; 134 | interval: string; 135 | bullishBlocks: OrderBlock[]; 136 | bearishBlocks: OrderBlock[]; 137 | currentSupport: number[]; 138 | currentResistance: number[]; 139 | metadata: { 140 | volumePivotLength: number; 141 | mitigationMethod: string; 142 | blocksDetected: number; 143 | activeBullishBlocks: number; 144 | activeBearishBlocks: number; 145 | }; 146 | } 147 | 148 | export interface MarketStructureData { 149 | symbol: string; 150 | interval: string; 151 | marketRegime: 'trending_up' | 'trending_down' | 'ranging' | 'volatile'; 152 | trendStrength: number; 153 | volatilityLevel: 'low' | 'medium' | 'high'; 154 | keyLevels: { 155 | support: number[]; 156 | resistance: number[]; 157 | liquidityZones: Array<{ 158 | price: number; 159 | strength: number; 160 | type: 'support' | 'resistance'; 161 | }>; 162 | }; 163 | orderBlocks?: OrderBlocksData; 164 | mlRsi?: MLRSIData; 165 | recommendations: string[]; 166 | metadata: { 167 | analysisDepth: number; 168 | calculationTime: number; 169 | confidence: number; 170 | }; 171 | } 172 | 173 | // Tool parameter types 174 | export interface GetTickerParams { 175 | symbol: string; 176 | category?: keyof BybitCategory; 177 | } 178 | 179 | export interface GetKlineParams { 180 | symbol: string; 181 | category?: keyof BybitCategory; 182 | interval?: string; 183 | limit?: number; 184 | } 185 | 186 | export interface GetOrderbookParams { 187 | symbol: string; 188 | category?: keyof BybitCategory; 189 | limit?: number; 190 | } 191 | 192 | export interface GetMLRSIParams { 193 | symbol: string; 194 | category: keyof BybitCategory; 195 | interval: string; 196 | rsiLength?: number; 197 | knnNeighbors?: number; 198 | knnLookback?: number; 199 | mlWeight?: number; 200 | featureCount?: number; 201 | smoothingMethod?: string; 202 | limit?: number; 203 | } 204 | 205 | export interface GetOrderBlocksParams { 206 | symbol: string; 207 | category: keyof BybitCategory; 208 | interval: string; 209 | volumePivotLength?: number; 210 | bullishBlocks?: number; 211 | bearishBlocks?: number; 212 | mitigationMethod?: string; 213 | limit?: number; 214 | } 215 | 216 | export interface GetMarketStructureParams { 217 | symbol: string; 218 | category: keyof BybitCategory; 219 | interval: string; 220 | analysisDepth?: number; 221 | includeOrderBlocks?: boolean; 222 | includeMLRSI?: boolean; 223 | includeLiquidityZones?: boolean; 224 | } 225 | 226 | // Available MCP tools 227 | export type MCPToolName = 228 | | 'get_ticker' 229 | | 'get_orderbook' 230 | | 'get_kline' 231 | | 'get_market_info' 232 | | 'get_trades' 233 | | 'get_instrument_info' 234 | | 'get_wallet_balance' 235 | | 'get_positions' 236 | | 'get_order_history' 237 | | 'get_ml_rsi' 238 | | 'get_order_blocks' 239 | | 'get_market_structure'; 240 | 241 | export type MCPToolParams<T extends MCPToolName> = 242 | T extends 'get_ticker' ? GetTickerParams : 243 | T extends 'get_kline' ? GetKlineParams : 244 | T extends 'get_orderbook' ? GetOrderbookParams : 245 | T extends 'get_ml_rsi' ? GetMLRSIParams : 246 | T extends 'get_order_blocks' ? GetOrderBlocksParams : 247 | T extends 'get_market_structure' ? GetMarketStructureParams : 248 | Record<string, unknown>; 249 | 250 | export type MCPToolResponse<T extends MCPToolName> = 251 | T extends 'get_ticker' ? TickerData : 252 | T extends 'get_kline' ? KlineData : 253 | T extends 'get_orderbook' ? OrderbookData : 254 | T extends 'get_ml_rsi' ? MLRSIData : 255 | T extends 'get_order_blocks' ? OrderBlocksData : 256 | T extends 'get_market_structure' ? MarketStructureData : 257 | unknown; 258 | ``` -------------------------------------------------------------------------------- /docs/HTTP_SERVER.md: -------------------------------------------------------------------------------- ```markdown 1 | # Bybit MCP HTTP Server 2 | 3 | The Bybit MCP server now supports HTTP/SSE transport in addition to the standard stdio transport. This enables web applications and other HTTP clients to interact with the MCP server. 4 | 5 | ## Features 6 | 7 | - **Modern Streamable HTTP Transport**: Latest MCP protocol support with session management 8 | - **Legacy SSE Transport**: Backwards compatibility with older MCP clients 9 | - **Health Monitoring**: Built-in health check endpoint 10 | - **CORS Support**: Configurable cross-origin resource sharing 11 | - **Session Management**: Automatic session lifecycle management 12 | - **Graceful Shutdown**: Proper cleanup on server termination 13 | 14 | ## Configuration 15 | 16 | ### Environment Variables 17 | 18 | - `MCP_HTTP_PORT`: Server port (default: 8080) 19 | - `MCP_HTTP_HOST`: Server host (default: localhost) 20 | - `CORS_ORIGIN`: CORS origin policy (default: *) 21 | 22 | ### Example Configuration 23 | 24 | ```bash 25 | export MCP_HTTP_PORT=8080 26 | export MCP_HTTP_HOST=0.0.0.0 27 | export CORS_ORIGIN="https://myapp.com" 28 | ``` 29 | 30 | ## Endpoints 31 | 32 | ### Health Check 33 | - **URL**: `GET /health` 34 | - **Description**: Server health and status information 35 | - **Response**: JSON with server status, version, and active transport counts 36 | 37 | ```json 38 | { 39 | "status": "healthy", 40 | "name": "bybit-mcp", 41 | "version": "0.2.0", 42 | "timestamp": "2025-05-24T04:19:35.168Z", 43 | "transports": { 44 | "streamable": 0, 45 | "sse": 0 46 | } 47 | } 48 | ``` 49 | 50 | ### Modern Streamable HTTP Transport 51 | - **URL**: `POST|GET|DELETE /mcp` 52 | - **Description**: Modern MCP protocol endpoint with session management 53 | - **Headers**: 54 | - `Content-Type: application/json` 55 | - `mcp-session-id: <session-id>` (for existing sessions) 56 | 57 | ### Legacy SSE Transport 58 | - **URL**: `GET /sse` 59 | - **Description**: Server-Sent Events endpoint for legacy clients 60 | - **Response**: SSE stream with session ID 61 | 62 | - **URL**: `POST /messages?sessionId=<session-id>` 63 | - **Description**: Message endpoint for SSE clients 64 | - **Headers**: `Content-Type: application/json` 65 | 66 | ## Usage 67 | 68 | ### Starting the HTTP Server 69 | 70 | ```bash 71 | # Build the project 72 | pnpm build 73 | 74 | # Start HTTP server 75 | pnpm start:http 76 | 77 | # Or run directly 78 | node build/httpServer.js 79 | ``` 80 | 81 | ### Client Connection Examples 82 | 83 | #### Modern HTTP Client (Recommended) 84 | 85 | ```typescript 86 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 87 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; 88 | 89 | const client = new Client({ 90 | name: 'my-client', 91 | version: '1.0.0' 92 | }); 93 | 94 | const transport = new StreamableHTTPClientTransport( 95 | new URL('http://localhost:8080/mcp') 96 | ); 97 | 98 | await client.connect(transport); 99 | ``` 100 | 101 | #### Legacy SSE Client 102 | 103 | ```typescript 104 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 105 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; 106 | 107 | const client = new Client({ 108 | name: 'legacy-client', 109 | version: '1.0.0' 110 | }); 111 | 112 | const transport = new SSEClientTransport( 113 | new URL('http://localhost:8080/sse') 114 | ); 115 | 116 | await client.connect(transport); 117 | ``` 118 | 119 | #### Backwards Compatible Client 120 | 121 | ```typescript 122 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 123 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; 124 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; 125 | 126 | const baseUrl = new URL('http://localhost:8080'); 127 | let client: Client; 128 | 129 | try { 130 | // Try modern transport first 131 | client = new Client({ name: 'client', version: '1.0.0' }); 132 | const transport = new StreamableHTTPClientTransport(new URL('/mcp', baseUrl)); 133 | await client.connect(transport); 134 | console.log("Connected using Streamable HTTP transport"); 135 | } catch (error) { 136 | // Fall back to SSE transport 137 | console.log("Falling back to SSE transport"); 138 | client = new Client({ name: 'client', version: '1.0.0' }); 139 | const sseTransport = new SSEClientTransport(new URL('/sse', baseUrl)); 140 | await client.connect(sseTransport); 141 | console.log("Connected using SSE transport"); 142 | } 143 | ``` 144 | 145 | ### Web Application Integration 146 | 147 | The HTTP server is designed to work seamlessly with web applications. The included WebUI demonstrates how to integrate with the MCP server over HTTP. 148 | 149 | #### Proxy Configuration (Vite) 150 | 151 | ```typescript 152 | // vite.config.ts 153 | export default defineConfig({ 154 | server: { 155 | proxy: { 156 | '/api/mcp': { 157 | target: 'http://localhost:8080', 158 | changeOrigin: true, 159 | rewrite: (path) => path.replace(/^\/api\/mcp/, ''), 160 | }, 161 | }, 162 | }, 163 | }); 164 | ``` 165 | 166 | ## Available Tools 167 | 168 | The HTTP server exposes all the same tools as the stdio version: 169 | 170 | - `get_instrument_info` - Get trading instrument information 171 | - `get_kline` - Get candlestick/kline data 172 | - `get_ml_rsi` - Get ML-enhanced RSI indicator 173 | - `get_market_info` - Get market information 174 | - `get_market_structure` - Get market structure analysis 175 | - `get_order_blocks` - Get order block detection 176 | - `get_order_history` - Get order history 177 | - `get_orderbook` - Get order book data 178 | - `get_positions` - Get current positions 179 | - `get_ticker` - Get ticker information 180 | - `get_trades` - Get recent trades 181 | - `get_wallet_balance` - Get wallet balance 182 | 183 | ## Security Considerations 184 | 185 | - Configure CORS appropriately for production use 186 | - Use HTTPS in production environments 187 | - Consider rate limiting for public deployments 188 | - Validate all input parameters 189 | - Monitor session counts and cleanup 190 | 191 | ## Troubleshooting 192 | 193 | ### Common Issues 194 | 195 | 1. **Port already in use**: Change the port using `MCP_HTTP_PORT` environment variable 196 | 2. **CORS errors**: Configure `CORS_ORIGIN` environment variable 197 | 3. **Connection refused**: Ensure the server is running and accessible 198 | 4. **Session errors**: Check that session IDs are properly managed 199 | 200 | ### Debugging 201 | 202 | Enable debug logging by setting the log level: 203 | 204 | ```bash 205 | export LOG_LEVEL=debug 206 | node build/httpServer.js 207 | ``` 208 | 209 | ### Health Check 210 | 211 | Always verify the server is healthy: 212 | 213 | ```bash 214 | curl http://localhost:8080/health 215 | ``` 216 | 217 | ## Performance 218 | 219 | - Session management is memory-based (consider Redis for production) 220 | - Automatic cleanup of closed sessions 221 | - Configurable timeouts and limits 222 | - Graceful shutdown handling 223 | 224 | ## Development 225 | 226 | For development, you can run both the HTTP server and WebUI simultaneously: 227 | 228 | ```bash 229 | # Terminal 1: Start MCP HTTP server 230 | pnpm start:http 231 | 232 | # Terminal 2: Start WebUI development server 233 | cd webui && pnpm dev 234 | ``` 235 | 236 | The WebUI will proxy MCP requests to the HTTP server automatically. 237 | ``` -------------------------------------------------------------------------------- /webui/src/services/citationProcessor.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Citation processor for parsing AI responses and creating interactive citations 3 | */ 4 | 5 | import type { CitationReference, ProcessedMessage, CitationTooltipData } from '@/types/citation'; 6 | import { citationStore } from './citationStore'; 7 | 8 | export class CitationProcessor { 9 | // Regex pattern to match citation references like [REF001], [REF123], etc. 10 | private static readonly CITATION_PATTERN = /\[REF(\d{3})\]/g; 11 | 12 | /** 13 | * Process AI response content to extract and convert citations 14 | */ 15 | processMessage(content: string): ProcessedMessage { 16 | const citations: CitationReference[] = []; 17 | let processedContent = content; 18 | let match; 19 | 20 | // Reset regex lastIndex to ensure we find all matches 21 | CitationProcessor.CITATION_PATTERN.lastIndex = 0; 22 | 23 | // Find all citation patterns in the content 24 | while ((match = CitationProcessor.CITATION_PATTERN.exec(content)) !== null) { 25 | const fullMatch = match[0]; // e.g., "[REF001]" 26 | const referenceId = fullMatch; // Keep the full format for consistency 27 | 28 | citations.push({ 29 | referenceId, 30 | startIndex: match.index, 31 | endIndex: match.index + fullMatch.length, 32 | text: fullMatch 33 | }); 34 | } 35 | 36 | // Convert citation patterns to interactive elements 37 | if (citations.length > 0) { 38 | processedContent = this.convertCitationsToInteractive(content, citations); 39 | } 40 | 41 | return { 42 | originalContent: content, 43 | processedContent, 44 | citations 45 | }; 46 | } 47 | 48 | /** 49 | * Convert citation patterns to interactive HTML elements 50 | */ 51 | private convertCitationsToInteractive(content: string, citations: CitationReference[]): string { 52 | let processedContent = content; 53 | 54 | // Process citations in reverse order to maintain correct indices 55 | const sortedCitations = [...citations].sort((a, b) => b.startIndex - a.startIndex); 56 | 57 | for (const citation of sortedCitations) { 58 | const citationData = citationStore.getCitation(citation.referenceId); 59 | let hasData = citationData !== undefined; 60 | 61 | // For testing: create mock data if no real data exists 62 | if (!hasData) { 63 | console.log(`🧪 Creating mock citation data for ${citation.referenceId}`); 64 | const mockData = { 65 | referenceId: citation.referenceId, 66 | timestamp: new Date().toISOString(), 67 | toolName: 'get_ticker', 68 | endpoint: '/v5/market/tickers', 69 | rawData: { 70 | symbol: 'BTCUSDT', 71 | lastPrice: '$103,411.53', 72 | price24hPcnt: '-0.73%', 73 | volume24h: '19.73 BTC' 74 | }, 75 | extractedMetrics: [ 76 | { 77 | type: 'price' as const, 78 | label: 'Last Price', 79 | value: '$103,411.53', 80 | unit: 'USD', 81 | significance: 'high' as const 82 | }, 83 | { 84 | type: 'percentage' as const, 85 | label: '24h Change', 86 | value: '-0.73%', 87 | unit: '%', 88 | significance: 'high' as const 89 | } 90 | ] 91 | }; 92 | citationStore.storeCitation(mockData); 93 | hasData = true; 94 | } 95 | 96 | // Create a more compact, single-line span element 97 | const interactiveElement = `<span class="citation-ref ${hasData ? 'has-data' : 'no-data'}" data-reference-id="${citation.referenceId}" data-has-data="${hasData}" title="${hasData ? 'Click to view data details' : 'Citation data not available'}" role="button" tabindex="0">${citation.text}</span>`; 98 | 99 | processedContent = 100 | processedContent.slice(0, citation.startIndex) + 101 | interactiveElement + 102 | processedContent.slice(citation.endIndex); 103 | } 104 | 105 | return processedContent; 106 | } 107 | 108 | /** 109 | * Get tooltip data for a citation reference 110 | */ 111 | getCitationTooltipData(referenceId: string): CitationTooltipData | null { 112 | const citationData = citationStore.getCitation(referenceId); 113 | 114 | if (!citationData) { 115 | return null; 116 | } 117 | 118 | return { 119 | referenceId: citationData.referenceId, 120 | toolName: citationData.toolName, 121 | timestamp: citationData.timestamp, 122 | endpoint: citationData.endpoint, 123 | keyMetrics: citationData.extractedMetrics || [], 124 | hasFullData: true 125 | }; 126 | } 127 | 128 | /** 129 | * Format timestamp for display 130 | */ 131 | formatTimestamp(timestamp: string): string { 132 | try { 133 | const date = new Date(timestamp); 134 | return date.toLocaleString(); 135 | } catch (error) { 136 | return timestamp; 137 | } 138 | } 139 | 140 | /** 141 | * Create tooltip HTML content 142 | */ 143 | createTooltipContent(tooltipData: CitationTooltipData): string { 144 | const formattedTime = this.formatTimestamp(tooltipData.timestamp); 145 | 146 | let metricsHtml = ''; 147 | if (tooltipData.keyMetrics.length > 0) { 148 | metricsHtml = ` 149 | <div class="citation-metrics"> 150 | <h4>Key Data Points:</h4> 151 | <ul> 152 | ${tooltipData.keyMetrics.map(metric => ` 153 | <li class="metric-${metric.significance}"> 154 | <span class="metric-label">${metric.label}:</span> 155 | <span class="metric-value">${metric.value}${metric.unit ? ' ' + metric.unit : ''}</span> 156 | </li> 157 | `).join('')} 158 | </ul> 159 | </div> 160 | `; 161 | } 162 | 163 | return ` 164 | <div class="citation-tooltip"> 165 | <div class="citation-header"> 166 | <span class="citation-id">${tooltipData.referenceId}</span> 167 | <span class="citation-tool">${tooltipData.toolName}</span> 168 | </div> 169 | <div class="citation-time">${formattedTime}</div> 170 | ${tooltipData.endpoint ? `<div class="citation-endpoint">${tooltipData.endpoint}</div>` : ''} 171 | ${metricsHtml} 172 | <div class="citation-actions"> 173 | <button class="btn-view-full" data-reference-id="${tooltipData.referenceId}"> 174 | View Full Data 175 | </button> 176 | </div> 177 | </div> 178 | `; 179 | } 180 | 181 | /** 182 | * Extract all citation references from content 183 | */ 184 | extractCitationReferences(content: string): string[] { 185 | const references: string[] = []; 186 | let match; 187 | 188 | CitationProcessor.CITATION_PATTERN.lastIndex = 0; 189 | while ((match = CitationProcessor.CITATION_PATTERN.exec(content)) !== null) { 190 | references.push(match[0]); 191 | } 192 | 193 | return references; 194 | } 195 | 196 | /** 197 | * Validate citation reference format 198 | */ 199 | isValidCitationReference(reference: string): boolean { 200 | return CitationProcessor.CITATION_PATTERN.test(reference); 201 | } 202 | } 203 | 204 | // Singleton instance 205 | export const citationProcessor = new CitationProcessor(); 206 | ``` -------------------------------------------------------------------------------- /webui/DOCKER.md: -------------------------------------------------------------------------------- ```markdown 1 | # 🐳 Docker Deployment Guide 2 | 3 | This guide covers how to build and deploy the Bybit MCP WebUI using Docker. 4 | 5 | ## 📋 Prerequisites 6 | 7 | - Docker Engine 20.10+ 8 | - Docker Compose 2.0+ 9 | - 2GB+ available RAM 10 | - 1GB+ available disk space 11 | 12 | ## 🚀 Quick Start 13 | 14 | ### Option 1: Using Docker Compose (Recommended) 15 | 16 | ```bash 17 | # Clone the repository 18 | git clone https://github.com/sammcj/bybit-mcp.git 19 | cd bybit-mcp/webui 20 | 21 | # Start the application 22 | docker-compose up -d 23 | 24 | # View logs 25 | docker-compose logs -f 26 | 27 | # Stop the application 28 | docker-compose down 29 | ``` 30 | 31 | ### Option 2: Using Docker Build 32 | 33 | ```bash 34 | # Build the image 35 | docker build -t bybit-mcp-webui -f webui/Dockerfile . 36 | 37 | # Run the container 38 | docker run -d \ 39 | --name bybit-mcp-webui \ 40 | -p 8080:8080 \ 41 | --restart unless-stopped \ 42 | bybit-mcp-webui 43 | ``` 44 | 45 | ## 🌐 Access the Application 46 | 47 | Once running, access the WebUI at: 48 | - **Local**: http://localhost:8080 49 | - **Network**: http://YOUR_SERVER_IP:8080 50 | 51 | ## ⚙️ Configuration 52 | 53 | ### Environment Variables 54 | 55 | | Variable | Default | Description | 56 | |----------|---------|-------------| 57 | | `NODE_ENV` | `production` | Application environment | 58 | | `PORT` | `8080` | HTTP server port | 59 | | `MCP_PORT` | `8080` | MCP server port | 60 | | `HOST` | `0.0.0.0` | Bind address | 61 | | `BYBIT_API_KEY` | - | Bybit API key (optional) | 62 | | `BYBIT_API_SECRET` | - | Bybit API secret (optional) | 63 | | `BYBIT_TESTNET` | `true` | Use Bybit testnet | 64 | 65 | ### Custom Configuration 66 | 67 | Create a `.env` file in the webui directory: 68 | 69 | ```bash 70 | # .env 71 | BYBIT_API_KEY=your_api_key_here 72 | BYBIT_API_SECRET=your_api_secret_here 73 | BYBIT_TESTNET=false 74 | PORT=3000 75 | ``` 76 | 77 | Then update docker-compose.yml: 78 | 79 | ```yaml 80 | services: 81 | bybit-mcp-webui: 82 | env_file: 83 | - .env 84 | ``` 85 | 86 | ## 🔧 Advanced Usage 87 | 88 | ### Development Mode 89 | 90 | For development with hot reload: 91 | 92 | ```bash 93 | # Build development image 94 | docker build -t bybit-mcp-webui:dev --target deps -f webui/Dockerfile . 95 | 96 | # Run with volume mounts 97 | docker run -d \ 98 | --name bybit-mcp-dev \ 99 | -p 8080:8080 \ 100 | -v $(pwd):/app \ 101 | -v /app/node_modules \ 102 | -v /app/webui/node_modules \ 103 | bybit-mcp-webui:dev \ 104 | sh -c "cd /app && pnpm dev:full" 105 | ``` 106 | 107 | ### Production Deployment 108 | 109 | For production with reverse proxy: 110 | 111 | ```yaml 112 | # docker-compose.prod.yml 113 | services: 114 | bybit-mcp-webui: 115 | image: bybit-mcp-webui:latest 116 | environment: 117 | - NODE_ENV=production 118 | - BYBIT_TESTNET=false 119 | deploy: 120 | replicas: 2 121 | resources: 122 | limits: 123 | memory: 1G 124 | cpus: '1.0' 125 | networks: 126 | - traefik 127 | labels: 128 | - "traefik.enable=true" 129 | - "traefik.http.routers.bybit.rule=Host(`your-domain.com`)" 130 | - "traefik.http.routers.bybit.tls.certresolver=letsencrypt" 131 | 132 | networks: 133 | traefik: 134 | external: true 135 | ``` 136 | 137 | ## 🔍 Monitoring & Debugging 138 | 139 | ### Health Checks 140 | 141 | The container includes built-in health checks: 142 | 143 | ```bash 144 | # Check container health 145 | docker ps 146 | docker inspect bybit-mcp-webui | grep -A 10 Health 147 | 148 | # Manual health check 149 | docker exec bybit-mcp-webui /app/healthcheck.sh 150 | ``` 151 | 152 | ### Logs 153 | 154 | ```bash 155 | # View application logs 156 | docker logs bybit-mcp-webui 157 | 158 | # Follow logs in real-time 159 | docker logs -f bybit-mcp-webui 160 | 161 | # View last 100 lines 162 | docker logs --tail 100 bybit-mcp-webui 163 | ``` 164 | 165 | ### Container Shell Access 166 | 167 | ```bash 168 | # Access container shell 169 | docker exec -it bybit-mcp-webui sh 170 | 171 | # Check running processes 172 | docker exec bybit-mcp-webui ps aux 173 | 174 | # Check disk usage 175 | docker exec bybit-mcp-webui df -h 176 | ``` 177 | 178 | ## 🛠️ Troubleshooting 179 | 180 | ### Common Issues 181 | 182 | **Port Already in Use** 183 | ```bash 184 | # Find process using port 8080 185 | lsof -i :8080 186 | # or 187 | netstat -tulpn | grep 8080 188 | 189 | # Kill the process or use different port 190 | docker run -p 3000:8080 bybit-mcp-webui 191 | ``` 192 | 193 | **Permission Denied** 194 | ```bash 195 | # Check if Docker daemon is running 196 | sudo systemctl status docker 197 | 198 | # Add user to docker group 199 | sudo usermod -aG docker $USER 200 | newgrp docker 201 | ``` 202 | 203 | **Build Failures** 204 | ```bash 205 | # Clear Docker cache 206 | docker system prune -a 207 | 208 | # Rebuild without cache 209 | docker build --no-cache -t bybit-mcp-webui -f webui/Dockerfile . 210 | ``` 211 | 212 | **Memory Issues** 213 | ```bash 214 | # Increase Docker memory limit 215 | # Docker Desktop: Settings > Resources > Memory 216 | 217 | # Check container memory usage 218 | docker stats bybit-mcp-webui 219 | ``` 220 | 221 | ### Performance Optimization 222 | 223 | **Two-stage Build Benefits:** 224 | - ✅ Smaller final image (~200MB vs ~1GB) 225 | - ✅ Faster deployments 226 | - ✅ Better security (no dev dependencies) 227 | - ✅ Optimized layer caching 228 | - ✅ Cleaner, more maintainable Dockerfile 229 | 230 | **Resource Limits:** 231 | ```yaml 232 | deploy: 233 | resources: 234 | limits: 235 | memory: 512M # Adjust based on usage 236 | cpus: '0.5' # Adjust based on load 237 | ``` 238 | 239 | ## 🔒 Security 240 | 241 | ### Best Practices 242 | 243 | 1. **Non-root User**: Container runs as user `bybit` (UID 1001) 244 | 2. **Read-only Filesystem**: Where possible 245 | 3. **No New Privileges**: Security option enabled 246 | 4. **Minimal Base Image**: Alpine Linux 247 | 5. **Health Checks**: Built-in monitoring 248 | 6. **Resource Limits**: Prevent resource exhaustion 249 | 250 | ### API Key Security 251 | 252 | **Never commit API keys to version control!** 253 | 254 | ```bash 255 | # Use environment variables 256 | export BYBIT_API_KEY="your_key" 257 | export BYBIT_API_SECRET="your_secret" 258 | 259 | # Or use Docker secrets 260 | echo "your_key" | docker secret create bybit_api_key - 261 | echo "your_secret" | docker secret create bybit_api_secret - 262 | ``` 263 | 264 | ## 📊 Monitoring 265 | 266 | ### Prometheus Metrics 267 | 268 | Add monitoring with Prometheus: 269 | 270 | ```yaml 271 | # docker-compose.monitoring.yml 272 | services: 273 | prometheus: 274 | image: prom/prometheus:latest 275 | ports: 276 | - "9090:9090" 277 | volumes: 278 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 279 | 280 | grafana: 281 | image: grafana/grafana:latest 282 | ports: 283 | - "3000:3000" 284 | environment: 285 | - GF_SECURITY_ADMIN_PASSWORD=admin 286 | ``` 287 | 288 | ## 🚀 Deployment Platforms 289 | 290 | ### Docker Swarm 291 | ```bash 292 | docker stack deploy -c docker-compose.yml bybit-mcp 293 | ``` 294 | 295 | ### Kubernetes 296 | ```bash 297 | # Generate Kubernetes manifests 298 | kompose convert -f docker-compose.yml 299 | kubectl apply -f . 300 | ``` 301 | 302 | ### Cloud Platforms 303 | - **AWS ECS**: Use the provided Dockerfile 304 | - **Google Cloud Run**: Compatible with minimal changes 305 | - **Azure Container Instances**: Direct deployment support 306 | - **DigitalOcean App Platform**: Git-based deployment 307 | 308 | ## 📚 Additional Resources 309 | 310 | - [Docker Best Practices](https://docs.docker.com/develop/dev-best-practices/) 311 | - [Multi-stage Builds](https://docs.docker.com/develop/dev-best-practices/dockerfile_best-practices/#use-multi-stage-builds) 312 | - [Docker Compose Reference](https://docs.docker.com/compose/compose-file/) 313 | - [Container Security](https://docs.docker.com/engine/security/) 314 | 315 | --- 316 | 317 | **Need help?** Open an issue on [GitHub](https://github.com/sammcj/bybit-mcp/issues) 🚀 318 | ``` -------------------------------------------------------------------------------- /webui/src/utils/formatters.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Utility functions for formatting data 3 | */ 4 | 5 | /** 6 | * Format a number as currency 7 | */ 8 | export function formatCurrency(value: number, currency: string = 'USD', decimals?: number): string { 9 | const options: Intl.NumberFormatOptions = { 10 | style: 'currency', 11 | currency, 12 | }; 13 | 14 | if (decimals !== undefined) { 15 | options.minimumFractionDigits = decimals; 16 | options.maximumFractionDigits = decimals; 17 | } 18 | 19 | return new Intl.NumberFormat('en-US', options).format(value); 20 | } 21 | 22 | /** 23 | * Format a number with appropriate decimal places 24 | */ 25 | export function formatNumber(value: number, decimals: number = 2): string { 26 | return new Intl.NumberFormat('en-US', { 27 | minimumFractionDigits: decimals, 28 | maximumFractionDigits: decimals, 29 | }).format(value); 30 | } 31 | 32 | /** 33 | * Format a large number with K, M, B suffixes 34 | */ 35 | export function formatLargeNumber(value: number): string { 36 | const absValue = Math.abs(value); 37 | 38 | if (absValue >= 1e9) { 39 | return formatNumber(value / 1e9, 2) + 'B'; 40 | } else if (absValue >= 1e6) { 41 | return formatNumber(value / 1e6, 2) + 'M'; 42 | } else if (absValue >= 1e3) { 43 | return formatNumber(value / 1e3, 2) + 'K'; 44 | } 45 | 46 | return formatNumber(value, 2); 47 | } 48 | 49 | /** 50 | * Format a percentage 51 | */ 52 | export function formatPercentage(value: number, decimals: number = 2): string { 53 | return new Intl.NumberFormat('en-US', { 54 | style: 'percent', 55 | minimumFractionDigits: decimals, 56 | maximumFractionDigits: decimals, 57 | }).format(value / 100); 58 | } 59 | 60 | /** 61 | * Format a timestamp 62 | */ 63 | export function formatTimestamp(timestamp: number, options?: Intl.DateTimeFormatOptions): string { 64 | const defaultOptions: Intl.DateTimeFormatOptions = { 65 | year: 'numeric', 66 | month: 'short', 67 | day: 'numeric', 68 | hour: '2-digit', 69 | minute: '2-digit', 70 | second: '2-digit', 71 | }; 72 | 73 | return new Intl.DateTimeFormat('en-US', { ...defaultOptions, ...options }).format(new Date(timestamp)); 74 | } 75 | 76 | /** 77 | * Format a relative time (e.g., "2 minutes ago") 78 | */ 79 | export function formatRelativeTime(timestamp: number): string { 80 | const now = Date.now(); 81 | const diff = now - timestamp; 82 | 83 | const seconds = Math.floor(diff / 1000); 84 | const minutes = Math.floor(seconds / 60); 85 | const hours = Math.floor(minutes / 60); 86 | const days = Math.floor(hours / 24); 87 | 88 | if (days > 0) { 89 | return `${days} day${days > 1 ? 's' : ''} ago`; 90 | } else if (hours > 0) { 91 | return `${hours} hour${hours > 1 ? 's' : ''} ago`; 92 | } else if (minutes > 0) { 93 | return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; 94 | } else { 95 | return 'Just now'; 96 | } 97 | } 98 | 99 | /** 100 | * Format a trading symbol for display 101 | */ 102 | export function formatSymbol(symbol: string): string { 103 | // Convert BTCUSDT to BTC/USDT 104 | const commonQuotes = ['USDT', 'USDC', 'BTC', 'ETH', 'BNB']; 105 | 106 | for (const quote of commonQuotes) { 107 | if (symbol.endsWith(quote)) { 108 | const base = symbol.slice(0, -quote.length); 109 | return `${base}/${quote}`; 110 | } 111 | } 112 | 113 | return symbol; 114 | } 115 | 116 | /** 117 | * Format price change with color indication 118 | */ 119 | export function formatPriceChange(change: number, isPercentage: boolean = false): { 120 | formatted: string; 121 | className: string; 122 | icon: string; 123 | } { 124 | const isPositive = change > 0; 125 | const isNegative = change < 0; 126 | 127 | let formatted: string; 128 | if (isPercentage) { 129 | formatted = formatPercentage(Math.abs(change)); 130 | } else { 131 | formatted = formatNumber(Math.abs(change)); 132 | } 133 | 134 | const className = isPositive ? 'text-success' : isNegative ? 'text-danger' : 'text-secondary'; 135 | const icon = isPositive ? '↗' : isNegative ? '↘' : '→'; 136 | 137 | return { 138 | formatted: `${isPositive ? '+' : isNegative ? '-' : ''}${formatted}`, 139 | className, 140 | icon, 141 | }; 142 | } 143 | 144 | /** 145 | * Format volume with appropriate units 146 | */ 147 | export function formatVolume(volume: number): string { 148 | return formatLargeNumber(volume); 149 | } 150 | 151 | /** 152 | * Format market cap 153 | */ 154 | export function formatMarketCap(marketCap: number): string { 155 | return formatLargeNumber(marketCap); 156 | } 157 | 158 | /** 159 | * Format order book price levels 160 | */ 161 | export function formatOrderBookLevel(price: string, size: string): { 162 | price: string; 163 | size: string; 164 | total: string; 165 | } { 166 | const priceNum = parseFloat(price); 167 | const sizeNum = parseFloat(size); 168 | const total = priceNum * sizeNum; 169 | 170 | return { 171 | price: formatNumber(priceNum, 4), 172 | size: formatNumber(sizeNum, 6), 173 | total: formatNumber(total, 2), 174 | }; 175 | } 176 | 177 | /** 178 | * Format RSI value with overbought/oversold indication 179 | */ 180 | export function formatRSI(rsi: number): { 181 | formatted: string; 182 | className: string; 183 | status: 'overbought' | 'oversold' | 'neutral'; 184 | } { 185 | const formatted = formatNumber(rsi, 2); 186 | 187 | let className: string; 188 | let status: 'overbought' | 'oversold' | 'neutral'; 189 | 190 | if (rsi >= 70) { 191 | className = 'text-danger'; 192 | status = 'overbought'; 193 | } else if (rsi <= 30) { 194 | className = 'text-success'; 195 | status = 'oversold'; 196 | } else { 197 | className = 'text-secondary'; 198 | status = 'neutral'; 199 | } 200 | 201 | return { formatted, className, status }; 202 | } 203 | 204 | /** 205 | * Format file size 206 | */ 207 | export function formatFileSize(bytes: number): string { 208 | const units = ['B', 'KB', 'MB', 'GB', 'TB']; 209 | let size = bytes; 210 | let unitIndex = 0; 211 | 212 | while (size >= 1024 && unitIndex < units.length - 1) { 213 | size /= 1024; 214 | unitIndex++; 215 | } 216 | 217 | return `${formatNumber(size, unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`; 218 | } 219 | 220 | /** 221 | * Truncate text with ellipsis 222 | */ 223 | export function truncateText(text: string, maxLength: number): string { 224 | if (text.length <= maxLength) { 225 | return text; 226 | } 227 | 228 | return text.slice(0, maxLength - 3) + '...'; 229 | } 230 | 231 | /** 232 | * Format duration in milliseconds to human readable 233 | */ 234 | export function formatDuration(ms: number): string { 235 | if (ms < 1000) { 236 | return `${ms}ms`; 237 | } 238 | 239 | const seconds = ms / 1000; 240 | if (seconds < 60) { 241 | return `${formatNumber(seconds, 1)}s`; 242 | } 243 | 244 | const minutes = seconds / 60; 245 | if (minutes < 60) { 246 | return `${formatNumber(minutes, 1)}m`; 247 | } 248 | 249 | const hours = minutes / 60; 250 | return `${formatNumber(hours, 1)}h`; 251 | } 252 | 253 | /** 254 | * Format confidence score 255 | */ 256 | export function formatConfidence(confidence: number): { 257 | formatted: string; 258 | className: string; 259 | level: 'high' | 'medium' | 'low'; 260 | } { 261 | const formatted = formatPercentage(confidence); 262 | 263 | let className: string; 264 | let level: 'high' | 'medium' | 'low'; 265 | 266 | if (confidence >= 80) { 267 | className = 'text-success'; 268 | level = 'high'; 269 | } else if (confidence >= 60) { 270 | className = 'text-warning'; 271 | level = 'medium'; 272 | } else { 273 | className = 'text-danger'; 274 | level = 'low'; 275 | } 276 | 277 | return { formatted, className, level }; 278 | } 279 | ``` -------------------------------------------------------------------------------- /webui/src/services/configService.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Configuration service for managing application settings 3 | */ 4 | 5 | import type { ChatSettings, AIConfig } from '@/types/ai'; 6 | import { systemPromptService } from './systemPrompt'; 7 | 8 | const STORAGE_KEY = 'bybit-mcp-webui-settings'; 9 | 10 | // Get environment-based defaults 11 | function getDefaultSettings(): ChatSettings { 12 | // Check for environment variables (available in build-time or runtime) 13 | const ollamaHost = (typeof window !== 'undefined' && (window as any).__OLLAMA_HOST__) || 14 | (typeof process !== 'undefined' && process.env?.OLLAMA_HOST) || 15 | 'http://localhost:11434'; 16 | 17 | const mcpEndpoint = (typeof window !== 'undefined' && (window as any).__MCP_ENDPOINT__) || 18 | (typeof process !== 'undefined' && process.env?.MCP_ENDPOINT) || 19 | ''; // Empty means use current origin in production 20 | 21 | return { 22 | ai: { 23 | endpoint: ollamaHost, 24 | model: 'qwen3-30b-a3b-ud-nothink-128k:q4_k_xl', 25 | temperature: 0.7, 26 | maxTokens: 2048, 27 | systemPrompt: systemPromptService.generateLegacySystemPrompt(), 28 | }, 29 | mcp: { 30 | endpoint: mcpEndpoint, 31 | timeout: 30000, 32 | }, 33 | ui: { 34 | theme: 'auto', 35 | fontSize: 'medium', 36 | showTimestamps: true, 37 | enableSounds: false, 38 | }, 39 | }; 40 | } 41 | 42 | const DEFAULT_SETTINGS: ChatSettings = getDefaultSettings(); 43 | 44 | export class ConfigService { 45 | private settings: ChatSettings; 46 | private listeners: Set<(settings: ChatSettings) => void> = new Set(); 47 | 48 | constructor() { 49 | this.settings = this.loadSettings(); 50 | this.applyTheme(); 51 | } 52 | 53 | /** 54 | * Get current settings 55 | */ 56 | getSettings(): ChatSettings { 57 | return { ...this.settings }; 58 | } 59 | 60 | /** 61 | * Update settings 62 | */ 63 | updateSettings(updates: Partial<ChatSettings>): void { 64 | this.settings = this.mergeSettings(this.settings, updates); 65 | this.saveSettings(); 66 | this.applyTheme(); 67 | this.notifyListeners(); 68 | } 69 | 70 | /** 71 | * Reset settings to defaults 72 | */ 73 | resetSettings(): void { 74 | this.settings = { ...DEFAULT_SETTINGS }; 75 | this.saveSettings(); 76 | this.applyTheme(); 77 | this.notifyListeners(); 78 | } 79 | 80 | /** 81 | * Get AI configuration 82 | */ 83 | getAIConfig(): AIConfig { 84 | return { ...this.settings.ai }; 85 | } 86 | 87 | /** 88 | * Update AI configuration 89 | */ 90 | updateAIConfig(config: Partial<AIConfig>): void { 91 | this.updateSettings({ 92 | ai: { ...this.settings.ai, ...config }, 93 | }); 94 | } 95 | 96 | /** 97 | * Get MCP configuration 98 | */ 99 | getMCPConfig(): { endpoint: string; timeout: number } { 100 | return { ...this.settings.mcp }; 101 | } 102 | 103 | /** 104 | * Update MCP configuration 105 | */ 106 | updateMCPConfig(config: Partial<{ endpoint: string; timeout: number }>): void { 107 | this.updateSettings({ 108 | mcp: { ...this.settings.mcp, ...config }, 109 | }); 110 | } 111 | 112 | /** 113 | * Subscribe to settings changes 114 | */ 115 | subscribe(listener: (settings: ChatSettings) => void): () => void { 116 | this.listeners.add(listener); 117 | return () => this.listeners.delete(listener); 118 | } 119 | 120 | /** 121 | * Apply theme to document 122 | */ 123 | private applyTheme(): void { 124 | const { theme } = this.settings.ui; 125 | const root = document.documentElement; 126 | 127 | if (theme === 'auto') { 128 | // Use system preference 129 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 130 | root.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); 131 | } else { 132 | root.setAttribute('data-theme', theme); 133 | } 134 | 135 | // Apply font size 136 | const { fontSize } = this.settings.ui; 137 | root.setAttribute('data-font-size', fontSize); 138 | } 139 | 140 | /** 141 | * Load settings from localStorage 142 | */ 143 | private loadSettings(): ChatSettings { 144 | try { 145 | const stored = localStorage.getItem(STORAGE_KEY); 146 | if (stored) { 147 | const parsed = JSON.parse(stored); 148 | const merged = this.mergeSettings(DEFAULT_SETTINGS, parsed); 149 | 150 | // Fix legacy localhost URLs when running in production 151 | if (typeof window !== 'undefined' && window.location.hostname !== 'localhost') { 152 | if (merged.mcp.endpoint === 'http://localhost:8080' || merged.mcp.endpoint.includes('localhost')) { 153 | console.log('🔧 Detected legacy localhost MCP endpoint, resetting to auto-detect'); 154 | merged.mcp.endpoint = ''; // Reset to auto-detect current origin 155 | } 156 | } 157 | 158 | return merged; 159 | } 160 | } catch (error) { 161 | console.warn('Failed to load settings from localStorage:', error); 162 | } 163 | 164 | return { ...DEFAULT_SETTINGS }; 165 | } 166 | 167 | /** 168 | * Save settings to localStorage 169 | */ 170 | private saveSettings(): void { 171 | try { 172 | localStorage.setItem(STORAGE_KEY, JSON.stringify(this.settings)); 173 | } catch (error) { 174 | console.warn('Failed to save settings to localStorage:', error); 175 | } 176 | } 177 | 178 | /** 179 | * Deep merge settings objects 180 | */ 181 | private mergeSettings(base: ChatSettings, updates: Partial<ChatSettings>): ChatSettings { 182 | return { 183 | ai: { ...base.ai, ...updates.ai }, 184 | mcp: { ...base.mcp, ...updates.mcp }, 185 | ui: { ...base.ui, ...updates.ui }, 186 | }; 187 | } 188 | 189 | /** 190 | * Notify all listeners of settings changes 191 | */ 192 | private notifyListeners(): void { 193 | this.listeners.forEach(listener => { 194 | try { 195 | listener(this.settings); 196 | } catch (error) { 197 | console.error('Error in settings listener:', error); 198 | } 199 | }); 200 | } 201 | } 202 | 203 | // Singleton instance 204 | export const configService = new ConfigService(); 205 | 206 | // Listen for system theme changes 207 | if (typeof window !== 'undefined') { 208 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 209 | const settings = configService.getSettings(); 210 | if (settings.ui.theme === 'auto') { 211 | configService.updateSettings({}); // Trigger theme reapplication 212 | } 213 | }); 214 | } 215 | 216 | // Export convenience functions 217 | export function getAIConfig(): AIConfig { 218 | return configService.getAIConfig(); 219 | } 220 | 221 | export function getMCPConfig(): { endpoint: string; timeout: number } { 222 | return configService.getMCPConfig(); 223 | } 224 | 225 | export function updateAIConfig(config: Partial<AIConfig>): void { 226 | configService.updateAIConfig(config); 227 | } 228 | 229 | export function updateMCPConfig(config: Partial<{ endpoint: string; timeout: number }>): void { 230 | configService.updateMCPConfig(config); 231 | } 232 | 233 | export function toggleTheme(): void { 234 | const settings = configService.getSettings(); 235 | const currentTheme = settings.ui.theme; 236 | 237 | let newTheme: 'light' | 'dark' | 'auto'; 238 | if (currentTheme === 'light') { 239 | newTheme = 'dark'; 240 | } else if (currentTheme === 'dark') { 241 | newTheme = 'auto'; 242 | } else { 243 | newTheme = 'light'; 244 | } 245 | 246 | configService.updateSettings({ 247 | ui: { ...settings.ui, theme: newTheme }, 248 | }); 249 | } 250 | ``` -------------------------------------------------------------------------------- /webui/src/components/DebugConsole.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Debug Console Component - Real-time streaming log viewer 3 | */ 4 | 5 | import { logService, type LogEntry } from '../services/logService'; 6 | 7 | export class DebugConsole { 8 | private container: HTMLElement; 9 | private isVisible: boolean = false; 10 | private autoScroll: boolean = true; 11 | private filterLevels: Set<LogEntry['level']> = new Set(['log', 'info', 'warn', 'error']); 12 | private unsubscribe?: () => void; 13 | 14 | constructor(container: HTMLElement) { 15 | this.container = container; 16 | this.render(); 17 | this.setupEventListeners(); 18 | 19 | // Subscribe to log updates 20 | this.unsubscribe = logService.subscribe((logs) => { 21 | this.updateLogs(logs); 22 | }); 23 | } 24 | 25 | private render(): void { 26 | this.container.innerHTML = ` 27 | <div class="debug-console ${this.isVisible ? 'visible' : 'hidden'}"> 28 | <div class="debug-header"> 29 | <div class="debug-title"> 30 | <span class="debug-icon">🔍</span> 31 | <span>Debug Console</span> 32 | <span class="debug-count">(${logService.getLogs().length})</span> 33 | </div> 34 | <div class="debug-controls"> 35 | <div class="debug-filters"> 36 | <label><input type="checkbox" data-level="log" ${this.filterLevels.has('log') ? 'checked' : ''}> Log</label> 37 | <label><input type="checkbox" data-level="info" ${this.filterLevels.has('info') ? 'checked' : ''}> Info</label> 38 | <label><input type="checkbox" data-level="warn" ${this.filterLevels.has('warn') ? 'checked' : ''}> Warn</label> 39 | <label><input type="checkbox" data-level="error" ${this.filterLevels.has('error') ? 'checked' : ''}> Error</label> 40 | </div> 41 | <button class="debug-btn" data-action="clear">Clear</button> 42 | <button class="debug-btn" data-action="export">Export</button> 43 | <button class="debug-btn" data-action="scroll-toggle"> 44 | ${this.autoScroll ? '📌' : '📌'} 45 | </button> 46 | <button class="debug-btn debug-toggle" data-action="toggle"> 47 | ${this.isVisible ? '▼' : '▲'} 48 | </button> 49 | </div> 50 | </div> 51 | <div class="debug-content"> 52 | <div class="debug-logs" id="debug-logs"></div> 53 | </div> 54 | </div> 55 | `; 56 | 57 | this.updateLogs(logService.getLogs()); 58 | } 59 | 60 | private setupEventListeners(): void { 61 | this.container.addEventListener('click', (e) => { 62 | const target = e.target as HTMLElement; 63 | const action = target.getAttribute('data-action'); 64 | 65 | switch (action) { 66 | case 'toggle': 67 | this.toggle(); 68 | break; 69 | case 'clear': 70 | logService.clearLogs(); 71 | break; 72 | case 'export': 73 | this.exportLogs(); 74 | break; 75 | case 'scroll-toggle': 76 | this.autoScroll = !this.autoScroll; 77 | target.textContent = this.autoScroll ? '📌' : '📌'; 78 | target.title = this.autoScroll ? 'Auto-scroll enabled' : 'Auto-scroll disabled'; 79 | break; 80 | } 81 | }); 82 | 83 | this.container.addEventListener('change', (e) => { 84 | const target = e.target as HTMLInputElement; 85 | const level = target.getAttribute('data-level') as LogEntry['level']; 86 | 87 | if (level) { 88 | if (target.checked) { 89 | this.filterLevels.add(level); 90 | } else { 91 | this.filterLevels.delete(level); 92 | } 93 | this.updateLogs(logService.getLogs()); 94 | } 95 | }); 96 | } 97 | 98 | private updateLogs(logs: LogEntry[]): void { 99 | const logsContainer = this.container.querySelector('#debug-logs') as HTMLElement; 100 | if (!logsContainer) return; 101 | 102 | // Filter logs by selected levels 103 | const filteredLogs = logs.filter(log => this.filterLevels.has(log.level)); 104 | 105 | // Update count 106 | const countElement = this.container.querySelector('.debug-count') as HTMLElement; 107 | if (countElement) { 108 | countElement.textContent = `(${filteredLogs.length}/${logs.length})`; 109 | } 110 | 111 | // Render logs 112 | logsContainer.innerHTML = filteredLogs.map(log => this.renderLogEntry(log)).join(''); 113 | 114 | // Auto-scroll to bottom 115 | if (this.autoScroll && this.isVisible) { 116 | logsContainer.scrollTop = logsContainer.scrollHeight; 117 | } 118 | } 119 | 120 | private renderLogEntry(log: LogEntry): string { 121 | const time = new Date(log.timestamp).toLocaleTimeString(); 122 | const levelClass = `debug-log-${log.level}`; 123 | const source = log.source ? ` <span class="debug-source">[${log.source}]</span>` : ''; 124 | 125 | let dataHtml = ''; 126 | if (log.data) { 127 | const dataStr = typeof log.data === 'object' 128 | ? JSON.stringify(log.data, null, 2) 129 | : String(log.data); 130 | dataHtml = `<div class="debug-data">${this.escapeHtml(dataStr)}</div>`; 131 | } 132 | 133 | return ` 134 | <div class="debug-log-entry ${levelClass}"> 135 | <div class="debug-log-header"> 136 | <span class="debug-time">${time}</span> 137 | <span class="debug-level">${log.level.toUpperCase()}</span> 138 | ${source} 139 | </div> 140 | <div class="debug-message">${this.escapeHtml(log.message)}</div> 141 | ${dataHtml} 142 | </div> 143 | `; 144 | } 145 | 146 | private escapeHtml(text: string): string { 147 | const div = document.createElement('div'); 148 | div.textContent = text; 149 | return div.innerHTML; 150 | } 151 | 152 | private exportLogs(): void { 153 | const logs = logService.exportLogs(); 154 | const blob = new Blob([logs], { type: 'text/plain' }); 155 | const url = URL.createObjectURL(blob); 156 | 157 | const a = document.createElement('a'); 158 | a.href = url; 159 | a.download = `debug-logs-${new Date().toISOString().slice(0, 19)}.txt`; 160 | document.body.appendChild(a); 161 | a.click(); 162 | document.body.removeChild(a); 163 | URL.revokeObjectURL(url); 164 | } 165 | 166 | public toggle(): void { 167 | this.isVisible = !this.isVisible; 168 | const debugConsole = this.container.querySelector('.debug-console') as HTMLElement; 169 | const toggleBtn = this.container.querySelector('.debug-toggle') as HTMLElement; 170 | 171 | if (debugConsole) { 172 | debugConsole.className = `debug-console ${this.isVisible ? 'visible' : 'hidden'}`; 173 | } 174 | 175 | if (toggleBtn) { 176 | toggleBtn.textContent = this.isVisible ? '▼' : '▲'; 177 | } 178 | 179 | // Auto-scroll when opening 180 | if (this.isVisible && this.autoScroll) { 181 | setTimeout(() => { 182 | const logsContainer = this.container.querySelector('#debug-logs') as HTMLElement; 183 | if (logsContainer) { 184 | logsContainer.scrollTop = logsContainer.scrollHeight; 185 | } 186 | }, 100); 187 | } 188 | } 189 | 190 | public show(): void { 191 | if (!this.isVisible) { 192 | this.toggle(); 193 | } 194 | } 195 | 196 | public hide(): void { 197 | if (this.isVisible) { 198 | this.toggle(); 199 | } 200 | } 201 | 202 | public destroy(): void { 203 | if (this.unsubscribe) { 204 | this.unsubscribe(); 205 | } 206 | } 207 | } 208 | ``` -------------------------------------------------------------------------------- /webui/src/services/agentConfig.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Agent configuration service for managing agent settings 3 | */ 4 | 5 | import type { 6 | AgentConfig, 7 | AgentState 8 | } from '@/types/agent'; 9 | import { DEFAULT_AGENT_CONFIG } from '@/types/agent'; 10 | 11 | export class AgentConfigService { 12 | private static readonly STORAGE_KEY = 'bybit-mcp-agent-config'; 13 | private static readonly STATE_KEY = 'bybit-mcp-agent-state'; 14 | 15 | private config: AgentConfig; 16 | private state: AgentState; 17 | private listeners: Set<(config: AgentConfig) => void> = new Set(); 18 | 19 | constructor() { 20 | this.config = this.loadConfig(); 21 | this.state = this.loadState(); 22 | } 23 | 24 | /** 25 | * Get current agent configuration 26 | */ 27 | getConfig(): AgentConfig { 28 | return { ...this.config }; 29 | } 30 | 31 | /** 32 | * Update agent configuration 33 | */ 34 | updateConfig(updates: Partial<AgentConfig>): void { 35 | this.config = { ...this.config, ...updates }; 36 | this.saveConfig(); 37 | this.notifyListeners(); 38 | } 39 | 40 | /** 41 | * Reset configuration to defaults 42 | */ 43 | resetConfig(): void { 44 | this.config = { ...DEFAULT_AGENT_CONFIG }; 45 | this.saveConfig(); 46 | this.notifyListeners(); 47 | } 48 | 49 | /** 50 | * Apply a simple preset configuration 51 | */ 52 | applyPreset(presetName: 'quick' | 'standard' | 'comprehensive'): void { 53 | const updates: Partial<AgentConfig> = {}; 54 | 55 | switch (presetName) { 56 | case 'quick': 57 | updates.maxIterations = 2; 58 | updates.showWorkflowSteps = false; 59 | updates.showToolCalls = false; 60 | break; 61 | case 'standard': 62 | updates.maxIterations = 5; 63 | updates.showWorkflowSteps = false; 64 | updates.showToolCalls = false; 65 | break; 66 | case 'comprehensive': 67 | updates.maxIterations = 8; 68 | updates.showWorkflowSteps = true; 69 | updates.showToolCalls = true; 70 | break; 71 | } 72 | 73 | this.updateConfig(updates); 74 | } 75 | 76 | /** 77 | * Get current agent state 78 | */ 79 | getState(): AgentState { 80 | return { ...this.state }; 81 | } 82 | 83 | /** 84 | * Update agent state 85 | */ 86 | updateState(updates: Partial<AgentState>): void { 87 | this.state = { ...this.state, ...updates }; 88 | this.saveState(); 89 | } 90 | 91 | /** 92 | * Record a successful query 93 | */ 94 | recordQuery(responseTime: number, _toolCallsCount: number): void { 95 | const currentState = this.getState(); 96 | const queryCount = currentState.queryCount + 1; 97 | const averageResponseTime = 98 | (currentState.averageResponseTime * currentState.queryCount + responseTime) / queryCount; 99 | 100 | this.updateState({ 101 | queryCount, 102 | averageResponseTime, 103 | successRate: (currentState.successRate * currentState.queryCount + 1) / queryCount, 104 | lastQuery: undefined, 105 | lastResponse: undefined 106 | }); 107 | } 108 | 109 | /** 110 | * Record a failed query 111 | */ 112 | recordFailure(): void { 113 | const currentState = this.getState(); 114 | const queryCount = currentState.queryCount + 1; 115 | 116 | this.updateState({ 117 | queryCount, 118 | successRate: (currentState.successRate * currentState.queryCount) / queryCount 119 | }); 120 | } 121 | 122 | /** 123 | * Subscribe to configuration changes 124 | */ 125 | subscribe(listener: (config: AgentConfig) => void): () => void { 126 | this.listeners.add(listener); 127 | return () => this.listeners.delete(listener); 128 | } 129 | 130 | /** 131 | * Get configuration for specific analysis type 132 | */ 133 | getConfigForAnalysis(analysisType: 'quick' | 'standard' | 'comprehensive'): AgentConfig { 134 | const baseConfig = this.getConfig(); 135 | 136 | switch (analysisType) { 137 | case 'quick': 138 | return { 139 | ...baseConfig, 140 | maxIterations: 2, 141 | showWorkflowSteps: false, 142 | showToolCalls: false 143 | }; 144 | 145 | case 'standard': 146 | return { 147 | ...baseConfig, 148 | maxIterations: 5, 149 | showWorkflowSteps: false, 150 | showToolCalls: false 151 | }; 152 | 153 | case 'comprehensive': 154 | return { 155 | ...baseConfig, 156 | maxIterations: 8, 157 | showWorkflowSteps: true, 158 | showToolCalls: true 159 | }; 160 | 161 | default: 162 | return baseConfig; 163 | } 164 | } 165 | 166 | /** 167 | * Validate configuration 168 | */ 169 | validateConfig(config: Partial<AgentConfig>): string[] { 170 | const errors: string[] = []; 171 | 172 | if (config.maxIterations !== undefined) { 173 | if (config.maxIterations < 1 || config.maxIterations > 20) { 174 | errors.push('Max iterations must be between 1 and 20'); 175 | } 176 | } 177 | 178 | if (config.toolTimeout !== undefined) { 179 | if (config.toolTimeout < 5000 || config.toolTimeout > 120000) { 180 | errors.push('Tool timeout must be between 5 and 120 seconds'); 181 | } 182 | } 183 | 184 | return errors; 185 | } 186 | 187 | /** 188 | * Export configuration 189 | */ 190 | exportConfig(): string { 191 | return JSON.stringify({ 192 | config: this.config, 193 | state: this.state, 194 | exportedAt: new Date().toISOString() 195 | }, null, 2); 196 | } 197 | 198 | /** 199 | * Import configuration 200 | */ 201 | importConfig(configJson: string): void { 202 | try { 203 | const imported = JSON.parse(configJson); 204 | if (imported.config) { 205 | const errors = this.validateConfig(imported.config); 206 | if (errors.length > 0) { 207 | throw new Error(`Invalid configuration: ${errors.join(', ')}`); 208 | } 209 | this.config = { ...DEFAULT_AGENT_CONFIG, ...imported.config }; 210 | this.saveConfig(); 211 | this.notifyListeners(); 212 | } 213 | } catch (error) { 214 | throw new Error(`Failed to import configuration: ${error instanceof Error ? error.message : 'Unknown error'}`); 215 | } 216 | } 217 | 218 | // Private methods 219 | private loadConfig(): AgentConfig { 220 | try { 221 | const stored = localStorage.getItem(AgentConfigService.STORAGE_KEY); 222 | if (stored) { 223 | const parsed = JSON.parse(stored); 224 | return { ...DEFAULT_AGENT_CONFIG, ...parsed }; 225 | } 226 | } catch (error) { 227 | console.warn('Failed to load agent config from localStorage:', error); 228 | } 229 | return { ...DEFAULT_AGENT_CONFIG }; 230 | } 231 | 232 | private saveConfig(): void { 233 | try { 234 | localStorage.setItem(AgentConfigService.STORAGE_KEY, JSON.stringify(this.config)); 235 | } catch (error) { 236 | console.warn('Failed to save agent config to localStorage:', error); 237 | } 238 | } 239 | 240 | private loadState(): AgentState { 241 | try { 242 | const stored = localStorage.getItem(AgentConfigService.STATE_KEY); 243 | if (stored) { 244 | return JSON.parse(stored); 245 | } 246 | } catch (error) { 247 | console.warn('Failed to load agent state from localStorage:', error); 248 | } 249 | return { 250 | isProcessing: false, 251 | queryCount: 0, 252 | averageResponseTime: 0, 253 | successRate: 0 254 | }; 255 | } 256 | 257 | private saveState(): void { 258 | try { 259 | localStorage.setItem(AgentConfigService.STATE_KEY, JSON.stringify(this.state)); 260 | } catch (error) { 261 | console.warn('Failed to save agent state to localStorage:', error); 262 | } 263 | } 264 | 265 | // Removed metrics-related methods - now using simplified state tracking 266 | 267 | private notifyListeners(): void { 268 | this.listeners.forEach(listener => listener(this.config)); 269 | } 270 | } 271 | 272 | // Singleton instance 273 | export const agentConfigService = new AgentConfigService(); 274 | ``` -------------------------------------------------------------------------------- /webui/build-docker.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | # ============================================================================== 4 | # Bybit MCP WebUI - Docker Build Script 5 | # ============================================================================== 6 | # This script provides convenient commands for building and managing 7 | # the Docker container for the Bybit MCP WebUI. 8 | # ============================================================================== 9 | 10 | set -e 11 | 12 | # Colors for output 13 | RED='\033[0;31m' 14 | GREEN='\033[0;32m' 15 | YELLOW='\033[1;33m' 16 | BLUE='\033[0;34m' 17 | NC='\033[0m' # No Color 18 | 19 | # Configuration 20 | IMAGE_NAME="bybit-mcp-webui" 21 | CONTAINER_NAME="bybit-mcp-webui" 22 | PORT="8080" 23 | 24 | # Functions 25 | print_header() { 26 | echo -e "${BLUE}==============================================================================${NC}" 27 | echo -e "${BLUE} $1${NC}" 28 | echo -e "${BLUE}==============================================================================${NC}" 29 | } 30 | 31 | print_success() { 32 | echo -e "${GREEN}✅ $1${NC}" 33 | } 34 | 35 | print_warning() { 36 | echo -e "${YELLOW}⚠️ $1${NC}" 37 | } 38 | 39 | print_error() { 40 | echo -e "${RED}❌ $1${NC}" 41 | } 42 | 43 | print_info() { 44 | echo -e "${BLUE}ℹ️ $1${NC}" 45 | } 46 | 47 | # Check if Docker is running 48 | check_docker() { 49 | if ! docker info > /dev/null 2>&1; then 50 | print_error "Docker is not running. Please start Docker and try again." 51 | exit 1 52 | fi 53 | } 54 | 55 | # Build the Docker image 56 | build_image() { 57 | print_header "Building Docker Image" 58 | 59 | print_info "Building $IMAGE_NAME with Node.js 22..." 60 | docker build -t "$IMAGE_NAME:latest" -f Dockerfile .. 61 | 62 | print_success "Docker image built successfully!" 63 | docker images | grep "$IMAGE_NAME" 64 | 65 | # Show image size 66 | print_info "Image size:" 67 | docker images "$IMAGE_NAME:latest" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" 68 | } 69 | 70 | # Run the container 71 | run_container() { 72 | print_header "Running Container" 73 | 74 | # Stop existing container if running 75 | if docker ps -q -f name="$CONTAINER_NAME" | grep -q .; then 76 | print_warning "Stopping existing container..." 77 | docker stop "$CONTAINER_NAME" 78 | docker rm "$CONTAINER_NAME" 79 | fi 80 | 81 | print_info "Starting new container..." 82 | docker run -d \ 83 | --name "$CONTAINER_NAME" \ 84 | -p "$PORT:8080" \ 85 | --restart unless-stopped \ 86 | "$IMAGE_NAME:latest" 87 | 88 | print_success "Container started successfully!" 89 | print_info "Access the WebUI at: http://localhost:$PORT" 90 | } 91 | 92 | # Stop the container 93 | stop_container() { 94 | print_header "Stopping Container" 95 | 96 | if docker ps -q -f name="$CONTAINER_NAME" | grep -q .; then 97 | docker stop "$CONTAINER_NAME" 98 | docker rm "$CONTAINER_NAME" 99 | print_success "Container stopped and removed." 100 | else 101 | print_warning "No running container found." 102 | fi 103 | } 104 | 105 | # Show container logs 106 | show_logs() { 107 | print_header "Container Logs" 108 | 109 | if docker ps -q -f name="$CONTAINER_NAME" | grep -q .; then 110 | docker logs -f "$CONTAINER_NAME" 111 | else 112 | print_error "Container is not running." 113 | exit 1 114 | fi 115 | } 116 | 117 | # Show container status 118 | show_status() { 119 | print_header "Container Status" 120 | 121 | echo "Docker Images:" 122 | docker images | grep "$IMAGE_NAME" || echo "No images found." 123 | 124 | echo "" 125 | echo "Running Containers:" 126 | docker ps | grep "$CONTAINER_NAME" || echo "No running containers found." 127 | 128 | echo "" 129 | echo "All Containers:" 130 | docker ps -a | grep "$CONTAINER_NAME" || echo "No containers found." 131 | } 132 | 133 | # Clean up Docker resources 134 | cleanup() { 135 | print_header "Cleaning Up" 136 | 137 | print_info "Stopping and removing containers..." 138 | docker ps -a -q -f name="$CONTAINER_NAME" | xargs -r docker rm -f 139 | 140 | print_info "Removing images..." 141 | docker images -q "$IMAGE_NAME" | xargs -r docker rmi -f 142 | 143 | print_info "Cleaning up unused Docker resources..." 144 | docker system prune -f 145 | 146 | print_success "Cleanup completed!" 147 | } 148 | 149 | # Development mode with volume mounts 150 | dev_mode() { 151 | print_header "Development Mode" 152 | 153 | # Stop existing container 154 | docker ps -q -f name="$CONTAINER_NAME-dev" | xargs -r docker rm -f 155 | 156 | print_info "Starting development container with volume mounts..." 157 | docker run -d \ 158 | --name "$CONTAINER_NAME-dev" \ 159 | -p "$PORT:8080" \ 160 | -v "$(pwd)/..:/app" \ 161 | -v "/app/node_modules" \ 162 | -v "/app/webui/node_modules" \ 163 | --restart unless-stopped \ 164 | "$IMAGE_NAME:latest" \ 165 | sh -c "cd /app && pnpm dev:full" 166 | 167 | print_success "Development container started!" 168 | print_info "Access the WebUI at: http://localhost:$PORT" 169 | print_warning "Note: Changes to source files will trigger rebuilds." 170 | } 171 | 172 | # Show help 173 | show_help() { 174 | echo "Bybit MCP WebUI - Docker Build Script" 175 | echo "" 176 | echo "Usage: $0 [COMMAND]" 177 | echo "" 178 | echo "Commands:" 179 | echo " build Build the Docker image" 180 | echo " run Run the container" 181 | echo " stop Stop and remove the container" 182 | echo " restart Stop and start the container" 183 | echo " logs Show container logs" 184 | echo " status Show container and image status" 185 | echo " cleanup Remove all containers and images" 186 | echo " dev Run in development mode with volume mounts" 187 | echo " compose Use Docker Compose (up/down/logs)" 188 | echo " help Show this help message" 189 | echo "" 190 | echo "Examples:" 191 | echo " $0 build && $0 run # Build and run" 192 | echo " $0 restart # Restart container" 193 | echo " $0 logs # Follow logs" 194 | echo " $0 compose up # Use Docker Compose" 195 | } 196 | 197 | # Docker Compose commands 198 | compose_command() { 199 | case "$1" in 200 | up) 201 | print_header "Starting with Docker Compose" 202 | docker-compose up -d 203 | print_success "Services started with Docker Compose!" 204 | ;; 205 | down) 206 | print_header "Stopping Docker Compose Services" 207 | docker-compose down 208 | print_success "Services stopped!" 209 | ;; 210 | logs) 211 | print_header "Docker Compose Logs" 212 | docker-compose logs -f 213 | ;; 214 | *) 215 | print_error "Unknown compose command: $1" 216 | echo "Available: up, down, logs" 217 | exit 1 218 | ;; 219 | esac 220 | } 221 | 222 | # Main script logic 223 | main() { 224 | check_docker 225 | 226 | case "$1" in 227 | build) 228 | build_image 229 | ;; 230 | run) 231 | run_container 232 | ;; 233 | stop) 234 | stop_container 235 | ;; 236 | restart) 237 | stop_container 238 | sleep 2 239 | run_container 240 | ;; 241 | logs) 242 | show_logs 243 | ;; 244 | status) 245 | show_status 246 | ;; 247 | cleanup) 248 | cleanup 249 | ;; 250 | dev) 251 | dev_mode 252 | ;; 253 | compose) 254 | compose_command "$2" 255 | ;; 256 | help|--help|-h) 257 | show_help 258 | ;; 259 | "") 260 | print_warning "No command specified." 261 | show_help 262 | ;; 263 | *) 264 | print_error "Unknown command: $1" 265 | show_help 266 | exit 1 267 | ;; 268 | esac 269 | } 270 | 271 | # Run the script 272 | main "$@" 273 | ``` -------------------------------------------------------------------------------- /webui/src/styles/agent-dashboard.css: -------------------------------------------------------------------------------- ```css 1 | /** 2 | * Agent Dashboard Styles 3 | */ 4 | 5 | /* Dashboard container */ 6 | .agent-dashboard { 7 | position: fixed; 8 | top: 0; 9 | left: 0; 10 | width: 350px; 11 | height: 100vh; 12 | background: var(--dashboard-bg, #ffffff); 13 | border-right: 1px solid var(--dashboard-border, #e0e0e0); 14 | box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); 15 | z-index: 1000; 16 | transform: translateX(-100%); 17 | transition: transform 0.3s ease; 18 | display: flex; 19 | flex-direction: column; 20 | overflow: hidden; 21 | } 22 | 23 | .agent-dashboard.visible { 24 | transform: translateX(0); 25 | } 26 | 27 | .agent-dashboard.hidden { 28 | transform: translateX(-100%); 29 | } 30 | 31 | /* Dashboard header */ 32 | .dashboard-header { 33 | display: flex; 34 | justify-content: space-between; 35 | align-items: center; 36 | padding: 16px 20px; 37 | border-bottom: 1px solid var(--dashboard-border, #e0e0e0); 38 | background: var(--dashboard-header-bg, #f8f9fa); 39 | flex-shrink: 0; 40 | } 41 | 42 | .dashboard-header h3 { 43 | margin: 0; 44 | font-size: 1.1em; 45 | font-weight: 600; 46 | color: var(--dashboard-title, #333); 47 | } 48 | 49 | .dashboard-controls { 50 | display: flex; 51 | align-items: center; 52 | gap: 8px; 53 | } 54 | 55 | .refresh-btn, 56 | .toggle-btn { 57 | background: var(--accent-color, #007acc); 58 | color: white; 59 | border: none; 60 | padding: 6px 10px; 61 | border-radius: 4px; 62 | cursor: pointer; 63 | transition: background 0.2s ease; 64 | font-size: 0.9em; 65 | } 66 | 67 | .refresh-btn:hover, 68 | .toggle-btn:hover { 69 | background: var(--accent-hover, #005a9e); 70 | } 71 | 72 | /* Dashboard content */ 73 | .dashboard-content { 74 | flex: 1; 75 | overflow-y: auto; 76 | padding: 16px 20px; 77 | } 78 | 79 | /* Dashboard sections */ 80 | .dashboard-section { 81 | margin-bottom: 24px; 82 | } 83 | 84 | .dashboard-section h4 { 85 | margin: 0 0 12px 0; 86 | font-size: 1em; 87 | font-weight: 600; 88 | color: var(--section-title, #333); 89 | border-bottom: 1px solid var(--section-border, #eee); 90 | padding-bottom: 6px; 91 | } 92 | 93 | /* Statistics grid */ 94 | .stats-grid { 95 | display: grid; 96 | grid-template-columns: 1fr 1fr; 97 | gap: 12px; 98 | } 99 | 100 | .stat-item { 101 | background: var(--stat-bg, #f8f9fa); 102 | border: 1px solid var(--stat-border, #e9ecef); 103 | border-radius: 6px; 104 | padding: 12px; 105 | text-align: center; 106 | } 107 | 108 | .stat-label { 109 | display: block; 110 | font-size: 0.8em; 111 | color: var(--text-muted, #666); 112 | margin-bottom: 4px; 113 | } 114 | 115 | .stat-value { 116 | display: block; 117 | font-size: 1.2em; 118 | font-weight: 600; 119 | color: var(--accent-color, #007acc); 120 | } 121 | 122 | /* Analysis list */ 123 | .analysis-list { 124 | display: flex; 125 | flex-direction: column; 126 | gap: 12px; 127 | max-height: 300px; 128 | overflow-y: auto; 129 | } 130 | 131 | .analysis-item { 132 | background: var(--analysis-bg, #fff); 133 | border: 1px solid var(--analysis-border, #e0e0e0); 134 | border-radius: 6px; 135 | padding: 12px; 136 | transition: all 0.2s ease; 137 | } 138 | 139 | .analysis-item:hover { 140 | border-color: var(--accent-color, #007acc); 141 | box-shadow: 0 2px 4px rgba(0, 122, 204, 0.1); 142 | } 143 | 144 | .analysis-header { 145 | display: flex; 146 | justify-content: space-between; 147 | align-items: center; 148 | margin-bottom: 6px; 149 | flex-wrap: wrap; 150 | gap: 6px; 151 | } 152 | 153 | .analysis-symbol { 154 | background: var(--accent-color, #007acc); 155 | color: white; 156 | padding: 2px 6px; 157 | border-radius: 3px; 158 | font-size: 0.8em; 159 | font-weight: 500; 160 | } 161 | 162 | .analysis-type { 163 | background: var(--type-bg, #e9ecef); 164 | color: var(--type-text, #495057); 165 | padding: 2px 6px; 166 | border-radius: 3px; 167 | font-size: 0.8em; 168 | text-transform: capitalize; 169 | } 170 | 171 | .analysis-time { 172 | font-size: 0.8em; 173 | color: var(--text-muted, #666); 174 | } 175 | 176 | .analysis-query { 177 | font-size: 0.85em; 178 | color: var(--text-primary, #333); 179 | margin-bottom: 6px; 180 | line-height: 1.3; 181 | } 182 | 183 | .analysis-metrics { 184 | display: flex; 185 | gap: 8px; 186 | flex-wrap: wrap; 187 | } 188 | 189 | .analysis-metrics .metric { 190 | font-size: 0.75em; 191 | color: var(--text-muted, #666); 192 | background: var(--metric-bg, #f1f3f4); 193 | padding: 2px 6px; 194 | border-radius: 3px; 195 | } 196 | 197 | /* Action buttons */ 198 | .action-buttons { 199 | display: flex; 200 | flex-direction: column; 201 | gap: 8px; 202 | } 203 | 204 | .action-btn { 205 | background: var(--button-bg, #fff); 206 | color: var(--button-text, #333); 207 | border: 1px solid var(--button-border, #ddd); 208 | padding: 8px 12px; 209 | border-radius: 4px; 210 | cursor: pointer; 211 | transition: all 0.2s ease; 212 | font-size: 0.9em; 213 | } 214 | 215 | .action-btn:hover { 216 | background: var(--button-hover-bg, #f8f9fa); 217 | border-color: var(--accent-color, #007acc); 218 | } 219 | 220 | .action-btn:active { 221 | background: var(--button-active-bg, #e9ecef); 222 | } 223 | 224 | /* Empty state */ 225 | .empty-state { 226 | text-align: center; 227 | padding: 20px; 228 | color: var(--text-muted, #666); 229 | } 230 | 231 | .empty-state p { 232 | margin: 0; 233 | font-style: italic; 234 | font-size: 0.9em; 235 | } 236 | 237 | /* Toast notifications */ 238 | .dashboard-toast { 239 | position: fixed; 240 | top: 20px; 241 | left: 370px; /* Position next to dashboard */ 242 | padding: 12px 20px; 243 | border-radius: 6px; 244 | color: white; 245 | font-weight: 500; 246 | z-index: 3000; 247 | transform: translateX(-100%); 248 | transition: transform 0.3s ease; 249 | max-width: 300px; 250 | } 251 | 252 | .dashboard-toast.show { 253 | transform: translateX(0); 254 | } 255 | 256 | .dashboard-toast.toast-success { 257 | background: var(--success-color, #28a745); 258 | } 259 | 260 | .dashboard-toast.toast-error { 261 | background: var(--danger-color, #dc3545); 262 | } 263 | 264 | /* Dark theme */ 265 | [data-theme="dark"] .agent-dashboard { 266 | --dashboard-bg: #2d2d2d; 267 | --dashboard-border: #444; 268 | --dashboard-header-bg: #333; 269 | --dashboard-title: #fff; 270 | --section-title: #fff; 271 | --section-border: #444; 272 | --stat-bg: #333; 273 | --stat-border: #444; 274 | --text-muted: #aaa; 275 | --text-primary: #fff; 276 | --analysis-bg: #333; 277 | --analysis-border: #444; 278 | --type-bg: #444; 279 | --type-text: #ccc; 280 | --metric-bg: #404040; 281 | --button-bg: #444; 282 | --button-text: #fff; 283 | --button-border: #555; 284 | --button-hover-bg: #505050; 285 | --button-active-bg: #555; 286 | } 287 | 288 | /* Responsive design */ 289 | @media (max-width: 768px) { 290 | .agent-dashboard { 291 | width: 100vw; 292 | } 293 | 294 | .dashboard-toast { 295 | left: 20px; 296 | right: 20px; 297 | max-width: none; 298 | } 299 | 300 | .stats-grid { 301 | grid-template-columns: 1fr; 302 | } 303 | } 304 | 305 | /* Scrollbar styling */ 306 | .dashboard-content::-webkit-scrollbar, 307 | .analysis-list::-webkit-scrollbar { 308 | width: 6px; 309 | } 310 | 311 | .dashboard-content::-webkit-scrollbar-track, 312 | .analysis-list::-webkit-scrollbar-track { 313 | background: var(--scrollbar-track, #f1f1f1); 314 | } 315 | 316 | .dashboard-content::-webkit-scrollbar-thumb, 317 | .analysis-list::-webkit-scrollbar-thumb { 318 | background: var(--scrollbar-thumb, #c1c1c1); 319 | border-radius: 3px; 320 | } 321 | 322 | .dashboard-content::-webkit-scrollbar-thumb:hover, 323 | .analysis-list::-webkit-scrollbar-thumb:hover { 324 | background: var(--scrollbar-thumb-hover, #a8a8a8); 325 | } 326 | 327 | /* Animation for stats updates */ 328 | .stat-value { 329 | transition: color 0.3s ease; 330 | } 331 | 332 | .stat-value.updated { 333 | color: var(--success-color, #28a745); 334 | } 335 | 336 | /* Performance indicators */ 337 | .stat-item.performance-good .stat-value { 338 | color: var(--success-color, #28a745); 339 | } 340 | 341 | .stat-item.performance-warning .stat-value { 342 | color: var(--warning-color, #ffc107); 343 | } 344 | 345 | .stat-item.performance-poor .stat-value { 346 | color: var(--danger-color, #dc3545); 347 | } 348 | 349 | /* Loading state */ 350 | .dashboard-section.loading { 351 | opacity: 0.6; 352 | pointer-events: none; 353 | } 354 | 355 | .dashboard-section.loading::after { 356 | content: ''; 357 | position: absolute; 358 | top: 50%; 359 | left: 50%; 360 | width: 20px; 361 | height: 20px; 362 | margin: -10px 0 0 -10px; 363 | border: 2px solid var(--accent-color, #007acc); 364 | border-top: 2px solid transparent; 365 | border-radius: 50%; 366 | animation: spin 1s linear infinite; 367 | } 368 | 369 | @keyframes spin { 370 | 0% { transform: rotate(0deg); } 371 | 100% { transform: rotate(360deg); } 372 | } 373 | ``` -------------------------------------------------------------------------------- /webui/src/types/workflow.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Workflow event types and utilities for agent workflows 3 | */ 4 | 5 | // Base workflow event interface 6 | export interface BaseWorkflowEvent { 7 | id: string; 8 | timestamp: number; 9 | source: 'agent' | 'tool' | 'workflow' | 'user'; 10 | } 11 | 12 | // Market analysis workflow events 13 | export interface MarketAnalysisRequestEvent extends BaseWorkflowEvent { 14 | type: 'market_analysis_request'; 15 | data: { 16 | symbol: string; 17 | analysisType: 'quick' | 'standard' | 'comprehensive'; 18 | userQuery: string; 19 | preferences: { 20 | includeTechnical: boolean; 21 | includeStructure: boolean; 22 | includeRisk: boolean; 23 | }; 24 | }; 25 | } 26 | 27 | export interface TechnicalDataGatheredEvent extends BaseWorkflowEvent { 28 | type: 'technical_data_gathered'; 29 | data: { 30 | symbol: string; 31 | priceData: any; 32 | indicators: any; 33 | volume: any; 34 | confidence: number; 35 | }; 36 | } 37 | 38 | export interface StructureAnalysisCompleteEvent extends BaseWorkflowEvent { 39 | type: 'structure_analysis_complete'; 40 | data: { 41 | symbol: string; 42 | orderBlocks: any; 43 | marketStructure: any; 44 | liquidityZones: any; 45 | confidence: number; 46 | }; 47 | } 48 | 49 | export interface RiskAssessmentDoneEvent extends BaseWorkflowEvent { 50 | type: 'risk_assessment_done'; 51 | data: { 52 | symbol: string; 53 | riskLevel: 'low' | 'medium' | 'high'; 54 | positionSizing: any; 55 | stopLoss: number; 56 | takeProfit: number; 57 | confidence: number; 58 | }; 59 | } 60 | 61 | export interface FinalRecommendationEvent extends BaseWorkflowEvent { 62 | type: 'final_recommendation'; 63 | data: { 64 | symbol: string; 65 | action: 'buy' | 'sell' | 'hold' | 'wait'; 66 | confidence: number; 67 | reasoning: string; 68 | technicalAnalysis?: any; 69 | structureAnalysis?: any; 70 | riskAssessment?: any; 71 | timeframe: string; 72 | }; 73 | } 74 | 75 | // Agent communication events 76 | export interface AgentHandoffEvent extends BaseWorkflowEvent { 77 | type: 'agent_handoff'; 78 | data: { 79 | fromAgent: string; 80 | toAgent: string; 81 | context: any; 82 | reason: string; 83 | }; 84 | } 85 | 86 | export interface AgentCollaborationEvent extends BaseWorkflowEvent { 87 | type: 'agent_collaboration'; 88 | data: { 89 | participants: string[]; 90 | topic: string; 91 | consensus?: any; 92 | disagreements?: any; 93 | }; 94 | } 95 | 96 | // Tool execution events 97 | export interface ToolExecutionStartEvent extends BaseWorkflowEvent { 98 | type: 'tool_execution_start'; 99 | data: { 100 | toolName: string; 101 | parameters: Record<string, any>; 102 | expectedDuration?: number; 103 | agent: string; 104 | }; 105 | } 106 | 107 | export interface ToolExecutionCompleteEvent extends BaseWorkflowEvent { 108 | type: 'tool_execution_complete'; 109 | data: { 110 | toolName: string; 111 | parameters: Record<string, any>; 112 | result: any; 113 | duration: number; 114 | success: boolean; 115 | agent: string; 116 | }; 117 | } 118 | 119 | export interface ToolExecutionErrorEvent extends BaseWorkflowEvent { 120 | type: 'tool_execution_error'; 121 | data: { 122 | toolName: string; 123 | parameters: Record<string, any>; 124 | error: string; 125 | duration: number; 126 | agent: string; 127 | retryable: boolean; 128 | }; 129 | } 130 | 131 | // Workflow control events 132 | export interface WorkflowStartEvent extends BaseWorkflowEvent { 133 | type: 'workflow_start'; 134 | data: { 135 | workflowName: string; 136 | initialQuery: string; 137 | configuration: any; 138 | }; 139 | } 140 | 141 | export interface WorkflowStepEvent extends BaseWorkflowEvent { 142 | type: 'workflow_step'; 143 | data: { 144 | stepName: string; 145 | stepDescription: string; 146 | progress: number; 147 | totalSteps: number; 148 | currentAgent?: string; 149 | }; 150 | } 151 | 152 | export interface WorkflowCompleteEvent extends BaseWorkflowEvent { 153 | type: 'workflow_complete'; 154 | data: { 155 | workflowName: string; 156 | result: any; 157 | duration: number; 158 | stepsCompleted: number; 159 | success: boolean; 160 | }; 161 | } 162 | 163 | export interface WorkflowErrorEvent extends BaseWorkflowEvent { 164 | type: 'workflow_error'; 165 | data: { 166 | workflowName: string; 167 | error: string; 168 | step?: string; 169 | agent?: string; 170 | recoverable: boolean; 171 | }; 172 | } 173 | 174 | // Agent thinking and reasoning events 175 | export interface AgentReasoningEvent extends BaseWorkflowEvent { 176 | type: 'agent_reasoning'; 177 | data: { 178 | agent: string; 179 | thought: string; 180 | nextAction: string; 181 | confidence: number; 182 | context: any; 183 | }; 184 | } 185 | 186 | export interface AgentDecisionEvent extends BaseWorkflowEvent { 187 | type: 'agent_decision'; 188 | data: { 189 | agent: string; 190 | decision: string; 191 | reasoning: string; 192 | alternatives: string[]; 193 | confidence: number; 194 | }; 195 | } 196 | 197 | // Union type for all workflow events 198 | export type WorkflowEvent = 199 | | MarketAnalysisRequestEvent 200 | | TechnicalDataGatheredEvent 201 | | StructureAnalysisCompleteEvent 202 | | RiskAssessmentDoneEvent 203 | | FinalRecommendationEvent 204 | | AgentHandoffEvent 205 | | AgentCollaborationEvent 206 | | ToolExecutionStartEvent 207 | | ToolExecutionCompleteEvent 208 | | ToolExecutionErrorEvent 209 | | WorkflowStartEvent 210 | | WorkflowStepEvent 211 | | WorkflowCompleteEvent 212 | | WorkflowErrorEvent 213 | | AgentReasoningEvent 214 | | AgentDecisionEvent; 215 | 216 | // Event utilities 217 | export class WorkflowEventEmitter { 218 | private listeners: Map<string, ((event: WorkflowEvent) => void)[]> = new Map(); 219 | 220 | on(eventType: string, listener: (event: WorkflowEvent) => void): void { 221 | if (!this.listeners.has(eventType)) { 222 | this.listeners.set(eventType, []); 223 | } 224 | this.listeners.get(eventType)!.push(listener); 225 | } 226 | 227 | off(eventType: string, listener: (event: WorkflowEvent) => void): void { 228 | const listeners = this.listeners.get(eventType); 229 | if (listeners) { 230 | const index = listeners.indexOf(listener); 231 | if (index > -1) { 232 | listeners.splice(index, 1); 233 | } 234 | } 235 | } 236 | 237 | emit(event: WorkflowEvent): void { 238 | const listeners = this.listeners.get(event.type); 239 | if (listeners) { 240 | listeners.forEach(listener => listener(event)); 241 | } 242 | 243 | // Also emit to 'all' listeners 244 | const allListeners = this.listeners.get('all'); 245 | if (allListeners) { 246 | allListeners.forEach(listener => listener(event)); 247 | } 248 | } 249 | 250 | clear(): void { 251 | this.listeners.clear(); 252 | } 253 | } 254 | 255 | // Event factory functions 256 | export function createWorkflowEvent<T extends WorkflowEvent>( 257 | type: T['type'], 258 | data: T['data'], 259 | source: BaseWorkflowEvent['source'] = 'workflow' 260 | ): T { 261 | return { 262 | id: `${type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, 263 | timestamp: Date.now(), 264 | source, 265 | type, 266 | data 267 | } as T; 268 | } 269 | 270 | // Event type guards 271 | export function isToolEvent(event: WorkflowEvent): event is ToolExecutionStartEvent | ToolExecutionCompleteEvent | ToolExecutionErrorEvent { 272 | return event.type.startsWith('tool_execution'); 273 | } 274 | 275 | export function isAgentEvent(event: WorkflowEvent): event is AgentHandoffEvent | AgentCollaborationEvent | AgentReasoningEvent | AgentDecisionEvent { 276 | return event.type.startsWith('agent_'); 277 | } 278 | 279 | export function isWorkflowControlEvent(event: WorkflowEvent): event is WorkflowStartEvent | WorkflowStepEvent | WorkflowCompleteEvent | WorkflowErrorEvent { 280 | return event.type.startsWith('workflow_'); 281 | } 282 | 283 | export function isAnalysisEvent(event: WorkflowEvent): event is MarketAnalysisRequestEvent | TechnicalDataGatheredEvent | StructureAnalysisCompleteEvent | RiskAssessmentDoneEvent | FinalRecommendationEvent { 284 | return ['market_analysis_request', 'technical_data_gathered', 'structure_analysis_complete', 'risk_assessment_done', 'final_recommendation'].includes(event.type); 285 | } 286 | ``` -------------------------------------------------------------------------------- /webui/src/services/citationStore.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Citation store for managing tool response data and references 3 | */ 4 | 5 | import type { CitationData, ExtractedMetric } from '@/types/citation'; 6 | 7 | export class CitationStore { 8 | private citations: Map<string, CitationData> = new Map(); 9 | private maxCitations = 100; // Limit to prevent memory issues 10 | private cleanupThreshold = 120; // Clean up citations older than 2 hours 11 | 12 | /** 13 | * Store tool response data with citation metadata 14 | */ 15 | storeCitation(data: CitationData): void { 16 | this.citations.set(data.referenceId, data); 17 | 18 | // Clean up old citations if we exceed the limit 19 | if (this.citations.size > this.maxCitations) { 20 | this.cleanupOldCitations(); 21 | } 22 | } 23 | 24 | /** 25 | * Retrieve citation data by reference ID 26 | */ 27 | getCitation(referenceId: string): CitationData | undefined { 28 | return this.citations.get(referenceId); 29 | } 30 | 31 | /** 32 | * Get all citations sorted by timestamp (newest first) 33 | */ 34 | getAllCitations(): CitationData[] { 35 | return Array.from(this.citations.values()) 36 | .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); 37 | } 38 | 39 | /** 40 | * Get recent citations (last N citations) 41 | */ 42 | getRecentCitations(limit: number = 10): CitationData[] { 43 | return this.getAllCitations().slice(0, limit); 44 | } 45 | 46 | /** 47 | * Extract key metrics from tool response data 48 | */ 49 | extractMetrics(toolName: string, rawData: any): ExtractedMetric[] { 50 | const metrics: ExtractedMetric[] = []; 51 | 52 | try { 53 | switch (toolName) { 54 | case 'get_ticker': 55 | if (rawData.lastPrice) { 56 | metrics.push({ 57 | type: 'price', 58 | label: 'Last Price', 59 | value: rawData.lastPrice, 60 | unit: 'USD', 61 | significance: 'high' 62 | }); 63 | } 64 | if (rawData.price24hPcnt) { 65 | metrics.push({ 66 | type: 'percentage', 67 | label: '24h Change', 68 | value: rawData.price24hPcnt, 69 | unit: '%', 70 | significance: 'high' 71 | }); 72 | } 73 | if (rawData.volume24h) { 74 | metrics.push({ 75 | type: 'volume', 76 | label: '24h Volume', 77 | value: rawData.volume24h, 78 | significance: 'medium' 79 | }); 80 | } 81 | break; 82 | 83 | case 'get_kline': 84 | if (rawData.data && Array.isArray(rawData.data) && rawData.data.length > 0) { 85 | const latestCandle = rawData.data[0]; 86 | if (latestCandle.close) { 87 | metrics.push({ 88 | type: 'price', 89 | label: 'Close Price', 90 | value: latestCandle.close, 91 | unit: 'USD', 92 | significance: 'high' 93 | }); 94 | } 95 | } 96 | break; 97 | 98 | case 'get_ml_rsi': 99 | if (rawData.data && Array.isArray(rawData.data) && rawData.data.length > 0) { 100 | const latestRsi = rawData.data[0]; 101 | if (latestRsi.mlRsi !== undefined) { 102 | metrics.push({ 103 | type: 'indicator', 104 | label: 'ML RSI', 105 | value: latestRsi.mlRsi.toFixed(2), 106 | significance: 'high' 107 | }); 108 | } 109 | if (latestRsi.trend) { 110 | metrics.push({ 111 | type: 'other', 112 | label: 'Trend', 113 | value: latestRsi.trend, 114 | significance: 'medium' 115 | }); 116 | } 117 | } 118 | break; 119 | 120 | case 'get_orderbook': 121 | if (rawData.bids && rawData.bids.length > 0) { 122 | metrics.push({ 123 | type: 'price', 124 | label: 'Best Bid', 125 | value: rawData.bids[0][0], 126 | unit: 'USD', 127 | significance: 'high' 128 | }); 129 | } 130 | if (rawData.asks && rawData.asks.length > 0) { 131 | metrics.push({ 132 | type: 'price', 133 | label: 'Best Ask', 134 | value: rawData.asks[0][0], 135 | unit: 'USD', 136 | significance: 'high' 137 | }); 138 | } 139 | break; 140 | 141 | default: 142 | // Generic extraction for unknown tools 143 | if (typeof rawData === 'object' && rawData !== null) { 144 | Object.entries(rawData).forEach(([key, value]) => { 145 | if (typeof value === 'string' || typeof value === 'number') { 146 | metrics.push({ 147 | type: 'other', 148 | label: key, 149 | value: value, 150 | significance: 'low' 151 | }); 152 | } 153 | }); 154 | } 155 | break; 156 | } 157 | } catch (error) { 158 | console.warn('Error extracting metrics:', error); 159 | } 160 | 161 | return metrics.slice(0, 5); // Limit to 5 key metrics 162 | } 163 | 164 | /** 165 | * Process tool response and store citation if it has reference metadata 166 | */ 167 | processToolResponse(toolResponse: any): void { 168 | console.log('🔍 Processing tool response for citations:', toolResponse); 169 | 170 | if (!toolResponse || typeof toolResponse !== 'object') { 171 | console.log('❌ Invalid tool response format'); 172 | return; 173 | } 174 | 175 | // MCP responses are wrapped in a content array, so we need to extract the actual data 176 | let actualData = toolResponse; 177 | 178 | // Check if response has content array (MCP format) 179 | if (toolResponse.content && Array.isArray(toolResponse.content) && toolResponse.content.length > 0) { 180 | console.log('🔍 Found MCP content array, extracting data...'); 181 | const contentItem = toolResponse.content[0]; 182 | if (contentItem.type === 'text' && contentItem.text) { 183 | try { 184 | actualData = JSON.parse(contentItem.text); 185 | console.log('🔍 Parsed content data:', actualData); 186 | } catch (e) { 187 | console.log('❌ Failed to parse content text as JSON'); 188 | return; 189 | } 190 | } 191 | } 192 | 193 | // Check if response has reference metadata 194 | if (actualData._referenceId && actualData._timestamp && actualData._toolName) { 195 | console.log('✅ Found reference metadata:', { 196 | referenceId: actualData._referenceId, 197 | toolName: actualData._toolName, 198 | timestamp: actualData._timestamp 199 | }); 200 | 201 | const extractedMetrics = this.extractMetrics(actualData._toolName, actualData); 202 | 203 | const citationData: CitationData = { 204 | referenceId: actualData._referenceId, 205 | timestamp: actualData._timestamp, 206 | toolName: actualData._toolName, 207 | endpoint: actualData._endpoint, 208 | rawData: actualData, 209 | extractedMetrics 210 | }; 211 | 212 | this.storeCitation(citationData); 213 | console.log('📋 Stored citation data for', actualData._referenceId); 214 | } else { 215 | console.log('❌ No reference metadata found in tool response'); 216 | console.log('🔍 Available keys in actualData:', Object.keys(actualData)); 217 | } 218 | } 219 | 220 | /** 221 | * Clean up citations older than the threshold 222 | */ 223 | private cleanupOldCitations(): void { 224 | const now = Date.now(); 225 | const thresholdMs = this.cleanupThreshold * 60 * 1000; // Convert minutes to milliseconds 226 | 227 | for (const [referenceId, citation] of this.citations.entries()) { 228 | const citationAge = now - new Date(citation.timestamp).getTime(); 229 | if (citationAge > thresholdMs) { 230 | this.citations.delete(referenceId); 231 | } 232 | } 233 | 234 | // Cleanup completed silently 235 | } 236 | 237 | /** 238 | * Clear all citations 239 | */ 240 | clear(): void { 241 | this.citations.clear(); 242 | } 243 | 244 | /** 245 | * Get citation count 246 | */ 247 | getCount(): number { 248 | return this.citations.size; 249 | } 250 | } 251 | 252 | // Singleton instance 253 | export const citationStore = new CitationStore(); 254 | ``` -------------------------------------------------------------------------------- /client/src/cli.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | import { Command } from 'commander' 3 | import chalk from 'chalk' 4 | import { BybitMcpClient, Message } from './client.js' 5 | import { Config } from './config.js' 6 | import { createInterface } from 'readline' 7 | 8 | const program = new Command() 9 | const config = new Config() 10 | let client: BybitMcpClient | null = null 11 | 12 | // Debug helper to log configuration 13 | function logDebugInfo() { 14 | if (config.get('debug')) { 15 | console.log(chalk.yellow('Debug Info:')) 16 | console.log('Ollama Host:', config.get('ollamaHost')) 17 | console.log('Default Model:', config.get('defaultModel')) 18 | console.log('Debug Mode:', config.get('debug')) 19 | } 20 | } 21 | 22 | program 23 | .name('bybit-mcp-client') 24 | .description('CLI for interacting with Ollama LLMs and bybit-mcp server') 25 | .version('0.1.0') 26 | .option('-i, --integrated', 'Run in integrated mode with built-in server') 27 | .option('-d, --debug', 'Enable debug logging') 28 | 29 | program 30 | .command('config') 31 | .description('Configure client settings') 32 | .option('-h, --ollama-host <url>', 'Set Ollama host URL') 33 | .option('-m, --default-model <model>', 'Set default Ollama model') 34 | .option('-d, --debug <boolean>', 'Enable/disable debug mode') 35 | .action((options: { ollamaHost?: string; defaultModel?: string; debug?: string }) => { 36 | if (options.ollamaHost) { 37 | config.set('ollamaHost', options.ollamaHost) 38 | console.log(chalk.green(`Ollama host set to: ${options.ollamaHost}`)) 39 | } 40 | if (options.defaultModel) { 41 | config.set('defaultModel', options.defaultModel) 42 | console.log(chalk.green(`Default model set to: ${options.defaultModel}`)) 43 | } 44 | if (options.debug !== undefined) { 45 | const debugEnabled = options.debug.toLowerCase() === 'true' 46 | config.set('debug', debugEnabled) 47 | console.log(chalk.green(`Debug mode ${debugEnabled ? 'enabled' : 'disabled'}`)) 48 | } 49 | logDebugInfo() 50 | }) 51 | 52 | program 53 | .command('models') 54 | .description('List available Ollama models') 55 | .action(async () => { 56 | try { 57 | logDebugInfo() 58 | client = new BybitMcpClient(config) 59 | const models = await client.listModels() 60 | console.log(chalk.cyan('Available models:')) 61 | models.forEach(model => console.log(` ${model}`)) 62 | } catch (error) { 63 | console.error(chalk.red('Error listing models:'), error) 64 | } finally { 65 | await client?.close() 66 | } 67 | }) 68 | 69 | program 70 | .command('tools') 71 | .description('List available bybit-mcp tools') 72 | .argument('[server-command]', 'Command to start the bybit-mcp server (not needed in integrated mode)') 73 | .action(async (serverCommand?: string) => { 74 | try { 75 | logDebugInfo() 76 | client = new BybitMcpClient(config) 77 | 78 | if (program.opts().integrated) { 79 | if (program.opts().debug) { 80 | console.log(chalk.yellow('Starting integrated server...')) 81 | } 82 | await client.startIntegratedServer() 83 | if (program.opts().debug) { 84 | console.log(chalk.green('Started integrated server')) 85 | } 86 | } else if (serverCommand) { 87 | await client.connectToServer(serverCommand) 88 | } else { 89 | throw new Error('Either use --integrated or provide a server command') 90 | } 91 | 92 | const tools = await client.listTools() 93 | console.log(chalk.cyan('Available tools:')) 94 | tools.forEach(tool => { 95 | console.log(chalk.bold(`\n${tool.name}`)) 96 | if (tool.description) console.log(` Description: ${tool.description}`) 97 | if (tool.inputSchema) console.log(` Input Schema: ${JSON.stringify(tool.inputSchema, null, 2)}`) 98 | }) 99 | } catch (error) { 100 | console.error(chalk.red('Error listing tools:'), error) 101 | } finally { 102 | await client?.close() 103 | } 104 | }) 105 | 106 | program 107 | .command('chat') 108 | .description('Chat with an Ollama model') 109 | .argument('[model]', 'Model to use (defaults to config setting)') 110 | .option('-s, --system <message>', 'System message to set context') 111 | .action(async (modelArg: string | undefined, options: { system?: string }) => { 112 | try { 113 | // Enable debug mode for chat to help diagnose issues 114 | config.set('debug', true) 115 | logDebugInfo() 116 | 117 | client = new BybitMcpClient(config) 118 | 119 | // Always start in integrated mode for chat 120 | if (program.opts().debug) { 121 | console.log(chalk.yellow('Starting integrated server for chat...')) 122 | } 123 | await client.startIntegratedServer() 124 | if (program.opts().debug) { 125 | console.log(chalk.green('Started integrated server')) 126 | } 127 | 128 | const model = modelArg || config.get('defaultModel') 129 | if (!model) { 130 | throw new Error('No model specified and no default model configured') 131 | } 132 | 133 | const messages: Message[] = [] 134 | 135 | if (options.system) { 136 | messages.push({ role: 'system', content: options.system }) 137 | } 138 | 139 | console.log(chalk.cyan(`Chatting with ${model} (Ctrl+C to exit)`)) 140 | console.log(chalk.yellow('Tools are available - ask about cryptocurrency data!')) 141 | 142 | // Start chat loop 143 | while (true) { 144 | const userInput = await question(chalk.green('You: ')) 145 | if (!userInput) continue 146 | 147 | messages.push({ role: 'user', content: userInput }) 148 | 149 | process.stdout.write(chalk.blue('Assistant: ')) 150 | await client.streamChat(model, messages, (token) => { 151 | process.stdout.write(token) 152 | }) 153 | process.stdout.write('\n') 154 | 155 | messages.push({ role: 'assistant', content: await client.chat(model, messages) }) 156 | } 157 | } catch (error) { 158 | console.error(chalk.red('Error in chat:'), error) 159 | if (program.opts().debug) { 160 | console.error('Full error:', error) 161 | } 162 | } finally { 163 | await client?.close() 164 | } 165 | }) 166 | 167 | program 168 | .command('tool') 169 | .description('Call a bybit-mcp tool') 170 | .argument('[server-command]', 'Command to start the bybit-mcp server (not needed in integrated mode)') 171 | .argument('<tool-name>', 'Name of the tool to call') 172 | .argument('[args...]', 'Tool arguments as key=value pairs') 173 | .action(async (serverCommand: string | undefined, toolName: string, args: string[]) => { 174 | try { 175 | logDebugInfo() 176 | client = new BybitMcpClient(config) 177 | 178 | if (program.opts().integrated) { 179 | if (program.opts().debug) { 180 | console.log(chalk.yellow('Starting integrated server...')) 181 | } 182 | await client.startIntegratedServer() 183 | if (program.opts().debug) { 184 | console.log(chalk.green('Started integrated server')) 185 | } 186 | } else if (serverCommand) { 187 | await client.connectToServer(serverCommand) 188 | } else { 189 | throw new Error('Either use --integrated or provide a server command') 190 | } 191 | 192 | // Parse arguments 193 | const toolArgs: Record<string, unknown> = {} 194 | args.forEach((arg: string) => { 195 | const [key, value] = arg.split('=') 196 | if (key && value) { 197 | // Try to parse as number or boolean if possible 198 | if (value === 'true') toolArgs[key] = true 199 | else if (value === 'false') toolArgs[key] = false 200 | else if (!isNaN(Number(value))) toolArgs[key] = Number(value) 201 | else toolArgs[key] = value 202 | } 203 | }) 204 | 205 | const result = await client.callTool(toolName, toolArgs) 206 | console.log(result) 207 | } catch (error) { 208 | console.error(chalk.red('Error calling tool:'), error) 209 | } finally { 210 | await client?.close() 211 | } 212 | }) 213 | 214 | // Helper function to read user input 215 | function question(query: string): Promise<string> { 216 | const readline = createInterface({ 217 | input: process.stdin, 218 | output: process.stdout 219 | }) 220 | 221 | return new Promise(resolve => readline.question(query, (answer: string) => { 222 | readline.close() 223 | resolve(answer) 224 | })) 225 | } 226 | 227 | // Set debug mode from command line option 228 | if (program.opts().debug) { 229 | config.set('debug', true) 230 | } 231 | 232 | program.parse() 233 | ``` -------------------------------------------------------------------------------- /webui/index.html: -------------------------------------------------------------------------------- ```html 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8" /> 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 | <title>Bybit MCP WebUI</title> 7 | <meta name="description" content="Modern web interface for Bybit MCP server with AI chat capabilities" /> 8 | <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 9 | 10 | <!-- Preload critical CSS to prevent layout shift --> 11 | <link rel="preload" href="/src/styles/main.css" as="style" /> 12 | </head> 13 | <body> 14 | <div id="app"> 15 | <!-- Loading spinner --> 16 | <div id="loading" class="loading-container"> 17 | <div class="loading-spinner"></div> 18 | <p>Loading Bybit MCP WebUI...</p> 19 | </div> 20 | 21 | <!-- Main application container --> 22 | <div id="main-container" class="main-container hidden"> 23 | <!-- Header --> 24 | <header class="header"> 25 | <div class="header-content"> 26 | <div class="logo"> 27 | <h1>Bybit MCP</h1> 28 | <span class="version">v1.0.0</span> 29 | </div> 30 | <div class="header-controls"> 31 | <button id="agent-dashboard-btn" class="agent-dashboard-btn" aria-label="Agent Dashboard"> 32 | <span class="dashboard-icon">🤖</span> 33 | </button> 34 | <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme"> 35 | <span class="theme-icon">🌙</span> 36 | </button> 37 | <button id="settings-btn" class="settings-btn" aria-label="Settings"> 38 | <span class="settings-icon">⚙️</span> 39 | </button> 40 | </div> 41 | </div> 42 | </header> 43 | 44 | <!-- Main content area --> 45 | <main class="main-content"> 46 | <!-- Sidebar --> 47 | <aside class="sidebar"> 48 | <nav class="nav-menu"> 49 | <button class="nav-item active" data-view="chat"> 50 | <span class="nav-icon">💬</span> 51 | <span class="nav-label">AI Chat</span> 52 | </button> 53 | <button class="nav-item" data-view="tools"> 54 | <span class="nav-icon">🔧</span> 55 | <span class="nav-label">MCP Tools</span> 56 | </button> 57 | <button class="nav-item" data-view="dashboard"> 58 | <span class="nav-icon">🤖</span> 59 | <span class="nav-label">Agent Dashboard</span> 60 | </button> 61 | </nav> 62 | </aside> 63 | 64 | <!-- Content area --> 65 | <section class="content-area"> 66 | <!-- Chat View --> 67 | <div id="chat-view" class="view active"> 68 | <div class="chat-container"> 69 | <div class="chat-messages" id="chat-messages"> 70 | <div class="welcome-message"> 71 | <h2>Welcome to Bybit MCP AI Assistant</h2> 72 | <p>Ask me anything about cryptocurrency markets, trading data, or technical analysis!</p> 73 | <div class="example-queries"> 74 | <button class="example-query">What's the current BTC price?</button> 75 | <button class="example-query">Check for XRPUSDT order blocks in the 30 minute window</button> 76 | <button class="example-query">Review XRPUSDT over the past week, where is the price likely to go in the next day?</button> 77 | <button class="example-query">Analyse XRPUSDT with ML-RSI</button> 78 | <button class="example-query">Compare XRPUSDT RSI with ML-RSI</button> 79 | <button class="example-query">Show me the latest price candles for XRPUSDT</button> 80 | </div> 81 | </div> 82 | </div> 83 | <div class="chat-input-container"> 84 | <div class="chat-input-wrapper"> 85 | <textarea 86 | id="chat-input" 87 | class="chat-input" 88 | placeholder="Ask about markets, trading data, or technical analysis..." 89 | rows="1" 90 | ></textarea> 91 | <button id="send-btn" class="send-btn" disabled> 92 | <span class="send-icon">➤</span> 93 | </button> 94 | </div> 95 | <div class="input-status"> 96 | <span id="connection-status" class="connection-status">🔴 Disconnected</span> 97 | <span id="typing-indicator" class="typing-indicator hidden">AI is typing...</span> 98 | </div> 99 | </div> 100 | </div> 101 | </div> 102 | 103 | <!-- Tools View --> 104 | <div id="tools-view" class="view"> 105 | <div class="tools-container"> 106 | <h2>MCP Tools</h2> 107 | <div class="tools-grid" id="tools-grid"> 108 | <!-- Tools will be populated dynamically --> 109 | </div> 110 | </div> 111 | </div> 112 | 113 | <!-- Dashboard View --> 114 | <div id="dashboard-view" class="view"> 115 | <div class="dashboard-container"> 116 | <h2>Agent Dashboard</h2> 117 | <div id="dashboard-content-wrapper"> 118 | <!-- Agent dashboard will be embedded here --> 119 | </div> 120 | </div> 121 | </div> 122 | </section> 123 | </main> 124 | </div> 125 | 126 | <!-- Settings Modal --> 127 | <div id="settings-modal" class="modal hidden"> 128 | <div class="modal-content"> 129 | <div class="modal-header"> 130 | <h2>Settings</h2> 131 | <button id="close-settings" class="close-btn">×</button> 132 | </div> 133 | <div class="modal-body"> 134 | <div class="settings-section"> 135 | <h3>AI Configuration</h3> 136 | <label for="ai-endpoint">AI Endpoint:</label> 137 | <input type="url" id="ai-endpoint" placeholder="https://ollama.example.com" /> 138 | <label for="ai-model">Model:</label> 139 | <input type="text" id="ai-model" placeholder="qwen3-30b-a3b-ud-nothink-128k:q4_k_xl" /> 140 | </div> 141 | <div class="settings-section"> 142 | <h3>MCP Server</h3> 143 | <label for="mcp-endpoint">MCP Endpoint:</label> 144 | <input type="url" id="mcp-endpoint" placeholder="(auto-detect current domain)" /> 145 | </div> 146 | <div class="settings-section"> 147 | <h3>Agent Settings</h3> 148 | <label class="checkbox-label"> 149 | <input type="checkbox" id="agent-mode-enabled" /> 150 | <span>Enable Agent Mode</span> 151 | <small>Use multi-step reasoning agent instead of simple AI chat</small> 152 | </label> 153 | <label for="max-iterations">Max Iterations:</label> 154 | <input type="number" id="max-iterations" min="1" max="20" value="5" /> 155 | <label for="tool-timeout">Tool Timeout (ms):</label> 156 | <input type="number" id="tool-timeout" min="5000" max="120000" step="1000" value="30000" /> 157 | <label class="checkbox-label"> 158 | <input type="checkbox" id="show-workflow-steps" /> 159 | <span>Show Workflow Steps</span> 160 | <small>Display agent reasoning process</small> 161 | </label> 162 | <label class="checkbox-label"> 163 | <input type="checkbox" id="show-tool-calls" /> 164 | <span>Show Tool Calls</span> 165 | <small>Display tool execution details</small> 166 | </label> 167 | <label class="checkbox-label"> 168 | <input type="checkbox" id="enable-debug-mode" /> 169 | <span>Debug Mode</span> 170 | <small>Enable verbose logging</small> 171 | </label> 172 | </div> 173 | </div> 174 | <div class="modal-footer"> 175 | <button id="save-settings" class="save-btn">Save Settings</button> 176 | </div> 177 | </div> 178 | </div> 179 | 180 | <!-- Agent Dashboard --> 181 | <div id="agent-dashboard-container"></div> 182 | 183 | <!-- Data Verification Panel --> 184 | <div id="verification-panel-container"></div> 185 | </div> 186 | 187 | <script type="module" src="/src/main.ts"></script> 188 | </body> 189 | </html> 190 | ``` -------------------------------------------------------------------------------- /src/tools/GetOrderBlocks.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 } from "../utils/mathUtils.js" 6 | import { 7 | detectOrderBlocks, 8 | getActiveLevels, 9 | calculateOrderBlockStats, 10 | OrderBlock, 11 | VolumeAnalysisConfig 12 | } from "../utils/volumeAnalysis.js" 13 | import { GetKlineParamsV5, KlineIntervalV3 } from "bybit-api" 14 | 15 | // Zod schema for input validation 16 | const inputSchema = z.object({ 17 | symbol: z.string() 18 | .min(1, "Symbol is required") 19 | .regex(/^[A-Z0-9]+$/, "Symbol must contain only uppercase letters and numbers"), 20 | category: z.enum(["spot", "linear", "inverse"]), 21 | interval: z.enum(["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"]), 22 | volumePivotLength: z.number().min(1).max(20).optional().default(5), 23 | bullishBlocks: z.number().min(1).max(10).optional().default(3), 24 | bearishBlocks: z.number().min(1).max(10).optional().default(3), 25 | mitigationMethod: z.enum(["wick", "close"]).optional().default("wick"), 26 | limit: z.number().min(100).max(1000).optional().default(200) 27 | }) 28 | 29 | type ToolArguments = z.infer<typeof inputSchema> 30 | 31 | interface OrderBlockResponse { 32 | symbol: string; 33 | interval: string; 34 | bullishBlocks: Array<{ 35 | id: string; 36 | timestamp: number; 37 | top: number; 38 | bottom: number; 39 | average: number; 40 | volume: number; 41 | mitigated: boolean; 42 | mitigationTime?: number; 43 | }>; 44 | bearishBlocks: Array<{ 45 | id: string; 46 | timestamp: number; 47 | top: number; 48 | bottom: number; 49 | average: number; 50 | volume: number; 51 | mitigated: boolean; 52 | mitigationTime?: number; 53 | }>; 54 | currentSupport: number[]; 55 | currentResistance: number[]; 56 | metadata: { 57 | volumePivotLength: number; 58 | mitigationMethod: string; 59 | blocksDetected: number; 60 | activeBullishBlocks: number; 61 | activeBearishBlocks: number; 62 | averageVolume: number; 63 | calculationTime: number; 64 | }; 65 | } 66 | 67 | class GetOrderBlocks extends BaseToolImplementation { 68 | name = "get_order_blocks" 69 | toolDefinition: Tool = { 70 | name: this.name, 71 | description: "Detect institutional order accumulation zones based on volume analysis. Identifies bullish and bearish order blocks using volume peaks and tracks their mitigation status.", 72 | inputSchema: { 73 | type: "object", 74 | properties: { 75 | symbol: { 76 | type: "string", 77 | description: "Trading pair symbol (e.g., 'BTCUSDT')", 78 | pattern: "^[A-Z0-9]+$" 79 | }, 80 | category: { 81 | type: "string", 82 | description: "Category of the instrument", 83 | enum: ["spot", "linear", "inverse"] 84 | }, 85 | interval: { 86 | type: "string", 87 | description: "Kline interval", 88 | enum: ["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"] 89 | }, 90 | volumePivotLength: { 91 | type: "number", 92 | description: "Volume pivot detection period (default: 5)", 93 | minimum: 1, 94 | maximum: 20 95 | }, 96 | bullishBlocks: { 97 | type: "number", 98 | description: "Number of bullish blocks to track (default: 3)", 99 | minimum: 1, 100 | maximum: 10 101 | }, 102 | bearishBlocks: { 103 | type: "number", 104 | description: "Number of bearish blocks to track (default: 3)", 105 | minimum: 1, 106 | maximum: 10 107 | }, 108 | mitigationMethod: { 109 | type: "string", 110 | description: "Mitigation detection method (default: wick)", 111 | enum: ["wick", "close"] 112 | }, 113 | limit: { 114 | type: "number", 115 | description: "Historical data points to analyse (default: 200)", 116 | minimum: 100, 117 | maximum: 1000 118 | } 119 | }, 120 | required: ["symbol", "category", "interval"] 121 | } 122 | } 123 | 124 | async toolCall(request: z.infer<typeof CallToolRequestSchema>): Promise<CallToolResult> { 125 | const startTime = Date.now() 126 | 127 | try { 128 | this.logInfo("Starting get_order_blocks tool call") 129 | 130 | // Parse and validate input 131 | const validationResult = inputSchema.safeParse(request.params.arguments) 132 | if (!validationResult.success) { 133 | const errorDetails = validationResult.error.errors.map(err => ({ 134 | field: err.path.join('.'), 135 | message: err.message, 136 | code: err.code 137 | })) 138 | throw new Error(`Invalid input: ${JSON.stringify(errorDetails)}`) 139 | } 140 | 141 | const args = validationResult.data 142 | 143 | // Fetch kline data 144 | const klineData = await this.fetchKlineData(args) 145 | 146 | if (klineData.length < args.volumePivotLength * 2 + 10) { 147 | throw new Error(`Insufficient data. Need at least ${args.volumePivotLength * 2 + 10} data points, got ${klineData.length}`) 148 | } 149 | 150 | // Configure volume analysis 151 | const config: VolumeAnalysisConfig = { 152 | volumePivotLength: args.volumePivotLength, 153 | bullishBlocks: args.bullishBlocks, 154 | bearishBlocks: args.bearishBlocks, 155 | mitigationMethod: args.mitigationMethod 156 | } 157 | 158 | // Detect order blocks 159 | const { bullishBlocks, bearishBlocks } = detectOrderBlocks(klineData, config) 160 | 161 | // Get active support and resistance levels 162 | const { support, resistance } = getActiveLevels([...bullishBlocks, ...bearishBlocks]) 163 | 164 | // Calculate statistics 165 | const stats = calculateOrderBlockStats(bullishBlocks, bearishBlocks) 166 | 167 | const calculationTime = Date.now() - startTime 168 | 169 | const response: OrderBlockResponse = { 170 | symbol: args.symbol, 171 | interval: args.interval, 172 | bullishBlocks: bullishBlocks.map(block => ({ 173 | id: block.id, 174 | timestamp: block.timestamp, 175 | top: block.top, 176 | bottom: block.bottom, 177 | average: block.average, 178 | volume: block.volume, 179 | mitigated: block.mitigated, 180 | mitigationTime: block.mitigationTime 181 | })), 182 | bearishBlocks: bearishBlocks.map(block => ({ 183 | id: block.id, 184 | timestamp: block.timestamp, 185 | top: block.top, 186 | bottom: block.bottom, 187 | average: block.average, 188 | volume: block.volume, 189 | mitigated: block.mitigated, 190 | mitigationTime: block.mitigationTime 191 | })), 192 | currentSupport: support.slice(0, 5), // Top 5 support levels 193 | currentResistance: resistance.slice(0, 5), // Top 5 resistance levels 194 | metadata: { 195 | volumePivotLength: args.volumePivotLength, 196 | mitigationMethod: args.mitigationMethod, 197 | blocksDetected: stats.totalBlocks, 198 | activeBullishBlocks: stats.activeBullishBlocks, 199 | activeBearishBlocks: stats.activeBearishBlocks, 200 | averageVolume: stats.averageVolume, 201 | calculationTime 202 | } 203 | } 204 | 205 | this.logInfo(`Order block detection completed in ${calculationTime}ms. Found ${stats.totalBlocks} blocks (${stats.activeBullishBlocks} bullish, ${stats.activeBearishBlocks} bearish active)`) 206 | return this.formatResponse(response) 207 | 208 | } catch (error) { 209 | this.logInfo(`Order block detection failed: ${error instanceof Error ? error.message : String(error)}`) 210 | return this.handleError(error) 211 | } 212 | } 213 | 214 | private async fetchKlineData(args: ToolArguments): Promise<KlineData[]> { 215 | const params: GetKlineParamsV5 = { 216 | category: args.category, 217 | symbol: args.symbol, 218 | interval: args.interval as KlineIntervalV3, 219 | limit: args.limit 220 | } 221 | 222 | const response = await this.executeRequest(() => this.client.getKline(params)) 223 | 224 | if (!response.list || response.list.length === 0) { 225 | throw new Error("No kline data received from API") 226 | } 227 | 228 | // Convert API response to KlineData format 229 | return response.list.map(kline => ({ 230 | timestamp: parseInt(kline[0]), 231 | open: parseFloat(kline[1]), 232 | high: parseFloat(kline[2]), 233 | low: parseFloat(kline[3]), 234 | close: parseFloat(kline[4]), 235 | volume: parseFloat(kline[5]) 236 | })).reverse() // Reverse to get chronological order 237 | } 238 | } 239 | 240 | export default GetOrderBlocks 241 | ``` -------------------------------------------------------------------------------- /src/utils/mathUtils.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Mathematical utility functions for technical analysis 3 | * Inspired by pinescript mathematical operations 4 | */ 5 | 6 | export interface KlineData { 7 | timestamp: number; 8 | open: number; 9 | high: number; 10 | low: number; 11 | close: number; 12 | volume: number; 13 | } 14 | 15 | /** 16 | * Calculate RSI (Relative Strength Index) 17 | */ 18 | export function calculateRSI(prices: number[], period: number = 14): number[] { 19 | if (prices.length < period + 1) { 20 | return [] 21 | } 22 | 23 | const rsiValues: number[] = [] 24 | const gains: number[] = [] 25 | const losses: number[] = [] 26 | 27 | // Calculate initial gains and losses 28 | for (let i = 1; i < prices.length; i++) { 29 | const change = prices[i] - prices[i - 1] 30 | gains.push(change > 0 ? change : 0) 31 | losses.push(change < 0 ? Math.abs(change) : 0) 32 | } 33 | 34 | // Calculate initial average gain and loss 35 | let avgGain = gains.slice(0, period).reduce((sum, gain) => sum + gain, 0) / period 36 | let avgLoss = losses.slice(0, period).reduce((sum, loss) => sum + loss, 0) / period 37 | 38 | // Calculate first RSI value 39 | const rs = avgGain / (avgLoss || 0.0001) // Avoid division by zero 40 | rsiValues.push(100 - (100 / (1 + rs))) 41 | 42 | // Calculate subsequent RSI values using smoothed averages 43 | for (let i = period; i < gains.length; i++) { 44 | avgGain = (avgGain * (period - 1) + gains[i]) / period 45 | avgLoss = (avgLoss * (period - 1) + losses[i]) / period 46 | 47 | const rs = avgGain / (avgLoss || 0.0001) 48 | rsiValues.push(100 - (100 / (1 + rs))) 49 | } 50 | 51 | return rsiValues 52 | } 53 | 54 | /** 55 | * Calculate momentum (rate of change) 56 | */ 57 | export function calculateMomentum(values: number[], period: number = 1): number[] { 58 | const momentum: number[] = [] 59 | 60 | for (let i = period; i < values.length; i++) { 61 | momentum.push(values[i] - values[i - period]) 62 | } 63 | 64 | return momentum 65 | } 66 | 67 | /** 68 | * Calculate volatility using standard deviation 69 | */ 70 | export function calculateVolatility(values: number[], period: number = 10): number[] { 71 | const volatility: number[] = [] 72 | 73 | for (let i = period - 1; i < values.length; i++) { 74 | const slice = values.slice(i - period + 1, i + 1) 75 | const mean = slice.reduce((sum, val) => sum + val, 0) / slice.length 76 | const variance = slice.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / slice.length 77 | volatility.push(Math.sqrt(variance)) 78 | } 79 | 80 | return volatility 81 | } 82 | 83 | /** 84 | * Calculate linear regression slope 85 | */ 86 | export function calculateSlope(values: number[], period: number = 5): number[] { 87 | const slopes: number[] = [] 88 | 89 | for (let i = period - 1; i < values.length; i++) { 90 | const slice = values.slice(i - period + 1, i + 1) 91 | const n = slice.length 92 | const x = Array.from({ length: n }, (_, idx) => idx) 93 | 94 | const sumX = x.reduce((sum, val) => sum + val, 0) 95 | const sumY = slice.reduce((sum, val) => sum + val, 0) 96 | const sumXY = x.reduce((sum, val, idx) => sum + val * slice[idx], 0) 97 | const sumXX = x.reduce((sum, val) => sum + val * val, 0) 98 | 99 | const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX) 100 | slopes.push(slope) 101 | } 102 | 103 | return slopes 104 | } 105 | 106 | /** 107 | * Min-max normalisation 108 | */ 109 | export function normalize(values: number[], period: number): number[] { 110 | const normalized: number[] = [] 111 | 112 | for (let i = period - 1; i < values.length; i++) { 113 | const slice = values.slice(i - period + 1, i + 1) 114 | const min = Math.min(...slice) 115 | const max = Math.max(...slice) 116 | const range = max - min 117 | 118 | if (range === 0) { 119 | normalized.push(0.5) // Middle value when no variation 120 | } else { 121 | normalized.push((values[i] - min) / range) 122 | } 123 | } 124 | 125 | return normalized 126 | } 127 | 128 | /** 129 | * Calculate Euclidean distance between two feature vectors 130 | */ 131 | export function euclideanDistance(vector1: number[], vector2: number[]): number { 132 | if (vector1.length !== vector2.length) { 133 | throw new Error("Vectors must have the same length") 134 | } 135 | 136 | const sumSquares = vector1.reduce((sum, val, idx) => { 137 | return sum + Math.pow(val - vector2[idx], 2) 138 | }, 0) 139 | 140 | return Math.sqrt(sumSquares) 141 | } 142 | 143 | /** 144 | * Simple Moving Average 145 | */ 146 | export function sma(values: number[], period: number): number[] { 147 | const smaValues: number[] = [] 148 | 149 | for (let i = period - 1; i < values.length; i++) { 150 | const slice = values.slice(i - period + 1, i + 1) 151 | const average = slice.reduce((sum, val) => sum + val, 0) / slice.length 152 | smaValues.push(average) 153 | } 154 | 155 | return smaValues 156 | } 157 | 158 | /** 159 | * Exponential Moving Average 160 | */ 161 | export function ema(values: number[], period: number): number[] { 162 | if (values.length === 0) return [] 163 | 164 | const emaValues: number[] = [] 165 | const multiplier = 2 / (period + 1) 166 | 167 | // First EMA value is the first price 168 | emaValues.push(values[0]) 169 | 170 | for (let i = 1; i < values.length; i++) { 171 | const emaValue = (values[i] * multiplier) + (emaValues[i - 1] * (1 - multiplier)) 172 | emaValues.push(emaValue) 173 | } 174 | 175 | return emaValues 176 | } 177 | 178 | /** 179 | * Kalman Filter implementation for smoothing 180 | */ 181 | export function kalmanFilter(values: number[], processNoise: number = 0.01, measurementNoise: number = 0.1): number[] { 182 | if (values.length === 0) return [] 183 | 184 | const filtered: number[] = [] 185 | let estimate = values[0] 186 | let errorEstimate = 1.0 187 | 188 | filtered.push(estimate) 189 | 190 | for (let i = 1; i < values.length; i++) { 191 | // Prediction step 192 | const predictedEstimate = estimate 193 | const predictedError = errorEstimate + processNoise 194 | 195 | // Update step 196 | const kalmanGain = predictedError / (predictedError + measurementNoise) 197 | estimate = predictedEstimate + kalmanGain * (values[i] - predictedEstimate) 198 | errorEstimate = (1 - kalmanGain) * predictedError 199 | 200 | filtered.push(estimate) 201 | } 202 | 203 | return filtered 204 | } 205 | 206 | /** 207 | * ALMA (Arnaud Legoux Moving Average) implementation 208 | */ 209 | export function alma(values: number[], period: number, offset: number = 0.85, sigma: number = 6): number[] { 210 | if (values.length < period) return [] 211 | 212 | const almaValues: number[] = [] 213 | const m = Math.floor(offset * (period - 1)) 214 | const s = period / sigma 215 | 216 | for (let i = period - 1; i < values.length; i++) { 217 | let weightedSum = 0 218 | let weightSum = 0 219 | 220 | for (let j = 0; j < period; j++) { 221 | const weight = Math.exp(-Math.pow(j - m, 2) / (2 * Math.pow(s, 2))) 222 | weightedSum += values[i - period + 1 + j] * weight 223 | weightSum += weight 224 | } 225 | 226 | almaValues.push(weightedSum / weightSum) 227 | } 228 | 229 | return almaValues 230 | } 231 | 232 | /** 233 | * Double EMA implementation 234 | */ 235 | export function doubleEma(values: number[], period: number): number[] { 236 | const firstEma = ema(values, period) 237 | const secondEma = ema(firstEma, period) 238 | 239 | return firstEma.map((val, idx) => { 240 | if (idx < secondEma.length) { 241 | return 2 * val - secondEma[idx] 242 | } 243 | return val 244 | }).slice(period - 1) // Remove initial values that don't have corresponding second EMA 245 | } 246 | 247 | /** 248 | * Extract features for KNN analysis 249 | */ 250 | export interface FeatureVector { 251 | rsi: number; 252 | momentum?: number; 253 | volatility?: number; 254 | slope?: number; 255 | priceMomentum?: number; 256 | } 257 | 258 | export function extractFeatures( 259 | klineData: KlineData[], 260 | index: number, 261 | rsiValues: number[], 262 | featureCount: number, 263 | lookbackPeriod: number 264 | ): FeatureVector | null { 265 | if (index < lookbackPeriod || index >= rsiValues.length) { 266 | return null 267 | } 268 | 269 | const features: FeatureVector = { 270 | rsi: rsiValues[index] 271 | } 272 | 273 | if (featureCount >= 2) { 274 | const rsiMomentum = calculateMomentum(rsiValues.slice(0, index + 1), 3) 275 | if (rsiMomentum.length > 0) { 276 | features.momentum = rsiMomentum[rsiMomentum.length - 1] 277 | } 278 | } 279 | 280 | if (featureCount >= 3) { 281 | const rsiVolatility = calculateVolatility(rsiValues.slice(0, index + 1), 10) 282 | if (rsiVolatility.length > 0) { 283 | features.volatility = rsiVolatility[rsiVolatility.length - 1] 284 | } 285 | } 286 | 287 | if (featureCount >= 4) { 288 | const rsiSlope = calculateSlope(rsiValues.slice(0, index + 1), 5) 289 | if (rsiSlope.length > 0) { 290 | features.slope = rsiSlope[rsiSlope.length - 1] 291 | } 292 | } 293 | 294 | if (featureCount >= 5) { 295 | const closePrices = klineData.slice(0, index + 1).map(k => k.close) 296 | const priceMomentum = calculateMomentum(closePrices, 5) 297 | if (priceMomentum.length > 0) { 298 | features.priceMomentum = priceMomentum[priceMomentum.length - 1] 299 | } 300 | } 301 | 302 | return features 303 | } 304 | ``` -------------------------------------------------------------------------------- /src/utils/volumeAnalysis.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Volume analysis utilities for Order Block Detection 3 | * Based on the pinescript order-block-detector implementation 4 | */ 5 | 6 | import { KlineData } from './mathUtils.js' 7 | 8 | export interface OrderBlock { 9 | id: string; 10 | timestamp: number; 11 | top: number; 12 | bottom: number; 13 | average: number; 14 | volume: number; 15 | mitigated: boolean; 16 | mitigationTime?: number; 17 | type: 'bullish' | 'bearish'; 18 | } 19 | 20 | export interface VolumeAnalysisConfig { 21 | volumePivotLength: number; 22 | bullishBlocks: number; 23 | bearishBlocks: number; 24 | mitigationMethod: 'wick' | 'close'; 25 | } 26 | 27 | /** 28 | * Detect volume pivots (peaks) in the data 29 | */ 30 | export function detectVolumePivots(klineData: KlineData[], pivotLength: number): number[] { 31 | const pivotIndices: number[] = [] 32 | 33 | for (let i = pivotLength; i < klineData.length - pivotLength; i++) { 34 | const currentVolume = klineData[i].volume 35 | let isPivot = true 36 | 37 | // Check if current volume is higher than surrounding volumes 38 | for (let j = i - pivotLength; j <= i + pivotLength; j++) { 39 | if (j !== i && klineData[j].volume >= currentVolume) { 40 | isPivot = false 41 | break 42 | } 43 | } 44 | 45 | if (isPivot) { 46 | pivotIndices.push(i) 47 | } 48 | } 49 | 50 | return pivotIndices 51 | } 52 | 53 | /** 54 | * Determine market structure (uptrend/downtrend) at a given point 55 | */ 56 | export function getMarketStructure(klineData: KlineData[], index: number, lookback: number): 'uptrend' | 'downtrend' { 57 | const startIndex = Math.max(0, index - lookback) 58 | const slice = klineData.slice(startIndex, index + 1) 59 | 60 | if (slice.length < 2) return 'uptrend' 61 | 62 | const highs = slice.map(k => k.high) 63 | const lows = slice.map(k => k.low) 64 | 65 | const currentHigh = highs[highs.length - 1] 66 | const currentLow = lows[lows.length - 1] 67 | const previousHigh = Math.max(...highs.slice(0, -1)) 68 | const previousLow = Math.min(...lows.slice(0, -1)) 69 | 70 | // Simple trend detection based on higher highs/lower lows 71 | if (currentHigh > previousHigh && currentLow > previousLow) { 72 | return 'uptrend' 73 | } else if (currentHigh < previousHigh && currentLow < previousLow) { 74 | return 'downtrend' 75 | } 76 | 77 | // Default to uptrend if unclear 78 | return 'uptrend' 79 | } 80 | 81 | /** 82 | * Create order block from volume pivot 83 | */ 84 | export function createOrderBlock( 85 | klineData: KlineData[], 86 | pivotIndex: number, 87 | pivotLength: number, 88 | type: 'bullish' | 'bearish' 89 | ): OrderBlock { 90 | const kline = klineData[pivotIndex] 91 | const { high, low, close, volume, timestamp } = kline 92 | 93 | let top: number, bottom: number 94 | 95 | if (type === 'bullish') { 96 | // Bullish order block: from low to median (hl2) 97 | bottom = low 98 | top = (high + low) / 2 99 | } else { 100 | // Bearish order block: from median (hl2) to high 101 | bottom = (high + low) / 2 102 | top = high 103 | } 104 | 105 | const average = (top + bottom) / 2 106 | 107 | return { 108 | id: `${type}_${timestamp}_${pivotIndex}`, 109 | timestamp, 110 | top, 111 | bottom, 112 | average, 113 | volume, 114 | mitigated: false, 115 | type 116 | } 117 | } 118 | 119 | /** 120 | * Check if an order block has been mitigated 121 | */ 122 | export function checkMitigation( 123 | orderBlock: OrderBlock, 124 | klineData: KlineData[], 125 | currentIndex: number, 126 | method: 'wick' | 'close' 127 | ): boolean { 128 | if (orderBlock.mitigated) return true 129 | 130 | const currentKline = klineData[currentIndex] 131 | 132 | if (orderBlock.type === 'bullish') { 133 | // Bullish order block is mitigated when price goes below the bottom 134 | if (method === 'wick') { 135 | return currentKline.low < orderBlock.bottom 136 | } else { 137 | return currentKline.close < orderBlock.bottom 138 | } 139 | } else { 140 | // Bearish order block is mitigated when price goes above the top 141 | if (method === 'wick') { 142 | return currentKline.high > orderBlock.top 143 | } else { 144 | return currentKline.close > orderBlock.top 145 | } 146 | } 147 | } 148 | 149 | /** 150 | * Update order block mitigation status 151 | */ 152 | export function updateOrderBlockMitigation( 153 | orderBlocks: OrderBlock[], 154 | klineData: KlineData[], 155 | currentIndex: number, 156 | method: 'wick' | 'close' 157 | ): { mitigatedBullish: boolean; mitigatedBearish: boolean } { 158 | let mitigatedBullish = false 159 | let mitigatedBearish = false 160 | 161 | for (const block of orderBlocks) { 162 | if (!block.mitigated && checkMitigation(block, klineData, currentIndex, method)) { 163 | block.mitigated = true 164 | block.mitigationTime = klineData[currentIndex].timestamp 165 | 166 | if (block.type === 'bullish') { 167 | mitigatedBullish = true 168 | } else { 169 | mitigatedBearish = true 170 | } 171 | } 172 | } 173 | 174 | return { mitigatedBullish, mitigatedBearish } 175 | } 176 | 177 | /** 178 | * Remove mitigated order blocks from arrays 179 | */ 180 | export function removeMitigatedBlocks(orderBlocks: OrderBlock[]): OrderBlock[] { 181 | return orderBlocks.filter(block => !block.mitigated) 182 | } 183 | 184 | /** 185 | * Get active support and resistance levels from order blocks 186 | */ 187 | export function getActiveLevels(orderBlocks: OrderBlock[]): { 188 | support: number[]; 189 | resistance: number[]; 190 | } { 191 | const activeBlocks = orderBlocks.filter(block => !block.mitigated) 192 | 193 | const support = activeBlocks 194 | .filter(block => block.type === 'bullish') 195 | .map(block => block.average) 196 | .sort((a, b) => b - a) // Descending order 197 | 198 | const resistance = activeBlocks 199 | .filter(block => block.type === 'bearish') 200 | .map(block => block.average) 201 | .sort((a, b) => a - b) // Ascending order 202 | 203 | return { support, resistance } 204 | } 205 | 206 | /** 207 | * Detect order blocks from kline data 208 | */ 209 | export function detectOrderBlocks( 210 | klineData: KlineData[], 211 | config: VolumeAnalysisConfig 212 | ): { 213 | bullishBlocks: OrderBlock[]; 214 | bearishBlocks: OrderBlock[]; 215 | volumePivots: number[]; 216 | } { 217 | const volumePivots = detectVolumePivots(klineData, config.volumePivotLength) 218 | const bullishBlocks: OrderBlock[] = [] 219 | const bearishBlocks: OrderBlock[] = [] 220 | 221 | for (const pivotIndex of volumePivots) { 222 | // Determine market structure at pivot point 223 | const marketStructure = getMarketStructure(klineData, pivotIndex, config.volumePivotLength) 224 | 225 | if (marketStructure === 'uptrend') { 226 | // In uptrend, create bullish order block 227 | const block = createOrderBlock(klineData, pivotIndex, config.volumePivotLength, 'bullish') 228 | bullishBlocks.push(block) 229 | } else { 230 | // In downtrend, create bearish order block 231 | const block = createOrderBlock(klineData, pivotIndex, config.volumePivotLength, 'bearish') 232 | bearishBlocks.push(block) 233 | } 234 | } 235 | 236 | // Process mitigation for all blocks 237 | for (let i = 0; i < klineData.length; i++) { 238 | updateOrderBlockMitigation([...bullishBlocks, ...bearishBlocks], klineData, i, config.mitigationMethod) 239 | } 240 | 241 | // Keep only the most recent unmitigated blocks 242 | const activeBullishBlocks = removeMitigatedBlocks(bullishBlocks) 243 | .slice(-config.bullishBlocks) 244 | 245 | const activeBearishBlocks = removeMitigatedBlocks(bearishBlocks) 246 | .slice(-config.bearishBlocks) 247 | 248 | return { 249 | bullishBlocks: activeBullishBlocks, 250 | bearishBlocks: activeBearishBlocks, 251 | volumePivots 252 | } 253 | } 254 | 255 | /** 256 | * Calculate order block statistics 257 | */ 258 | export function calculateOrderBlockStats( 259 | bullishBlocks: OrderBlock[], 260 | bearishBlocks: OrderBlock[] 261 | ): { 262 | totalBlocks: number; 263 | activeBullishBlocks: number; 264 | activeBearishBlocks: number; 265 | mitigatedBlocks: number; 266 | averageVolume: number; 267 | } { 268 | const allBlocks = [...bullishBlocks, ...bearishBlocks] 269 | const activeBlocks = allBlocks.filter(block => !block.mitigated) 270 | const mitigatedBlocks = allBlocks.filter(block => block.mitigated) 271 | 272 | const activeBullishBlocks = bullishBlocks.filter(block => !block.mitigated).length 273 | const activeBearishBlocks = bearishBlocks.filter(block => !block.mitigated).length 274 | 275 | const averageVolume = allBlocks.length > 0 276 | ? allBlocks.reduce((sum, block) => sum + block.volume, 0) / allBlocks.length 277 | : 0 278 | 279 | return { 280 | totalBlocks: allBlocks.length, 281 | activeBullishBlocks, 282 | activeBearishBlocks, 283 | mitigatedBlocks: mitigatedBlocks.length, 284 | averageVolume 285 | } 286 | } 287 | 288 | /** 289 | * Find nearest order blocks to current price 290 | */ 291 | export function findNearestOrderBlocks( 292 | orderBlocks: OrderBlock[], 293 | currentPrice: number, 294 | maxDistance: number = 0.05 // 5% price distance 295 | ): OrderBlock[] { 296 | return orderBlocks 297 | .filter(block => !block.mitigated) 298 | .filter(block => { 299 | const distance = Math.abs(block.average - currentPrice) / currentPrice 300 | return distance <= maxDistance 301 | }) 302 | .sort((a, b) => { 303 | const distanceA = Math.abs(a.average - currentPrice) 304 | const distanceB = Math.abs(b.average - currentPrice) 305 | return distanceA - distanceB 306 | }) 307 | } 308 | ``` -------------------------------------------------------------------------------- /webui/src/styles/citations.css: -------------------------------------------------------------------------------- ```css 1 | /** 2 | * Citation system styles 3 | */ 4 | 5 | /* Citation reference styling */ 6 | .citation-ref { 7 | display: inline-block; 8 | background: var(--accent-color, #007acc); 9 | color: white; 10 | padding: 2px 6px; 11 | border-radius: 4px; 12 | font-size: 0.8em; 13 | font-weight: 500; 14 | cursor: pointer; 15 | transition: all 0.2s ease; 16 | text-decoration: none; 17 | margin: 0 2px; 18 | vertical-align: baseline; 19 | } 20 | 21 | .citation-ref:hover { 22 | background: var(--accent-hover, #005a9e); 23 | transform: translateY(-1px); 24 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 25 | } 26 | 27 | .citation-ref:focus { 28 | outline: 2px solid var(--focus-color, #4a90e2); 29 | outline-offset: 2px; 30 | } 31 | 32 | .citation-ref.no-data { 33 | background: var(--warning-color, #ff9800); 34 | cursor: not-allowed; 35 | } 36 | 37 | .citation-ref.no-data:hover { 38 | background: var(--warning-hover, #f57c00); 39 | transform: none; 40 | } 41 | 42 | /* Citation tooltip */ 43 | .citation-tooltip-container { 44 | background: var(--tooltip-bg, #ffffff); 45 | color: var(--tooltip-text, #333333); 46 | border: 1px solid var(--tooltip-border, #e0e0e0); 47 | border-radius: 12px; 48 | padding: 16px; 49 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08); 50 | max-width: 320px; 51 | font-size: 0.9em; 52 | z-index: 1000; 53 | animation: tooltipFadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); 54 | backdrop-filter: blur(8px); 55 | position: relative; 56 | } 57 | 58 | /* Tooltip arrow */ 59 | .citation-tooltip-container::before { 60 | content: ''; 61 | position: absolute; 62 | top: -6px; 63 | left: 50%; 64 | transform: translateX(-50%); 65 | width: 12px; 66 | height: 12px; 67 | background: var(--tooltip-bg, #ffffff); 68 | border: 1px solid var(--tooltip-border, #e0e0e0); 69 | border-bottom: none; 70 | border-right: none; 71 | transform: translateX(-50%) rotate(45deg); 72 | z-index: -1; 73 | } 74 | 75 | .citation-tooltip { 76 | display: flex; 77 | flex-direction: column; 78 | gap: 8px; 79 | } 80 | 81 | .citation-header { 82 | display: flex; 83 | justify-content: space-between; 84 | align-items: center; 85 | border-bottom: 1px solid var(--tooltip-border, #f0f0f0); 86 | padding-bottom: 8px; 87 | margin-bottom: 8px; 88 | } 89 | 90 | .citation-id { 91 | font-weight: 600; 92 | color: var(--accent-color, #007acc); 93 | font-size: 0.9em; 94 | background: var(--accent-bg, #f0f8ff); 95 | padding: 2px 8px; 96 | border-radius: 6px; 97 | border: 1px solid var(--accent-border, #b3d9ff); 98 | } 99 | 100 | .citation-tool { 101 | font-size: 0.8em; 102 | color: var(--text-muted, #666666); 103 | font-weight: 500; 104 | background: var(--tool-bg, #f8f9fa); 105 | padding: 2px 6px; 106 | border-radius: 4px; 107 | } 108 | 109 | .citation-time { 110 | font-size: 0.8em; 111 | color: var(--text-muted, #666666); 112 | display: flex; 113 | align-items: center; 114 | gap: 4px; 115 | } 116 | 117 | .citation-time::before { 118 | content: "🕒"; 119 | font-size: 0.9em; 120 | } 121 | 122 | .citation-endpoint { 123 | font-size: 0.8em; 124 | color: var(--text-muted, #666666); 125 | font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; 126 | background: var(--code-bg, #f6f8fa); 127 | padding: 4px 6px; 128 | border-radius: 4px; 129 | border: 1px solid var(--code-border, #e1e4e8); 130 | margin-top: 4px; 131 | } 132 | 133 | .citation-metrics { 134 | margin-top: 12px; 135 | background: var(--metrics-bg, #f8f9fa); 136 | border-radius: 8px; 137 | padding: 12px; 138 | border: 1px solid var(--metrics-border, #e9ecef); 139 | } 140 | 141 | .citation-metrics h4 { 142 | margin: 0 0 8px 0; 143 | font-size: 0.85em; 144 | color: var(--text-primary, #333333); 145 | font-weight: 600; 146 | text-transform: uppercase; 147 | letter-spacing: 0.5px; 148 | display: flex; 149 | align-items: center; 150 | gap: 4px; 151 | } 152 | 153 | .citation-metrics h4::before { 154 | content: "📊"; 155 | font-size: 1em; 156 | } 157 | 158 | .citation-metrics ul { 159 | margin: 0; 160 | padding: 0; 161 | list-style: none; 162 | display: flex; 163 | flex-direction: column; 164 | gap: 6px; 165 | } 166 | 167 | .citation-metrics li { 168 | display: flex; 169 | justify-content: space-between; 170 | align-items: center; 171 | padding: 6px 8px; 172 | font-size: 0.8em; 173 | background: var(--metric-item-bg, #ffffff); 174 | border-radius: 6px; 175 | border: 1px solid var(--metric-item-border, #e9ecef); 176 | } 177 | 178 | .citation-metrics .metric-high { 179 | color: var(--success-color, #22c55e); 180 | font-weight: 600; 181 | } 182 | 183 | .citation-metrics .metric-medium { 184 | color: var(--warning-color, #f59e0b); 185 | font-weight: 600; 186 | } 187 | 188 | .citation-metrics .metric-low { 189 | color: var(--text-muted, #6b7280); 190 | font-weight: 500; 191 | } 192 | 193 | .metric-label { 194 | font-weight: 500; 195 | color: var(--text-secondary, #4b5563); 196 | } 197 | 198 | .metric-value { 199 | font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; 200 | font-weight: 600; 201 | font-size: 0.9em; 202 | } 203 | 204 | .citation-actions { 205 | margin-top: 12px; 206 | padding-top: 12px; 207 | border-top: 1px solid var(--tooltip-border, #f0f0f0); 208 | display: flex; 209 | justify-content: center; 210 | } 211 | 212 | .btn-view-full { 213 | background: linear-gradient(135deg, var(--accent-color, #007acc) 0%, var(--accent-secondary, #0066cc) 100%); 214 | color: white; 215 | border: none; 216 | padding: 8px 16px; 217 | border-radius: 8px; 218 | font-size: 0.8em; 219 | font-weight: 600; 220 | cursor: pointer; 221 | transition: all 0.2s ease; 222 | box-shadow: 0 2px 4px rgba(0, 122, 204, 0.2); 223 | display: flex; 224 | align-items: center; 225 | gap: 6px; 226 | } 227 | 228 | .btn-view-full::before { 229 | content: "👁️"; 230 | font-size: 0.9em; 231 | } 232 | 233 | .btn-view-full:hover { 234 | background: linear-gradient(135deg, var(--accent-hover, #005a9e) 0%, var(--accent-secondary-hover, #0052a3) 100%); 235 | transform: translateY(-1px); 236 | box-shadow: 0 4px 8px rgba(0, 122, 204, 0.3); 237 | } 238 | 239 | .btn-view-full:active { 240 | transform: translateY(0); 241 | box-shadow: 0 2px 4px rgba(0, 122, 204, 0.2); 242 | } 243 | 244 | /* Citation modal */ 245 | .citation-modal-overlay { 246 | position: fixed; 247 | top: 0; 248 | left: 0; 249 | right: 0; 250 | bottom: 0; 251 | background: rgba(0, 0, 0, 0.7); 252 | display: flex; 253 | align-items: center; 254 | justify-content: center; 255 | z-index: 2000; 256 | animation: fadeIn 0.3s ease; 257 | } 258 | 259 | .citation-modal { 260 | background: var(--modal-bg, #fff); 261 | color: var(--modal-text, #333); 262 | border-radius: 12px; 263 | max-width: 80vw; 264 | max-height: 80vh; 265 | overflow: hidden; 266 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); 267 | animation: slideIn 0.3s ease; 268 | } 269 | 270 | .citation-modal-header { 271 | display: flex; 272 | justify-content: space-between; 273 | align-items: center; 274 | padding: 16px 20px; 275 | border-bottom: 1px solid var(--modal-border, #eee); 276 | background: var(--modal-header-bg, #f8f9fa); 277 | } 278 | 279 | .citation-modal-header h3 { 280 | margin: 0; 281 | font-size: 1.2em; 282 | color: var(--modal-title, #333); 283 | } 284 | 285 | .citation-modal-close { 286 | background: none; 287 | border: none; 288 | font-size: 1.5em; 289 | cursor: pointer; 290 | color: var(--text-muted, #666); 291 | padding: 0; 292 | width: 30px; 293 | height: 30px; 294 | display: flex; 295 | align-items: center; 296 | justify-content: center; 297 | border-radius: 50%; 298 | transition: background 0.2s ease; 299 | } 300 | 301 | .citation-modal-close:hover { 302 | background: var(--hover-bg, #f0f0f0); 303 | } 304 | 305 | .citation-modal-content { 306 | padding: 20px; 307 | overflow-y: auto; 308 | max-height: calc(80vh - 80px); 309 | } 310 | 311 | .citation-info { 312 | margin-bottom: 20px; 313 | } 314 | 315 | .citation-info p { 316 | margin: 8px 0; 317 | font-size: 0.9em; 318 | } 319 | 320 | .citation-raw-data { 321 | margin-top: 20px; 322 | } 323 | 324 | .citation-raw-data h4 { 325 | margin: 0 0 12px 0; 326 | font-size: 1em; 327 | color: var(--modal-title, #333); 328 | } 329 | 330 | .citation-raw-data pre { 331 | background: var(--code-bg, #f5f5f5); 332 | border: 1px solid var(--code-border, #ddd); 333 | border-radius: 6px; 334 | padding: 12px; 335 | overflow-x: auto; 336 | font-size: 0.8em; 337 | line-height: 1.4; 338 | max-height: 300px; 339 | } 340 | 341 | .citation-raw-data code { 342 | color: var(--code-text, #333); 343 | font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; 344 | } 345 | 346 | /* Animations */ 347 | @keyframes fadeIn { 348 | from { 349 | opacity: 0; 350 | } 351 | to { 352 | opacity: 1; 353 | } 354 | } 355 | 356 | @keyframes slideIn { 357 | from { 358 | opacity: 0; 359 | transform: translateY(-20px) scale(0.95); 360 | } 361 | to { 362 | opacity: 1; 363 | transform: translateY(0) scale(1); 364 | } 365 | } 366 | 367 | @keyframes tooltipFadeIn { 368 | from { 369 | opacity: 0; 370 | transform: translateY(-8px) scale(0.95); 371 | } 372 | to { 373 | opacity: 1; 374 | transform: translateY(0) scale(1); 375 | } 376 | } 377 | 378 | /* Dark theme adjustments */ 379 | [data-theme="dark"] .citation-modal { 380 | --modal-bg: #2d2d2d; 381 | --modal-text: #e0e0e0; 382 | --modal-border: #444; 383 | --modal-header-bg: #333; 384 | --modal-title: #fff; 385 | --code-bg: #1e1e1e; 386 | --code-border: #444; 387 | --code-text: #e0e0e0; 388 | --hover-bg: #404040; 389 | } 390 | 391 | [data-theme="dark"] .citation-tooltip-container { 392 | --tooltip-bg: #1f2937; 393 | --tooltip-text: #f9fafb; 394 | --tooltip-border: #374151; 395 | --accent-bg: #1e3a8a; 396 | --accent-border: #3b82f6; 397 | --tool-bg: #374151; 398 | --code-bg: #111827; 399 | --code-border: #374151; 400 | --metrics-bg: #374151; 401 | --metrics-border: #4b5563; 402 | --metric-item-bg: #1f2937; 403 | --metric-item-border: #4b5563; 404 | --text-primary: #f9fafb; 405 | --text-secondary: #d1d5db; 406 | --text-muted: #9ca3af; 407 | } 408 | 409 | /* Responsive design */ 410 | @media (max-width: 768px) { 411 | .citation-modal { 412 | max-width: 95vw; 413 | max-height: 90vh; 414 | margin: 20px; 415 | } 416 | 417 | .citation-tooltip-container { 418 | max-width: 250px; 419 | font-size: 0.8em; 420 | } 421 | } 422 | ``` -------------------------------------------------------------------------------- /webui/src/services/systemPrompt.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Centralised system prompt service for consistent AI agent behavior 3 | */ 4 | 5 | import { mcpClient } from './mcpClient'; 6 | import type { MCPTool } from '@/types/mcp'; 7 | 8 | export interface SystemPromptConfig { 9 | includeTimestamp?: boolean; 10 | includeTools?: boolean; 11 | includeMemoryContext?: boolean; 12 | customInstructions?: string; 13 | } 14 | 15 | export class SystemPromptService { 16 | private static instance: SystemPromptService; 17 | private cachedTools: MCPTool[] = []; 18 | private lastToolsUpdate: number = 0; 19 | private readonly TOOLS_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes 20 | 21 | private constructor() {} 22 | 23 | public static getInstance(): SystemPromptService { 24 | if (!SystemPromptService.instance) { 25 | SystemPromptService.instance = new SystemPromptService(); 26 | } 27 | return SystemPromptService.instance; 28 | } 29 | 30 | /** 31 | * Generate the complete system prompt with all components 32 | */ 33 | public async generateSystemPrompt(config: SystemPromptConfig = {}): Promise<string> { 34 | console.log('🎯 Generating system prompt with dynamic tools...'); 35 | const { 36 | includeTimestamp = true, 37 | includeTools = true, 38 | includeMemoryContext = false, // Reserved for future use 39 | customInstructions 40 | } = config; 41 | 42 | let prompt = this.getBasePrompt(); 43 | 44 | // Add current timestamp 45 | if (includeTimestamp) { 46 | prompt += `\n\nCurrent date and time: ${this.getCurrentTimestamp()}`; 47 | } 48 | 49 | // Add dynamic tools list 50 | if (includeTools) { 51 | const toolsSection = await this.generateToolsSection(); 52 | if (toolsSection) { 53 | prompt += `\n\n${toolsSection}`; 54 | } 55 | } 56 | 57 | // Add guidelines and instructions 58 | prompt += `\n\n${this.getGuidelines()}`; 59 | 60 | // Add custom instructions if provided 61 | if (customInstructions) { 62 | prompt += `\n\nAdditional Instructions:\n${customInstructions}`; 63 | } 64 | 65 | // Note: includeMemoryContext is reserved for future memory integration 66 | if (includeMemoryContext) { 67 | // Future: Add memory context here 68 | console.log('Memory context integration is planned for future releases'); 69 | } 70 | 71 | return prompt; 72 | } 73 | 74 | /** 75 | * Get the base system prompt without dynamic content 76 | */ 77 | private getBasePrompt(): string { 78 | return `You are an expert cryptocurrency trading assistant with access to real-time market data and advanced analysis tools through the Bybit MCP server. 79 | 80 | You specialise in: 81 | - Real-time market analysis and price monitoring 82 | - Technical analysis using advanced indicators (RSI, MACD, Order Blocks, ML-RSI) 83 | - Risk assessment and trading recommendations 84 | - Market structure analysis and trend identification 85 | - Portfolio analysis and position management 86 | 87 | Your responses should be: 88 | - Data-driven and based on current market conditions 89 | - Clear and actionable with proper risk warnings 90 | - Professional yet accessible to traders of all levels 91 | - Comprehensive when needed, concise when appropriate 92 | - Formatted in markdown for better readability`; 93 | } 94 | 95 | /** 96 | * Generate the tools section with dynamic tool discovery 97 | */ 98 | private async generateToolsSection(): Promise<string> { 99 | try { 100 | const tools = await this.getAvailableTools(); 101 | 102 | if (tools.length === 0) { 103 | return 'No tools are currently available.'; 104 | } 105 | 106 | let toolsSection = 'Available tools:\n\n'; 107 | 108 | for (const tool of tools) { 109 | toolsSection += `**${tool.name}**\n`; 110 | toolsSection += `${tool.description || 'No description available'}\n`; 111 | 112 | // Add parameter information if available 113 | if (tool.inputSchema?.properties) { 114 | const params = Object.entries(tool.inputSchema.properties); 115 | if (params.length > 0) { 116 | toolsSection += 'Parameters:\n'; 117 | for (const [paramName, paramDef] of params) { 118 | const isRequired = tool.inputSchema.required?.includes(paramName) || false; 119 | const description = (paramDef as any)?.description || 'No description'; 120 | toolsSection += `- ${paramName}${isRequired ? ' (required)' : ''}: ${description}\n`; 121 | } 122 | } 123 | } 124 | 125 | toolsSection += '\n'; 126 | } 127 | 128 | return toolsSection.trim(); 129 | } catch (error) { 130 | console.warn('Failed to generate tools section:', error); 131 | return 'Tools are currently unavailable due to connection issues.'; 132 | } 133 | } 134 | 135 | /** 136 | * Get available tools with caching 137 | */ 138 | private async getAvailableTools(): Promise<MCPTool[]> { 139 | const now = Date.now(); 140 | 141 | // Return cached tools if still fresh 142 | if (this.cachedTools.length > 0 && (now - this.lastToolsUpdate) < this.TOOLS_CACHE_DURATION) { 143 | return this.cachedTools; 144 | } 145 | 146 | try { 147 | // Fetch fresh tools from MCP client 148 | const tools = await mcpClient.listTools(); 149 | this.cachedTools = tools; 150 | this.lastToolsUpdate = now; 151 | return tools; 152 | } catch (error) { 153 | console.warn('Failed to fetch tools, using cached version:', error); 154 | return this.cachedTools; 155 | } 156 | } 157 | 158 | /** 159 | * Get current timestamp in consistent format 160 | */ 161 | private getCurrentTimestamp(): string { 162 | const now = new Date(); 163 | return now.getFullYear() + '-' + 164 | String(now.getMonth() + 1).padStart(2, '0') + '-' + 165 | String(now.getDate()).padStart(2, '0') + ' ' + 166 | String(now.getHours()).padStart(2, '0') + ':' + 167 | String(now.getMinutes()).padStart(2, '0') + ':' + 168 | String(now.getSeconds()).padStart(2, '0') + ' UTC'; 169 | } 170 | 171 | /** 172 | * Get standard guidelines and instructions 173 | */ 174 | private getGuidelines(): string { 175 | return `Guidelines: 176 | 1. **Always use relevant tools** to gather current data before making recommendations 177 | 2. **Include reference IDs** - For all Bybit tool calls, always include the parameter "includeReferenceId": true to enable data verification 178 | 3. **Cite your sources** - When citing specific data from tool responses, include the reference ID in square brackets like [REF001] 179 | 4. **Provide clear insights** with proper risk warnings and confidence levels 180 | 5. **Explain your reasoning** and methodology behind analysis 181 | 6. **Consider multiple timeframes** when relevant for comprehensive analysis 182 | 7. **Use tools intelligently** - Don't call unnecessary tools, focus on what's needed for the user's question 183 | 8. **Be comprehensive when appropriate** but concise when a simple answer suffices 184 | 9. **Include confidence levels** in your analysis and recommendations 185 | 10. **Always include risk warnings** for trading recommendations and emphasize that this is not financial advice 186 | 187 | Remember: You are providing analysis and insights, not financial advice. Users should always do their own research, consider their risk tolerance, and consult with qualified financial advisors before making trading decisions.`; 188 | } 189 | 190 | /** 191 | * Generate a simplified system prompt for legacy compatibility 192 | */ 193 | public generateLegacySystemPrompt(): string { 194 | const timestamp = this.getCurrentTimestamp(); 195 | 196 | return `You are an AI assistant specialised in cryptocurrency trading and market analysis. You have access to the Bybit MCP server which provides real-time market data and advanced technical analysis tools. 197 | 198 | Current date and time: ${timestamp} 199 | 200 | Available tools include: 201 | - get_ticker: Get current price and 24h statistics for any trading pair 202 | - get_orderbook: View current buy/sell orders and market depth 203 | - get_kline: Retrieve historical price data (candlestick charts) 204 | - get_trades: See recent trade history and market activity 205 | - get_ml_rsi: Advanced ML-enhanced RSI indicator for trend analysis 206 | - get_order_blocks: Detect institutional order blocks and liquidity zones 207 | - get_market_structure: Comprehensive market structure analysis 208 | 209 | Guidelines: 210 | - Always use relevant tools to gather current data before making recommendations 211 | - Provide clear, actionable insights with proper risk warnings 212 | - Explain your reasoning and methodology 213 | - Include confidence levels in your analysis 214 | - Consider multiple timeframes when relevant 215 | - Use tools intelligently based on the user's question - don't call unnecessary tools 216 | - Provide comprehensive analysis when appropriate, but be concise when a simple answer suffices 217 | - IMPORTANT: For all Bybit tool calls, always include the parameter "includeReferenceId": true to enable data verification. When citing specific data from tool responses, include the reference ID in square brackets like [REF001]. 218 | 219 | Be helpful, accurate, and focused on providing valuable trading insights.`; 220 | } 221 | 222 | /** 223 | * Clear the tools cache to force refresh 224 | */ 225 | public clearToolsCache(): void { 226 | this.cachedTools = []; 227 | this.lastToolsUpdate = 0; 228 | } 229 | 230 | /** 231 | * Get tools cache status 232 | */ 233 | public getToolsCacheStatus(): { count: number; lastUpdate: number; isStale: boolean } { 234 | const now = Date.now(); 235 | const isStale = (now - this.lastToolsUpdate) > this.TOOLS_CACHE_DURATION; 236 | 237 | return { 238 | count: this.cachedTools.length, 239 | lastUpdate: this.lastToolsUpdate, 240 | isStale 241 | }; 242 | } 243 | } 244 | 245 | // Export singleton instance 246 | export const systemPromptService = SystemPromptService.getInstance(); 247 | ```