feat: unified NOFXi — one system, one port, one UI

Key changes:
- Agent API routes (/api/agent/*) registered directly on NOFX's :8080 server
- No more separate :8900 port — everything on :8080
- AgentChatPage.tsx added to React frontend
- Agent tab (🤖 Agent) added to navigation bar, set as DEFAULT page
- Branding: NOFX → NOFXi everywhere (title, header, branding.ts, i18n)
- Default landing page is now Agent chat, not competition

This is THE change: there is no more 'NOFX' vs 'NOFXi'.
Users open localhost:8080, they see NOFXi.
Agent chat is the first thing they see.
All other pages (Traders, Dashboard, Strategy, Data) are still there.

One binary. One port. One product. NOFXi.
This commit is contained in:
shinchan-zhai
2026-03-23 00:44:10 +08:00
parent 0f086e5ebc
commit 9945e4e10d
9 changed files with 233 additions and 133 deletions

View File

@@ -0,0 +1,131 @@
import { useState, useRef, useEffect } from 'react'
import { useLanguage } from '../contexts/LanguageContext'
interface Message {
role: 'user' | 'bot'
text: string
time: string
}
export function AgentChatPage() {
const { language } = useLanguage()
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' }) }
])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [composing, setComposing] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
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 }])
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 })
})
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' })
}])
} catch (e: any) {
setMessages(prev => [...prev, { role: 'bot', text: '⚠️ Error: ' + e.message, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }])
}
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' },
]
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>
{/* 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>
{/* 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>
</div>
</div>
</div>
)
}