diff --git a/agent/web.go b/agent/web.go
index 9eefb898..b20bad76 100644
--- a/agent/web.go
+++ b/agent/web.go
@@ -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("
NOFXi Agent
API is running. Web UI not found.
"))
- })
-
- 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 }
- }
-}
diff --git a/api/agent_routes.go b/api/agent_routes.go
new file mode 100644
index 00000000..893197cf
--- /dev/null
+++ b/api/agent_routes.go
@@ -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))
+}
diff --git a/main.go b/main.go
index 16a4a4bd..524ac198 100644
--- a/main.go
+++ b/main.go
@@ -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)")
diff --git a/web/index.html b/web/index.html
index 574bc83a..60c197e5 100644
--- a/web/index.html
+++ b/web/index.html
@@ -11,7 +11,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
- NOFX - AI Auto Trading Dashboard
+ NOFXi - AI Auto Trading Dashboard
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 17a9c2b1..3b2033b1 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -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 = {
+ '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 = {
+ 'agent': '/agent',
'data': '/data',
'competition': '/competition',
'strategy-market': '/strategy-market',
@@ -476,6 +483,8 @@ function App() {
>
{currentPage === 'competition' ? (
+ ) : currentPage === 'agent' ? (
+
) : currentPage === 'data' ? (
) : currentPage === 'strategy-market' ? (
diff --git a/web/src/components/common/HeaderBar.tsx b/web/src/components/common/HeaderBar.tsx
index 1b580b53..7e06bc8b 100644
--- a/web/src/components/common/HeaderBar.tsx
+++ b/web/src/components/common/HeaderBar.tsx
@@ -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({
{(() => {
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 },
diff --git a/web/src/constants/branding.ts b/web/src/constants/branding.ts
index e6512848..ad3848ba 100644
--- a/web/src/constants/branding.ts
+++ b/web/src/constants/branding.ts
@@ -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: {
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 25c89a5e..a3fafd42 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -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',
@@ -1349,7 +1349,7 @@ export const translations = {
},
zh: {
// Header
- appTitle: 'NOFX',
+ appTitle: 'NOFXi',
subtitle: '多AI模型交易平台',
aiTraders: 'AI交易员',
details: '详情',
@@ -2635,7 +2635,7 @@ export const translations = {
},
id: {
// Header
- appTitle: 'NOFX',
+ appTitle: 'NOFXi',
subtitle: 'Platform Trading Multi-AI',
aiTraders: 'Trader AI',
details: 'Detail',
diff --git a/web/src/pages/AgentChatPage.tsx b/web/src/pages/AgentChatPage.tsx
new file mode 100644
index 00000000..49ca7e5e
--- /dev/null
+++ b/web/src/pages/AgentChatPage.tsx
@@ -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
([
+ { 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)
+
+ 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 (
+
+ {/* Quick actions */}
+
+ {quickActions.map((a, i) => (
+
+ ))}
+
+
+ {/* Messages */}
+
+ {messages.map((m, i) => (
+
+
+ {m.role === 'user' ? '👤' : '⚡'}
+
+
+
+ ))}
+ {loading && (
+
+
⚡
+
+ {language === 'zh' ? '思考中...' : 'Thinking...'}
+
+
+ )}
+
+
+
+ {/* 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' }}
+ />
+
+
+
+
+ )
+}