diff --git a/web/src/components/agent/MarketTicker.tsx b/web/src/components/agent/MarketTicker.tsx index 1e6d19fa..72aa3853 100644 --- a/web/src/components/agent/MarketTicker.tsx +++ b/web/src/components/agent/MarketTicker.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { TrendingUp, TrendingDown, RefreshCw } from 'lucide-react' +// icons reserved for future use interface TickerData { symbol: string @@ -12,9 +12,16 @@ interface TickerData { const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT'] +const SYMBOL_ICONS: Record = { + BTC: '₿', + ETH: 'Ξ', + SOL: '◎', +} + export function MarketTicker() { const [tickers, setTickers] = useState>({}) const [loading, setLoading] = useState(true) + const [, setRefreshing] = useState(false) const fetchTickers = async () => { try { @@ -34,6 +41,7 @@ export function MarketTicker() { // ignore } finally { setLoading(false) + setRefreshing(false) } } @@ -60,9 +68,33 @@ export function MarketTicker() { if (loading) { return ( -
- - Loading market data... +
+ {SYMBOLS.map((sym) => ( +
+
+
+ ))} +
) } @@ -73,9 +105,12 @@ export function MarketTicker() { const t = tickers[sym] if (!t) return null const pct = parseFloat(t.priceChangePercent) - const isUp = pct >= 0 - const color = isUp ? '#00e5a0' : '#F6465D' + const isUp = pct > 0 + const isDown = pct < 0 + const color = isUp ? '#00e5a0' : isDown ? '#F6465D' : '#6c6c82' + const bgColor = isUp ? 'rgba(0,229,160,0.06)' : isDown ? 'rgba(246,70,93,0.06)' : 'rgba(108,108,130,0.06)' const label = sym.replace('USDT', '') + const icon = SYMBOL_ICONS[label] || label[0] return (
{ + e.currentTarget.style.background = 'rgba(255,255,255,0.04)' + e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'rgba(255,255,255,0.02)' + e.currentTarget.style.borderColor = 'rgba(255,255,255,0.04)' }} >
@@ -95,32 +140,37 @@ export function MarketTicker() { style={{ width: 28, height: 28, - borderRadius: 7, - background: isUp ? 'rgba(0,229,160,0.08)' : 'rgba(246,70,93,0.08)', + borderRadius: 8, + background: bgColor, display: 'grid', placeItems: 'center', + fontSize: 13, + fontWeight: 700, + color: color, + fontFamily: 'system-ui', }} > - {isUp ? ( - - ) : ( - - )} + {icon}
-
+
{label}
-
+
Vol {formatVolume(t.volume)}
-
+
${formatPrice(t.lastPrice)}
-
+
{isUp ? '+' : ''}{pct.toFixed(2)}%
diff --git a/web/src/pages/AgentChatPage.tsx b/web/src/pages/AgentChatPage.tsx index e5aa0fbc..db98bc82 100644 --- a/web/src/pages/AgentChatPage.tsx +++ b/web/src/pages/AgentChatPage.tsx @@ -8,8 +8,11 @@ import { Bot, ChevronDown, ChevronRight, - Send, - Sparkles, + ArrowUp, + Zap, + BarChart3, + Lightbulb, + Search, } from 'lucide-react' import { useLanguage } from '../contexts/LanguageContext' import { MarketTicker } from '../components/agent/MarketTicker' @@ -24,62 +27,186 @@ interface Message { streaming?: boolean } -// Simple markdown-ish renderer: bold, code, newlines +// Enhanced markdown renderer: headers, bold, italic, code, lists, links function renderMessageContent(text: string) { - // Split by code blocks first - const parts = text.split(/(```[\s\S]*?```|`[^`]+`)/g) - return parts.map((part, i) => { - if (part.startsWith('```') && part.endsWith('```')) { - const code = part.slice(3, -3).replace(/^\w+\n/, '') // strip language hint - return ( -
-          {code}
-        
- ) + 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 (part.startsWith('`') && part.endsWith('`')) { - return ( + + 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)} ) - } - // Handle bold **text** - const boldParts = part.split(/(\*\*[^*]+\*\*)/g) - return boldParts.map((bp, j) => { - if (bp.startsWith('**') && bp.endsWith('**')) { - return ( - - {bp.slice(2, -2)} - + } 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) { + result.push( + + {match[1]} + ) } - return {bp} - }) - }) + } else { + result.push(part) + } + } + + return result } let msgIdCounter = 0 @@ -87,23 +214,18 @@ function nextId() { return `msg-${Date.now()}-${++msgIdCounter}` } +// Suggestion cards for welcome state +interface SuggestionCard { + icon: JSX.Element + title: string + subtitle: string + cmd: string +} + export function AgentChatPage() { const { language } = useLanguage() const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024) - const [messages, setMessages] = useState([ - { - id: nextId(), - role: 'bot', - text: - language === 'zh' - ? '👋 你好!我是 NOFXi,你的 AI 交易 Agent。\n\n跟我说话就行,我能帮你分析市场、管理交易、配置策略。\n\n试试: "分析一下BTC" 或 /help' - : "👋 Hi! I'm NOFXi, your AI trading agent.\n\nJust talk to me. I can analyze markets, manage trades, and configure strategies.\n\nTry: \"Analyze BTC\" or /help", - time: new Date().toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }), - }, - ]) + const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [composing, setComposing] = useState(false) @@ -114,7 +236,7 @@ export function AgentChatPage() { const [sections, setSections] = useState({ market: true, positions: true, - traders: true, + traders: false, }) const toggleSection = (key: keyof typeof sections) => { @@ -135,13 +257,28 @@ export function AgentChatPage() { return () => window.removeEventListener('resize', handleResize) }, []) + // Keyboard shortcut: Cmd+K to focus input + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + inputRef.current?.focus() + } + if (e.key === 'Escape' && window.innerWidth <= 768) { + setSidebarOpen(false) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + // Auto-resize textarea const handleInputChange = useCallback( (e: React.ChangeEvent) => { setInput(e.target.value) const el = e.target el.style.height = 'auto' - el.style.height = Math.min(el.scrollHeight, 120) + 'px' + el.style.height = Math.min(el.scrollHeight, 150) + 'px' }, [] ) @@ -202,7 +339,7 @@ export function AgentChatPage() { ) ) if (i < words.length - 1) { - await new Promise((r) => setTimeout(r, 12)) + await new Promise((r) => setTimeout(r, 10)) } } // Mark streaming done @@ -230,26 +367,36 @@ export function AgentChatPage() { inputRef.current?.focus() } - const quickActions = - language === 'zh' - ? [ - { label: '📊 分析 BTC', cmd: '/analyze BTC' }, - { label: '📊 分析 ETH', cmd: '/analyze ETH' }, - { label: '💼 持仓', cmd: '/positions' }, - { label: '💰 余额', cmd: '/balance' }, - { label: '📋 Traders', cmd: '/traders' }, - { label: '🧹 清除记忆', cmd: '/clear' }, - { label: '❓ 帮助', cmd: '/help' }, - ] - : [ - { label: '📊 Analyze BTC', cmd: '/analyze BTC' }, - { label: '📊 Analyze ETH', cmd: '/analyze ETH' }, - { label: '💼 Positions', cmd: '/positions' }, - { label: '💰 Balance', cmd: '/balance' }, - { label: '📋 Traders', cmd: '/traders' }, - { label: '🧹 Clear', cmd: '/clear' }, - { label: '❓ Help', cmd: '/help' }, - ] + // Welcome suggestions (ChatGPT style) + const suggestions: SuggestionCard[] = language === 'zh' + ? [ + { icon: , title: '分析 BTC 走势', subtitle: '技术分析 + 市场情绪', cmd: '分析一下 BTC 的走势' }, + { icon: , title: '做多 ETH', subtitle: 'Agent 帮你自动下单', cmd: '帮我做多 ETH 0.01 手' }, + { icon: , title: '搜索股票', subtitle: '输入名称或代码即可', cmd: '搜索一下中远海控' }, + { icon: , title: '策略建议', subtitle: '根据当前市场给出建议', cmd: '当前市场适合什么策略?' }, + ] + : [ + { icon: , title: 'Analyze BTC', subtitle: 'Technical analysis + sentiment', cmd: 'Analyze BTC price action' }, + { icon: , title: 'Trade ETH', subtitle: 'Agent executes for you', cmd: 'Open a long position on ETH 0.01' }, + { icon: , title: 'Search Stocks', subtitle: 'Enter name or ticker', cmd: 'Search for NVIDIA stock' }, + { icon: , title: 'Strategy Ideas', subtitle: 'Market-based suggestions', cmd: 'What strategy fits the current market?' }, + ] + + const quickActions = language === 'zh' + ? [ + { label: '💼 持仓', cmd: '/positions' }, + { label: '💰 余额', cmd: '/balance' }, + { label: '📋 Traders', cmd: '/traders' }, + { label: '🧹 清除记忆', cmd: '/clear' }, + { label: '❓ 帮助', cmd: '/help' }, + ] + : [ + { label: '💼 Positions', cmd: '/positions' }, + { label: '💰 Balance', cmd: '/balance' }, + { label: '📋 Traders', cmd: '/traders' }, + { label: '🧹 Clear', cmd: '/clear' }, + { label: '❓ Help', cmd: '/help' }, + ] const sidebarSections = [ { @@ -267,11 +414,13 @@ export function AgentChatPage() { { key: 'traders' as const, icon: , - title: language === 'zh' ? 'Traders' : 'Traders', + title: 'Traders', component: , }, ] + const isWelcomeState = messages.length === 0 + return (
- {/* Quick actions bar */} + {/* Top bar with quick actions */}
- {quickActions.map((a, i) => ( ))} - {/* Sidebar toggle (desktop) */}
- {/* Messages area */} + {/* Messages area or Welcome state */}
-
- {messages.map((m) => ( + {isWelcomeState ? ( + /* ========== WELCOME STATE ========== */ +
+ {/* Logo / greeting */} +
+ ⚡ +
+

+ {language === 'zh' ? '跟 NOFXi 聊点什么' : 'What can I help with?'} +

+

+ {language === 'zh' + ? '分析行情、执行交易、搜索股票 — 用自然语言就行' + : 'Analyze markets, execute trades, search stocks — just ask'} +

+
+ + {/* Suggestion cards grid */} + - {/* Avatar */} -
- {m.role === 'user' ? '👤' : '⚡'} -
- - {/* Message bubble */} -
-
( +
+
+
+ {s.title} +
+
+ {s.subtitle} +
+
+ + ))} + +
+ ) : ( + /* ========== MESSAGES ========== */ +
+ {messages.map((m) => ( + + {/* Avatar */} +
+ {m.role === 'user' ? '👤' : '⚡'} +
+ + {/* Message content */} +
+ {m.role === 'user' ? ( +
+ > + {m.text} +
+ ) : ( +
+ {renderMessageContent(m.text)} + {m.streaming && m.text === '' && ( +
+ + + +
+ )} + {m.streaming && m.text !== '' && ( + + )} +
+ )} + {m.time && !m.streaming && ( +
+ {m.role === 'bot' && 'NOFXi · '}{m.time} +
)}
- {m.time && ( -
- {m.role === 'bot' && 'NOFXi · '} - {m.time} -
- )} -
-
- ))} -
-
+ + ))} +
+
+ )}
{/* Input area */}
{ - ;(e.currentTarget as HTMLElement).style.borderColor = - '#F0B90B40' - }} - onBlur={(e) => { - ;(e.currentTarget as HTMLElement).style.borderColor = - '#1f1f2c' + transition: 'all 0.2s ease', }} >