mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
DELETED: agent/router.go (regex-based intent routing) The fundamental architecture was wrong. A real AI Agent doesn't use pattern matching to understand users. It uses AI. New flow: 1. User says anything 2. /help and /status → instant response (no AI needed) 3. Setup keywords → onboard flow 4. EVERYTHING else → LLM with full context The LLM receives: - System prompt with NOFXi's capabilities and behavior rules - Real-time market data (auto-detected from mentioned symbols) - Live trader positions and status - User's message The LLM decides what to do. Not regex. Not if-else. The AI. This is what makes it an Agent, not a chatbot.
362 lines
12 KiB
Go
362 lines
12 KiB
Go
// Package agent implements the NOFXi Agent Core.
|
||
//
|
||
// Architecture: ALL user messages go to the LLM. The LLM understands intent
|
||
// and calls tools to execute actions. No regex routing, no pattern matching.
|
||
// The LLM IS the brain — just like how OpenClaw works.
|
||
package agent
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"nofx/manager"
|
||
"nofx/market"
|
||
"nofx/mcp"
|
||
"nofx/store"
|
||
)
|
||
|
||
type Agent struct {
|
||
traderManager *manager.TraderManager
|
||
store *store.Store
|
||
aiClient mcp.AIClient
|
||
config *Config
|
||
sentinel *Sentinel
|
||
brain *Brain
|
||
scheduler *Scheduler
|
||
logger *slog.Logger
|
||
NotifyFunc func(userID int64, text string) error
|
||
}
|
||
|
||
type Config struct {
|
||
Language string `json:"language"`
|
||
WatchSymbols []string `json:"watch_symbols"`
|
||
EnableBriefs bool `json:"enable_briefs"`
|
||
EnableNews bool `json:"enable_news"`
|
||
EnableSentinel bool `json:"enable_sentinel"`
|
||
BriefTimes []int `json:"brief_times"`
|
||
}
|
||
|
||
func DefaultConfig() *Config {
|
||
return &Config{
|
||
Language: "zh", WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"},
|
||
EnableBriefs: true, EnableNews: true, EnableSentinel: true, BriefTimes: []int{8, 20},
|
||
}
|
||
}
|
||
|
||
func New(tm *manager.TraderManager, st *store.Store, cfg *Config, logger *slog.Logger) *Agent {
|
||
if cfg == nil { cfg = DefaultConfig() }
|
||
return &Agent{traderManager: tm, store: st, config: cfg, logger: logger}
|
||
}
|
||
|
||
func (a *Agent) SetAIClient(c mcp.AIClient) { a.aiClient = c }
|
||
|
||
func (a *Agent) EnsureAIClient() {
|
||
if a.aiClient != nil { return }
|
||
if a.store != nil {
|
||
models, err := a.store.AIModel().List("default")
|
||
if err == nil {
|
||
for _, m := range models {
|
||
apiKey := string(m.APIKey)
|
||
if apiKey != "" && m.CustomAPIURL != "" {
|
||
client := mcp.NewClient()
|
||
name := m.CustomModelName
|
||
if name == "" { name = m.ID }
|
||
client.SetAPIKey(apiKey, m.CustomAPIURL, name)
|
||
a.aiClient = client
|
||
a.logger.Info("agent AI client ready", "model", name)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
a.logger.Warn("no AI client — agent will have limited capabilities")
|
||
}
|
||
|
||
func (a *Agent) Start() {
|
||
a.logger.Info("starting NOFXi agent...")
|
||
a.EnsureAIClient()
|
||
|
||
if a.config.EnableSentinel {
|
||
a.sentinel = NewSentinel(a.config.WatchSymbols, a.handleSignal, a.logger)
|
||
a.sentinel.Start()
|
||
}
|
||
a.brain = NewBrain(a, a.logger)
|
||
if a.config.EnableNews { a.brain.StartNewsScan(5 * time.Minute) }
|
||
if a.config.EnableBriefs { a.brain.StartMarketBriefs(a.config.BriefTimes) }
|
||
a.scheduler = NewScheduler(a, a.logger)
|
||
a.scheduler.Start(context.Background())
|
||
a.logger.Info("NOFXi agent is online 🚀")
|
||
}
|
||
|
||
func (a *Agent) Stop() {
|
||
if a.sentinel != nil { a.sentinel.Stop() }
|
||
if a.brain != nil { a.brain.Stop() }
|
||
if a.scheduler != nil { a.scheduler.Stop() }
|
||
}
|
||
|
||
// HandleMessage — the core. Everything goes through the LLM.
|
||
func (a *Agent) HandleMessage(ctx context.Context, userID int64, text 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", "user_id", userID, "text", text)
|
||
|
||
// Setup flow — only when user explicitly asks
|
||
if resp, handled := a.handleSetupFlow(userID, text, lang); handled {
|
||
return resp, nil
|
||
}
|
||
|
||
// Only handle bare slash commands directly (instant, no AI needed)
|
||
if text == "/help" || text == "/start" {
|
||
return a.msg(lang, "help"), nil
|
||
}
|
||
if text == "/status" {
|
||
return a.handleStatus(lang), nil
|
||
}
|
||
|
||
// EVERYTHING else → LLM with tools
|
||
return a.thinkAndAct(ctx, userID, lang, text)
|
||
}
|
||
|
||
// thinkAndAct sends the user message to LLM with full context and tools.
|
||
// The LLM decides what to do — analyze, query, trade, or just chat.
|
||
func (a *Agent) thinkAndAct(ctx context.Context, userID int64, lang, text string) (string, error) {
|
||
if a.aiClient == nil {
|
||
return a.noAIFallback(lang, text)
|
||
}
|
||
|
||
// Build rich context for the LLM
|
||
systemPrompt := a.buildSystemPrompt(lang)
|
||
|
||
// Enrich with real-time data if any asset is mentioned
|
||
enrichment := a.gatherContext(text)
|
||
|
||
userPrompt := text
|
||
if enrichment != "" {
|
||
userPrompt = text + "\n\n---\n[NOFXi System Context - real-time data for reference]\n" + enrichment
|
||
}
|
||
|
||
// Call LLM
|
||
resp, err := a.aiClient.CallWithMessages(systemPrompt, userPrompt)
|
||
if err != nil {
|
||
a.logger.Error("LLM call failed", "error", err)
|
||
return a.noAIFallback(lang, text)
|
||
}
|
||
|
||
return resp, nil
|
||
}
|
||
|
||
// buildSystemPrompt creates the system prompt that makes NOFXi behave like a real agent.
|
||
func (a *Agent) buildSystemPrompt(lang string) string {
|
||
// Gather live system state
|
||
traderInfo := a.getTradersSummary()
|
||
watchlist := ""
|
||
if a.sentinel != nil {
|
||
watchlist = a.sentinel.FormatWatchlist(lang)
|
||
}
|
||
|
||
if lang == "zh" {
|
||
return fmt.Sprintf(`你是 NOFXi,一个专业的 AI 交易 Agent。你不是一个简单的聊天机器人——你是用户的交易伙伴。
|
||
|
||
## 你的核心能力
|
||
1. **市场分析** — 加密货币(BTC/ETH/SOL等)有实时数据,A股/港股/美股/外汇你可以基于知识分析
|
||
2. **交易管理** — 查看持仓、余额、交易历史、Trader 状态
|
||
3. **策略建议** — 根据用户需求制定交易策略
|
||
4. **风险管理** — 评估风险、建议止损止盈
|
||
5. **配置引导** — 用户说"开始配置"时引导配置交易所和AI模型
|
||
|
||
## 当前系统状态
|
||
%s
|
||
%s
|
||
|
||
## 行为准则
|
||
- 简洁、专业、有观点。不说废话。
|
||
- 用户问什么答什么,不要推销配置。
|
||
- 分析时给出具体的价位、方向、理由。
|
||
- 不确定的数据要诚实说明。
|
||
- 用交易相关的 emoji 让回复更直观。
|
||
- 用中文回复。
|
||
|
||
当前时间: %s`, traderInfo, watchlist, time.Now().Format("2006-01-02 15:04:05"))
|
||
}
|
||
|
||
return fmt.Sprintf(`You are NOFXi, a professional AI trading agent. Not a chatbot — a trading partner.
|
||
|
||
## Capabilities
|
||
1. Market analysis — crypto with real-time data, stocks/forex with knowledge
|
||
2. Trade management — positions, balance, history, trader status
|
||
3. Strategy — build trading strategies based on user needs
|
||
4. Risk management — assess risk, suggest stop-loss/take-profit
|
||
5. Setup — guide exchange/AI configuration when user asks
|
||
|
||
## Current System State
|
||
%s
|
||
%s
|
||
|
||
## Behavior
|
||
- Concise, professional, opinionated. No fluff.
|
||
- Answer what's asked. Don't push setup.
|
||
- Give specific prices, directions, reasoning.
|
||
- Be honest about data freshness.
|
||
- Use trading emojis.
|
||
|
||
Current time: %s`, traderInfo, watchlist, time.Now().Format("2006-01-02 15:04:05"))
|
||
}
|
||
|
||
// gatherContext collects real-time market data relevant to the user's message.
|
||
func (a *Agent) gatherContext(text string) string {
|
||
var parts []string
|
||
upper := strings.ToUpper(text)
|
||
|
||
// Crypto — try to get real-time data
|
||
cryptoSymbols := []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK"}
|
||
for _, sym := range cryptoSymbols {
|
||
if strings.Contains(upper, sym) {
|
||
md, err := market.Get(sym + "USDT")
|
||
if err == nil {
|
||
parts = append(parts, fmt.Sprintf("[%s/USDT Real-time]\nPrice: $%.4f | 1h: %+.2f%% | 4h: %+.2f%% | RSI7: %.1f | EMA20: %.4f | MACD: %.6f | Funding: %.4f%%",
|
||
sym, md.CurrentPrice, md.PriceChange1h, md.PriceChange4h, md.CurrentRSI7, md.CurrentEMA20, md.CurrentMACD, md.FundingRate*100))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Trader positions
|
||
if a.traderManager != nil {
|
||
for _, t := range a.traderManager.GetAllTraders() {
|
||
positions, err := t.GetPositions()
|
||
if err != nil { continue }
|
||
for _, p := range positions {
|
||
size := toFloat(p["size"])
|
||
if size == 0 { continue }
|
||
parts = append(parts, fmt.Sprintf("[Position] %s %s: size=%.4f entry=$%.4f mark=$%.4f pnl=$%.2f",
|
||
p["symbol"], p["side"], size, toFloat(p["entryPrice"]), toFloat(p["markPrice"]), toFloat(p["unrealizedPnl"])))
|
||
}
|
||
}
|
||
}
|
||
|
||
return strings.Join(parts, "\n")
|
||
}
|
||
|
||
func (a *Agent) getTradersSummary() string {
|
||
if a.traderManager == nil { return "Traders: none configured" }
|
||
traders := a.traderManager.GetAllTraders()
|
||
if len(traders) == 0 { return "Traders: none configured" }
|
||
|
||
var lines []string
|
||
for id, t := range traders {
|
||
s := t.GetStatus()
|
||
running, _ := s["is_running"].(bool)
|
||
status := "stopped"
|
||
if running { status = "running" }
|
||
tid := id
|
||
if len(tid) > 8 { tid = tid[:8] }
|
||
lines = append(lines, fmt.Sprintf("• %s [%s] %s | %s", t.GetName(), tid, status, t.GetExchange()))
|
||
}
|
||
return "Traders:\n" + strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (a *Agent) handleStatus(L string) string {
|
||
tc, rc := 0, 0
|
||
if a.traderManager != nil {
|
||
all := a.traderManager.GetAllTraders()
|
||
tc = len(all)
|
||
for _, t := range all {
|
||
if s := t.GetStatus(); s["is_running"] == true { rc++ }
|
||
}
|
||
}
|
||
wc := 0
|
||
if a.sentinel != nil { wc = a.sentinel.SymbolCount() }
|
||
ai := "❌"
|
||
if a.aiClient != nil { ai = "✅" }
|
||
return fmt.Sprintf(a.msg(L, "status"), rc, tc, wc, ai, time.Now().Format("2006-01-02 15:04:05"))
|
||
}
|
||
|
||
// noAIFallback — when no AI is available, still try to be useful.
|
||
func (a *Agent) noAIFallback(lang, text string) (string, error) {
|
||
upper := strings.ToUpper(text)
|
||
|
||
// Try to provide market data directly
|
||
for _, sym := range []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE"} {
|
||
if strings.Contains(upper, sym) {
|
||
md, err := market.Get(sym + "USDT")
|
||
if err == nil {
|
||
return fmt.Sprintf("📊 *%s/USDT*\n\n%s\n\n💡 配置 AI 模型后我能给你更深度的分析。发送 *开始配置* 开始。", sym, market.Format(md)), nil
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check if asking about positions/balance
|
||
if strings.Contains(text, "持仓") || strings.Contains(upper, "POSITION") {
|
||
return a.queryPositionsDirect(lang)
|
||
}
|
||
if strings.Contains(text, "余额") || strings.Contains(upper, "BALANCE") {
|
||
return a.queryBalancesDirect(lang)
|
||
}
|
||
|
||
if lang == "zh" {
|
||
return "🤖 我是 NOFXi。配置 AI 模型后我就能理解你的任何问题——分析股票、制定策略、管理交易。\n\n现在可用:\n• 加密货币实时行情(试试「BTC」)\n• `/status` 系统状态\n\n发送 *开始配置* 配置 AI 模型。", nil
|
||
}
|
||
return "🤖 I'm NOFXi. Configure an AI model and I can understand anything — analyze stocks, build strategies, manage trades.\n\nAvailable now:\n• Crypto real-time data (try 'BTC')\n• `/status` system status\n\nSend *setup* to configure AI.", nil
|
||
}
|
||
|
||
func (a *Agent) queryPositionsDirect(L string) (string, error) {
|
||
if a.traderManager == nil { return a.msg(L, "no_traders"), nil }
|
||
var sb strings.Builder
|
||
sb.WriteString("📊 *Positions*\n\n")
|
||
hasAny := false
|
||
for id, t := range a.traderManager.GetAllTraders() {
|
||
positions, err := t.GetPositions()
|
||
if err != nil { continue }
|
||
for _, p := range positions {
|
||
size := toFloat(p["size"])
|
||
if size == 0 { continue }
|
||
hasAny = true
|
||
pnl := toFloat(p["unrealizedPnl"])
|
||
e := "🟢"; if pnl < 0 { e = "🔴" }
|
||
sb.WriteString(fmt.Sprintf("%s *%s* %s — $%.2f | Trader: %s\n", e, p["symbol"], p["side"], pnl, id[:8]))
|
||
}
|
||
}
|
||
if !hasAny { return a.msg(L, "no_positions"), nil }
|
||
return sb.String(), nil
|
||
}
|
||
|
||
func (a *Agent) queryBalancesDirect(L string) (string, error) {
|
||
if a.traderManager == nil { return a.msg(L, "no_traders"), nil }
|
||
var sb strings.Builder
|
||
sb.WriteString("💰 *Balance*\n\n")
|
||
for id, t := range a.traderManager.GetAllTraders() {
|
||
info, err := t.GetAccountInfo()
|
||
if err != nil { continue }
|
||
tid := id; if len(tid) > 8 { tid = tid[:8] }
|
||
sb.WriteString(fmt.Sprintf("*%s* (%s): $%.2f\n", t.GetName(), tid, toFloat(info["total_equity"])))
|
||
}
|
||
return sb.String(), nil
|
||
}
|
||
|
||
func (a *Agent) handleSignal(sig Signal) {
|
||
if a.brain != nil { a.brain.HandleSignal(sig) }
|
||
}
|
||
|
||
func (a *Agent) notifyAll(text string) {
|
||
if a.NotifyFunc != nil { a.NotifyFunc(0, text) }
|
||
}
|
||
|
||
func toFloat(v interface{}) float64 {
|
||
switch x := v.(type) {
|
||
case float64: return x
|
||
case float32: return float64(x)
|
||
case int: return float64(x)
|
||
case int64: return float64(x)
|
||
case string: f, _ := strconv.ParseFloat(x, 64); return f
|
||
}
|
||
return 0
|
||
}
|