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

@@ -4,104 +4,94 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"
)
// WebHandler provides HTTP endpoints for the NOFXi agent.
// These are registered on the existing NOFX API server.
type WebHandler struct {
agent *Agent
logger *slog.Logger
}
// NewWebHandler creates a new web handler.
func NewWebHandler(agent *Agent, logger *slog.Logger) *WebHandler {
return &WebHandler{agent: agent, logger: logger}
}
// RegisterRoutes registers agent API routes on an existing mux or gin router.
// For now we use a standalone http server on a separate port.
func (w *WebHandler) StartStandalone(port int) {
mux := http.NewServeMux()
// HandleHealth handles GET /api/agent/health.
func (w *WebHandler) HandleHealth(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
}
// Health
mux.HandleFunc("/api/agent/health", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
})
// HandleChat handles POST /api/agent/chat.
func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", 405)
return
}
var req struct {
Message string `json:"message"`
UserID int64 `json:"user_id"`
Lang string `json:"lang"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
return
}
if req.Message == "" {
writeJSON(rw, 400, map[string]string{"error": "message required"})
return
}
if req.UserID == 0 {
req.UserID = 1
}
msg := req.Message
if req.Lang != "" {
msg = "[lang:" + req.Lang + "] " + msg
}
// Chat endpoint
mux.HandleFunc("/api/agent/chat", func(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", 405)
return
}
var req struct {
Message string `json:"message"`
UserID int64 `json:"user_id"`
Lang string `json:"lang"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
return
}
if req.Message == "" {
writeJSON(rw, 400, map[string]string{"error": "message required"})
return
}
if req.UserID == 0 {
req.UserID = 1
}
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
defer cancel()
msg := req.Message
if req.Lang != "" {
msg = "[lang:" + req.Lang + "] " + msg
}
resp, err := w.agent.HandleMessage(ctx, req.UserID, msg)
if err != nil {
writeJSON(rw, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(rw, 200, map[string]string{"response": resp})
}
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
defer cancel()
// HandleKlines proxies kline data from Binance.
func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" { symbol = "BTCUSDT" }
interval := r.URL.Query().Get("interval")
if interval == "" { interval = "1h" }
resp, err := w.agent.HandleMessage(ctx, req.UserID, msg)
if err != nil {
writeJSON(rw, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(rw, 200, map[string]string{"response": resp})
})
proxyBinance(rw, fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval))
}
// Kline data
mux.HandleFunc("/api/agent/klines", handleKlines)
// HandleTicker proxies ticker data from Binance.
func (w *WebHandler) HandleTicker(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" { symbol = "BTCUSDT" }
// Ticker
mux.HandleFunc("/api/agent/ticker", handleTicker)
proxyBinance(rw, fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
}
// Serve nofxi.html as the root page
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(rw, r)
return
}
// Try web/nofxi.html relative to executable, then working dir
paths := []string{"web/nofxi.html", "../web/nofxi.html"}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
http.ServeFile(rw, r, p)
return
}
}
rw.Header().Set("Content-Type", "text/html")
rw.Write([]byte("<h1>NOFXi Agent</h1><p>API is running. Web UI not found.</p>"))
})
go func() {
addr := fmt.Sprintf(":%d", port)
w.logger.Info("NOFXi agent web starting", "port", port, "url", fmt.Sprintf("http://localhost:%d", port))
if err := http.ListenAndServe(addr, mux); err != nil {
w.logger.Error("agent web server error", "error", err)
}
}()
func proxyBinance(rw http.ResponseWriter, url string) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
writeJSON(rw, 502, map[string]string{"error": err.Error()})
return
}
defer resp.Body.Close()
rw.Header().Set("Content-Type", "application/json")
rw.Header().Set("Access-Control-Allow-Origin", "*")
io.Copy(rw, resp.Body)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
@@ -110,51 +100,3 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func handleKlines(w http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" { symbol = "BTCUSDT" }
interval := r.URL.Query().Get("interval")
if interval == "" { interval = "1h" }
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
writeJSON(w, 502, map[string]string{"error": err.Error()})
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(200)
buf := make([]byte, 32768)
for {
n, err := resp.Body.Read(buf)
if n > 0 { w.Write(buf[:n]) }
if err != nil { break }
}
}
func handleTicker(w http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" { symbol = "BTCUSDT" }
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
writeJSON(w, 502, map[string]string{"error": err.Error()})
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(200)
buf := make([]byte, 4096)
for {
n, err := resp.Body.Read(buf)
if n > 0 { w.Write(buf[:n]) }
if err != nil { break }
}
}

15
api/agent_routes.go Normal file
View File

@@ -0,0 +1,15 @@
package api
import (
"nofx/agent"
"github.com/gin-gonic/gin"
)
// RegisterAgentHandler registers NOFXi agent API routes on the main router.
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
s.router.POST("/api/agent/chat", gin.WrapF(h.HandleChat))
s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth))
s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines))
s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker))
}

View File

