From a41ee3ca3d6b9caef4e805aef9b0f1ff113d78fa Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Mon, 23 Mar 2026 02:10:48 +0800 Subject: [PATCH] feat: redesign AgentChatPage - Agent-centric UI with side panels - Rewrite AgentChatPage as primary interface with Perplexity-style layout - Add right sidebar with Market Ticker, Positions, and Trader Status panels - Sidebar is collapsible and responsive (auto-hides on mobile) - Each sidebar section independently collapsible - Chat area: centered 720px max-width with smooth streaming effect - Simple markdown rendering (bold, code blocks, inline code) - Quick action chips redesigned as pill buttons with hover effects - Input upgraded to auto-resizing textarea - New components: MarketTicker, PositionsPanel, TraderStatusPanel - Dark theme consistent with NOFXi brand colors - Real-time data via SWR for positions and trader status --- web/src/components/agent/MarketTicker.tsx | 132 ++++ web/src/components/agent/PositionsPanel.tsx | 147 ++++ .../components/agent/TraderStatusPanel.tsx | 110 +++ web/src/pages/AgentChatPage.tsx | 719 ++++++++++++++++-- 4 files changed, 1025 insertions(+), 83 deletions(-) create mode 100644 web/src/components/agent/MarketTicker.tsx create mode 100644 web/src/components/agent/PositionsPanel.tsx create mode 100644 web/src/components/agent/TraderStatusPanel.tsx diff --git a/web/src/components/agent/MarketTicker.tsx b/web/src/components/agent/MarketTicker.tsx new file mode 100644 index 00000000..1e6d19fa --- /dev/null +++ b/web/src/components/agent/MarketTicker.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect } from 'react' +import { TrendingUp, TrendingDown, RefreshCw } from 'lucide-react' + +interface TickerData { + symbol: string + lastPrice: string + priceChangePercent: string + highPrice: string + lowPrice: string + volume: string +} + +const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT'] + +export function MarketTicker() { + const [tickers, setTickers] = useState>({}) + const [loading, setLoading] = useState(true) + + const fetchTickers = async () => { + try { + const results = await Promise.all( + SYMBOLS.map(async (symbol) => { + const res = await fetch(`/api/agent/ticker?symbol=${symbol}`) + const data = await res.json() + return { symbol, ...data } + }) + ) + const map: Record = {} + results.forEach((r) => { + if (r.lastPrice) map[r.symbol] = r + }) + setTickers(map) + } catch { + // ignore + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchTickers() + const interval = setInterval(fetchTickers, 15000) + return () => clearInterval(interval) + }, []) + + const formatPrice = (price: string) => { + const n = parseFloat(price) + if (n >= 1000) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + if (n >= 1) return n.toFixed(2) + return n.toFixed(4) + } + + const formatVolume = (vol: string) => { + const n = parseFloat(vol) + if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B' + if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M' + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K' + return n.toFixed(0) + } + + if (loading) { + return ( +
+ + Loading market data... +
+ ) + } + + return ( +
+ {SYMBOLS.map((sym) => { + const t = tickers[sym] + if (!t) return null + const pct = parseFloat(t.priceChangePercent) + const isUp = pct >= 0 + const color = isUp ? '#00e5a0' : '#F6465D' + const label = sym.replace('USDT', '') + + return ( +
+
+
+ {isUp ? ( + + ) : ( + + )} +
+
+
+ {label} +
+
+ Vol {formatVolume(t.volume)} +
+
+
+
+
+ ${formatPrice(t.lastPrice)} +
+
+ {isUp ? '+' : ''}{pct.toFixed(2)}% +
+
+
+ ) + })} +
+ ) +} diff --git a/web/src/components/agent/PositionsPanel.tsx b/web/src/components/agent/PositionsPanel.tsx new file mode 100644 index 00000000..8d5746a0 --- /dev/null +++ b/web/src/components/agent/PositionsPanel.tsx @@ -0,0 +1,147 @@ +import useSWR from 'swr' +import { useAuth } from '../../contexts/AuthContext' +import { api } from '../../lib/api' +import { ArrowUpRight, ArrowDownRight, Wallet } from 'lucide-react' +import type { Position, TraderInfo } from '../../types' + +export function PositionsPanel() { + const { user, token } = useAuth() + + const { data: traders } = useSWR( + user && token ? 'agent-traders' : null, + api.getTraders, + { refreshInterval: 30000, shouldRetryOnError: false } + ) + + // Get first running trader's positions + const runningTrader = traders?.find((t) => t.is_running) + const traderId = runningTrader?.trader_id + + const { data: positions } = useSWR( + traderId ? `agent-positions-${traderId}` : null, + () => api.getPositions(traderId), + { refreshInterval: 15000, shouldRetryOnError: false } + ) + + if (!user || !token) { + return ( +
+ +
Login to view positions
+
+ ) + } + + const openPositions = positions?.filter((p) => p.quantity !== 0) || [] + + if (openPositions.length === 0) { + return ( +
+ No open positions +
+ ) + } + + return ( +
+ {openPositions.map((pos, i) => { + const pnl = pos.unrealized_pnl + const isProfit = pnl >= 0 + const color = isProfit ? '#00e5a0' : '#F6465D' + const side = pos.side?.toUpperCase() || (pos.quantity > 0 ? 'LONG' : 'SHORT') + const symbol = (pos.symbol || '').replace('USDT', '') + + return ( +
+
+
+ + {symbol} + + + {side} + +
+
+ {isProfit ? ( + + ) : ( + + )} + {isProfit ? '+' : ''} + {pnl.toFixed(2)} +
+
+
+ Qty: {pos.quantity} + Entry: {pos.entry_price.toFixed(2)} +
+
+ ) + })} +
+ ) +} diff --git a/web/src/components/agent/TraderStatusPanel.tsx b/web/src/components/agent/TraderStatusPanel.tsx new file mode 100644 index 00000000..849849a8 --- /dev/null +++ b/web/src/components/agent/TraderStatusPanel.tsx @@ -0,0 +1,110 @@ +import useSWR from 'swr' +import { useAuth } from '../../contexts/AuthContext' +import { api } from '../../lib/api' +import { Activity, CircleOff, Bot } from 'lucide-react' +import type { TraderInfo } from '../../types' + +export function TraderStatusPanel() { + const { user, token } = useAuth() + + const { data: traders } = useSWR( + user && token ? 'agent-sidebar-traders' : null, + api.getTraders, + { refreshInterval: 30000, shouldRetryOnError: false } + ) + + if (!user || !token) { + return ( +
+ +
Login to view traders
+
+ ) + } + + if (!traders || traders.length === 0) { + return ( +
+ No traders configured +
+ ) + } + + return ( +
+ {traders.map((trader) => ( +
+
+
+ {trader.is_running ? ( + + ) : ( + + )} +
+
+
+ {trader.trader_name} +
+
+ {trader.trader_id.slice(0, 8)}... +
+
+
+
+ {trader.is_running ? 'RUNNING' : 'STOPPED'} +
+
+ ))} +
+ ) +} diff --git a/web/src/pages/AgentChatPage.tsx b/web/src/pages/AgentChatPage.tsx index 49ca7e5e..1b0912fb 100644 --- a/web/src/pages/AgentChatPage.tsx +++ b/web/src/pages/AgentChatPage.tsx @@ -1,131 +1,684 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { + PanelRightClose, + PanelRightOpen, + TrendingUp, + Wallet, + Bot, + ChevronDown, + ChevronRight, + Send, + Sparkles, +} from 'lucide-react' import { useLanguage } from '../contexts/LanguageContext' +import { MarketTicker } from '../components/agent/MarketTicker' +import { PositionsPanel } from '../components/agent/PositionsPanel' +import { TraderStatusPanel } from '../components/agent/TraderStatusPanel' interface Message { + id: string role: 'user' | 'bot' text: string time: string + streaming?: boolean +} + +// Simple markdown-ish renderer: bold, code, newlines +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}
+        
+ ) + } + if (part.startsWith('`') && part.endsWith('`')) { + return ( + + {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)} + + ) + } + return {bp} + }) + }) +} + +let msgIdCounter = 0 +function nextId() { + return `msg-${Date.now()}-${++msgIdCounter}` } export function AgentChatPage() { const { language } = useLanguage() + const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024) const [messages, setMessages] = useState([ - { 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' }) } + { + 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 [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [composing, setComposing] = useState(false) const messagesEndRef = useRef(null) - const inputRef = useRef(null) + const inputRef = useRef(null) + // Sidebar section collapse state + const [sections, setSections] = useState({ + market: true, + positions: true, + traders: true, + }) + + const toggleSection = (key: keyof typeof sections) => { + setSections((prev) => ({ ...prev, [key]: !prev[key] })) + } + + // Auto-scroll useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) + // Responsive sidebar + useEffect(() => { + const handleResize = () => { + if (window.innerWidth <= 768) setSidebarOpen(false) + } + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + // 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' + }, + [] + ) + const send = async (text?: string) => { const msg = text || input.trim() if (!msg || loading) return setInput('') - const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - setMessages(prev => [...prev, { role: 'user', text: msg, time }]) + if (inputRef.current) { + inputRef.current.style.height = 'auto' + } + const time = new Date().toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) + const userMsg: Message = { id: nextId(), role: 'user', text: msg, time } + const botId = nextId() + setMessages((prev) => [ + ...prev, + userMsg, + { + id: botId, + role: 'bot', + text: '', + time: '', + streaming: true, + }, + ]) setLoading(true) try { const res = await fetch('/api/agent/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: msg, user_id: 1, lang: language }) + body: JSON.stringify({ message: msg, user_id: 1, lang: language }), }) const data = await res.json() - setMessages(prev => [...prev, { - role: 'bot', - text: data.response || data.error || 'No response', - time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - }]) + const responseText = data.response || data.error || 'No response' + + // Simulate streaming by revealing text progressively + const words = responseText.split(/(\s+)/) + let displayed = '' + for (let i = 0; i < words.length; i++) { + displayed += words[i] + const current = displayed + setMessages((prev) => + prev.map((m) => + m.id === botId + ? { + ...m, + text: current, + time: new Date().toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), + } + : m + ) + ) + if (i < words.length - 1) { + await new Promise((r) => setTimeout(r, 12)) + } + } + // Mark streaming done + setMessages((prev) => + prev.map((m) => (m.id === botId ? { ...m, streaming: false } : m)) + ) } catch (e: any) { - setMessages(prev => [...prev, { role: 'bot', text: '⚠️ Error: ' + e.message, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]) + setMessages((prev) => + prev.map((m) => + m.id === botId + ? { + ...m, + text: '⚠️ Error: ' + e.message, + time: new Date().toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), + streaming: false, + } + : m + ) + ) } setLoading(false) 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: '/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: '❓ Help', cmd: '/help' }, - ] + 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: '/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: '❓ Help', cmd: '/help' }, + ] + + const sidebarSections = [ + { + key: 'market' as const, + icon: , + title: language === 'zh' ? '市场行情' : 'Market', + component: , + }, + { + key: 'positions' as const, + icon: , + title: language === 'zh' ? '持仓' : 'Positions', + component: , + }, + { + key: 'traders' as const, + icon: , + title: language === 'zh' ? 'Traders' : 'Traders', + component: , + }, + ] return ( -
- {/* Quick actions */} -
- {quickActions.map((a, i) => ( - - ))} -
+
+ {/* ==================== MAIN CHAT AREA ==================== */} +
+ {/* Quick actions bar */} +
+ + {quickActions.map((a, i) => ( + + ))} - {/* Messages */} -
- {messages.map((m, i) => ( -
-
- {m.role === 'user' ? '👤' : '⚡'} -
-
-
{m.text}
-
{m.time}
-
-
- ))} - {loading && ( -
-
-
- {language === 'zh' ? '思考中...' : 'Thinking...'} -
-
- )} -
-
+ {/* Sidebar toggle (desktop) */} + +
- {/* Input */} -
-
- setInput(e.target.value)} - onCompositionStart={() => setComposing(true)} - onCompositionEnd={() => setComposing(false)} - onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !composing) { e.preventDefault(); send() } }} - placeholder={language === 'zh' ? '跟 NOFXi 聊点什么...' : 'Ask NOFXi anything...'} - style={{ flex: 1, background: 'none', border: 'none', color: '#eaeaf0', fontSize: 13, outline: 'none', padding: '8px 0', fontFamily: 'inherit' }} - /> - + {/* Messages area */} +
+
+ {messages.map((m) => ( + + {/* Avatar */} +
+ {m.role === 'user' ? '👤' : '⚡'} +
+ + {/* Message bubble */} +
+
+ {m.role === 'bot' + ? renderMessageContent(m.text) + : m.text} + {m.streaming && ( + + )} +
+ {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' + }} + > +