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 d524fad6..c06aefc9 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', @@ -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', 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' ? '👤' : '⚡'} +
+
+
{m.text}
+
{m.time}
+
+
+ ))} + {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' }} + /> + +
+
+
+ ) +}