mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50: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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<pre
|
||||
key={i}
|
||||
style={{
|
||||
background: '#0a0a12',
|
||||
border: '1px solid #1f1f2c',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
fontSize: 12,
|
||||
overflowX: 'auto',
|
||||
margin: '6px 0',
|
||||
fontFamily: 'monospace',
|
||||
color: '#c0c0d0',
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return (
|
||||
<code
|
||||
key={i}
|
||||
style={{
|
||||
background: '#1a1a28',
|
||||
padding: '1px 5px',
|
||||
borderRadius: 4,
|
||||
fontSize: '0.9em',
|
||||
fontFamily: 'monospace',
|
||||
color: '#d0d0e0',
|
||||
}}
|
||||
>
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
// Handle bold **text**
|
||||
const boldParts = part.split(/(\*\*[^*]+\*\*)/g)
|
||||
return boldParts.map((bp, j) => {
|
||||
if (bp.startsWith('**') && bp.endsWith('**')) {
|
||||
return (
|
||||
<strong key={`${i}-${j}`} style={{ fontWeight: 600, color: '#f0f0f8' }}>
|
||||
{bp.slice(2, -2)}
|
||||
</strong>
|
||||
)
|
||||
}
|
||||
return <span key={`${i}-${j}`}>{bp}</span>
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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<Message[]>([
|
||||
{ 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<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
|
||||
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: <TrendingUp size={14} />,
|
||||
title: language === 'zh' ? '市场行情' : 'Market',
|
||||
component: <MarketTicker />,
|
||||
},
|
||||
{
|
||||
key: 'positions' as const,
|
||||
icon: <Wallet size={14} />,
|
||||
title: language === 'zh' ? '持仓' : 'Positions',
|
||||
component: <PositionsPanel />,
|
||||
},
|
||||
{
|
||||
key: 'traders' as const,
|
||||
icon: <Bot size={14} />,
|
||||
title: language === 'zh' ? 'Traders' : 'Traders',
|
||||
component: <TraderStatusPanel />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#09090b' }}>
|
||||
{/* Quick actions */}
|
||||
<div style={{ display: 'flex', gap: 6, padding: '10px 16px', borderBottom: '1px solid #1f1f2c', overflowX: 'auto', flexShrink: 0 }}>
|
||||
{quickActions.map((a, i) => (
|
||||
<button key={i} onClick={() => send(a.cmd)}
|
||||
style={{ padding: '5px 12px', background: '#13131b', border: '1px solid #1f1f2c', borderRadius: 8, color: '#a0a0b0', fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap', fontFamily: 'inherit' }}
|
||||
>{a.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: 'calc(100vh - 64px)',
|
||||
background: '#09090b',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* ==================== MAIN CHAT AREA ==================== */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Quick actions bar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid #1a1a28',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Sparkles size={14} color="#F0B90B" style={{ flexShrink: 0 }} />
|
||||
{quickActions.map((a, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => send(a.cmd)}
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
background: '#111118',
|
||||
border: '1px solid #1f1f2c',
|
||||
borderRadius: 20,
|
||||
color: '#8a8aa0',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#F0B90B40'
|
||||
e.currentTarget.style.color = '#F0B90B'
|
||||
e.currentTarget.style.background = '#F0B90B08'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#1f1f2c'
|
||||
e.currentTarget.style.color = '#8a8aa0'
|
||||
e.currentTarget.style.background = '#111118'
|
||||
}}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Messages */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px' }}>
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 10, marginBottom: 14, flexDirection: m.role === 'user' ? 'row-reverse' : 'row' }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 8, display: 'grid', placeItems: 'center', fontSize: 15, flexShrink: 0, background: m.role === 'user' ? 'rgba(139,92,246,.1)' : 'rgba(0,229,160,.05)', border: '1px solid ' + (m.role === 'user' ? 'rgba(139,92,246,.15)' : '#1f1f2c') }}>
|
||||
{m.role === 'user' ? '👤' : '⚡'}
|
||||
</div>
|
||||
<div style={{ maxWidth: '70%' }}>
|
||||
<div style={{ padding: '10px 14px', borderRadius: 14, fontSize: 13, lineHeight: 1.65, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
background: m.role === 'user' ? 'linear-gradient(135deg, #8b5cf6, #6d28d9)' : '#13131b',
|
||||
color: m.role === 'user' ? '#fff' : '#eaeaf0',
|
||||
border: m.role === 'bot' ? '1px solid #1f1f2c' : 'none',
|
||||
borderTopLeftRadius: m.role === 'bot' ? 3 : 14,
|
||||
borderTopRightRadius: m.role === 'user' ? 3 : 14,
|
||||
}}>{m.text}</div>
|
||||
<div style={{ fontSize: 10, color: '#5c5c72', marginTop: 2, textAlign: m.role === 'user' ? 'right' : 'left' }}>{m.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 14 }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 8, display: 'grid', placeItems: 'center', fontSize: 15, background: 'rgba(0,229,160,.05)', border: '1px solid #1f1f2c' }}>⚡</div>
|
||||
<div style={{ padding: '10px 14px', background: '#13131b', border: '1px solid #1f1f2c', borderRadius: 14, borderTopLeftRadius: 3, color: '#5c5c72', fontSize: 13 }}>
|
||||
{language === 'zh' ? '思考中...' : 'Thinking...'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{/* Sidebar toggle (desktop) */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: 6,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#5c5c72',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<PanelRightClose size={18} />
|
||||
) : (
|
||||
<PanelRightOpen size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div style={{ padding: '12px 16px 16px', borderTop: '1px solid #1f1f2c', background: '#0c0c12' }}>
|
||||
<div style={{ display: 'flex', gap: 8, maxWidth: 800, margin: '0 auto', background: '#13131b', border: '1px solid #1f1f2c', borderRadius: 14, padding: '3px 3px 3px 14px' }}>
|
||||
<input ref={inputRef} value={input} onChange={e => 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' }}
|
||||
/>
|
||||
<button onClick={() => send()} disabled={loading}
|
||||
style={{ width: 36, height: 36, borderRadius: 10, border: 'none', background: 'linear-gradient(135deg, #00e5a0, #00c896)', color: '#000', fontSize: 16, cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? .35 : 1, display: 'grid', placeItems: 'center', flexShrink: 0 }}
|
||||
>➤</button>
|
||||
{/* Messages area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '20px 0',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 720, margin: '0 auto', padding: '0 20px' }}>
|
||||
{messages.map((m) => (
|
||||
<motion.div
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
flexDirection: m.role === 'user' ? 'row-reverse' : 'row',
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 15,
|
||||
flexShrink: 0,
|
||||
background:
|
||||
m.role === 'user'
|
||||
? 'linear-gradient(135deg, rgba(139,92,246,.15), rgba(139,92,246,.05))'
|
||||
: 'linear-gradient(135deg, rgba(240,185,11,.1), rgba(0,229,160,.05))',
|
||||
border:
|
||||
'1px solid ' +
|
||||
(m.role === 'user'
|
||||
? 'rgba(139,92,246,.2)'
|
||||
: 'rgba(240,185,11,.15)'),
|
||||
}}
|
||||
>
|
||||
{m.role === 'user' ? '👤' : '⚡'}
|
||||
</div>
|
||||
|
||||
{/* Message bubble */}
|
||||
<div style={{ maxWidth: '75%', minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: 16,
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.7,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
background:
|
||||
m.role === 'user'
|
||||
? 'linear-gradient(135deg, #8b5cf6, #6d28d9)'
|
||||
: '#111118',
|
||||
color: m.role === 'user' ? '#fff' : '#eaeaf0',
|
||||
border:
|
||||
m.role === 'bot' ? '1px solid #1f1f2c' : 'none',
|
||||
borderTopLeftRadius: m.role === 'bot' ? 4 : 16,
|
||||
borderTopRightRadius: m.role === 'user' ? 4 : 16,
|
||||
}}
|
||||
>
|
||||
{m.role === 'bot'
|
||||
? renderMessageContent(m.text)
|
||||
: m.text}
|
||||
{m.streaming && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 6,
|
||||
height: 14,
|
||||
background: '#F0B90B',
|
||||
marginLeft: 2,
|
||||
borderRadius: 1,
|
||||
animation: 'blink 1s infinite',
|
||||
verticalAlign: 'text-bottom',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{m.time && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#3c3c52',
|
||||
marginTop: 4,
|
||||
textAlign: m.role === 'user' ? 'right' : 'left',
|
||||
paddingLeft: m.role === 'bot' ? 4 : 0,
|
||||
paddingRight: m.role === 'user' ? 4 : 0,
|
||||
}}
|
||||
>
|
||||
{m.role === 'bot' && 'NOFXi · '}
|
||||
{m.time}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px 20px',
|
||||
borderTop: '1px solid #1a1a28',
|
||||
background:
|
||||
'linear-gradient(to top, #09090b 60%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 720,
|
||||
margin: '0 auto',
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
background: '#111118',
|
||||
border: '1px solid #1f1f2c',
|
||||
borderRadius: 16,
|
||||
padding: '4px 4px 4px 16px',
|
||||
alignItems: 'flex-end',
|
||||
transition: 'border-color 0.2s',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.borderColor =
|
||||
'#F0B90B40'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.borderColor =
|
||||
'#1f1f2c'
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
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...'
|
||||
}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#eaeaf0',
|
||||
fontSize: 13.5,
|
||||
outline: 'none',
|
||||
padding: '10px 0',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'none',
|
||||
lineHeight: 1.5,
|
||||
maxHeight: 120,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => send()}
|
||||
disabled={loading || !input.trim()}
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background:
|
||||
loading || !input.trim()
|
||||
? '#1a1a28'
|
||||
: 'linear-gradient(135deg, #F0B90B, #d4a30a)',
|
||||
color: loading || !input.trim() ? '#3c3c52' : '#000',
|
||||
cursor:
|
||||
loading || !input.trim()
|
||||
? 'not-allowed'
|
||||
: 'pointer',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 720,
|
||||
margin: '6px auto 0',
|
||||
textAlign: 'center',
|
||||
fontSize: 10,
|
||||
color: '#2c2c42',
|
||||
}}
|
||||
>
|
||||
NOFXi may make mistakes. Always verify trading decisions.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ==================== RIGHT SIDEBAR ==================== */}
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 300, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
style={{
|
||||
borderLeft: '1px solid #1a1a28',
|
||||
background: '#0b0b13',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '12px 12px 20px', width: 300 }}>
|
||||
{/* Sidebar header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 14,
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: '#5c5c72',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{language === 'zh' ? '交易面板' : 'Trading Panel'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sidebar sections */}
|
||||
{sidebarSections.map((section) => (
|
||||
<div key={section.key} style={{ marginBottom: 10 }}>
|
||||
<button
|
||||
onClick={() => toggleSection(section.key)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
width: '100%',
|
||||
padding: '8px 8px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#8a8aa0',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
transition: 'background 0.15s',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#111118'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
{section.icon}
|
||||
<span>{section.title}</span>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
{sections[section.key] ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{sections[section.key] && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ overflow: 'hidden', padding: '0 4px' }}
|
||||
>
|
||||
<div style={{ paddingTop: 4 }}>
|
||||
{section.component}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Blinking cursor animation */}
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user