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