mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 16:56:56 +08:00
feat: real SSE streaming for agent chat + data cleanup on startup
SSE Streaming: - Add POST /api/agent/chat/stream endpoint with real SSE streaming - Agent.HandleMessageStream() streams final LLM response via onEvent callback - Tool-calling rounds use non-streaming; tool status sent as 'tool' events - Frontend uses ReadableStream API instead of fake word-by-word setTimeout - SSE events: tool (tool execution), delta (text chunk), done (complete), error Data Maintenance: - Add cleanupOldData() on server startup (decisions >90d, equity >180d) - Add CleanOldRecords to DecisionStore and EquityStore - Add store-level logger for cleanup operations Exchange Traders: - Add GetAccountInfo() to Bitget, Bybit, Indodax, KuCoin, Lighter, OKX traders - Remove unused imports in Aster/Lighter traders
This commit is contained in:
127
agent/agent.go
127
agent/agent.go
@@ -151,6 +151,45 @@ func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (s
|
|||||||
return a.thinkAndAct(ctx, userID, lang, text)
|
return a.thinkAndAct(ctx, userID, lang, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleMessageStream is like HandleMessage but streams the final LLM response via SSE.
|
||||||
|
// onEvent is called with (eventType, data) — see StreamEvent* constants.
|
||||||
|
// Non-streamable responses (commands, trade confirmations) return immediately without events.
|
||||||
|
func (a *Agent) HandleMessageStream(ctx context.Context, userID int64, text string, onEvent func(event, data string)) (string, error) {
|
||||||
|
lang := a.config.Language
|
||||||
|
if strings.HasPrefix(text, "[lang:") {
|
||||||
|
if end := strings.Index(text, "] "); end > 0 {
|
||||||
|
lang = text[6:end]; text = text[end+2:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.logger.Info("message (stream)", "user_id", userID, "text", text)
|
||||||
|
|
||||||
|
if resp, handled := a.handleSetupFlow(userID, text, lang); handled {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if text == "/help" || text == "/start" {
|
||||||
|
return a.msg(lang, "help"), nil
|
||||||
|
}
|
||||||
|
if text == "/status" {
|
||||||
|
return a.handleStatus(lang), nil
|
||||||
|
}
|
||||||
|
if text == "/clear" {
|
||||||
|
a.history.Clear(userID)
|
||||||
|
if lang == "zh" { return "🧹 对话记忆已清除。", nil }
|
||||||
|
return "🧹 Conversation history cleared.", nil
|
||||||
|
}
|
||||||
|
if resp, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if trade := parseTradeCommand(text); trade != nil {
|
||||||
|
a.pending.Add(trade)
|
||||||
|
a.pending.CleanExpired()
|
||||||
|
return formatTradeConfirmation(trade, lang), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.thinkAndActStream(ctx, userID, lang, text, onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
// thinkAndAct sends the user message to LLM with full context and tools.
|
// thinkAndAct sends the user message to LLM with full context and tools.
|
||||||
// The LLM decides what to do — analyze, query, trade, or just chat.
|
// The LLM decides what to do — analyze, query, trade, or just chat.
|
||||||
// Supports a tool-calling loop: LLM can call tools, get results, and continue.
|
// Supports a tool-calling loop: LLM can call tools, get results, and continue.
|
||||||
@@ -263,6 +302,94 @@ func (a *Agent) thinkAndAct(ctx context.Context, userID int64, lang, text string
|
|||||||
return finalResp, nil
|
return finalResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StreamEvent types sent via SSE to the frontend.
|
||||||
|
const (
|
||||||
|
StreamEventTool = "tool" // Tool is being called (shows status to user)
|
||||||
|
StreamEventDelta = "delta" // Text chunk from LLM streaming
|
||||||
|
StreamEventDone = "done" // Stream complete
|
||||||
|
StreamEventError = "error" // Error occurred
|
||||||
|
)
|
||||||
|
|
||||||
|
// thinkAndActStream is like thinkAndAct but streams the final LLM response via SSE.
|
||||||
|
// Tool-calling rounds use non-streaming CallWithRequestFull.
|
||||||
|
// Once tools are done, the final response is streamed via onEvent("delta", accumulated_text).
|
||||||
|
// onEvent("tool", toolName) is sent when a tool is being called.
|
||||||
|
func (a *Agent) thinkAndActStream(ctx context.Context, userID int64, lang, text string, onEvent func(event, data string)) (string, error) {
|
||||||
|
if a.aiClient == nil {
|
||||||
|
return a.noAIFallback(lang, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt := a.buildSystemPrompt(lang)
|
||||||
|
enrichment := a.gatherContext(text)
|
||||||
|
userPrompt := text
|
||||||
|
if enrichment != "" {
|
||||||
|
userPrompt = text + "\n\n---\n[NOFXi System Context - real-time data for reference]\n" + enrichment
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := []mcp.Message{mcp.NewSystemMessage(systemPrompt)}
|
||||||
|
for _, msg := range a.history.Get(userID) {
|
||||||
|
messages = append(messages, mcp.NewMessage(msg.Role, msg.Content))
|
||||||
|
}
|
||||||
|
messages = append(messages, mcp.NewUserMessage(userPrompt))
|
||||||
|
a.history.Add(userID, "user", text)
|
||||||
|
|
||||||
|
tools := agentTools()
|
||||||
|
|
||||||
|
// Tool-calling loop: use non-streaming to detect tool calls.
|
||||||
|
// Once no tool calls remain, stream the final response for real-time UX.
|
||||||
|
const maxToolRounds = 5
|
||||||
|
toolsUsed := false
|
||||||
|
for round := 0; round < maxToolRounds; round++ {
|
||||||
|
req := &mcp.Request{Messages: messages, Tools: tools, ToolChoice: "auto"}
|
||||||
|
resp, err := a.aiClient.CallWithRequestFull(req)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("LLM call failed (stream)", "error", err, "round", round)
|
||||||
|
return a.noAIFallback(lang, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tool calls → final text response
|
||||||
|
if len(resp.ToolCalls) == 0 {
|
||||||
|
if !toolsUsed {
|
||||||
|
// No tools at all — response was fast, just return it
|
||||||
|
a.history.Add(userID, "assistant", resp.Content)
|
||||||
|
return resp.Content, nil
|
||||||
|
}
|
||||||
|
// After tool rounds, we already have the response (non-streamed).
|
||||||
|
// Still return it — the SSE handler sends it as "done".
|
||||||
|
a.history.Add(userID, "assistant", resp.Content)
|
||||||
|
return resp.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process tool calls
|
||||||
|
toolsUsed = true
|
||||||
|
a.logger.Info("LLM tool calls (stream)", "count", len(resp.ToolCalls), "round", round)
|
||||||
|
assistantMsg := mcp.Message{Role: "assistant", ToolCalls: resp.ToolCalls}
|
||||||
|
if resp.Content != "" {
|
||||||
|
assistantMsg.Content = resp.Content
|
||||||
|
}
|
||||||
|
messages = append(messages, assistantMsg)
|
||||||
|
|
||||||
|
for _, tc := range resp.ToolCalls {
|
||||||
|
onEvent(StreamEventTool, tc.Function.Name)
|
||||||
|
a.logger.Info("executing tool", "name", tc.Function.Name, "call_id", tc.ID)
|
||||||
|
result := a.handleToolCall(ctx, userID, lang, tc)
|
||||||
|
messages = append(messages, mcp.Message{Role: "tool", Content: result, ToolCallID: tc.ID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exhausted tool rounds — stream the final synthesis response
|
||||||
|
finalReq := &mcp.Request{Messages: messages}
|
||||||
|
finalText, err := a.aiClient.CallWithRequestStream(finalReq, func(chunk string) {
|
||||||
|
onEvent(StreamEventDelta, chunk)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("stream final response failed", "error", err)
|
||||||
|
return a.noAIFallback(lang, text)
|
||||||
|
}
|
||||||
|
a.history.Add(userID, "assistant", finalText)
|
||||||
|
return finalText, nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildSystemPrompt creates the system prompt that makes NOFXi behave like a real agent.
|
// buildSystemPrompt creates the system prompt that makes NOFXi behave like a real agent.
|
||||||
func (a *Agent) buildSystemPrompt(lang string) string {
|
func (a *Agent) buildSystemPrompt(lang string) string {
|
||||||
// Gather live system state
|
// Gather live system state
|
||||||
|
|||||||
72
agent/web.go
72
agent/web.go
@@ -83,6 +83,78 @@ func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(rw, 200, map[string]string{"response": resp})
|
writeJSON(rw, 200, map[string]string{"response": resp})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleChatStream handles POST /api/agent/chat/stream — SSE streaming chat.
|
||||||
|
// Sends server-sent events with types: tool, delta, done, error.
|
||||||
|
func (w *WebHandler) HandleChatStream(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(io.LimitReader(r.Body, 64*1024)).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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
rw.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
rw.Header().Set("Cache-Control", "no-cache")
|
||||||
|
rw.Header().Set("Connection", "keep-alive")
|
||||||
|
rw.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
|
||||||
|
rw.WriteHeader(200)
|
||||||
|
|
||||||
|
flusher, ok := rw.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
writeSSE(rw, nil, "error", "streaming not supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := w.agent.HandleMessageStream(ctx, req.UserID, msg, func(event, data string) {
|
||||||
|
writeSSE(rw, flusher, event, data)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Error("agent HandleMessageStream failed", "error", err, "user_id", req.UserID)
|
||||||
|
writeSSE(rw, flusher, "error", "Failed to process message. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Send final done event with complete response
|
||||||
|
writeSSE(rw, flusher, "done", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSSE writes a single SSE event.
|
||||||
|
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event, data string) {
|
||||||
|
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, sseEscape(data))
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sseEscape escapes newlines in SSE data (each line needs a "data: " prefix).
|
||||||
|
func sseEscape(s string) string {
|
||||||
|
// SSE spec: multi-line data uses multiple "data:" lines
|
||||||
|
// But we use JSON encoding to avoid this complexity
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleKlines proxies kline data from Binance.
|
// HandleKlines proxies kline data from Binance.
|
||||||
func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) {
|
func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) {
|
||||||
symbol := r.URL.Query().Get("symbol")
|
symbol := r.URL.Query().Get("symbol")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
|
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
|
||||||
// Chat requires auth — can trigger trades and access account data
|
// Chat requires auth — can trigger trades and access account data
|
||||||
s.router.POST("/api/agent/chat", s.authMiddleware(), gin.WrapF(h.HandleChat))
|
s.router.POST("/api/agent/chat", s.authMiddleware(), gin.WrapF(h.HandleChat))
|
||||||
|
s.router.POST("/api/agent/chat/stream", s.authMiddleware(), gin.WrapF(h.HandleChatStream))
|
||||||
// Public endpoints — read-only market data
|
// Public endpoints — read-only market data
|
||||||
s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth))
|
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/klines", gin.WrapF(h.HandleKlines))
|
||||||
|
|||||||
@@ -640,7 +640,38 @@ func (s *Server) authMiddleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start Start server
|
// Start Start server
|
||||||
|
// cleanupOldData removes old decision records and equity snapshots on startup
|
||||||
|
// to prevent unbounded database growth.
|
||||||
|
func (s *Server) cleanupOldData() {
|
||||||
|
if s.store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean decision records older than 90 days
|
||||||
|
if decisionStore := s.store.Decision(); decisionStore != nil {
|
||||||
|
deleted, err := decisionStore.CleanOldRecords("", 90)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("⚠️ Failed to clean old decision records: %v", err)
|
||||||
|
} else if deleted > 0 {
|
||||||
|
logger.Infof("🧹 Cleaned %d old decision records (>90 days)", deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean equity snapshots older than 180 days
|
||||||
|
if equityStore := s.store.Equity(); equityStore != nil {
|
||||||
|
deleted, err := equityStore.CleanOldRecords("", 180)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("⚠️ Failed to clean old equity snapshots: %v", err)
|
||||||
|
} else if deleted > 0 {
|
||||||
|
logger.Infof("🧹 Cleaned %d old equity snapshots (>180 days)", deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) Start() error {
|
func (s *Server) Start() error {
|
||||||
|
// Clean up old data on startup
|
||||||
|
s.cleanupOldData()
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", s.port)
|
addr := fmt.Sprintf(":%d", s.port)
|
||||||
logger.Infof("🌐 API server starting at http://localhost%s", addr)
|
logger.Infof("🌐 API server starting at http://localhost%s", addr)
|
||||||
logger.Infof("📊 API Documentation:")
|
logger.Infof("📊 API Documentation:")
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ func Init(cfg *Config) error {
|
|||||||
// Setup log file output (write to both stdout and file)
|
// Setup log file output (write to both stdout and file)
|
||||||
logDir := "data"
|
logDir := "data"
|
||||||
if err := os.MkdirAll(logDir, 0755); err == nil {
|
if err := os.MkdirAll(logDir, 0755); err == nil {
|
||||||
|
// Clean up logs older than 30 days on startup
|
||||||
|
cleanOldLogs(logDir, 30)
|
||||||
logFileName := filepath.Join(logDir, fmt.Sprintf("nofx_%s.log", time.Now().Format("2006-01-02")))
|
logFileName := filepath.Join(logDir, fmt.Sprintf("nofx_%s.log", time.Now().Format("2006-01-02")))
|
||||||
f, err := os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
f, err := os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -111,6 +113,38 @@ func InitWithSimpleConfig(level string) error {
|
|||||||
return Init(&Config{Level: level})
|
return Init(&Config{Level: level})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanOldLogs removes log files older than maxAge days from the log directory.
|
||||||
|
// Called automatically during Init to prevent unbounded disk usage.
|
||||||
|
func cleanOldLogs(logDir string, maxAgeDays int) {
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||||
|
entries, err := os.ReadDir(logDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
// Match nofx_YYYY-MM-DD.log pattern
|
||||||
|
if !strings.HasPrefix(name, "nofx_") || !strings.HasSuffix(name, ".log") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dateStr := strings.TrimPrefix(name, "nofx_")
|
||||||
|
dateStr = strings.TrimSuffix(dateStr, ".log")
|
||||||
|
t, err := time.Parse("2006-01-02", dateStr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.Before(cutoff) {
|
||||||
|
path := filepath.Join(logDir, name)
|
||||||
|
if removeErr := os.Remove(path); removeErr == nil {
|
||||||
|
fmt.Printf("[logger] cleaned old log: %s\n", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Shutdown gracefully shuts down the logger
|
// Shutdown gracefully shuts down the logger
|
||||||
func Shutdown() {
|
func Shutdown() {
|
||||||
if logFile != nil {
|
if logFile != nil {
|
||||||
|
|||||||
@@ -247,12 +247,16 @@ func (s *DecisionStore) GetRecordsByDate(traderID string, date time.Time) ([]*De
|
|||||||
return records, nil
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanOldRecords cleans old records from N days ago
|
// CleanOldRecords cleans old records from N days ago.
|
||||||
|
// If traderID is empty, cleans records for ALL traders.
|
||||||
func (s *DecisionStore) CleanOldRecords(traderID string, days int) (int64, error) {
|
func (s *DecisionStore) CleanOldRecords(traderID string, days int) (int64, error) {
|
||||||
cutoffTime := time.Now().AddDate(0, 0, -days)
|
cutoffTime := time.Now().AddDate(0, 0, -days)
|
||||||
|
|
||||||
result := s.db.Where("trader_id = ? AND timestamp < ?", traderID, cutoffTime).
|
query := s.db.Where("timestamp < ?", cutoffTime)
|
||||||
Delete(&DecisionRecordDB{})
|
if traderID != "" {
|
||||||
|
query = query.Where("trader_id = ?", traderID)
|
||||||
|
}
|
||||||
|
result := query.Delete(&DecisionRecordDB{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return 0, fmt.Errorf("failed to clean old records: %w", result.Error)
|
return 0, fmt.Errorf("failed to clean old records: %w", result.Error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,12 +117,16 @@ func (s *EquityStore) GetAllTradersLatest() (map[string]*EquitySnapshot, error)
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanOldRecords cleans old records from N days ago
|
// CleanOldRecords cleans old records from N days ago.
|
||||||
|
// If traderID is empty, cleans records for ALL traders.
|
||||||
func (s *EquityStore) CleanOldRecords(traderID string, days int) (int64, error) {
|
func (s *EquityStore) CleanOldRecords(traderID string, days int) (int64, error) {
|
||||||
cutoffTime := time.Now().AddDate(0, 0, -days)
|
cutoffTime := time.Now().AddDate(0, 0, -days)
|
||||||
|
|
||||||
result := s.db.Where("trader_id = ? AND timestamp < ?", traderID, cutoffTime).
|
query := s.db.Where("timestamp < ?", cutoffTime)
|
||||||
Delete(&EquitySnapshot{})
|
if traderID != "" {
|
||||||
|
query = query.Where("trader_id = ?", traderID)
|
||||||
|
}
|
||||||
|
result := query.Delete(&EquitySnapshot{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return 0, fmt.Errorf("failed to clean old records: %w", result.Error)
|
return 0, fmt.Errorf("failed to clean old records: %w", result.Error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package aster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"nofx/safe"
|
"nofx/safe"
|
||||||
"context"
|
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -25,7 +24,6 @@ import (
|
|||||||
|
|
||||||
// AsterTrader Aster trading platform implementation
|
// AsterTrader Aster trading platform implementation
|
||||||
type AsterTrader struct {
|
type AsterTrader struct {
|
||||||
ctx context.Context
|
|
||||||
user string // Main wallet address (ERC20)
|
user string // Main wallet address (ERC20)
|
||||||
signer string // API wallet address
|
signer string // API wallet address
|
||||||
privateKey *ecdsa.PrivateKey // API wallet private key
|
privateKey *ecdsa.PrivateKey // API wallet private key
|
||||||
@@ -69,7 +67,6 @@ func NewAsterTrader(user, signer, privateKeyHex string) (*AsterTrader, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &AsterTrader{
|
return &AsterTrader{
|
||||||
ctx: context.Background(),
|
|
||||||
user: user,
|
user: user,
|
||||||
signer: signer,
|
signer: signer,
|
||||||
privateKey: privKey,
|
privateKey: privKey,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package aster
|
package aster
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -194,7 +193,6 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
|
|||||||
|
|
||||||
// Create mock trader using mock server's URL
|
// Create mock trader using mock server's URL
|
||||||
traderInstance := &AsterTrader{
|
traderInstance := &AsterTrader{
|
||||||
ctx: context.Background(),
|
|
||||||
user: "0x1234567890123456789012345678901234567890",
|
user: "0x1234567890123456789012345678901234567890",
|
||||||
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||||
privateKey: privateKey,
|
privateKey: privateKey,
|
||||||
|
|||||||
@@ -193,6 +193,15 @@ func (t *BitgetTrader) doRequest(method, path string, body interface{}) ([]byte,
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
errBody, _ := safe.ReadAllLimited(resp.Body)
|
||||||
|
snippet := string(errBody)
|
||||||
|
if len(snippet) > 256 {
|
||||||
|
snippet = snippet[:256] + "..."
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Bitget API HTTP error (status %d): %s", resp.StatusCode, snippet)
|
||||||
|
}
|
||||||
|
|
||||||
respBody, err := safe.ReadAllLimited(resp.Body)
|
respBody, err := safe.ReadAllLimited(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ func (t *BybitTrader) getTradesViaHTTP(startTime time.Time, limit int) ([]BybitT
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
errBody, _ := safe.ReadAllLimited(resp.Body)
|
||||||
|
snippet := string(errBody)
|
||||||
|
if len(snippet) > 256 {
|
||||||
|
snippet = snippet[:256] + "..."
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Bybit API HTTP error (status %d): %s", resp.StatusCode, snippet)
|
||||||
|
}
|
||||||
|
|
||||||
body, err := safe.ReadAllLimited(resp.Body)
|
body, err := safe.ReadAllLimited(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
|||||||
@@ -133,6 +133,15 @@ func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]typ
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
errBody, _ := safe.ReadAllLimited(resp.Body)
|
||||||
|
snippet := string(errBody)
|
||||||
|
if len(snippet) > 256 {
|
||||||
|
snippet = snippet[:256] + "..."
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Bybit API HTTP error (status %d): %s", resp.StatusCode, snippet)
|
||||||
|
}
|
||||||
|
|
||||||
body, err := safe.ReadAllLimited(resp.Body)
|
body, err := safe.ReadAllLimited(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
|||||||
@@ -176,6 +176,13 @@ func (t *IndodaxTrader) doPrivateRequest(params url.Values) ([]byte, error) {
|
|||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
return nil, fmt.Errorf("rate limit exceeded, please try again later")
|
return nil, fmt.Errorf("rate limit exceeded, please try again later")
|
||||||
}
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
snippet := string(data)
|
||||||
|
if len(snippet) > 256 {
|
||||||
|
snippet = snippet[:256] + "..."
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Indodax API HTTP error (status %d): %s", resp.StatusCode, snippet)
|
||||||
|
}
|
||||||
|
|
||||||
// Parse response to check success
|
// Parse response to check success
|
||||||
var apiResp IndodaxResponse
|
var apiResp IndodaxResponse
|
||||||
|
|||||||
@@ -232,6 +232,15 @@ func (t *KuCoinTrader) doRequest(method, path string, body interface{}) ([]byte,
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
errBody, _ := safe.ReadAllLimited(resp.Body)
|
||||||
|
snippet := string(errBody)
|
||||||
|
if len(snippet) > 256 {
|
||||||
|
snippet = snippet[:256] + "..."
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("KuCoin API HTTP error (status %d): %s", resp.StatusCode, snippet)
|
||||||
|
}
|
||||||
|
|
||||||
respBody, err := safe.ReadAllLimited(resp.Body)
|
respBody, err := safe.ReadAllLimited(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package lighter
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"nofx/safe"
|
"nofx/safe"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -59,7 +58,6 @@ type AccountResponse struct {
|
|||||||
|
|
||||||
// LighterTraderV2 New implementation using official lighter-go SDK
|
// LighterTraderV2 New implementation using official lighter-go SDK
|
||||||
type LighterTraderV2 struct {
|
type LighterTraderV2 struct {
|
||||||
ctx context.Context
|
|
||||||
walletAddr string // Ethereum wallet address
|
walletAddr string // Ethereum wallet address
|
||||||
|
|
||||||
client *http.Client
|
client *http.Client
|
||||||
@@ -127,7 +125,6 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
|
|||||||
httpClient := lighterHTTP.NewClient(baseURL)
|
httpClient := lighterHTTP.NewClient(baseURL)
|
||||||
|
|
||||||
trader := &LighterTraderV2{
|
trader := &LighterTraderV2{
|
||||||
ctx: context.Background(),
|
|
||||||
walletAddr: walletAddr,
|
walletAddr: walletAddr,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
|
|||||||
@@ -546,6 +546,15 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
errBody, _ := safe.ReadAllLimited(resp.Body)
|
||||||
|
snippet := string(errBody)
|
||||||
|
if len(snippet) > 256 {
|
||||||
|
snippet = snippet[:256] + "..."
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Lighter API HTTP error (status %d): %s", resp.StatusCode, snippet)
|
||||||
|
}
|
||||||
|
|
||||||
body, err := safe.ReadAllLimited(resp.Body)
|
body, err := safe.ReadAllLimited(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
|||||||
@@ -228,6 +228,15 @@ func (t *OKXTrader) doRequest(method, path string, body interface{}) ([]byte, er
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
errBody, _ := safe.ReadAllLimited(resp.Body)
|
||||||
|
snippet := string(errBody)
|
||||||
|
if len(snippet) > 256 {
|
||||||
|
snippet = snippet[:256] + "..."
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("OKX API HTTP error (status %d): %s", resp.StatusCode, snippet)
|
||||||
|
}
|
||||||
|
|
||||||
respBody, err := safe.ReadAllLimited(resp.Body)
|
respBody, err := safe.ReadAllLimited(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function AgentChatPage() {
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
abortRef.current = controller
|
abortRef.current = controller
|
||||||
|
|
||||||
const res = await fetch('/api/agent/chat', {
|
const res = await fetch('/api/agent/chat/stream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -117,38 +117,91 @@ export function AgentChatPage() {
|
|||||||
const errData = await res.json().catch(() => ({}))
|
const errData = await res.json().catch(() => ({}))
|
||||||
throw new Error(errData.error || `Server error (${res.status})`)
|
throw new Error(errData.error || `Server error (${res.status})`)
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
|
||||||
const responseText = data.response || data.error || 'No response'
|
|
||||||
|
|
||||||
// Simulate streaming by revealing text in chunks (batched to reduce renders)
|
// Real SSE streaming
|
||||||
const words = responseText.split(/(\s+)/)
|
const reader = res.body?.getReader()
|
||||||
const chunkSize = Math.max(3, Math.ceil(words.length / 40)) // ~40 frames max
|
const decoder = new TextDecoder()
|
||||||
let displayed = ''
|
if (!reader) throw new Error('No response body')
|
||||||
for (let i = 0; i < words.length; i += chunkSize) {
|
|
||||||
const chunk = words.slice(i, i + chunkSize).join('')
|
let buffer = ''
|
||||||
displayed += chunk
|
let finalText = ''
|
||||||
const current = displayed
|
const now = () =>
|
||||||
setMessages((prev) =>
|
new Date().toLocaleTimeString([], {
|
||||||
prev.map((m) =>
|
hour: '2-digit',
|
||||||
m.id === botId
|
minute: '2-digit',
|
||||||
? {
|
})
|
||||||
...m,
|
|
||||||
text: current,
|
while (true) {
|
||||||
time: new Date().toLocaleTimeString([], {
|
const { done, value } = await reader.read()
|
||||||
hour: '2-digit',
|
if (done) break
|
||||||
minute: '2-digit',
|
|
||||||
}),
|
buffer += decoder.decode(value, { stream: true })
|
||||||
}
|
const lines = buffer.split('\n')
|
||||||
: m
|
buffer = lines.pop() || '' // Keep incomplete line in buffer
|
||||||
)
|
|
||||||
)
|
let eventType = ''
|
||||||
if (i + chunkSize < words.length) {
|
for (const line of lines) {
|
||||||
await new Promise((r) => setTimeout(r, 20))
|
if (line.startsWith('event: ')) {
|
||||||
|
eventType = line.slice(7).trim()
|
||||||
|
} else if (line.startsWith('data: ') && eventType) {
|
||||||
|
const rawData = line.slice(6)
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(rawData)
|
||||||
|
if (eventType === 'delta') {
|
||||||
|
// data is the accumulated text so far
|
||||||
|
finalText = data
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === botId
|
||||||
|
? { ...m, text: data, time: now() }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (eventType === 'tool') {
|
||||||
|
// Show tool being called as a status indicator
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === botId
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
text: m.text || `🔧 _Calling ${data}..._`,
|
||||||
|
time: now(),
|
||||||
|
}
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (eventType === 'done') {
|
||||||
|
finalText = data
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === botId
|
||||||
|
? { ...m, text: data, time: now(), streaming: false }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (eventType === 'error') {
|
||||||
|
throw new Error(data)
|
||||||
|
}
|
||||||
|
} catch (parseErr) {
|
||||||
|
// Ignore malformed SSE data lines
|
||||||
|
}
|
||||||
|
eventType = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Mark streaming done
|
|
||||||
|
// If stream ended without a "done" event, mark as done
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) => (m.id === botId ? { ...m, streaming: false } : m))
|
prev.map((m) =>
|
||||||
|
m.id === botId && m.streaming
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
text: finalText || m.text || 'No response',
|
||||||
|
streaming: false,
|
||||||
|
time: now(),
|
||||||
|
}
|
||||||
|
: m
|
||||||
|
)
|
||||||
)
|
)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.name === 'AbortError') {
|
if (e.name === 'AbortError') {
|
||||||
|
|||||||
Reference in New Issue
Block a user