mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 00:36:56 +08:00
refactor: propagate request context in kline handlers, extract MessageRenderer component
- handler_klines.go: pass c.Request.Context() to all external API calls instead of context.Background(). Client disconnects now cancel upstream requests to CoinAnk, Alpaca, TwelveData, Hyperliquid. - Extract renderMessageContent + renderInline into components/agent/MessageRenderer.tsx (187 lines). AgentChatPage.tsx reduced from 1009 → 825 lines.
This commit is contained in:
@@ -41,6 +41,9 @@ func (s *Server) handleKlines(c *gin.Context) {
|
|||||||
limit = 1500
|
limit = 1500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use request context so external API calls are cancelled when client disconnects
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
var klines []market.Kline
|
var klines []market.Kline
|
||||||
exchangeLower := strings.ToLower(exchange)
|
exchangeLower := strings.ToLower(exchange)
|
||||||
|
|
||||||
@@ -48,21 +51,21 @@ func (s *Server) handleKlines(c *gin.Context) {
|
|||||||
switch exchangeLower {
|
switch exchangeLower {
|
||||||
case "alpaca":
|
case "alpaca":
|
||||||
// US Stocks via Alpaca
|
// US Stocks via Alpaca
|
||||||
klines, err = s.getKlinesFromAlpaca(symbol, interval, limit)
|
klines, err = s.getKlinesFromAlpaca(ctx, symbol, interval, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SafeInternalError(c, "Get klines from Alpaca", err)
|
SafeInternalError(c, "Get klines from Alpaca", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "forex", "metals":
|
case "forex", "metals":
|
||||||
// Forex and Metals via Twelve Data
|
// Forex and Metals via Twelve Data
|
||||||
klines, err = s.getKlinesFromTwelveData(symbol, interval, limit)
|
klines, err = s.getKlinesFromTwelveData(ctx, symbol, interval, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SafeInternalError(c, "Get klines from TwelveData", err)
|
SafeInternalError(c, "Get klines from TwelveData", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||||||
// Hyperliquid native API - supports both crypto perps and stock perps (xyz dex)
|
// 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 {
|
if err != nil {
|
||||||
SafeInternalError(c, "Get klines from Hyperliquid", err)
|
SafeInternalError(c, "Get klines from Hyperliquid", err)
|
||||||
return
|
return
|
||||||
@@ -70,7 +73,7 @@ func (s *Server) handleKlines(c *gin.Context) {
|
|||||||
default:
|
default:
|
||||||
// Crypto exchanges via CoinAnk
|
// Crypto exchanges via CoinAnk
|
||||||
symbol = market.Normalize(symbol)
|
symbol = market.Normalize(symbol)
|
||||||
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
|
klines, err = s.getKlinesFromCoinank(ctx, symbol, interval, exchange, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SafeInternalError(c, "Get klines from CoinAnk", err)
|
SafeInternalError(c, "Get klines from CoinAnk", err)
|
||||||
return
|
return
|
||||||
@@ -81,7 +84,7 @@ func (s *Server) handleKlines(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getKlinesFromCoinank fetches kline data from coinank free/open API for multiple exchanges
|
// 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
|
// Map exchange string to coinank enum
|
||||||
var coinankExchange coinank_enum.Exchange
|
var coinankExchange coinank_enum.Exchange
|
||||||
switch strings.ToLower(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)
|
// Call coinank free/open API (no authentication required)
|
||||||
ctx := context.Background()
|
|
||||||
ts := time.Now().UnixMilli()
|
ts := time.Now().UnixMilli()
|
||||||
// Use "To" side to search backward from current time (get historical klines)
|
// 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)
|
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
|
// 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
|
// Create Alpaca client
|
||||||
client := alpaca.NewClient()
|
client := alpaca.NewClient()
|
||||||
|
|
||||||
@@ -214,7 +216,6 @@ func (s *Server) getKlinesFromAlpaca(symbol, interval string, limit int) ([]mark
|
|||||||
timeframe := alpaca.MapTimeframe(interval)
|
timeframe := alpaca.MapTimeframe(interval)
|
||||||
|
|
||||||
// Fetch bars from Alpaca
|
// Fetch bars from Alpaca
|
||||||
ctx := context.Background()
|
|
||||||
bars, err := client.GetBars(ctx, symbol, timeframe, limit)
|
bars, err := client.GetBars(ctx, symbol, timeframe, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("alpaca API error: %w", err)
|
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
|
// 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
|
// Create Twelve Data client
|
||||||
client := twelvedata.NewClient()
|
client := twelvedata.NewClient()
|
||||||
|
|
||||||
@@ -247,7 +248,6 @@ func (s *Server) getKlinesFromTwelveData(symbol, interval string, limit int) ([]
|
|||||||
timeframe := twelvedata.MapTimeframe(interval)
|
timeframe := twelvedata.MapTimeframe(interval)
|
||||||
|
|
||||||
// Fetch time series from Twelve Data
|
// Fetch time series from Twelve Data
|
||||||
ctx := context.Background()
|
|
||||||
result, err := client.GetTimeSeries(ctx, symbol, timeframe, limit)
|
result, err := client.GetTimeSeries(ctx, symbol, timeframe, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("twelvedata API error: %w", err)
|
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
|
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API
|
||||||
// Supports both crypto perps (default dex) and stock perps/forex/commodities (xyz dex)
|
// 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
|
// Create Hyperliquid client
|
||||||
client := hyperliquid.NewClient()
|
client := hyperliquid.NewClient()
|
||||||
|
|
||||||
@@ -290,7 +290,6 @@ func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([
|
|||||||
|
|
||||||
// Fetch candles from Hyperliquid
|
// Fetch candles from Hyperliquid
|
||||||
// FormatCoinForAPI will automatically add xyz: prefix for stock perps
|
// FormatCoinForAPI will automatically add xyz: prefix for stock perps
|
||||||
ctx := context.Background()
|
|
||||||
candles, err := client.GetCandles(ctx, symbol, timeframe, limit)
|
candles, err := client.GetCandles(ctx, symbol, timeframe, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("hyperliquid API error: %w", err)
|
return nil, fmt.Errorf("hyperliquid API error: %w", err)
|
||||||
@@ -333,11 +332,13 @@ func (s *Server) handleSymbols(c *gin.Context) {
|
|||||||
|
|
||||||
var symbols []SymbolInfo
|
var symbols []SymbolInfo
|
||||||
|
|
||||||
|
// Use request context for cancellation propagation
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
switch strings.ToLower(exchange) {
|
switch strings.ToLower(exchange) {
|
||||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||||||
// Fetch symbols from Hyperliquid
|
// Fetch symbols from Hyperliquid
|
||||||
client := hyperliquid.NewClient()
|
client := hyperliquid.NewClient()
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Get crypto perps from default dex
|
// Get crypto perps from default dex
|
||||||
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
|
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
|
||||||
|
|||||||
187
web/src/components/agent/MessageRenderer.tsx
Normal file
187
web/src/components/agent/MessageRenderer.tsx
Normal file
@@ -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(
|
||||||
|
<code
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240,185,11,0.08)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 5,
|
||||||
|
fontSize: '0.88em',
|
||||||
|
fontFamily: '"IBM Plex Mono", monospace',
|
||||||
|
color: '#F0B90B',
|
||||||
|
border: '1px solid rgba(240,185,11,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{part.slice(1, -1)}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
} else if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
|
result.push(
|
||||||
|
<strong key={i} style={{ fontWeight: 600, color: '#f0f0f8' }}>
|
||||||
|
{part.slice(2, -2)}
|
||||||
|
</strong>
|
||||||
|
)
|
||||||
|
} else if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) {
|
||||||
|
result.push(
|
||||||
|
<em key={i} style={{ fontStyle: 'italic', color: '#d0d0e0' }}>
|
||||||
|
{part.slice(1, -1)}
|
||||||
|
</em>
|
||||||
|
)
|
||||||
|
} 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(
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={safeHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: '#F0B90B', textDecoration: 'underline', textUnderlineOffset: 2 }}
|
||||||
|
>
|
||||||
|
{match[1]}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} 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(
|
||||||
|
<pre
|
||||||
|
key={`code-${i}`}
|
||||||
|
style={{
|
||||||
|
background: '#0a0a12',
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '12px 14px',
|
||||||
|
fontSize: 12,
|
||||||
|
overflowX: 'auto',
|
||||||
|
margin: '8px 0',
|
||||||
|
fontFamily: '"IBM Plex Mono", monospace',
|
||||||
|
color: '#c0c0d0',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{codeContent.trim()}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
codeContent = ''
|
||||||
|
inCodeBlock = false
|
||||||
|
} else {
|
||||||
|
inCodeBlock = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCodeBlock) {
|
||||||
|
codeContent += (codeContent ? '\n' : '') + line
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
if (line.startsWith('### ')) {
|
||||||
|
elements.push(
|
||||||
|
<div key={i} style={{ fontSize: 14, fontWeight: 700, color: '#f0f0f8', margin: '12px 0 6px', letterSpacing: '-0.01em' }}>
|
||||||
|
{renderInline(line.slice(4))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line.startsWith('## ')) {
|
||||||
|
elements.push(
|
||||||
|
<div key={i} style={{ fontSize: 15, fontWeight: 700, color: '#f0f0f8', margin: '14px 0 6px', letterSpacing: '-0.01em' }}>
|
||||||
|
{renderInline(line.slice(3))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line.startsWith('# ')) {
|
||||||
|
elements.push(
|
||||||
|
<div key={i} style={{ fontSize: 16, fontWeight: 700, color: '#f0f0f8', margin: '16px 0 8px', letterSpacing: '-0.02em' }}>
|
||||||
|
{renderInline(line.slice(2))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bullet lists
|
||||||
|
if (line.match(/^[-•*]\s/)) {
|
||||||
|
elements.push(
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
|
||||||
|
<span style={{ color: '#F0B90B', flexShrink: 0, fontSize: 8, marginTop: 7 }}>●</span>
|
||||||
|
<span>{renderInline(line.replace(/^[-•*]\s/, ''))}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbered lists
|
||||||
|
if (line.match(/^\d+\.\s/)) {
|
||||||
|
const num = line.match(/^(\d+)\./)?.[1]
|
||||||
|
elements.push(
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
|
||||||
|
<span style={{ color: '#8a8aa0', flexShrink: 0, fontSize: 12, fontWeight: 600, minWidth: 16, fontFamily: '"IBM Plex Mono", monospace' }}>{num}.</span>
|
||||||
|
<span>{renderInline(line.replace(/^\d+\.\s/, ''))}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal rule
|
||||||
|
if (line.match(/^---+$/)) {
|
||||||
|
elements.push(
|
||||||
|
<hr key={i} style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.06)', margin: '12px 0' }} />
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty line → small gap
|
||||||
|
if (line.trim() === '') {
|
||||||
|
elements.push(<div key={i} style={{ height: 6 }} />)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular paragraph
|
||||||
|
elements.push(
|
||||||
|
<div key={i} style={{ lineHeight: 1.7, padding: '1px 0' }}>
|
||||||
|
{renderInline(line)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { useAuth } from '../contexts/AuthContext'
|
|||||||
import { MarketTicker } from '../components/agent/MarketTicker'
|
import { MarketTicker } from '../components/agent/MarketTicker'
|
||||||
import { PositionsPanel } from '../components/agent/PositionsPanel'
|
import { PositionsPanel } from '../components/agent/PositionsPanel'
|
||||||
import { TraderStatusPanel } from '../components/agent/TraderStatusPanel'
|
import { TraderStatusPanel } from '../components/agent/TraderStatusPanel'
|
||||||
|
import { renderMessageContent } from '../components/agent/MessageRenderer'
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string
|
id: string
|
||||||
@@ -28,191 +29,6 @@ interface Message {
|
|||||||
streaming?: boolean
|
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(
|
|
||||||
<pre
|
|
||||||
key={`code-${i}`}
|
|
||||||
style={{
|
|
||||||
background: '#0a0a12',
|
|
||||||
border: '1px solid rgba(255,255,255,0.06)',
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: '12px 14px',
|
|
||||||
fontSize: 12,
|
|
||||||
overflowX: 'auto',
|
|
||||||
margin: '8px 0',
|
|
||||||
fontFamily: '"IBM Plex Mono", monospace',
|
|
||||||
color: '#c0c0d0',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{codeContent.trim()}
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
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(
|
|
||||||
<div key={i} style={{ fontSize: 14, fontWeight: 700, color: '#f0f0f8', margin: '12px 0 6px', letterSpacing: '-0.01em' }}>
|
|
||||||
{renderInline(line.slice(4))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (line.startsWith('## ')) {
|
|
||||||
elements.push(
|
|
||||||
<div key={i} style={{ fontSize: 15, fontWeight: 700, color: '#f0f0f8', margin: '14px 0 6px', letterSpacing: '-0.01em' }}>
|
|
||||||
{renderInline(line.slice(3))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (line.startsWith('# ')) {
|
|
||||||
elements.push(
|
|
||||||
<div key={i} style={{ fontSize: 16, fontWeight: 700, color: '#f0f0f8', margin: '16px 0 8px', letterSpacing: '-0.02em' }}>
|
|
||||||
{renderInline(line.slice(2))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bullet lists
|
|
||||||
if (line.match(/^[-•*]\s/)) {
|
|
||||||
elements.push(
|
|
||||||
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
|
|
||||||
<span style={{ color: '#F0B90B', flexShrink: 0, fontSize: 8, marginTop: 7 }}>●</span>
|
|
||||||
<span>{renderInline(line.replace(/^[-•*]\s/, ''))}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Numbered lists
|
|
||||||
if (line.match(/^\d+\.\s/)) {
|
|
||||||
const num = line.match(/^(\d+)\./)?.[1]
|
|
||||||
elements.push(
|
|
||||||
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
|
|
||||||
<span style={{ color: '#8a8aa0', flexShrink: 0, fontSize: 12, fontWeight: 600, minWidth: 16, fontFamily: '"IBM Plex Mono", monospace' }}>{num}.</span>
|
|
||||||
<span>{renderInline(line.replace(/^\d+\.\s/, ''))}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal rule
|
|
||||||
if (line.match(/^---+$/)) {
|
|
||||||
elements.push(
|
|
||||||
<hr key={i} style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.06)', margin: '12px 0' }} />
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty line → small gap
|
|
||||||
if (line.trim() === '') {
|
|
||||||
elements.push(<div key={i} style={{ height: 6 }} />)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular paragraph
|
|
||||||
elements.push(
|
|
||||||
<div key={i} style={{ lineHeight: 1.7, padding: '1px 0' }}>
|
|
||||||
{renderInline(line)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
<code
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240,185,11,0.08)',
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: 5,
|
|
||||||
fontSize: '0.88em',
|
|
||||||
fontFamily: '"IBM Plex Mono", monospace',
|
|
||||||
color: '#F0B90B',
|
|
||||||
border: '1px solid rgba(240,185,11,0.12)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{part.slice(1, -1)}
|
|
||||||
</code>
|
|
||||||
)
|
|
||||||
} else if (part.startsWith('**') && part.endsWith('**')) {
|
|
||||||
result.push(
|
|
||||||
<strong key={i} style={{ fontWeight: 600, color: '#f0f0f8' }}>
|
|
||||||
{part.slice(2, -2)}
|
|
||||||
</strong>
|
|
||||||
)
|
|
||||||
} else if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) {
|
|
||||||
result.push(
|
|
||||||
<em key={i} style={{ fontStyle: 'italic', color: '#d0d0e0' }}>
|
|
||||||
{part.slice(1, -1)}
|
|
||||||
</em>
|
|
||||||
)
|
|
||||||
} 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(
|
|
||||||
<a
|
|
||||||
key={i}
|
|
||||||
href={safeHref}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: '#F0B90B', textDecoration: 'underline', textUnderlineOffset: 2 }}
|
|
||||||
>
|
|
||||||
{match[1]}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.push(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
let msgIdCounter = 0
|
let msgIdCounter = 0
|
||||||
function nextId() {
|
function nextId() {
|
||||||
return `msg-${Date.now()}-${++msgIdCounter}`
|
return `msg-${Date.now()}-${++msgIdCounter}`
|
||||||
|
|||||||
Reference in New Issue
Block a user