Files
nofx/agent/onboard.go
T
shinchan-zhai 4cadf6f442 fix(agent,claw402): harden agent runtime and strip max_tokens for thinking models
- Fix Stop() race condition using sync.Once
- Add ensureHistory() to prevent nil panic in planner/dispatcher
- Add bounds check on trader ID slicing
- Log saveExecutionState and clearSetupState errors instead of discarding
- Remove always-true modelID condition in onboard setup
- Add Chinese setup keywords and expand model name aliases
- Strip max_tokens from claw402 requests to avoid thinking-model budget exhaustion
- Hide Agent nav tab (Beta) pending merge to main
- Sync tests with code changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 11:48:37 +08:00

607 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"fmt"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"nofx/store"
)
var titleCaser = cases.Title(language.English)
const setupExchangeAccountName = "Default"
// Onboard handles first-time setup through natural language.
// When there's no trader configured, the agent guides the user.
// SetupState tracks where the user is in the setup flow.
type SetupState struct {
Step string // "", "await_exchange", "await_api_key", "await_api_secret", "await_passphrase", "await_ai_model", "await_ai_key"
Exchange string
ExchangeID string
APIKey string
APISecret string
Passphrase string
AIProvider string
AIModel string
AIModelID string
AIKey string
AIBaseURL string
}
// needsSetup returns true if no traders are configured.
func (a *Agent) needsSetup() bool {
if a.traderManager == nil {
return true
}
return len(a.traderManager.GetAllTraders()) == 0
}
// getSetupState loads the current setup state from user preferences.
func (a *Agent) getSetupState(userID int64) *SetupState {
step, _ := a.store.GetSystemConfig(fmt.Sprintf("setup_step_%d", userID))
if step == "" {
return &SetupState{}
}
return &SetupState{
Step: step,
Exchange: getConfig(a.store, userID, "exchange"),
ExchangeID: getConfig(a.store, userID, "exchange_id"),
APIKey: getConfig(a.store, userID, "api_key"),
APISecret: getConfig(a.store, userID, "api_secret"),
Passphrase: getConfig(a.store, userID, "passphrase"),
AIProvider: getConfig(a.store, userID, "ai_provider"),
AIModel: getConfig(a.store, userID, "ai_model"),
AIModelID: getConfig(a.store, userID, "ai_model_id"),
AIKey: getConfig(a.store, userID, "ai_key"),
AIBaseURL: getConfig(a.store, userID, "ai_base_url"),
}
}
func (a *Agent) saveSetupState(userID int64, s *SetupState) {
a.store.SetSystemConfig(fmt.Sprintf("setup_step_%d", userID), s.Step)
setConfig(a.store, userID, "exchange", s.Exchange)
setConfig(a.store, userID, "exchange_id", s.ExchangeID)
// Store only a masked marker for secrets — full values stay in memory only.
// This prevents plaintext credentials from lingering in the config store
// if the setup flow is interrupted before clearSetupState runs.
if s.APIKey != "" {
setConfig(a.store, userID, "api_key", "****")
}
if s.APISecret != "" {
setConfig(a.store, userID, "api_secret", "****")
}
if s.Passphrase != "" {
setConfig(a.store, userID, "passphrase", "****")
}
setConfig(a.store, userID, "ai_provider", s.AIProvider)
setConfig(a.store, userID, "ai_model", s.AIModel)
setConfig(a.store, userID, "ai_model_id", s.AIModelID)
if s.AIKey != "" {
setConfig(a.store, userID, "ai_key", "****")
}
setConfig(a.store, userID, "ai_base_url", s.AIBaseURL)
}
func (a *Agent) clearSetupState(userID int64) {
for _, k := range []string{"step", "exchange", "exchange_id", "api_key", "api_secret", "passphrase", "ai_provider", "ai_model", "ai_model_id", "ai_key", "ai_base_url"} {
if err := a.store.SetSystemConfig(fmt.Sprintf("setup_%s_%d", k, userID), ""); err != nil {
a.log().Warn("clearSetupState: failed to clear key", "key", k, "error", err)
}
}
}
func getConfig(st *store.Store, uid int64, key string) string {
v, _ := st.GetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid))
return v
}
func setConfig(st *store.Store, uid int64, key, val string) {
st.SetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid), val)
}
// handleSetupFlow processes the setup conversation.
// Returns (response, handled). If handled=false, continue to normal routing.
func (a *Agent) handleSetupFlow(userID int64, text string, L string) (string, bool) {
return a.handleSetupFlowForStoreUser("default", userID, text, L)
}
func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, text string, L string) (string, bool) {
state := a.getSetupState(userID)
lower := strings.ToLower(text)
// Cancel setup — explicit or implicit (user asking unrelated questions)
if lower == "cancel" || lower == "取消" || lower == "/cancel" {
a.clearSetupState(userID)
return a.setupMsg(L, "cancelled"), true
}
// If in a step that expects a key/secret, check if user is NOT sending a key
// Keys are typically long strings without spaces and Chinese characters
if state.Step == "await_api_key" || state.Step == "await_api_secret" || state.Step == "await_passphrase" || state.Step == "await_ai_key" {
trimmed := strings.TrimSpace(text)
hasChinese := false
for _, r := range trimmed {
if r >= 0x4e00 && r <= 0x9fff {
hasChinese = true
break
}
}
hasSpaces := strings.Contains(trimmed, " ") && !strings.HasPrefix(trimmed, "sk-")
tooShort := len(trimmed) < 8
if hasChinese || hasSpaces || tooShort {
// User is probably asking a question, not providing a key
a.clearSetupState(userID)
if L == "zh" {
return "👌 配置已暂停。我先回答你的问题——\n\n随时发送 *开始配置* 继续配置。", false
}
return "👌 Setup paused. Let me answer your question first—\n\nSend *setup* anytime to continue.", false
}
}
switch state.Step {
case "await_exchange":
return a.handleExchangeChoice(userID, text, state, L)
case "await_api_key":
state.APIKey = strings.TrimSpace(text)
state.Step = "await_api_secret"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_secret"), true
case "await_api_secret":
state.APISecret = strings.TrimSpace(text)
// OKX/Bitget/KuCoin need passphrase
if needsPassphrase(state.Exchange) {
state.Step = "await_passphrase"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_passphrase"), true
}
exchangeID, err := a.saveSetupExchange(storeUserID, state)
if err != nil {
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次,或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true
}
state.ExchangeID = exchangeID
state.Step = "await_ai_model"
a.saveSetupState(userID, state)
if L == "zh" {
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
}
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
case "await_passphrase":
state.Passphrase = strings.TrimSpace(text)
exchangeID, err := a.saveSetupExchange(storeUserID, state)
if err != nil {
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次,或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true
}
state.ExchangeID = exchangeID
state.Step = "await_ai_model"
a.saveSetupState(userID, state)
if L == "zh" {
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
}
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
case "await_ai_model":
return a.handleAIChoice(storeUserID, userID, text, state, L)
case "await_ai_key":
state.AIKey = strings.TrimSpace(text)
aiModelID, err := a.saveSetupAIModel(storeUserID, state)
if err != nil {
a.logger.Error("save AI model from setup failed", "error", err, "provider", state.AIProvider, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ AI 模型配置保存失败: %v\n请再试一次,或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save AI model config: %v\nPlease try again, or continue later in the Web UI.", err), true
}
state.AIModelID = aiModelID
return a.finishSetup(storeUserID, userID, state, L)
}
// Not in setup flow — only enter setup for a tiny set of explicit commands.
// Natural-language configuration requests should go to the planner first,
// including phrases like "开始配置" or "帮我配置交易所".
if isDirectSetupCommand(lower) {
state.Step = "await_exchange"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_exchange"), true
}
// Everything else — let normal routing handle it
return "", false
}
func isDirectSetupCommand(text string) bool {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" {
return false
}
switch text {
case "setup", "/setup", "开始配置", "配置", "开始设置":
return true
default:
return false
}
}
func (a *Agent) handleExchangeChoice(userID int64, text string, state *SetupState, L string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(text))
exchanges := map[string]string{
"binance": "binance", "币安": "binance", "1": "binance",
"okx": "okx", "欧易": "okx", "2": "okx",
"bybit": "bybit", "3": "bybit",
"bitget": "bitget", "4": "bitget",
"gate": "gate", "5": "gate",
"kucoin": "kucoin", "库币": "kucoin", "6": "kucoin",
"hyperliquid": "hyperliquid", "7": "hyperliquid",
}
ex, ok := exchanges[lower]
if !ok {
return a.setupMsg(L, "invalid_exchange"), true
}
state.Exchange = ex
state.Step = "await_api_key"
a.saveSetupState(userID, state)
if L == "zh" {
return fmt.Sprintf("✅ 选择了 *%s*\n\n请发送你的 API Key", titleCaser.String(ex)), true
}
return fmt.Sprintf("✅ Selected *%s*\n\nPlease send your API Key:", titleCaser.String(ex)), true
}
func (a *Agent) handleAIChoice(storeUserID string, userID int64, text string, state *SetupState, L string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(text))
models := map[string]struct{ provider, model, url string }{
"deepseek": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"1": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"qwen": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"通义": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"2": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"openai": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"gpt": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"3": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"claude": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"4": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"skip": {"", "", ""},
"跳过": {"", "", ""},
"5": {"", "", ""},
}
choice, ok := models[lower]
if !ok {
return a.setupMsg(L, "invalid_ai"), true
}
if choice.model == "" {
// Skip AI, just create trader with exchange
state.AIProvider = ""
state.AIModel = ""
state.AIModelID = ""
state.AIKey = ""
return a.finishSetup(storeUserID, userID, state, L)
}
state.AIProvider = choice.provider
state.AIModel = choice.model
state.AIBaseURL = choice.url
state.Step = "await_ai_key"
a.saveSetupState(userID, state)
if L == "zh" {
return fmt.Sprintf("✅ AI 模型: *%s*\n\n请发送你的 API Key", choice.model), true
}
return fmt.Sprintf("✅ AI Model: *%s*\n\nPlease send your API Key:", choice.model), true
}
func (a *Agent) finishSetup(storeUserID string, userID int64, state *SetupState, L string) (string, bool) {
// Create exchange in store
a.logger.Info("creating trader from setup",
"exchange", state.Exchange,
"ai_model", state.AIModel,
"store_user_id", storeUserID,
)
// TODO: Use store to create exchange + trader config
// For now, log the config and tell user
a.clearSetupState(userID)
result := ""
maskedKey := maskKey(state.APIKey)
if L == "zh" {
result = fmt.Sprintf("🎉 *配置完成!*\n\n"+
"• 交易所: %s\n"+
"• API Key: %s\n",
titleCaser.String(state.Exchange), maskedKey)
if state.AIModel != "" {
result += fmt.Sprintf("• AI 模型: %s\n", state.AIModel)
}
result += "\n正在创建 Trader..."
} else {
result = fmt.Sprintf("🎉 *Setup Complete!*\n\n"+
"• Exchange: %s\n"+
"• API Key: %s\n",
titleCaser.String(state.Exchange), maskedKey)
if state.AIModel != "" {
result += fmt.Sprintf("• AI Model: %s\n", state.AIModel)
}
result += "\nCreating Trader..."
}
// Actually create the trader via store
err := a.createTraderFromSetupForStoreUser(storeUserID, state)
if err != nil {
a.logger.Error("create trader failed", "error", err)
if L == "zh" {
result += fmt.Sprintf("\n\n⚠️ 创建失败: %v\n交易所配置已保存,下次配置时可直接复用。\n也可以在 Web UI 中继续完成。", err)
} else {
result += fmt.Sprintf("\n\n⚠️ Failed: %v\nYour exchange config was saved, so you can reuse it next time.\nYou can also finish setup in the Web UI.", err)
}
} else {
if L == "zh" {
result += "\n\n✅ Trader 已创建!现在你可以:\n• `/analyze BTC` — 分析市场\n• `/positions` — 查看持仓\n• 或者直接跟我聊天"
} else {
result += "\n\n✅ Trader created! Now you can:\n• `/analyze BTC` — analyze market\n• `/positions` — view positions\n• Or just chat with me"
}
}
return result, true
}
func (a *Agent) createTraderFromSetup(state *SetupState) error {
return a.createTraderFromSetupForStoreUser("default", state)
}
func (a *Agent) createTraderFromSetupForStoreUser(storeUserID string, state *SetupState) error {
if a.store == nil {
return fmt.Errorf("store not available")
}
exchangeID := state.ExchangeID
if exchangeID == "" {
var err error
exchangeID, err = a.saveSetupExchange(storeUserID, state)
if err != nil {
return fmt.Errorf("save exchange: %w", err)
}
}
aiModelID := state.AIModelID
if state.AIModel != "" && state.AIKey != "" && aiModelID == "" {
var err error
aiModelID, err = a.saveSetupAIModel(storeUserID, state)
if err != nil {
a.logger.Error("save AI model", "error", err)
}
}
// Reuse an existing trader if the same exchange/model pair already exists.
existingTraders, err := a.store.Trader().List(storeUserID)
if err != nil {
return fmt.Errorf("list traders: %w", err)
}
for _, existing := range existingTraders {
if existing.ExchangeID == exchangeID && existing.AIModelID == aiModelID {
a.logger.Info("reusing existing trader created via chat setup",
"trader", existing.Name,
"exchange_id", exchangeID,
"ai_model_id", aiModelID,
)
return nil
}
}
// Create trader config
exchangeIDShort := exchangeID
if len(exchangeIDShort) > 8 {
exchangeIDShort = exchangeIDShort[:8]
}
modelPart := aiModelID
if modelPart == "" {
modelPart = "manual"
}
trader := &store.Trader{
ID: fmt.Sprintf("%s_%s_%d", exchangeIDShort, modelPart, time.Now().UnixNano()),
Name: fmt.Sprintf("NOFXi-%s", titleCaser.String(state.Exchange)),
UserID: storeUserID,
ExchangeID: exchangeID,
AIModelID: aiModelID,
IsRunning: false,
}
if err := a.store.Trader().Create(trader); err != nil {
return fmt.Errorf("save trader: %w", err)
}
a.logger.Info("trader created via chat",
"trader", trader.Name,
"exchange", state.Exchange,
"ai", aiModelID,
)
return nil
}
func (a *Agent) saveSetupExchange(storeUserID string, state *SetupState) (string, error) {
if a.store == nil {
return "", fmt.Errorf("store not available")
}
hlWallet := ""
hlUnified := false
passphrase := state.Passphrase
apiKey := state.APIKey
apiSecret := state.APISecret
if state.Exchange == "hyperliquid" {
hlWallet = state.APISecret
apiKey = ""
apiSecret = state.APIKey
}
exchanges, err := a.store.Exchange().List(storeUserID)
if err != nil {
return "", err
}
for _, ex := range exchanges {
if ex.ExchangeType == state.Exchange && ex.AccountName == setupExchangeAccountName {
if err := a.store.Exchange().Update(
storeUserID, ex.ID, true,
apiKey, apiSecret, passphrase,
false,
hlWallet, hlUnified,
"", "", "",
"", "", "", 0,
); err != nil {
return "", err
}
return ex.ID, nil
}
}
return a.store.Exchange().Create(
storeUserID,
state.Exchange,
setupExchangeAccountName,
true,
apiKey, apiSecret, passphrase,
false,
hlWallet, hlUnified,
"", "", "",
"", "", "", 0,
)
}
func (a *Agent) saveSetupAIModel(storeUserID string, state *SetupState) (string, error) {
if a.store == nil {
return "", fmt.Errorf("store not available")
}
if state.AIProvider == "" {
return "", nil
}
modelID := state.AIProvider
if err := a.store.AIModel().Update(
storeUserID,
modelID,
true,
state.AIKey,
state.AIBaseURL,
state.AIModel,
); err != nil {
return "", err
}
modelID = fmt.Sprintf("%s_%s", storeUserID, state.AIProvider)
return modelID, nil
}
func maskKey(key string) string {
if len(key) <= 8 {
return "****"
}
return key[:4] + "****" + key[len(key)-4:]
}
func needsPassphrase(exchange string) bool {
return exchange == "okx" || exchange == "bitget" || exchange == "kucoin"
}
func containsAny(s string, words []string) bool {
for _, w := range words {
if strings.Contains(s, w) {
return true
}
}
return false
}
var setupMessages = map[string]map[string]string{
"welcome": {
"zh": "👋 你好!我是 *NOFXi*,你的 AI 交易 Agent。\n\n" +
"我发现你还没有配置交易所,让我帮你搞定吧!\n\n" +
"发送 *开始配置* 或 *setup* 开始\n" +
"发送 *取消* 随时退出",
"en": "👋 Hi! I'm *NOFXi*, your AI trading agent.\n\n" +
"I see you haven't configured an exchange yet. Let me help!\n\n" +
"Send *setup* to begin\n" +
"Send *cancel* to exit anytime",
},
"ask_exchange": {
"zh": "🏦 *选择你的交易所*\n\n" +
"1️⃣ Binance(币安)\n" +
"2️⃣ OKX(欧易)\n" +
"3️⃣ Bybit\n" +
"4️⃣ Bitget\n" +
"5️⃣ Gate\n" +
"6️⃣ KuCoin(库币)\n" +
"7️⃣ Hyperliquid\n\n" +
"发送数字或名称选择:",
"en": "🏦 *Choose your exchange*\n\n" +
"1️⃣ Binance\n" +
"2️⃣ OKX\n" +
"3️⃣ Bybit\n" +
"4️⃣ Bitget\n" +
"5️⃣ Gate\n" +
"6️⃣ KuCoin\n" +
"7️⃣ Hyperliquid\n\n" +
"Send number or name:",
},
"invalid_exchange": {
"zh": "❓ 没有识别到交易所。请发送数字 1-7 或交易所名称。",
"en": "❓ Exchange not recognized. Send a number 1-7 or exchange name.",
},
"ask_secret": {
"zh": "🔑 收到 API Key。\n\n现在请发送你的 *API Secret*",
"en": "🔑 Got API Key.\n\nNow send your *API Secret*:",
},
"ask_passphrase": {
"zh": "🔐 收到 API Secret。\n\n这个交易所还需要 *Passphrase*,请发送:",
"en": "🔐 Got API Secret.\n\nThis exchange also needs a *Passphrase*. Please send it:",
},
"ask_ai": {
"zh": "🤖 *选择 AI 模型*\n\n" +
"1️⃣ DeepSeek(推荐,便宜好用)\n" +
"2️⃣ 通义千问 (Qwen)\n" +
"3️⃣ OpenAI (GPT-4o)\n" +
"4️⃣ Claude\n" +
"5️⃣ 跳过(不配置 AI\n\n" +
"发送数字或名称选择:",
"en": "🤖 *Choose AI model*\n\n" +
"1️⃣ DeepSeek (recommended, affordable)\n" +
"2️⃣ Qwen\n" +
"3️⃣ OpenAI (GPT-4o)\n" +
"4️⃣ Claude\n" +
"5️⃣ Skip (no AI)\n\n" +
"Send number or name:",
},
"invalid_ai": {
"zh": "❓ 没有识别到 AI 模型。请发送数字 1-5 或模型名称。",
"en": "❓ AI model not recognized. Send a number 1-5 or model name.",
},
"cancelled": {
"zh": "👌 配置已取消。随时发送 *开始配置* 重新开始。",
"en": "👌 Setup cancelled. Send *setup* anytime to restart.",
},
}
func (a *Agent) setupMsg(L, key string) string {
if m, ok := setupMessages[key]; ok {
if s, ok := m[L]; ok {
return s
}
return m["en"]
}
return key
}