mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-09 14:00:57 +08:00
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
This commit is contained in:
132
web/src/components/agent/MarketTicker.tsx
Normal file
132
web/src/components/agent/MarketTicker.tsx
Normal file
@@ -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<Record<string, TickerData>>({})
|
||||
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<string, TickerData> = {}
|
||||
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 (
|
||||
<div style={{ padding: '12px 14px', color: '#5c5c72', fontSize: 12 }}>
|
||||
<RefreshCw size={14} className="animate-spin inline mr-2" />
|
||||
Loading market data...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{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 (
|
||||
<div
|
||||
key={sym}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 12px',
|
||||
background: '#0d0d15',
|
||||
borderRadius: 10,
|
||||
border: '1px solid #1a1a28',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 7,
|
||||
background: isUp ? 'rgba(0,229,160,0.08)' : 'rgba(246,70,93,0.08)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{isUp ? (
|
||||
<TrendingUp size={14} color={color} />
|
||||
) : (
|
||||
<TrendingDown size={14} color={color} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#5c5c72' }}>
|
||||
Vol {formatVolume(t.volume)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}>
|
||||
${formatPrice(t.lastPrice)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color }}>
|
||||
{isUp ? '+' : ''}{pct.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
web/src/components/agent/PositionsPanel.tsx
Normal file
147
web/src/components/agent/PositionsPanel.tsx
Normal file
@@ -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<TraderInfo[]>(
|
||||
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<Position[]>(
|
||||
traderId ? `agent-positions-${traderId}` : null,
|
||||
() => api.getPositions(traderId),
|
||||
{ refreshInterval: 15000, shouldRetryOnError: false }
|
||||
)
|
||||
|
||||
if (!user || !token) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '20px 14px',
|
||||
textAlign: 'center',
|
||||
color: '#5c5c72',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Wallet size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
|
||||
<div>Login to view positions</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const openPositions = positions?.filter((p) => p.quantity !== 0) || []
|
||||
|
||||
if (openPositions.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 14px',
|
||||
textAlign: 'center',
|
||||
color: '#5c5c72',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
No open positions
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{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 (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: '#0d0d15',
|
||||
borderRadius: 10,
|
||||
border: '1px solid #1a1a28',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#eaeaf0',
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
padding: '1px 5px',
|
||||
borderRadius: 4,
|
||||
background:
|
||||
side === 'LONG'
|
||||
? 'rgba(0,229,160,0.12)'
|
||||
: 'rgba(246,70,93,0.12)',
|
||||
color: side === 'LONG' ? '#00e5a0' : '#F6465D',
|
||||
}}
|
||||
>
|
||||
{side}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
color,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isProfit ? (
|
||||
<ArrowUpRight size={12} />
|
||||
) : (
|
||||
<ArrowDownRight size={12} />
|
||||
)}
|
||||
{isProfit ? '+' : ''}
|
||||
{pnl.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 11,
|
||||
color: '#5c5c72',
|
||||
}}
|
||||
>
|
||||
<span>Qty: {pos.quantity}</span>
|
||||
<span>Entry: {pos.entry_price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
web/src/components/agent/TraderStatusPanel.tsx
Normal file
110
web/src/components/agent/TraderStatusPanel.tsx
Normal file
@@ -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<TraderInfo[]>(
|
||||
user && token ? 'agent-sidebar-traders' : null,
|
||||
api.getTraders,
|
||||
{ refreshInterval: 30000, shouldRetryOnError: false }
|
||||
)
|
||||
|
||||
if (!user || !token) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '20px 14px',
|
||||
textAlign: 'center',
|
||||
color: '#5c5c72',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Bot size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
|
||||
<div>Login to view traders</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!traders || traders.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 14px',
|
||||
textAlign: 'center',
|
||||
color: '#5c5c72',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
No traders configured
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{traders.map((trader) => (
|
||||
<div
|
||||
key={trader.trader_id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 12px',
|
||||
background: '#0d0d15',
|
||||
borderRadius: 10,
|
||||
border: '1px solid #1a1a28',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 7,
|
||||
background: trader.is_running
|
||||
? 'rgba(0,229,160,0.08)'
|
||||
: 'rgba(92,92,114,0.08)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{trader.is_running ? (
|
||||
<Activity size={14} color="#00e5a0" />
|
||||
) : (
|
||||
<CircleOff size={14} color="#5c5c72" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}
|
||||
>
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#5c5c72' }}>
|
||||
{trader.trader_id.slice(0, 8)}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
padding: '3px 8px',
|
||||
borderRadius: 6,
|
||||
background: trader.is_running
|
||||
? 'rgba(0,229,160,0.12)'
|
||||
: 'rgba(92,92,114,0.12)',
|
||||
color: trader.is_running ? '#00e5a0' : '#5c5c72',
|
||||
}}
|
||||
>
|
||||
{trader.is_running ? 'RUNNING' : 'STOPPED'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user