mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 13:00:59 +08:00
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
This commit is contained in:
@@ -1,12 +1,4 @@
|
||||
// Package agent implements the NOFXi Agent Core.
|
||||
//
|
||||
// The Agent is the central orchestrator that:
|
||||
// 1. Receives user messages (via Interaction layer)
|
||||
// 2. Routes intents (trade, query, analyze, chat)
|
||||
// 3. Uses Thinking layer for AI decisions
|
||||
// 4. Stores context in Memory layer
|
||||
// 5. Executes trades via Execution bridge (→ NOFX engine)
|
||||
// 6. Monitors markets via Perception layer
|
||||
package agent
|
||||
|
||||
import (
|
||||
@@ -25,314 +17,235 @@ import (
|
||||
|
||||
// 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 sends proactive notifications to users (set by interaction layer)
|
||||
NotifyFunc func(userID int64, text string) error
|
||||
|
||||
// Strategy runner
|
||||
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
|
||||
}
|
||||
|
||||
// New creates a new Agent with the given config.
|
||||
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 := &Agent{config: cfg, memory: mem, thinker: thinker, logger: logger}
|
||||
a.strategyRunner = NewStrategyRunner(a, logger)
|
||||
return a
|
||||
}
|
||||
|
||||
// SetBridge attaches the execution bridge.
|
||||
func (a *Agent) SetBridge(bridge *execution.Bridge) {
|
||||
a.bridge = bridge
|
||||
}
|
||||
func (a *Agent) SetBridge(b *execution.Bridge) { a.bridge = b }
|
||||
func (a *Agent) SetMonitor(m *perception.MarketMonitor) { a.monitor = m }
|
||||
|
||||
// SetMonitor attaches the market monitor and wires up alert notifications.
|
||||
func (a *Agent) SetMonitor(monitor *perception.MarketMonitor) {
|
||||
a.monitor = monitor
|
||||
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) {
|
||||
a.logger.Info("incoming message", "user_id", userID, "text", text)
|
||||
|
||||
// Save user message to memory
|
||||
if err := a.memory.SaveMessage(userID, "user", text); err != nil {
|
||||
a.logger.Error("save user message", "error", err)
|
||||
// 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:]
|
||||
}
|
||||
}
|
||||
|
||||
// Route intent
|
||||
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)
|
||||
|
||||
var response string
|
||||
L := a.getLang(userID)
|
||||
var resp string
|
||||
var err error
|
||||
|
||||
switch intent.Type {
|
||||
case IntentHelp:
|
||||
response = a.handleHelp()
|
||||
resp = msg(L, "help")
|
||||
case IntentStatus:
|
||||
response = a.handleStatus()
|
||||
resp = a.handleStatus(L)
|
||||
case IntentQuery:
|
||||
response, err = a.handleQuery(ctx, intent)
|
||||
resp, err = a.handleQuery(ctx, L, intent)
|
||||
case IntentAnalyze:
|
||||
response, err = a.handleAnalyze(ctx, intent)
|
||||
resp, err = a.handleAnalyze(ctx, L, intent)
|
||||
case IntentTrade:
|
||||
response, err = a.handleTrade(ctx, userID, intent)
|
||||
resp, err = a.handleTrade(ctx, userID, L, intent)
|
||||
case IntentWatch:
|
||||
response = a.HandleWatchCommand(intent.Raw)
|
||||
resp = a.HandleWatchCommand(intent.Raw)
|
||||
case IntentStrategy:
|
||||
response = a.handleStrategyCommand(intent.Raw)
|
||||
resp = a.handleStrategyCommand(intent.Raw)
|
||||
case IntentSettings:
|
||||
response = a.handleSettings(intent)
|
||||
resp = fmt.Sprintf(msg(L, "settings"), L, a.config.LLM.Model, a.config.LLM.Provider, len(a.config.Exchanges))
|
||||
default:
|
||||
response, err = a.handleChat(ctx, userID, text)
|
||||
resp, err = a.handleChat(ctx, userID, L, text)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
a.logger.Error("handle message", "intent", intent.Type, "error", err)
|
||||
response = fmt.Sprintf("⚠️ Error: %v", err)
|
||||
resp = fmt.Sprintf("⚠️ Error: %v", err)
|
||||
}
|
||||
|
||||
// Save assistant response to memory
|
||||
if err := a.memory.SaveMessage(userID, "assistant", response); err != nil {
|
||||
a.logger.Error("save assistant message", "error", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
a.memory.SaveMessage(userID, "assistant", resp)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleHelp() string {
|
||||
return `🤖 *NOFXi — Your AI Trading Agent*
|
||||
|
||||
*Trading:*
|
||||
/buy BTC 0.01 — Open long (market order)
|
||||
/sell BTC 0.01 — Open short
|
||||
/close BTC — Close position
|
||||
/positions — View open positions
|
||||
/balance — Check balance
|
||||
/pnl — Profit & Loss history
|
||||
|
||||
*Analysis:*
|
||||
/analyze BTC — AI market analysis
|
||||
/watch BTC — Monitor price
|
||||
/alert BTC above 100000 — Price alert
|
||||
|
||||
*System:*
|
||||
/status — Agent status
|
||||
/settings — Preferences
|
||||
/help — This menu
|
||||
|
||||
*Natural Language:*
|
||||
• "帮我做多 ETH,2x 杠杆,0.1 个"
|
||||
• "分析一下 BTC 走势"
|
||||
• "现在持仓情况怎样"
|
||||
• "BTC 到 10 万提醒我"
|
||||
|
||||
Just talk to me 💬`
|
||||
}
|
||||
|
||||
func (a *Agent) handleStatus() string {
|
||||
// Market monitor status
|
||||
watchCount := 0
|
||||
func (a *Agent) handleStatus(L string) string {
|
||||
wc := 0
|
||||
if a.monitor != nil {
|
||||
watchCount = len(a.monitor.GetAllSnapshots())
|
||||
wc = len(a.monitor.GetAllSnapshots())
|
||||
}
|
||||
bridgeStatus := "❌ Not connected"
|
||||
bs := msg(L, "bridge_disconnected")
|
||||
if a.bridge != nil {
|
||||
bridgeStatus = "✅ Connected"
|
||||
bs = msg(L, "bridge_connected")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`📊 *NOFXi Status*
|
||||
|
||||
• Agent: %s
|
||||
• Model: %s
|
||||
• Provider: %s
|
||||
• Memory: ✅ Online
|
||||
• Execution: %s
|
||||
• Watching: %d symbols
|
||||
• Time: %s`,
|
||||
a.config.Agent.Name,
|
||||
a.config.LLM.Model,
|
||||
a.config.LLM.Provider,
|
||||
bridgeStatus,
|
||||
watchCount,
|
||||
time.Now().Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
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, intent Intent) (string, error) {
|
||||
func (a *Agent) handleQuery(ctx context.Context, L string, intent Intent) (string, error) {
|
||||
raw := strings.ToLower(intent.Raw)
|
||||
|
||||
// Try live positions from exchange first
|
||||
if a.bridge != nil && (strings.Contains(raw, "position") || strings.Contains(raw, "持仓")) {
|
||||
return a.queryPositions()
|
||||
return a.queryPositions(L)
|
||||
}
|
||||
if a.bridge != nil && (strings.Contains(raw, "balance") || strings.Contains(raw, "余额")) {
|
||||
return a.queryBalance()
|
||||
return a.queryBalance(L)
|
||||
}
|
||||
|
||||
// Fall back to trade history from memory
|
||||
trades, err := a.memory.GetRecentTrades(10)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get trades: %w", err)
|
||||
}
|
||||
|
||||
if len(trades) == 0 {
|
||||
return "📭 No trades yet. Start with `/buy BTC 0.01` or ask me to `/analyze BTC`.", nil
|
||||
return msg(L, "no_trades"), nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📋 *Recent Trades*\n\n")
|
||||
totalPnL := 0.0
|
||||
sb.WriteString(msg(L, "recent_trades"))
|
||||
total := 0.0
|
||||
for _, t := range trades {
|
||||
emoji := "🟢"
|
||||
e := "🟢"
|
||||
if t.PnL < 0 {
|
||||
emoji = "🔴"
|
||||
e = "🔴"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s %s %s — $%.2f (P/L: $%.2f)\n",
|
||||
emoji, t.Side, t.Symbol, t.Exchange, t.Price*t.Quantity, t.PnL))
|
||||
totalPnL += t.PnL
|
||||
e, t.Side, t.Symbol, t.Exchange, t.Price*t.Quantity, t.PnL))
|
||||
total += t.PnL
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n💰 Total P/L: $%.2f", totalPnL))
|
||||
sb.WriteString(fmt.Sprintf(msg(L, "total_pnl"), total))
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (a *Agent) queryPositions() (string, error) {
|
||||
var allPositions []execution.Position
|
||||
func (a *Agent) queryPositions(L string) (string, error) {
|
||||
var all []execution.Position
|
||||
for _, ex := range a.config.Exchanges {
|
||||
positions, err := a.bridge.GetPositions(ex.Name)
|
||||
pos, err := a.bridge.GetPositions(ex.Name)
|
||||
if err != nil {
|
||||
a.logger.Error("get positions", "exchange", ex.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
allPositions = append(allPositions, positions...)
|
||||
all = append(all, pos...)
|
||||
}
|
||||
|
||||
if len(allPositions) == 0 {
|
||||
return "📭 No open positions.", nil
|
||||
if len(all) == 0 {
|
||||
return msg(L, "no_positions"), nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📊 *Open Positions*\n\n")
|
||||
totalPnL := 0.0
|
||||
for _, p := range allPositions {
|
||||
emoji := "🟢"
|
||||
sb.WriteString(msg(L, "open_positions"))
|
||||
total := 0.0
|
||||
for _, p := range all {
|
||||
e := "🟢"
|
||||
if p.PnL < 0 {
|
||||
emoji = "🔴"
|
||||
e = "🔴"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s *%s* %s\n", emoji, p.Symbol, strings.ToUpper(p.Side)))
|
||||
sb.WriteString(fmt.Sprintf(" Size: %.4f | Entry: $%.2f\n", p.Size, p.EntryPrice))
|
||||
sb.WriteString(fmt.Sprintf(" Mark: $%.2f | P/L: $%.2f\n", p.MarkPrice, p.PnL))
|
||||
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")
|
||||
totalPnL += p.PnL
|
||||
total += p.PnL
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("💰 *Total Unrealized P/L: $%.2f*", totalPnL))
|
||||
sb.WriteString(fmt.Sprintf(msg(L, "total_unrealized"), total))
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (a *Agent) queryBalance() (string, error) {
|
||||
func (a *Agent) queryBalance(L string) (string, error) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("💰 *Account Balance*\n\n")
|
||||
|
||||
sb.WriteString(msg(L, "account_balance"))
|
||||
for _, ex := range a.config.Exchanges {
|
||||
bal, err := a.bridge.GetBalance(ex.Name)
|
||||
if err != nil {
|
||||
a.logger.Error("get balance", "exchange", ex.Name, "error", err)
|
||||
sb.WriteString(fmt.Sprintf("• %s: ⚠️ Error\n", ex.Name))
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n", strings.Title(ex.Name)))
|
||||
sb.WriteString(fmt.Sprintf(" Total: $%.2f\n", bal.Total))
|
||||
sb.WriteString(fmt.Sprintf(" Available: $%.2f\n", bal.Available))
|
||||
sb.WriteString(fmt.Sprintf(" In Position: $%.2f\n\n", bal.InPosition))
|
||||
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, intent Intent) (string, error) {
|
||||
func (a *Agent) handleAnalyze(ctx context.Context, L string, intent Intent) (string, error) {
|
||||
symbol := "BTC"
|
||||
if detail, ok := intent.Params["detail"]; ok && detail != "" {
|
||||
symbol = strings.ToUpper(strings.TrimSpace(detail))
|
||||
if d, ok := intent.Params["detail"]; ok && d != "" {
|
||||
symbol = strings.ToUpper(strings.TrimSpace(d))
|
||||
}
|
||||
|
||||
// Add live price if available
|
||||
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)
|
||||
}
|
||||
}
|
||||
if priceInfo == "" && a.bridge != nil {
|
||||
for _, ex := range a.config.Exchanges {
|
||||
if price, err := a.bridge.GetPrice(ex.Name, symbol+"USDT"); err == nil && price > 0 {
|
||||
priceInfo = fmt.Sprintf("\nCurrent price: $%.2f (from %s)", price, ex.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(
|
||||
"Analyze %s/USDT for trading. %s\n"+
|
||||
"Consider: trend, support/resistance, momentum indicators, volume, sentiment.\n"+
|
||||
"Give specific entry/exit levels and stop loss. Be concise.",
|
||||
symbol, priceInfo)
|
||||
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)
|
||||
}
|
||||
|
||||
emoji := map[string]string{
|
||||
"buy": "🟢 BUY",
|
||||
"sell": "🔴 SELL",
|
||||
"hold": "🟡 HOLD",
|
||||
"wait": "⏳ WAIT",
|
||||
}
|
||||
|
||||
action := emoji[analysis.Action]
|
||||
emojiMap := map[string]string{"buy": "🟢 BUY", "sell": "🔴 SELL", "hold": "🟡 HOLD", "wait": "⏳ WAIT"}
|
||||
action := emojiMap[analysis.Action]
|
||||
if action == "" {
|
||||
action = "🤔 " + analysis.Action
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("🔍 *%s/USDT Analysis*\n\nSignal: %s\nConfidence: %.0f%%\n\n%s",
|
||||
symbol, action, analysis.Confidence*100, analysis.Reasoning)
|
||||
|
||||
result := fmt.Sprintf(msg(L, "analysis_signal"), symbol, action, analysis.Confidence*100, analysis.Reasoning)
|
||||
if analysis.StopLoss > 0 {
|
||||
result += fmt.Sprintf("\n\n🛑 Stop Loss: $%.2f", analysis.StopLoss)
|
||||
result += fmt.Sprintf(msg(L, "stop_loss"), analysis.StopLoss)
|
||||
}
|
||||
if analysis.TakeProfit > 0 {
|
||||
result += fmt.Sprintf("\n🎯 Take Profit: $%.2f", analysis.TakeProfit)
|
||||
result += fmt.Sprintf(msg(L, "take_profit"), analysis.TakeProfit)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleTrade(ctx context.Context, userID int64, intent Intent) (string, error) {
|
||||
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 {
|
||||
return fmt.Sprintf("⚡ *Trade: %s %s*\n\n🔧 No exchange connected. Configure exchanges in config.yaml first.",
|
||||
strings.ToUpper(action), detail), nil
|
||||
if a.bridge == nil || len(a.config.Exchanges) == 0 {
|
||||
return msg(L, "no_exchange"), nil
|
||||
}
|
||||
|
||||
// Parse: "BTC 0.01" or "BTCUSDT 0.01 2x"
|
||||
parts := strings.Fields(detail)
|
||||
if len(parts) < 1 {
|
||||
return "❓ Usage: `/buy BTC 0.01` or `/sell ETH 0.5 3x`", nil
|
||||
return msg(L, "trade_usage"), nil
|
||||
}
|
||||
|
||||
symbol := strings.ToUpper(parts[0])
|
||||
@@ -345,75 +258,53 @@ func (a *Agent) handleTrade(ctx context.Context, userID int64, intent Intent) (s
|
||||
if len(parts) >= 2 {
|
||||
q, err := strconv.ParseFloat(parts[1], 64)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("❓ Invalid quantity: %s", parts[1]), nil
|
||||
return fmt.Sprintf(msg(L, "invalid_quantity"), parts[1]), nil
|
||||
}
|
||||
quantity = q
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
levStr := strings.TrimSuffix(strings.ToLower(parts[2]), "x")
|
||||
l, err := strconv.Atoi(levStr)
|
||||
if err == nil {
|
||||
if l, err := strconv.Atoi(strings.TrimSuffix(strings.ToLower(parts[2]), "x")); err == nil {
|
||||
leverage = l
|
||||
}
|
||||
}
|
||||
|
||||
if quantity <= 0 && (action == "buy" || action == "sell" || action == "long" || action == "short") {
|
||||
return "❓ Please specify quantity: `/buy BTC 0.01`", nil
|
||||
if quantity <= 0 {
|
||||
return msg(L, "specify_quantity"), nil
|
||||
}
|
||||
|
||||
// Map action to execution side
|
||||
var side string
|
||||
switch action {
|
||||
case "buy", "long", "open_long":
|
||||
case "buy", "long", "open_long", "做多":
|
||||
side = "LONG"
|
||||
case "sell", "short", "open_short":
|
||||
case "sell", "short", "open_short", "做空":
|
||||
side = "SHORT"
|
||||
case "close":
|
||||
side = "CLOSE_LONG" // TODO: detect which side to close
|
||||
case "close", "平仓":
|
||||
side = "CLOSE_LONG"
|
||||
default:
|
||||
side = strings.ToUpper(action)
|
||||
}
|
||||
|
||||
// Use first configured exchange
|
||||
if len(a.config.Exchanges) == 0 {
|
||||
return "⚠️ 还没有配置交易所。请在 config.yaml 的 exchanges 中添加交易所 API Key。", nil
|
||||
}
|
||||
exchange := a.config.Exchanges[0].Name
|
||||
|
||||
// Confirm with user before executing
|
||||
confirmMsg := fmt.Sprintf("⚡ *Confirm Trade*\n\n"+
|
||||
"• Action: %s\n"+
|
||||
"• Symbol: %s\n"+
|
||||
"• Quantity: %.6f\n"+
|
||||
"• Leverage: %dx\n"+
|
||||
"• Exchange: %s\n\n"+
|
||||
"Reply 'yes' to execute.",
|
||||
side, symbol, quantity, leverage, exchange)
|
||||
|
||||
// Store pending trade for confirmation
|
||||
a.memory.SetPreference(userID, "pending_trade",
|
||||
fmt.Sprintf("%s|%s|%f|%d|%s", side, symbol, quantity, leverage, exchange))
|
||||
|
||||
return confirmMsg, nil
|
||||
return fmt.Sprintf(msg(L, "confirm_trade"), side, symbol, quantity, leverage, exchange), nil
|
||||
}
|
||||
|
||||
// ExecutePendingTrade executes a trade that was previously confirmed.
|
||||
func (a *Agent) ExecutePendingTrade(ctx context.Context, userID int64) (string, error) {
|
||||
// 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("no pending trade")
|
||||
return "", fmt.Errorf(msg(L, "no_pending"))
|
||||
}
|
||||
|
||||
// Clear pending
|
||||
a.memory.SetPreference(userID, "pending_trade", "")
|
||||
|
||||
parts := strings.Split(pending, "|")
|
||||
if len(parts) != 5 {
|
||||
return "", fmt.Errorf("invalid pending trade data")
|
||||
return "", fmt.Errorf("invalid pending trade")
|
||||
}
|
||||
|
||||
side := parts[0]
|
||||
symbol := parts[1]
|
||||
side, symbol := parts[0], parts[1]
|
||||
quantity, _ := strconv.ParseFloat(parts[2], 64)
|
||||
leverage, _ := strconv.Atoi(parts[3])
|
||||
exchange := parts[4]
|
||||
@@ -423,27 +314,13 @@ func (a *Agent) ExecutePendingTrade(ctx context.Context, userID int64) (string,
|
||||
return "", fmt.Errorf("execute trade: %w", err)
|
||||
}
|
||||
|
||||
// Save trade to memory
|
||||
tradeRecord := &memory.TradeRecord{
|
||||
Exchange: exchange,
|
||||
Symbol: symbol,
|
||||
Side: strings.ToLower(side),
|
||||
Type: "market",
|
||||
Quantity: quantity,
|
||||
Status: "open",
|
||||
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
|
||||
}
|
||||
if price, ok := result["avgPrice"].(float64); ok {
|
||||
tradeRecord.Price = price
|
||||
}
|
||||
a.memory.SaveTrade(tradeRecord)
|
||||
a.memory.SaveTrade(tr)
|
||||
|
||||
return fmt.Sprintf("✅ *Trade Executed!*\n\n"+
|
||||
"• %s %s\n"+
|
||||
"• Qty: %.6f\n"+
|
||||
"• Leverage: %dx\n"+
|
||||
"• Exchange: %s\n"+
|
||||
"• Result: %v",
|
||||
side, symbol, quantity, leverage, exchange, result), nil
|
||||
return fmt.Sprintf(msg(L, "trade_executed"), side, symbol, quantity, leverage, exchange, result), nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleStrategyCommand(text string) string {
|
||||
@@ -451,43 +328,37 @@ func (a *Agent) handleStrategyCommand(text string) string {
|
||||
if len(parts) < 2 {
|
||||
return a.strategyRunner.FormatStrategyList()
|
||||
}
|
||||
|
||||
subcmd := strings.ToLower(parts[1])
|
||||
switch subcmd {
|
||||
switch strings.ToLower(parts[1]) {
|
||||
case "list":
|
||||
return a.strategyRunner.FormatStrategyList()
|
||||
case "start":
|
||||
if len(parts) < 3 {
|
||||
return "Usage: `/strategy start BTC 1h` or `/strategy start ETH 4h binance`"
|
||||
return "Usage: `/strategy start BTC 1h`"
|
||||
}
|
||||
symbol := strings.ToUpper(parts[2])
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
sym := strings.ToUpper(parts[2])
|
||||
if !strings.HasSuffix(sym, "USDT") {
|
||||
sym += "USDT"
|
||||
}
|
||||
interval := 1 * time.Hour
|
||||
iv := 1 * time.Hour
|
||||
if len(parts) >= 4 {
|
||||
switch parts[3] {
|
||||
case "15m":
|
||||
interval = 15 * time.Minute
|
||||
iv = 15 * time.Minute
|
||||
case "30m":
|
||||
interval = 30 * time.Minute
|
||||
case "1h":
|
||||
interval = 1 * time.Hour
|
||||
iv = 30 * time.Minute
|
||||
case "4h":
|
||||
interval = 4 * time.Hour
|
||||
iv = 4 * time.Hour
|
||||
}
|
||||
}
|
||||
exchange := "binance"
|
||||
ex := "binance"
|
||||
if len(parts) >= 5 {
|
||||
exchange = parts[4]
|
||||
ex = parts[4]
|
||||
}
|
||||
name := fmt.Sprintf("AI-%s", symbol)
|
||||
id, err := a.strategyRunner.StartStrategy(name, symbol, exchange, interval)
|
||||
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\n\nStop with: `/strategy stop %s`",
|
||||
id, symbol, interval, exchange, id)
|
||||
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>`"
|
||||
@@ -500,67 +371,26 @@ func (a *Agent) handleStrategyCommand(text string) string {
|
||||
a.strategyRunner.StopAll()
|
||||
return "✅ All strategies stopped."
|
||||
default:
|
||||
return "Unknown subcommand. Use: `/strategy list|start|stop|stopall`"
|
||||
return "Use: `/strategy list|start|stop|stopall`"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleSettings(intent Intent) string {
|
||||
return `⚙️ *Settings*
|
||||
|
||||
• Language: ` + a.config.Agent.Language + `
|
||||
• Model: ` + a.config.LLM.Model + `
|
||||
• Provider: ` + a.config.LLM.Provider + `
|
||||
• Exchanges: ` + fmt.Sprintf("%d configured", len(a.config.Exchanges))
|
||||
}
|
||||
|
||||
func (a *Agent) handleChat(ctx context.Context, userID int64, text string) (string, error) {
|
||||
// Check for trade confirmation
|
||||
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 == "是" {
|
||||
pending, _ := a.memory.GetPreference(userID, "pending_trade")
|
||||
if pending != "" {
|
||||
return a.ExecutePendingTrade(ctx, userID)
|
||||
if p, _ := a.memory.GetPreference(userID, "pending_trade"); p != "" {
|
||||
return a.ExecutePendingTrade(ctx, userID, L)
|
||||
}
|
||||
}
|
||||
|
||||
// Get conversation history for context
|
||||
history, err := a.memory.GetRecentMessages(userID, 20)
|
||||
if err != nil {
|
||||
a.logger.Error("get history", "error", err)
|
||||
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})
|
||||
|
||||
// Build messages with system prompt
|
||||
messages := []thinking.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf(`You are NOFXi, an AI trading agent built on NOFX.
|
||||
|
||||
Your capabilities:
|
||||
- Market analysis and trading recommendations
|
||||
- Real-time position and balance monitoring
|
||||
- Trade execution (open/close positions on exchanges)
|
||||
- Price alerts and market monitoring
|
||||
- Risk management advice
|
||||
|
||||
You support multiple exchanges: Binance, OKX, Bybit, Bitget, KuCoin, Gate, Hyperliquid, etc.
|
||||
|
||||
Be concise, confident, and action-oriented. Use trading emojis.
|
||||
Respond in the same language the user uses.
|
||||
Current time: %s`, time.Now().Format("2006-01-02 15:04:05")),
|
||||
},
|
||||
}
|
||||
|
||||
for _, msg := range history {
|
||||
messages = append(messages, thinking.Message{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
messages = append(messages, thinking.Message{
|
||||
Role: "user",
|
||||
Content: text,
|
||||
})
|
||||
|
||||
return a.thinker.Chat(ctx, messages)
|
||||
return a.thinker.Chat(ctx, msgs)
|
||||
}
|
||||
|
||||
204
nofxi/internal/agent/i18n.go
Normal file
204
nofxi/internal/agent/i18n.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package agent
|
||||
|
||||
// i18n message templates
|
||||
var messages = map[string]map[string]string{
|
||||
"help": {
|
||||
"zh": `🤖 *NOFXi — 你的 AI 交易 Agent*
|
||||
|
||||
*交易:*
|
||||
/buy BTC 0.01 — 做多(市价单)
|
||||
/sell BTC 0.01 — 做空
|
||||
/close BTC — 平仓
|
||||
/positions — 查看持仓
|
||||
/balance — 查看余额
|
||||
/pnl — 盈亏记录
|
||||
|
||||
*分析:*
|
||||
/analyze BTC — AI 市场分析
|
||||
/watch BTC — 监控价格
|
||||
/alert BTC above 100000 — 价格提醒
|
||||
/price BTC — 实时价格
|
||||
|
||||
*策略:*
|
||||
/strategy start BTC 1h — 启动 AI 自动策略
|
||||
/strategy list — 查看运行中的策略
|
||||
/strategy stop <id> — 停止策略
|
||||
|
||||
*系统:*
|
||||
/status — Agent 状态
|
||||
/help — 帮助菜单
|
||||
|
||||
直接跟我说话就行,中英文都可以 💬`,
|
||||
|
||||
"en": `🤖 *NOFXi — Your AI Trading Agent*
|
||||
|
||||
*Trading:*
|
||||
/buy BTC 0.01 — Open long (market order)
|
||||
/sell BTC 0.01 — Open short
|
||||
/close BTC — Close position
|
||||
/positions — View open positions
|
||||
/balance — Check balance
|
||||
/pnl — P/L history
|
||||
|
||||
*Analysis:*
|
||||
/analyze BTC — AI market analysis
|
||||
/watch BTC — Monitor price
|
||||
/alert BTC above 100000 — Price alert
|
||||
/price BTC — Real-time price
|
||||
|
||||
*Strategy:*
|
||||
/strategy start BTC 1h — Start AI auto strategy
|
||||
/strategy list — View active strategies
|
||||
/strategy stop <id> — Stop strategy
|
||||
|
||||
*System:*
|
||||
/status — Agent status
|
||||
/help — This menu
|
||||
|
||||
Just talk to me in any language 💬`,
|
||||
},
|
||||
|
||||
"no_trades": {
|
||||
"zh": "📭 暂无交易记录。试试 `/buy BTC 0.01` 或 `/analyze BTC` 开始吧。",
|
||||
"en": "📭 No trades yet. Start with `/buy BTC 0.01` or `/analyze BTC`.",
|
||||
},
|
||||
"no_positions": {
|
||||
"zh": "📭 当前没有持仓。",
|
||||
"en": "📭 No open positions.",
|
||||
},
|
||||
"recent_trades": {
|
||||
"zh": "📋 *最近交易*\n\n",
|
||||
"en": "📋 *Recent Trades*\n\n",
|
||||
},
|
||||
"total_pnl": {
|
||||
"zh": "\n💰 总盈亏: $%.2f",
|
||||
"en": "\n💰 Total P/L: $%.2f",
|
||||
},
|
||||
"open_positions": {
|
||||
"zh": "📊 *当前持仓*\n\n",
|
||||
"en": "📊 *Open Positions*\n\n",
|
||||
},
|
||||
"total_unrealized": {
|
||||
"zh": "💰 *未实现总盈亏: $%.2f*",
|
||||
"en": "💰 *Total Unrealized P/L: $%.2f*",
|
||||
},
|
||||
"account_balance": {
|
||||
"zh": "💰 *账户余额*\n\n",
|
||||
"en": "💰 *Account Balance*\n\n",
|
||||
},
|
||||
"balance_total": {
|
||||
"zh": " 总额: $%.2f\n",
|
||||
"en": " Total: $%.2f\n",
|
||||
},
|
||||
"balance_available": {
|
||||
"zh": " 可用: $%.2f\n",
|
||||
"en": " Available: $%.2f\n",
|
||||
},
|
||||
"balance_in_position": {
|
||||
"zh": " 持仓占用: $%.2f\n\n",
|
||||
"en": " In Position: $%.2f\n\n",
|
||||
},
|
||||
"no_exchange": {
|
||||
"zh": "⚠️ 还没有配置交易所。请在 config.yaml 的 exchanges 中添加交易所 API Key。",
|
||||
"en": "⚠️ No exchange configured. Add exchange API keys in config.yaml.",
|
||||
},
|
||||
"trade_usage": {
|
||||
"zh": "❓ 用法: `/buy BTC 0.01` 或 `/sell ETH 0.5 3x`",
|
||||
"en": "❓ Usage: `/buy BTC 0.01` or `/sell ETH 0.5 3x`",
|
||||
},
|
||||
"invalid_quantity": {
|
||||
"zh": "❓ 无效数量: %s",
|
||||
"en": "❓ Invalid quantity: %s",
|
||||
},
|
||||
"specify_quantity": {
|
||||
"zh": "❓ 请指定数量: `/buy BTC 0.01`",
|
||||
"en": "❓ Please specify quantity: `/buy BTC 0.01`",
|
||||
},
|
||||
"confirm_trade": {
|
||||
"zh": "⚡ *确认交易*\n\n• 操作: %s\n• 交易对: %s\n• 数量: %.6f\n• 杠杆: %dx\n• 交易所: %s\n\n回复 '确认' 执行交易。",
|
||||
"en": "⚡ *Confirm Trade*\n\n• Action: %s\n• Symbol: %s\n• Quantity: %.6f\n• Leverage: %dx\n• Exchange: %s\n\nReply 'yes' to execute.",
|
||||
},
|
||||
"trade_executed": {
|
||||
"zh": "✅ *交易已执行!*\n\n• %s %s\n• 数量: %.6f\n• 杠杆: %dx\n• 交易所: %s\n• 结果: %v",
|
||||
"en": "✅ *Trade Executed!*\n\n• %s %s\n• Qty: %.6f\n• Leverage: %dx\n• Exchange: %s\n• Result: %v",
|
||||
},
|
||||
"no_pending": {
|
||||
"zh": "没有待确认的交易",
|
||||
"en": "no pending trade",
|
||||
},
|
||||
"analysis_signal": {
|
||||
"zh": "🔍 *%s/USDT 分析*\n\n信号: %s\n置信度: %.0f%%\n\n%s",
|
||||
"en": "🔍 *%s/USDT Analysis*\n\nSignal: %s\nConfidence: %.0f%%\n\n%s",
|
||||
},
|
||||
"stop_loss": {
|
||||
"zh": "\n\n🛑 止损: $%.2f",
|
||||
"en": "\n\n🛑 Stop Loss: $%.2f",
|
||||
},
|
||||
"take_profit": {
|
||||
"zh": "\n🎯 止盈: $%.2f",
|
||||
"en": "\n🎯 Take Profit: $%.2f",
|
||||
},
|
||||
"settings": {
|
||||
"zh": "⚙️ *设置*\n\n• 语言: %s\n• 模型: %s\n• 提供商: %s\n• 交易所: %d 个已配置",
|
||||
"en": "⚙️ *Settings*\n\n• Language: %s\n• Model: %s\n• Provider: %s\n• Exchanges: %d configured",
|
||||
},
|
||||
"status_title": {
|
||||
"zh": "📊 *NOFXi 状态*\n\n• Agent: %s\n• 模型: %s\n• 提供商: %s\n• 记忆: ✅ 在线\n• 执行: %s\n• 监控: %d 个交易对\n• 时间: %s",
|
||||
"en": "📊 *NOFXi Status*\n\n• Agent: %s\n• Model: %s\n• Provider: %s\n• Memory: ✅ Online\n• Execution: %s\n• Watching: %d symbols\n• Time: %s",
|
||||
},
|
||||
"bridge_connected": {
|
||||
"zh": "✅ 已连接",
|
||||
"en": "✅ Connected",
|
||||
},
|
||||
"bridge_disconnected": {
|
||||
"zh": "❌ 未连接",
|
||||
"en": "❌ Not connected",
|
||||
},
|
||||
"ai_timeout": {
|
||||
"zh": "⏱️ AI 响应超时,请稍后再试。",
|
||||
"en": "⏱️ AI response timed out, please try again.",
|
||||
},
|
||||
"system_prompt": {
|
||||
"zh": `你是 NOFXi,一个基于 NOFX 构建的 AI 交易 Agent。
|
||||
|
||||
你的能力:
|
||||
- 市场分析和交易建议
|
||||
- 实时持仓和余额监控
|
||||
- 交易执行(开仓/平仓)
|
||||
- 价格提醒和行情监控
|
||||
- 风险管理建议
|
||||
|
||||
支持多家交易所:Binance、OKX、Bybit、Bitget、KuCoin、Gate、Hyperliquid 等。
|
||||
|
||||
简洁、自信、专注交易。使用交易相关的 emoji。
|
||||
用中文回复。
|
||||
当前时间: %s`,
|
||||
"en": `You are NOFXi, an AI trading agent built on NOFX.
|
||||
|
||||
Your capabilities:
|
||||
- Market analysis and trading recommendations
|
||||
- Real-time position and balance monitoring
|
||||
- Trade execution (open/close positions)
|
||||
- Price alerts and market monitoring
|
||||
- Risk management advice
|
||||
|
||||
Supports multiple exchanges: Binance, OKX, Bybit, Bitget, KuCoin, Gate, Hyperliquid, etc.
|
||||
|
||||
Be concise, confident, and action-oriented. Use trading emojis.
|
||||
Respond in English.
|
||||
Current time: %s`,
|
||||
},
|
||||
}
|
||||
|
||||
// msg returns the localized message for the given key and language.
|
||||
func msg(lang, key string) string {
|
||||
if m, ok := messages[key]; ok {
|
||||
if s, ok := m[lang]; ok {
|
||||
return s
|
||||
}
|
||||
if s, ok := m["en"]; ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -36,6 +36,7 @@ func NewWebServer(port int, handler MessageHandler, webDir string, logger *slog.
|
||||
type chatRequest struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Message string `json:"message"`
|
||||
Lang string `json:"lang"` // "zh" or "en"
|
||||
}
|
||||
|
||||
// chatResponse is the API response body.
|
||||
@@ -78,7 +79,12 @@ func (w *WebServer) Start(ctx context.Context) error {
|
||||
req.UserID = 1 // Default user for API access
|
||||
}
|
||||
|
||||
resp, err := w.handler(r.Context(), req.UserID, req.Message)
|
||||
// Pass language preference via message prefix (agent will extract it)
|
||||
message := req.Message
|
||||
if req.Lang != "" {
|
||||
message = "[lang:" + req.Lang + "] " + message
|
||||
}
|
||||
resp, err := w.handler(r.Context(), req.UserID, message)
|
||||
if err != nil {
|
||||
writeJSON(rw, http.StatusInternalServerError, chatAPIResponse{Error: err.Error()})
|
||||
return
|
||||
|
||||
@@ -337,7 +337,7 @@ async function send(text){
|
||||
addM('user',text);
|
||||
typEl.classList.add('on');sBtnEl.disabled=true;inpEl.focus();
|
||||
try{
|
||||
const r=await fetch(API+'/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,user_id:1})});
|
||||
const r=await fetch(API+'/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,user_id:1,lang:lang})});
|
||||
const d=await r.json();
|
||||
addM('bot',d.response||d.error||'No response');
|
||||
}catch(e){addM('bot','⚠️ Error: '+e.message)}
|
||||
|
||||
Reference in New Issue
Block a user