Files
nofx/nofxi/internal/interaction/web.go
shinchan-zhai b72ef9e6df feat(nofxi): K-line chart with TradingView lightweight-charts
- Candlestick chart + volume histogram
- 8 trading pairs: BTC/ETH/SOL/BNB/XRP/DOGE/ADA/AVAX
- 5 timeframes: 15m/1H/4H/1D/1W
- Real-time price & 24h change display
- Auto-refresh every 30s
- Backend: /api/klines + /api/ticker (Binance futures API)
- Dark trading terminal theme, responsive resize
- Tab switching: Chat ↔ Chart
2026-03-22 22:18:48 +08:00

190 lines
4.7 KiB
Go

package interaction
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
)
// WebServer provides a REST API and Web UI for NOFXi.
type WebServer struct {
handler MessageHandler
port int
webDir string // Path to web/ directory for static files
logger *slog.Logger
server *http.Server
}
// NewWebServer creates a new web API server.
// webDir is the path to the web/ directory containing index.html.
func NewWebServer(port int, handler MessageHandler, webDir string, logger *slog.Logger) *WebServer {
return &WebServer{
handler: handler,
port: port,
webDir: webDir,
logger: logger,
}
}
// chatRequest is the API request body.
type chatRequest struct {
UserID int64 `json:"user_id"`
Message string `json:"message"`
}
// chatResponse is the API response body.
type chatAPIResponse struct {
Response string `json:"response"`
Error string `json:"error,omitempty"`
}
// Start begins listening. Blocks until context is cancelled.
func (w *WebServer) Start(ctx context.Context) error {
mux := http.NewServeMux()
// Health check
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
json.NewEncoder(rw).Encode(map[string]string{
"status": "ok",
"agent": "NOFXi",
"time": time.Now().Format(time.RFC3339),
})
})
// Chat endpoint
mux.HandleFunc("/api/chat", func(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(rw, http.StatusBadRequest, chatAPIResponse{Error: "invalid request body"})
return
}
if req.Message == "" {
writeJSON(rw, http.StatusBadRequest, chatAPIResponse{Error: "message is required"})
return
}
if req.UserID == 0 {
req.UserID = 1 // Default user for API access
}
resp, err := w.handler(r.Context(), req.UserID, req.Message)
if err != nil {
writeJSON(rw, http.StatusInternalServerError, chatAPIResponse{Error: err.Error()})
return
}
writeJSON(rw, http.StatusOK, chatAPIResponse{Response: resp})
})
// OpenAI-compatible endpoint (so other tools can talk to NOFXi)
mux.HandleFunc("/v1/chat/completions", func(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(rw, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
// Get the last user message
userMsg := ""
for i := len(body.Messages) - 1; i >= 0; i-- {
if body.Messages[i].Role == "user" {
userMsg = body.Messages[i].Content
break
}
}
if userMsg == "" {
writeJSON(rw, http.StatusBadRequest, map[string]string{"error": "no user message"})
return
}
resp, err := w.handler(r.Context(), 1, userMsg)
if err != nil {
writeJSON(rw, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Return OpenAI-compatible response
writeJSON(rw, http.StatusOK, map[string]interface{}{
"id": fmt.Sprintf("nofxi-%d", time.Now().UnixNano()),
"object": "chat.completion",
"created": time.Now().Unix(),
"model": "nofxi",
"choices": []map[string]interface{}{
{
"index": 0,
"message": map[string]string{"role": "assistant", "content": resp},
"finish_reason": "stop",
},
},
})
})
// Register kline/market data API
RegisterKlineRoutes(mux)
// Serve web UI static files
if w.webDir != "" {
if _, err := os.Stat(filepath.Join(w.webDir, "index.html")); err == nil {
mux.Handle("/", http.FileServer(http.Dir(w.webDir)))
w.logger.Info("serving web UI", "dir", w.webDir)
}
}
addr := fmt.Sprintf(":%d", w.port)
w.server = &http.Server{
Addr: addr,
Handler: mux,
}
w.logger.Info("web server starting", "addr", addr)
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
w.server.Shutdown(shutdownCtx)
}()
if err := w.server.ListenAndServe(); err != http.ErrServerClosed {
return err
}
return nil
}
// Port returns the configured port.
func (w *WebServer) Port() int {
return w.port
}
// PortStr returns port as string.
func (w *WebServer) PortStr() string {
return strconv.Itoa(w.port)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}