Files
nofx/nofxi/internal/agent/agent.go
shinchan-zhai 9098afd3cf feat(nofxi): full i18n bilingual support (zh/en)
Agent i18n:
- All command responses bilingual (help, status, positions, balance, trades, analysis, settings, errors)
- Language auto-detected from web UI lang toggle
- Stored per-user in SQLite preferences
- AI chat system prompt switches to Chinese/English based on user lang
- Trade confirmation prompts bilingual

Web UI:
- Sends lang parameter with every API request
- Agent extracts [lang:xx] prefix and persists preference

i18n message catalog: nofxi/internal/agent/i18n.go
2026-03-22 22:32:38 +08:00

397 lines
11 KiB
Go

// Package agent implements the NOFXi Agent Core.
package agent
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"nofx/nofxi/internal/execution"
"nofx/nofxi/internal/memory"
"nofx/nofxi/internal/perception"
"nofx/nofxi/internal/thinking"
)
// Agent is the NOFXi agent core.
type Agent struct {
config *Config
memory *memory.Store
thinker thinking.Engine
bridge *execution.Bridge
monitor *perception.MarketMonitor
logger *slog.Logger
NotifyFunc func(userID int64, text string) error
strategyRunner *StrategyRunner
}
func New(cfg *Config, mem *memory.Store, thinker thinking.Engine, logger *slog.Logger) *Agent {
a := &Agent{config: cfg, memory: mem, thinker: thinker, logger: logger}
a.strategyRunner = NewStrategyRunner(a, logger)
return a
}
func (a *Agent) SetBridge(b *execution.Bridge) { a.bridge = b }
func (a *Agent) SetMonitor(m *perception.MarketMonitor) { a.monitor = m }
func (a *Agent) getLang(userID int64) string {
l, _ := a.memory.GetPreference(userID, "lang")
if l == "" {
l = a.config.Agent.Language
}
if l != "zh" && l != "en" {
l = "en"
}
return l
}
// HandleMessage processes a user message and returns a response.
func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) {
// Extract lang prefix from web UI
if strings.HasPrefix(text, "[lang:") {
if end := strings.Index(text, "] "); end > 0 {
lang := text[6:end]
if lang == "zh" || lang == "en" {
a.memory.SetPreference(userID, "lang", lang)
}
text = text[end+2:]
}
}
a.logger.Info("incoming message", "user_id", userID, "text", text)
a.memory.SaveMessage(userID, "user", text)
intent := Route(text)
a.logger.Info("routed intent", "type", intent.Type, "params", intent.Params)
L := a.getLang(userID)
var resp string
var err error
switch intent.Type {
case IntentHelp:
resp = msg(L, "help")
case IntentStatus:
resp = a.handleStatus(L)
case IntentQuery:
resp, err = a.handleQuery(ctx, L, intent)
case IntentAnalyze:
resp, err = a.handleAnalyze(ctx, L, intent)
case IntentTrade:
resp, err = a.handleTrade(ctx, userID, L, intent)
case IntentWatch:
resp = a.HandleWatchCommand(intent.Raw)
case IntentStrategy:
resp = a.handleStrategyCommand(intent.Raw)
case IntentSettings:
resp = fmt.Sprintf(msg(L, "settings"), L, a.config.LLM.Model, a.config.LLM.Provider, len(a.config.Exchanges))
default:
resp, err = a.handleChat(ctx, userID, L, text)
}
if err != nil {
a.logger.Error("handle message", "intent", intent.Type, "error", err)
resp = fmt.Sprintf("⚠️ Error: %v", err)
}
a.memory.SaveMessage(userID, "assistant", resp)
return resp, nil
}
func (a *Agent) handleStatus(L string) string {
wc := 0
if a.monitor != nil {
wc = len(a.monitor.GetAllSnapshots())
}
bs := msg(L, "bridge_disconnected")
if a.bridge != nil {
bs = msg(L, "bridge_connected")
}
return fmt.Sprintf(msg(L, "status_title"),
a.config.Agent.Name, a.config.LLM.Model, a.config.LLM.Provider,
bs, wc, time.Now().Format("2006-01-02 15:04:05"))
}
func (a *Agent) handleQuery(ctx context.Context, L string, intent Intent) (string, error) {
raw := strings.ToLower(intent.Raw)
if a.bridge != nil && (strings.Contains(raw, "position") || strings.Contains(raw, "持仓")) {
return a.queryPositions(L)
}
if a.bridge != nil && (strings.Contains(raw, "balance") || strings.Contains(raw, "余额")) {
return a.queryBalance(L)
}
trades, err := a.memory.GetRecentTrades(10)
if err != nil {
return "", fmt.Errorf("get trades: %w", err)
}
if len(trades) == 0 {
return msg(L, "no_trades"), nil
}
var sb strings.Builder
sb.WriteString(msg(L, "recent_trades"))
total := 0.0
for _, t := range trades {
e := "🟢"
if t.PnL < 0 {
e = "🔴"
}
sb.WriteString(fmt.Sprintf("%s %s %s %s — $%.2f (P/L: $%.2f)\n",
e, t.Side, t.Symbol, t.Exchange, t.Price*t.Quantity, t.PnL))
total += t.PnL
}
sb.WriteString(fmt.Sprintf(msg(L, "total_pnl"), total))
return sb.String(), nil
}
func (a *Agent) queryPositions(L string) (string, error) {
var all []execution.Position
for _, ex := range a.config.Exchanges {
pos, err := a.bridge.GetPositions(ex.Name)
if err != nil {
continue
}
all = append(all, pos...)
}
if len(all) == 0 {
return msg(L, "no_positions"), nil
}
var sb strings.Builder
sb.WriteString(msg(L, "open_positions"))
total := 0.0
for _, p := range all {
e := "🟢"
if p.PnL < 0 {
e = "🔴"
}
sb.WriteString(fmt.Sprintf("%s *%s* %s\n Size: %.4f | Entry: $%.2f\n Mark: $%.2f | P/L: $%.2f\n",
e, p.Symbol, strings.ToUpper(p.Side), p.Size, p.EntryPrice, p.MarkPrice, p.PnL))
if p.Leverage > 0 {
sb.WriteString(fmt.Sprintf(" Leverage: %.0fx | Exchange: %s\n", p.Leverage, p.Exchange))
}
sb.WriteString("\n")
total += p.PnL
}
sb.WriteString(fmt.Sprintf(msg(L, "total_unrealized"), total))
return sb.String(), nil
}
func (a *Agent) queryBalance(L string) (string, error) {
var sb strings.Builder
sb.WriteString(msg(L, "account_balance"))
for _, ex := range a.config.Exchanges {
bal, err := a.bridge.GetBalance(ex.Name)
if err != nil {
sb.WriteString(fmt.Sprintf("• %s: ⚠️ Error\n", ex.Name))
continue
}
sb.WriteString(fmt.Sprintf("*%s*\n", ex.Name))
sb.WriteString(fmt.Sprintf(msg(L, "balance_total"), bal.Total))
sb.WriteString(fmt.Sprintf(msg(L, "balance_available"), bal.Available))
sb.WriteString(fmt.Sprintf(msg(L, "balance_in_position"), bal.InPosition))
}
return sb.String(), nil
}
func (a *Agent) handleAnalyze(ctx context.Context, L string, intent Intent) (string, error) {
symbol := "BTC"
if d, ok := intent.Params["detail"]; ok && d != "" {
symbol = strings.ToUpper(strings.TrimSpace(d))
}
priceInfo := ""
if a.monitor != nil {
if snap, ok := a.monitor.GetSnapshot(symbol + "USDT"); ok && snap.LastPrice > 0 {
priceInfo = fmt.Sprintf("\nCurrent price: $%.2f", snap.LastPrice)
}
}
prompt := fmt.Sprintf("Analyze %s/USDT for trading. %s\nConsider: trend, support/resistance, momentum, volume, sentiment.\nGive specific entry/exit levels and stop loss. Be concise. Respond in %s.",
symbol, priceInfo, map[string]string{"zh": "Chinese", "en": "English"}[L])
analysis, err := a.thinker.Analyze(ctx, prompt)
if err != nil {
return "", fmt.Errorf("AI analyze: %w", err)
}
emojiMap := map[string]string{"buy": "🟢 BUY", "sell": "🔴 SELL", "hold": "🟡 HOLD", "wait": "⏳ WAIT"}
action := emojiMap[analysis.Action]
if action == "" {
action = "🤔 " + analysis.Action
}
result := fmt.Sprintf(msg(L, "analysis_signal"), symbol, action, analysis.Confidence*100, analysis.Reasoning)
if analysis.StopLoss > 0 {
result += fmt.Sprintf(msg(L, "stop_loss"), analysis.StopLoss)
}
if analysis.TakeProfit > 0 {
result += fmt.Sprintf(msg(L, "take_profit"), analysis.TakeProfit)
}
return result, nil
}
func (a *Agent) handleTrade(ctx context.Context, userID int64, L string, intent Intent) (string, error) {
action := strings.ToLower(intent.Params["action"])
detail := intent.Params["detail"]
if a.bridge == nil || len(a.config.Exchanges) == 0 {
return msg(L, "no_exchange"), nil
}
parts := strings.Fields(detail)
if len(parts) < 1 {
return msg(L, "trade_usage"), nil
}
symbol := strings.ToUpper(parts[0])
if !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
quantity := 0.0
leverage := 1
if len(parts) >= 2 {
q, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
return fmt.Sprintf(msg(L, "invalid_quantity"), parts[1]), nil
}
quantity = q
}
if len(parts) >= 3 {
if l, err := strconv.Atoi(strings.TrimSuffix(strings.ToLower(parts[2]), "x")); err == nil {
leverage = l
}
}
if quantity <= 0 {
return msg(L, "specify_quantity"), nil
}
var side string
switch action {
case "buy", "long", "open_long", "做多":
side = "LONG"
case "sell", "short", "open_short", "做空":
side = "SHORT"
case "close", "平仓":
side = "CLOSE_LONG"
default:
side = strings.ToUpper(action)
}
exchange := a.config.Exchanges[0].Name
a.memory.SetPreference(userID, "pending_trade",
fmt.Sprintf("%s|%s|%f|%d|%s", side, symbol, quantity, leverage, exchange))
return fmt.Sprintf(msg(L, "confirm_trade"), side, symbol, quantity, leverage, exchange), nil
}
// ExecutePendingTrade executes a confirmed trade.
func (a *Agent) ExecutePendingTrade(ctx context.Context, userID int64, L string) (string, error) {
pending, err := a.memory.GetPreference(userID, "pending_trade")
if err != nil || pending == "" {
return "", fmt.Errorf(msg(L, "no_pending"))
}
a.memory.SetPreference(userID, "pending_trade", "")
parts := strings.Split(pending, "|")
if len(parts) != 5 {
return "", fmt.Errorf("invalid pending trade")
}
side, symbol := parts[0], parts[1]
quantity, _ := strconv.ParseFloat(parts[2], 64)
leverage, _ := strconv.Atoi(parts[3])
exchange := parts[4]
result, err := a.bridge.PlaceOrder(exchange, symbol, side, quantity, leverage)
if err != nil {
return "", fmt.Errorf("execute trade: %w", err)
}
tr := &memory.TradeRecord{Exchange: exchange, Symbol: symbol, Side: strings.ToLower(side), Type: "market", Quantity: quantity, Status: "open"}
if p, ok := result["avgPrice"].(float64); ok {
tr.Price = p
}
a.memory.SaveTrade(tr)
return fmt.Sprintf(msg(L, "trade_executed"), side, symbol, quantity, leverage, exchange, result), nil
}
func (a *Agent) handleStrategyCommand(text string) string {
parts := strings.Fields(text)
if len(parts) < 2 {
return a.strategyRunner.FormatStrategyList()
}
switch strings.ToLower(parts[1]) {
case "list":
return a.strategyRunner.FormatStrategyList()
case "start":
if len(parts) < 3 {
return "Usage: `/strategy start BTC 1h`"
}
sym := strings.ToUpper(parts[2])
if !strings.HasSuffix(sym, "USDT") {
sym += "USDT"
}
iv := 1 * time.Hour
if len(parts) >= 4 {
switch parts[3] {
case "15m":
iv = 15 * time.Minute
case "30m":
iv = 30 * time.Minute
case "4h":
iv = 4 * time.Hour
}
}
ex := "binance"
if len(parts) >= 5 {
ex = parts[4]
}
id, err := a.strategyRunner.StartStrategy("AI-"+sym, sym, ex, iv)
if err != nil {
return fmt.Sprintf("⚠️ %v", err)
}
return fmt.Sprintf("🚀 Strategy started!\n\n• ID: `%s`\n• Symbol: %s\n• Interval: %s\n• Exchange: %s", id, sym, iv, ex)
case "stop":
if len(parts) < 3 {
return "Usage: `/strategy stop <id>`"
}
if err := a.strategyRunner.StopStrategy(parts[2]); err != nil {
return fmt.Sprintf("⚠️ %v", err)
}
return "✅ Strategy stopped."
case "stopall":
a.strategyRunner.StopAll()
return "✅ All strategies stopped."
default:
return "Use: `/strategy list|start|stop|stopall`"
}
}
func (a *Agent) handleChat(ctx context.Context, userID int64, L string, text string) (string, error) {
lower := strings.ToLower(text)
if lower == "yes" || lower == "y" || lower == "确认" || lower == "是" {
if p, _ := a.memory.GetPreference(userID, "pending_trade"); p != "" {
return a.ExecutePendingTrade(ctx, userID, L)
}
}
history, _ := a.memory.GetRecentMessages(userID, 20)
sysPrompt := fmt.Sprintf(msg(L, "system_prompt"), time.Now().Format("2006-01-02 15:04:05"))
msgs := []thinking.Message{{Role: "system", Content: sysPrompt}}
for _, m := range history {
msgs = append(msgs, thinking.Message{Role: m.Role, Content: m.Content})
}
msgs = append(msgs, thinking.Message{Role: "user", Content: text})
return a.thinker.Chat(ctx, msgs)
}