@@ -158,9 +158,9 @@ func main() {
nofxiAgent.Start()
defer nofxiAgent.Stop()
// Start NOFXi Agent Web API (port 8900)
// Register NOFXi Agent API on the main server's router
agentWeb := nofxiagent.NewWebHandler(nofxiAgent, slog.Default())
agentWeb.StartStandalone(8900)
server.RegisterAgentHandler(agentWeb)
logger.Info("🧠 NOFXi Agent started (sentinel + brain + scheduler + web:8900)")

View File

@@ -11,7 +11,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
<!-- End Google Tag Manager -->
<link rel="icon" type="image/svg+xml" href="/icons/nofx.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NOFX - AI Auto Trading Dashboard</title>
<title>NOFXi - AI Auto Trading Dashboard</title>
</head>
<body>
<!-- Google Tag Manager (noscript) -->

View File

@@ -11,6 +11,7 @@ import { SettingsPage } from './pages/SettingsPage'
import { ResetPasswordPage } from './components/auth/ResetPasswordPage'
import { CompetitionPage } from './components/trader/CompetitionPage'
import { LandingPage } from './pages/LandingPage'
import { AgentChatPage } from './pages/AgentChatPage'
import { FAQPage } from './pages/FAQPage'
import { StrategyStudioPage } from './pages/StrategyStudioPage'
import { StrategyMarketPage } from './pages/StrategyMarketPage'
@@ -35,6 +36,7 @@ import type {
} from './types'
type Page =
| 'agent'
| 'competition'
| 'traders'
| 'trader'
@@ -63,13 +65,14 @@ function App() {
const path = window.location.pathname
const hash = window.location.hash.slice(1) // 去掉 #
if (path === '/agent' || hash === 'agent') return 'agent'
if (path === '/traders' || hash === 'traders') return 'traders'
if (path === '/strategy' || hash === 'strategy') return 'strategy'
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
if (path === '/data' || hash === 'data') return 'data'
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
return 'trader'
return 'competition' // 默认为竞赛页面
return 'agent' // 默认为 Agent 页面
}
// Login required overlay state
@@ -84,6 +87,7 @@ function App() {
// Unified page navigation handler
const navigateToPage = (page: Page) => {
const pathMap: Record<Page, string> = {
'agent': '/agent',
'competition': '/competition',
'strategy-market': '/strategy-market',
'data': '/data',
@@ -141,7 +145,9 @@ function App() {
const params = new URLSearchParams(window.location.search)
const traderParam = params.get('trader')
if (path === '/traders' || hash === 'traders') {
if (path === '/agent' || hash === 'agent') {
setCurrentPage('agent')
} else if (path === '/traders' || hash === 'traders') {
setCurrentPage('traders')
} else if (path === '/strategy' || hash === 'strategy') {
setCurrentPage('strategy')
@@ -399,6 +405,7 @@ function App() {
if (route === '/data') {
const dataPageNavigate = (page: Page) => {
const pathMap: Record<string, string> = {
'agent': '/agent',
'data': '/data',
'competition': '/competition',
'strategy-market': '/strategy-market',
@@ -476,6 +483,8 @@ function App() {
>
{currentPage === 'competition' ? (
<CompetitionPage />
) : currentPage === 'agent' ? (
<AgentChatPage />
) : currentPage === 'data' ? (
<DataPage />
) : currentPage === 'strategy-market' ? (

View File

@@ -6,6 +6,7 @@ import { t, type Language } from '../../i18n/translations'
import { OFFICIAL_LINKS } from '../../constants/branding'
type Page =
| 'agent'
| 'competition'
| 'traders'
| 'trader'
@@ -93,6 +94,7 @@ export default function HeaderBar({
{(() => {
// Define all navigation tabs
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
{ page: 'agent', path: '/agent', label: language === 'zh' ? '🤖 Agent' : '🤖 Agent', requiresAuth: false },
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
@@ -334,6 +336,7 @@ export default function HeaderBar({
<div className="flex flex-col gap-6 mb-12">
{(() => {
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
{ page: 'agent', path: '/agent', label: '🤖 Agent', requiresAuth: false },
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },

View File

@@ -56,8 +56,8 @@ export const OFFICIAL_LINKS = {
// Brand watermark component data
export const BRAND_INFO = {
name: 'NOFX',
tagline: 'AI Trading Platform',
name: 'NOFXi',
tagline: 'AI Trading Agent',
version: '1.0.0',
// Links embedded in multiple formats for redundancy
social: {

View File

@@ -3,7 +3,7 @@ export type Language = 'en' | 'zh' | 'id'
export const translations = {
en: {
// Header
appTitle: 'NOFX',
appTitle: 'NOFXi',
subtitle: 'Multi-AI Model Trading Platform',
aiTraders: 'AI Traders',
details: 'Details',
@@ -1348,7 +1348,7 @@ export const translations = {
},
zh: {
// Header
appTitle: 'NOFX',
appTitle: 'NOFXi',
subtitle: '多AI模型交易平台',
aiTraders: 'AI交易员',
details: '详情',
@@ -2633,7 +2633,7 @@ export const translations = {
},
id: {
// Header
appTitle: 'NOFX',
appTitle: 'NOFXi',
subtitle: 'Platform Trading Multi-AI',
aiTraders: 'Trader AI',
details: 'Detail',

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>
)
}