diff --git a/api/handler_klines.go b/api/handler_klines.go index f5c787ee..95489a74 100644 --- a/api/handler_klines.go +++ b/api/handler_klines.go @@ -41,6 +41,9 @@ func (s *Server) handleKlines(c *gin.Context) { limit = 1500 } + // Use request context so external API calls are cancelled when client disconnects + ctx := c.Request.Context() + var klines []market.Kline exchangeLower := strings.ToLower(exchange) @@ -48,21 +51,21 @@ func (s *Server) handleKlines(c *gin.Context) { switch exchangeLower { case "alpaca": // US Stocks via Alpaca - klines, err = s.getKlinesFromAlpaca(symbol, interval, limit) + klines, err = s.getKlinesFromAlpaca(ctx, symbol, interval, limit) if err != nil { SafeInternalError(c, "Get klines from Alpaca", err) return } case "forex", "metals": // Forex and Metals via Twelve Data - klines, err = s.getKlinesFromTwelveData(symbol, interval, limit) + klines, err = s.getKlinesFromTwelveData(ctx, symbol, interval, limit) if err != nil { SafeInternalError(c, "Get klines from TwelveData", err) return } case "hyperliquid", "hyperliquid-xyz", "xyz": // Hyperliquid native API - supports both crypto perps and stock perps (xyz dex) - klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit) + klines, err = s.getKlinesFromHyperliquid(ctx, symbol, interval, limit) if err != nil { SafeInternalError(c, "Get klines from Hyperliquid", err) return @@ -70,7 +73,7 @@ func (s *Server) handleKlines(c *gin.Context) { default: // Crypto exchanges via CoinAnk symbol = market.Normalize(symbol) - klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit) + klines, err = s.getKlinesFromCoinank(ctx, symbol, interval, exchange, limit) if err != nil { SafeInternalError(c, "Get klines from CoinAnk", err) return @@ -81,7 +84,7 @@ func (s *Server) handleKlines(c *gin.Context) { } // getKlinesFromCoinank fetches kline data from coinank free/open API for multiple exchanges -func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit int) ([]market.Kline, error) { +func (s *Server) getKlinesFromCoinank(ctx context.Context, symbol, interval, exchange string, limit int) ([]market.Kline, error) { // Map exchange string to coinank enum var coinankExchange coinank_enum.Exchange switch strings.ToLower(exchange) { @@ -168,7 +171,6 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i } // Call coinank free/open API (no authentication required) - ctx := context.Background() ts := time.Now().UnixMilli() // Use "To" side to search backward from current time (get historical klines) coinankKlines, err := coinank_api.Kline(ctx, apiSymbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval) @@ -206,7 +208,7 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i } // getKlinesFromAlpaca fetches kline data from Alpaca API for US stocks -func (s *Server) getKlinesFromAlpaca(symbol, interval string, limit int) ([]market.Kline, error) { +func (s *Server) getKlinesFromAlpaca(ctx context.Context, symbol, interval string, limit int) ([]market.Kline, error) { // Create Alpaca client client := alpaca.NewClient() @@ -214,7 +216,6 @@ func (s *Server) getKlinesFromAlpaca(symbol, interval string, limit int) ([]mark timeframe := alpaca.MapTimeframe(interval) // Fetch bars from Alpaca - ctx := context.Background() bars, err := client.GetBars(ctx, symbol, timeframe, limit) if err != nil { return nil, fmt.Errorf("alpaca API error: %w", err) @@ -239,7 +240,7 @@ func (s *Server) getKlinesFromAlpaca(symbol, interval string, limit int) ([]mark } // getKlinesFromTwelveData fetches kline data from Twelve Data API for forex and metals -func (s *Server) getKlinesFromTwelveData(symbol, interval string, limit int) ([]market.Kline, error) { +func (s *Server) getKlinesFromTwelveData(ctx context.Context, symbol, interval string, limit int) ([]market.Kline, error) { // Create Twelve Data client client := twelvedata.NewClient() @@ -247,7 +248,6 @@ func (s *Server) getKlinesFromTwelveData(symbol, interval string, limit int) ([] timeframe := twelvedata.MapTimeframe(interval) // Fetch time series from Twelve Data - ctx := context.Background() result, err := client.GetTimeSeries(ctx, symbol, timeframe, limit) if err != nil { return nil, fmt.Errorf("twelvedata API error: %w", err) @@ -281,7 +281,7 @@ func (s *Server) getKlinesFromTwelveData(symbol, interval string, limit int) ([] // getKlinesFromHyperliquid fetches kline data from Hyperliquid API // Supports both crypto perps (default dex) and stock perps/forex/commodities (xyz dex) -func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([]market.Kline, error) { +func (s *Server) getKlinesFromHyperliquid(ctx context.Context, symbol, interval string, limit int) ([]market.Kline, error) { // Create Hyperliquid client client := hyperliquid.NewClient() @@ -290,7 +290,6 @@ func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([ // Fetch candles from Hyperliquid // FormatCoinForAPI will automatically add xyz: prefix for stock perps - ctx := context.Background() candles, err := client.GetCandles(ctx, symbol, timeframe, limit) if err != nil { return nil, fmt.Errorf("hyperliquid API error: %w", err) @@ -333,11 +332,13 @@ func (s *Server) handleSymbols(c *gin.Context) { var symbols []SymbolInfo + // Use request context for cancellation propagation + ctx := c.Request.Context() + switch strings.ToLower(exchange) { case "hyperliquid", "hyperliquid-xyz", "xyz": // Fetch symbols from Hyperliquid client := hyperliquid.NewClient() - ctx := context.Background() // Get crypto perps from default dex if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" { diff --git a/web/src/components/agent/MessageRenderer.tsx b/web/src/components/agent/MessageRenderer.tsx new file mode 100644 index 00000000..790f213b --- /dev/null +++ b/web/src/components/agent/MessageRenderer.tsx @@ -0,0 +1,187 @@ +/** + * MessageRenderer — markdown-to-JSX renderer for agent chat messages. + * Supports: headers, bold, italic, inline code, code blocks, lists, links, HR. + */ + +// Inline formatting: bold, italic, code, links +export function renderInline(text: string): (string | JSX.Element)[] { + const parts = text.split(/(```[\s\S]*?```|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\(([^)]+)\))/g) + const result: (string | JSX.Element)[] = [] + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (!part) continue + + if (part.startsWith('`') && part.endsWith('`') && !part.startsWith('```')) { + result.push( + + {part.slice(1, -1)} + + ) + } else if (part.startsWith('**') && part.endsWith('**')) { + result.push( + + {part.slice(2, -2)} + + ) + } else if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) { + result.push( + + {part.slice(1, -1)} + + ) + } else if (part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)) { + const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/) + if (match) { + const href = match[2] + // Only allow http/https links to prevent javascript: XSS + const safeHref = /^https?:\/\//i.test(href) ? href : '#' + result.push( + + {match[1]} + + ) + } + } else { + result.push(part) + } + } + + return result +} + +// Enhanced markdown renderer: headers, bold, italic, code, lists, links +export function renderMessageContent(text: string) { + const lines = text.split('\n') + const elements: JSX.Element[] = [] + let inCodeBlock = false + let codeContent = '' + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Code block toggle + if (line.startsWith('```')) { + if (inCodeBlock) { + elements.push( +
+            {codeContent.trim()}
+          
+ ) + codeContent = '' + inCodeBlock = false + } else { + inCodeBlock = true + } + continue + } + + if (inCodeBlock) { + codeContent += (codeContent ? '\n' : '') + line + continue + } + + // Headers + if (line.startsWith('### ')) { + elements.push( +
+ {renderInline(line.slice(4))} +
+ ) + continue + } + if (line.startsWith('## ')) { + elements.push( +
+ {renderInline(line.slice(3))} +
+ ) + continue + } + if (line.startsWith('# ')) { + elements.push( +
+ {renderInline(line.slice(2))} +
+ ) + continue + } + + // Bullet lists + if (line.match(/^[-•*]\s/)) { + elements.push( +
+ + {renderInline(line.replace(/^[-•*]\s/, ''))} +
+ ) + continue + } + + // Numbered lists + if (line.match(/^\d+\.\s/)) { + const num = line.match(/^(\d+)\./)?.[1] + elements.push( +
+ {num}. + {renderInline(line.replace(/^\d+\.\s/, ''))} +
+ ) + continue + } + + // Horizontal rule + if (line.match(/^---+$/)) { + elements.push( +
+ ) + continue + } + + // Empty line → small gap + if (line.trim() === '') { + elements.push(
) + continue + } + + // Regular paragraph + elements.push( +
+ {renderInline(line)} +
+ ) + } + + return elements +} diff --git a/web/src/pages/AgentChatPage.tsx b/web/src/pages/AgentChatPage.tsx index ab6443e4..57445c9b 100644 --- a/web/src/pages/AgentChatPage.tsx +++ b/web/src/pages/AgentChatPage.tsx @@ -19,6 +19,7 @@ import { useAuth } from '../contexts/AuthContext' import { MarketTicker } from '../components/agent/MarketTicker' import { PositionsPanel } from '../components/agent/PositionsPanel' import { TraderStatusPanel } from '../components/agent/TraderStatusPanel' +import { renderMessageContent } from '../components/agent/MessageRenderer' interface Message { id: string @@ -28,191 +29,6 @@ interface Message { streaming?: boolean } -// Enhanced markdown renderer: headers, bold, italic, code, lists, links -function renderMessageContent(text: string) { - const lines = text.split('\n') - const elements: JSX.Element[] = [] - let inCodeBlock = false - let codeContent = '' - - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - - // Code block toggle - if (line.startsWith('```')) { - if (inCodeBlock) { - elements.push( -
-            {codeContent.trim()}
-          
- ) - codeContent = '' - inCodeBlock = false - } else { - inCodeBlock = true - // language hint (reserved for syntax highlighting) - } - continue - } - - if (inCodeBlock) { - codeContent += (codeContent ? '\n' : '') + line - continue - } - - // Headers - if (line.startsWith('### ')) { - elements.push( -
- {renderInline(line.slice(4))} -
- ) - continue - } - if (line.startsWith('## ')) { - elements.push( -
- {renderInline(line.slice(3))} -
- ) - continue - } - if (line.startsWith('# ')) { - elements.push( -
- {renderInline(line.slice(2))} -
- ) - continue - } - - // Bullet lists - if (line.match(/^[-•*]\s/)) { - elements.push( -
- - {renderInline(line.replace(/^[-•*]\s/, ''))} -
- ) - continue - } - - // Numbered lists - if (line.match(/^\d+\.\s/)) { - const num = line.match(/^(\d+)\./)?.[1] - elements.push( -
- {num}. - {renderInline(line.replace(/^\d+\.\s/, ''))} -
- ) - continue - } - - // Horizontal rule - if (line.match(/^---+$/)) { - elements.push( -
- ) - continue - } - - // Empty line → small gap - if (line.trim() === '') { - elements.push(
) - continue - } - - // Regular paragraph - elements.push( -
- {renderInline(line)} -
- ) - } - - return elements -} - -// Inline formatting: bold, italic, code, links -function renderInline(text: string): (string | JSX.Element)[] { - const parts = text.split(/(```[\s\S]*?```|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\(([^)]+)\))/g) - const result: (string | JSX.Element)[] = [] - - for (let i = 0; i < parts.length; i++) { - const part = parts[i] - if (!part) continue - - if (part.startsWith('`') && part.endsWith('`') && !part.startsWith('```')) { - result.push( - - {part.slice(1, -1)} - - ) - } else if (part.startsWith('**') && part.endsWith('**')) { - result.push( - - {part.slice(2, -2)} - - ) - } else if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) { - result.push( - - {part.slice(1, -1)} - - ) - } else if (part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)) { - const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/) - if (match) { - const href = match[2] - // Only allow http/https links to prevent javascript: XSS - const safeHref = /^https?:\/\//i.test(href) ? href : '#' - result.push( - - {match[1]} - - ) - } - } else { - result.push(part) - } - } - - return result -} - let msgIdCounter = 0 function nextId() { return `msg-${Date.now()}-${++msgIdCounter}`