feat: port NOFXi agent module onto latest dev base (#1485)

* feat: integrate NOFXi agent into dev

* Enhance NOFXi agent workflow and diagnostics
This commit is contained in:
lky-spec
2026-04-21 23:47:55 +08:00
committed by GitHub
parent 1ba50bdedf
commit 3ca95b294d
88 changed files with 22630 additions and 1143 deletions
+806
View File
@@ -0,0 +1,806 @@
// 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"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sort"
"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
history *chatHistory
pending *pendingTrades
stopCh chan struct{} // signals background goroutines to stop
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, history: newChatHistory(100), pending: newPendingTrades(), stopCh: make(chan struct{})}
}
func (a *Agent) SetAIClient(c mcp.AIClient) { a.aiClient = c }
func (a *Agent) log() *slog.Logger {
if a != nil && a.logger != nil {
return a.logger
}
return slog.Default()
}
func (a *Agent) EnsureAIClient() {
a.ensureAIClientForStoreUser("default")
}
func (a *Agent) ensureAIClientForStoreUser(storeUserID string) {
if storeUserID == "" {
storeUserID = "default"
}
if a.store != nil {
if client, modelName, ok := a.loadAIClientFromStoreUser(storeUserID); ok {
a.aiClient = client
a.log().Info("agent AI client ready", "store_user_id", storeUserID, "model", modelName)
return
}
}
if a.aiClient != nil {
a.log().Warn("clearing stale AI client for store user", "store_user_id", storeUserID)
a.aiClient = nil
}
a.log().Warn("no AI client — agent will have limited capabilities", "store_user_id", storeUserID)
}
func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, string, bool) {
if a.store == nil {
a.log().Warn("cannot load AI client: store unavailable", "store_user_id", storeUserID)
return nil, "", false
}
if storeUserID == "" {
storeUserID = "default"
}
model, err := a.store.AIModel().GetDefault(storeUserID)
if err != nil || model == nil {
a.log().Warn("no enabled AI model found for store user", "store_user_id", storeUserID, "error", err)
return nil, "", false
}
a.log().Info(
"agent selected AI model config",
"store_user_id", storeUserID,
"model_id", model.ID,
"provider", model.Provider,
"enabled", model.Enabled,
"has_api_key", len(model.APIKey) > 0,
"custom_api_url", strings.TrimSpace(model.CustomAPIURL),
"custom_model_name", strings.TrimSpace(model.CustomModelName),
)
apiKey := string(model.APIKey)
customAPIURL := strings.TrimSpace(model.CustomAPIURL)
modelName := strings.TrimSpace(model.CustomModelName)
customAPIURL, modelName = resolveModelRuntimeConfig(model.Provider, customAPIURL, modelName, model.ID)
if apiKey == "" || customAPIURL == "" {
a.log().Warn(
"enabled AI model is incomplete",
"store_user_id", storeUserID,
"model_id", model.ID,
"provider", model.Provider,
"has_api_key", apiKey != "",
"has_custom_api_url", customAPIURL != "",
)
return nil, "", false
}
httpClient := &http.Client{Timeout: 60 * time.Second}
client := mcp.NewClient(mcp.WithHTTPClient(httpClient))
name := modelName
client.SetAPIKey(apiKey, customAPIURL, name)
return client, name, true
}
func resolveModelRuntimeConfig(provider, customAPIURL, customModelName, fallbackModelID string) (string, string) {
provider = strings.ToLower(strings.TrimSpace(provider))
customAPIURL = strings.TrimSpace(customAPIURL)
customModelName = strings.TrimSpace(customModelName)
fallbackModelID = strings.TrimSpace(fallbackModelID)
type providerDefaults struct {
url string
model string
}
defaults := map[string]providerDefaults{
"deepseek": {url: "https://api.deepseek.com/v1", model: "deepseek-chat"},
"qwen": {url: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen3-max"},
"openai": {url: "https://api.openai.com/v1", model: "gpt-5.2"},
"claude": {url: "https://api.anthropic.com/v1", model: "claude-opus-4-6"},
"gemini": {url: "https://generativelanguage.googleapis.com/v1beta/openai", model: "gemini-3-pro-preview"},
"grok": {url: "https://api.x.ai/v1", model: "grok-3-latest"},
"kimi": {url: "https://api.moonshot.ai/v1", model: "moonshot-v1-auto"},
"minimax": {url: "https://api.minimax.chat/v1", model: "MiniMax-M2.5"},
}
if customAPIURL == "" {
if cfg, ok := defaults[provider]; ok {
customAPIURL = cfg.url
}
}
if customModelName == "" {
if cfg, ok := defaults[provider]; ok {
customModelName = cfg.model
}
}
if customModelName == "" {
customModelName = fallbackModelID
}
return customAPIURL, customModelName
}
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() {
// Signal all background goroutines (e.g. chat-history-cleanup) to exit.
select {
case <-a.stopCh:
// Already closed
default:
close(a.stopCh)
}
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) {
a.EnsureAIClient()
return a.handleMessageForStoreUser(ctx, "default", userID, text)
}
// HandleMessageForStoreUser is like HandleMessage but stores setup artifacts
// (exchange/model) under the provided authenticated store user ID.
func (a *Agent) HandleMessageForStoreUser(ctx context.Context, storeUserID string, userID int64, text string) (string, error) {
return a.handleMessageForStoreUser(ctx, storeUserID, userID, text)
}
func (a *Agent) handleMessageForStoreUser(ctx context.Context, storeUserID string, userID int64, text string) (string, error) {
a.ensureAIClientForStoreUser(storeUserID)
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)
// Only keep a tiny command surface outside the planner.
if text == "/status" {
return a.handleStatus(lang), nil
}
if text == "/clear" {
a.history.Clear(userID)
a.clearTaskState(userID)
a.clearExecutionState(userID)
if lang == "zh" {
return "🧹 对话记忆已清除。", nil
}
return "🧹 Conversation history cleared.", nil
}
if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
return reply, nil
}
// Everything else goes through the planner and tool system.
return a.thinkAndAct(ctx, storeUserID, 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) {
a.EnsureAIClient()
return a.handleMessageStreamForStoreUser(ctx, "default", userID, text, onEvent)
}
// HandleMessageStreamForStoreUser mirrors HandleMessageForStoreUser for SSE responses.
func (a *Agent) HandleMessageStreamForStoreUser(ctx context.Context, storeUserID string, userID int64, text string, onEvent func(event, data string)) (string, error) {
return a.handleMessageStreamForStoreUser(ctx, storeUserID, userID, text, onEvent)
}
func (a *Agent) handleMessageStreamForStoreUser(ctx context.Context, storeUserID string, userID int64, text string, onEvent func(event, data string)) (string, error) {
a.ensureAIClientForStoreUser(storeUserID)
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 text == "/status" {
return a.handleStatus(lang), nil
}
if text == "/clear" {
a.history.Clear(userID)
a.clearTaskState(userID)
a.clearExecutionState(userID)
if lang == "zh" {
return "🧹 对话记忆已清除。", nil
}
return "🧹 Conversation history cleared.", nil
}
if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
if onEvent != nil {
onEvent(StreamEventDelta, reply)
}
return reply, nil
}
return a.thinkAndActStream(ctx, storeUserID, userID, lang, text, onEvent)
}
// StreamEvent types sent via SSE to the frontend.
const (
StreamEventPlanning = "planning"
StreamEventPlan = "plan"
StreamEventStepStart = "step_start"
StreamEventStepComplete = "step_complete"
StreamEventReplan = "replan"
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
)
// 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)
}
skillCatalog := skillCatalogPrompt(lang)
if lang == "zh" {
return fmt.Sprintf(`你是 NOFXi,一个专业的 AI 交易 Agent。你不是一个简单的聊天机器人——你是用户的交易伙伴。
## 你的核心能力
1. **市场分析** — 加密货币(BTC/ETH/SOL等)有实时数据,A股/港股/美股/外汇你可以基于知识分析
2. **交易管理** — 查看持仓、余额、交易历史、Trader 状态
3. **策略建议** — 根据用户需求制定交易策略
4. **策略模板管理** — 创建、查看、修改、删除、激活策略模板
5. **风险管理** — 评估风险、建议止损止盈
6. **配置引导** — 用户说"开始配置"时引导配置交易所和AI模型
## 当前系统状态
%s
%s
## 数据说明(极其重要,违反即失职!)
- 加密货币(BTC/ETH等):交易所实时数据,标注 [Real-time]
- A股/港股/美股:**必须调用 search_stock 工具**获取实时行情。不调工具就没有数据。
- 美股盘前盘后:search_stock 返回的 quote 中 ext_price/ext_change_pct/ext_time
- 外汇/指数期货:当前没有数据源,如实告知
### 铁律:禁止编造任何价格!
- **你的训练数据中的价格全部过时,不可使用**
- **没有通过工具获取的价格 = 你不知道 = 不能说**
- 用户问多只股票的盘前数据?→ 对每只股票调用 search_stock 工具
- 用户问"盘前概览"?→ 调用 search_stock 查主要股票(AAPL、TSLA、NVDA、MSFT、GOOGL、AMZN、META等),用真实数据回答
- **绝对不允许**不调工具就给出具体价格数字(如 $421.85)
- 如果某只股票 search_stock 查不到数据,就说"暂时无法获取该股票数据"
- 指数期货(纳指、标普、道琼斯期货)我们目前没有数据源,直接说"暂不支持指数期货数据"
## 工具使用
你可以调用以下工具来执行操作:
- **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。
- **execute_trade** — 下单交易(加密货币或美股)。美股:open_long=买入,close_long=卖出。调用后创建待确认订单,用户需回复"确认 trade_xxx"。
- **get_positions** — 查看当前所有持仓(加密货币 + 股票)
- **get_balance** — 查看账户余额
- **get_market_price** — 获取实时价格(加密货币或股票代码)
- **get_exchange_configs / manage_exchange_config** — 查看、新增、修改、删除交易所绑定配置
- **get_model_configs / manage_model_config** — 查看、新增、修改、删除 AI 模型配置
- **get_strategies / manage_strategy** — 查看、新增、修改、删除、激活、复制策略模板
- **manage_trader** — 查看、新增、修改、删除、启动、停止交易员
### 配置、策略与交易员管理规则
- 当用户要求创建、修改、删除、激活、复制策略模板时,优先使用 get_strategies / manage_strategy
- **策略模板本身是独立资源,不默认依赖交易所或 AI 模型**
- 只有当用户要求“运行策略 / 创建交易员 / 把策略部署到账户”时,才需要进一步关联交易所、模型或 trader
- 当用户要求配置交易所、绑定 API Key、修改交易所账户时,优先使用 manage_exchange_config
- 当用户要求配置大模型、设置 API Key、切换模型、修改模型地址时,优先使用 manage_model_config
- 当用户要求创建、修改、删除、启动、停止交易员时,优先使用 manage_trader
- 如果缺少必要字段,先追问缺失信息,再调用工具
- **在这些工具存在时,不要说“系统没有这个能力”**
- 对敏感信息(API Key、Secret、Private Key)只保存,不要在最终回复中完整回显
%s
### 交易安全规则
- 用户明确要求交易时才调用 execute_trade
- 分析和建议不需要调用工具,直接回复即可
- 交易确认信息要清晰展示:品种、方向、数量、杠杆
- 提醒用户确认命令格式
### 数据真实性规则(极其重要!)
- **持仓信息必须且只能通过 get_positions 工具获取**,绝对禁止编造持仓
- **余额信息必须且只能通过 get_balance 工具获取**,绝对禁止编造余额
- 如果用户问持仓但 get_positions 返回空,就说"当前没有持仓",不要编造
- 如果工具返回 error(如未配置交易所),如实告知用户
- **你不知道用户持有什么股票/币种,除非工具返回了数据**
- 查股票行情 ≠ 用户持有该股票。不要混淆"查价格"和"有持仓"
## 行为准则
- 简洁、专业、有观点。不说废话。
- 用户问什么答什么,不要推销配置。
- 有实时数据时给具体价位,没有时给策略框架和思路。
- **诚实是第一原则** — 不确定就说不确定,没数据就说没数据。绝不编造。
- 用交易相关的 emoji 让回复更直观。
- 用中文回复。
当前时间: %s`, traderInfo, watchlist, skillCatalog, 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. Strategy template management — create, inspect, update, delete, and activate strategy templates
5. Risk management — assess risk, suggest stop-loss/take-profit
6. Setup — guide exchange/AI configuration when user asks
## Current System State
%s
%s
## Data Notice (CRITICAL — violating this is unacceptable!)
- Crypto (BTC/ETH): Exchange real-time data, marked [Real-time]
- Stocks: You MUST call search_stock tool to get real-time quotes. No tool call = no data.
- US stocks pre/after-hours: ext_price/ext_change_pct/ext_time in search_stock results
- Forex/Index futures: No data source currently — tell user honestly
### ABSOLUTE RULE: NEVER fabricate any price!
- Your training data prices are ALL outdated and MUST NOT be used
- No tool result = you don't know = you cannot state a price
- User asks multiple stocks? → Call search_stock for EACH one
- User asks "pre-market overview"? → Call search_stock for major stocks (AAPL, TSLA, NVDA, MSFT, GOOGL, AMZN, META etc.) and use real data
- NEVER output a specific price number (like $421.85) without a tool having returned it
- If search_stock fails for a stock, say "unable to fetch data for this stock"
- Index futures (NDX, SPX, DJI futures) — we have no data source, say "index futures not supported yet"
## Tools
You can call these tools to take action:
- **search_stock** — Search for stocks by name, ticker, or code. Covers A-share, HK, and US markets. Use when the user mentions an unknown stock.
- **execute_trade** — Place a trade order (crypto or US stocks). For stocks: open_long=buy, close_long=sell. Creates a pending order that requires user confirmation.
- **get_positions** — View all current open positions (crypto + stocks)
- **get_balance** — View account balance and equity
- **get_market_price** — Get real-time price from the exchange (crypto or stock symbol)
- **get_exchange_configs / manage_exchange_config** — View, create, update, and delete exchange bindings
- **get_model_configs / manage_model_config** — View, create, update, and delete AI model bindings
- **get_strategies / manage_strategy** — View, create, update, delete, activate, and duplicate strategy templates
- **manage_trader** — List, create, update, delete, start, and stop traders
### Configuration, Strategy, and Trader Rules
- When the user wants to create, edit, delete, activate, or duplicate a strategy template, prefer get_strategies / manage_strategy
- **A strategy template is an independent asset and does not require exchange or model bindings by default**
- Only ask for exchange/model/trader details when the user wants to run, deploy, or attach a strategy to a trader
- When the user wants to bind or edit an exchange account, prefer manage_exchange_config
- When the user wants to bind or edit an AI model, prefer manage_model_config
- When the user wants to create, edit, delete, start, or stop a trader, prefer manage_trader
- If required fields are missing, ask a focused follow-up question first, then call the tool
- **Do not claim the system lacks these capabilities when the tools exist**
- For secrets such as API keys, secrets, and private keys: store them, but never echo them back in full
%s
### Trade Safety Rules
- Only call execute_trade when user explicitly requests a trade
- Analysis and advice don't need tools — just reply directly
- Show trade details clearly: symbol, direction, quantity, leverage
- Remind user of the confirmation command format
### Data Truthfulness Rules (CRITICAL!)
- **Position data MUST come from get_positions tool only** — NEVER fabricate positions
- **Balance data MUST come from get_balance tool only** — NEVER fabricate balances
- If get_positions returns empty, say "no open positions" — do NOT make up holdings
- If a tool returns an error (e.g. no exchange configured), tell the user honestly
- **You do NOT know what the user holds unless a tool tells you**
- Checking a stock price ≠ user owns that stock. Never confuse "quote lookup" with "holding"
## Behavior
- Concise, professional, opinionated. No fluff.
- Answer what's asked. Don't push setup.
- With real-time data: give specific levels. Without: give strategy frameworks.
- **Honesty is rule #1** — uncertain = say uncertain, no data = say no data.
- Use trading emojis.
Current time: %s`, traderInfo, watchlist, skillCatalog, 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 — detect symbols dynamically
// 1. Check known popular symbols (fast path)
// 2. Extract any "XXXUSDT" pattern from text (catches arbitrary pairs)
knownSymbols := []string{
"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK",
"PEPE", "SHIB", "ARB", "OP", "SUI", "APT", "SEI", "TIA", "JUP", "WIF",
"NEAR", "ATOM", "FTM", "MATIC", "INJ", "RENDER", "FET", "TAO", "WLD",
"AAVE", "UNI", "LDO", "MKR", "CRV", "PENDLE", "ENA", "ONDO", "TRUMP",
}
matched := make(map[string]bool)
for _, sym := range knownSymbols {
if strings.Contains(upper, sym) {
matched[sym] = true
}
}
// Also extract "XXXUSDT" patterns for coins not in the known list
for _, word := range strings.Fields(upper) {
word = strings.Trim(word, ".,!?;:()[]{}\"'")
if strings.HasSuffix(word, "USDT") && len(word) > 4 && len(word) <= 15 {
sym := strings.TrimSuffix(word, "USDT")
if len(sym) >= 2 && len(sym) <= 10 {
matched[sym] = true
}
}
}
// Collect and sort matched symbols for deterministic selection
sortedSymbols := make([]string, 0, len(matched))
for sym := range matched {
sortedSymbols = append(sortedSymbols, sym)
}
sort.Strings(sortedSymbols)
// Cap at 5 symbols to avoid slow context gathering
count := 0
for _, sym := range sortedSymbols {
if count >= 5 {
break
}
md, err := market.Get(sym + "USDT")
if err == nil && md.CurrentPrice > 0 {
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))
count++
}
}
// A-share / stocks — only call Sina API when text likely references stocks.
// Skip for purely crypto conversations to avoid unnecessary external API calls.
if looksLikeStockQuery(text) {
stockCode, stockName := resolveStockCodeDynamic(text)
if stockCode != "" {
quote, err := fetchStockQuote(stockCode)
if err == nil && quote.Price > 0 {
parts = append(parts, fmt.Sprintf("[%s(%s) Real-time A-share Data]\n%s", quote.Name, quote.Code, formatStockQuote(quote)))
} else if err != nil {
a.logger.Error("fetch stock quote", "code", stockCode, "name", stockName, "error", err)
}
}
}
// 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) aiServiceFailure(lang string, err error) (string, error) {
reason := "unknown error"
if err != nil {
reason = summarizeObservation(err.Error())
}
a.logger.Error("AI service call failed", "error", reason)
if lang == "zh" {
return fmt.Sprintf("当前 AI 服务调用失败:%s\n\n这不是“未配置模型”。更可能是模型服务余额不足、接口报错或超时。请检查当前启用模型的 API 状态后再试。", reason), nil
}
return fmt.Sprintf("The AI service call failed: %s\n\nThis is not a missing-model issue. The active model provider likely returned an error, timed out, or has insufficient balance. Please check the active model API and try again.", reason), 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)
}
}
// looksLikeStockQuery returns true if the text likely references stocks rather
// than being a pure crypto/general query. This avoids hitting the Sina search
// API on every single message (saves ~200ms latency + external API call).
func looksLikeStockQuery(text string) bool {
upper := strings.ToUpper(text)
// Check for known stock-related Chinese keywords
stockKeywords := []string{
"股", "A股", "港股", "美股", "股票", "涨停", "跌停", "大盘",
"沪指", "深指", "恒指", "纳指", "标普", "道琼斯",
"茅台", "比亚迪", "宁德", "腾讯", "阿里", "美团", "小米",
"京东", "百度", "苹果", "特斯拉", "英伟达", "微软", "谷歌",
"盘前", "盘后", "开盘", "收盘", "涨幅", "跌幅",
}
for _, kw := range stockKeywords {
if strings.Contains(text, kw) {
return true
}
}
// Check for US stock ticker patterns (1-5 uppercase letters not matching crypto)
for _, word := range strings.Fields(upper) {
word = strings.Trim(word, ".,!?;:()[]{}\"'")
if len(word) >= 1 && len(word) <= 5 {
allLetter := true
for _, c := range word {
if c < 'A' || c > 'Z' {
allLetter = false
break
}
}
if allLetter {
// Check if it's in the known US ticker map
if _, ok := usTickerMap[word]; ok {
return true
}
}
}
}
// Check for 6-digit A-share codes or 5-digit HK codes
for _, w := range strings.Fields(text) {
w = strings.TrimSpace(w)
if len(w) == 5 || len(w) == 6 {
if _, err := strconv.Atoi(w); err == nil {
return true
}
}
}
return false
}
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 int32:
return float64(x)
case string:
f, _ := strconv.ParseFloat(x, 64)
return f
case json.Number:
f, _ := x.Float64()
return f
}
return 0
}
+127
View File
@@ -0,0 +1,127 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestReadBackendLogEntriesReturnsRecentErrorLines(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir(tmp) error = %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
if err := os.MkdirAll("data", 0o755); err != nil {
t.Fatalf("MkdirAll(data) error = %v", err)
}
logPath := filepath.Join("data", "nofx_2099-01-01.log")
content := strings.Join([]string{
"04-19 13:00:00 [INFO] api/server.go:590 API server starting",
"04-19 13:00:01 [ERRO] api/server.go:600 invalid signature for okx account",
"04-19 13:00:02 [ERRO] agent/tools.go:123 model update failed: missing api key",
}, "\n") + "\n"
if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
path, entries, err := readBackendLogEntries(10, "model", true)
if err != nil {
t.Fatalf("readBackendLogEntries() error = %v", err)
}
if !strings.Contains(path, "nofx_2099-01-01.log") {
t.Fatalf("unexpected log path: %s", path)
}
if len(entries) != 1 || !strings.Contains(entries[0], "missing api key") {
t.Fatalf("unexpected filtered entries: %#v", entries)
}
}
func TestToolGetBackendLogsRequiresOwnedTrader(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir(tmp) error = %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
if err := os.MkdirAll("data", 0o755); err != nil {
t.Fatalf("MkdirAll(data) error = %v", err)
}
logPath := filepath.Join("data", "nofx_2099-01-01.log")
content := strings.Join([]string{
"04-19 13:00:00 [INFO] api/server.go:590 API server starting",
"04-19 13:00:01 [ERRO] trader/runtime.go:88 trader_id=trader-owned strategy execution failed",
"04-19 13:00:02 [ERRO] trader/runtime.go:89 trader_id=trader-other strategy execution failed",
}, "\n") + "\n"
if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
a := newTestAgentWithStore(t)
if err := a.store.Trader().Create(&store.Trader{
ID: "trader-owned",
UserID: "user-1",
Name: "Owned Trader",
AIModelID: "model-1",
ExchangeID: "exchange-1",
StrategyID: "strategy-1",
InitialBalance: 1000,
}); err != nil {
t.Fatalf("create owned trader: %v", err)
}
if err := a.store.Trader().Create(&store.Trader{
ID: "trader-other",
UserID: "user-2",
Name: "Other Trader",
AIModelID: "model-2",
ExchangeID: "exchange-2",
StrategyID: "strategy-2",
InitialBalance: 1000,
}); err != nil {
t.Fatalf("create other trader: %v", err)
}
resp := a.toolGetBackendLogs("user-1", `{"trader_id":"trader-owned","limit":5}`)
var okResult struct {
TraderID string `json:"trader_id"`
Entries []string `json:"entries"`
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(resp), &okResult); err != nil {
t.Fatalf("unmarshal owned response: %v\nraw=%s", err, resp)
}
if okResult.TraderID != "trader-owned" || okResult.Count != 1 {
t.Fatalf("unexpected owned response: %+v", okResult)
}
if len(okResult.Entries) != 1 || !strings.Contains(okResult.Entries[0], "trader-owned") {
t.Fatalf("unexpected owned entries: %#v", okResult.Entries)
}
resp = a.toolGetBackendLogs("user-1", `{"trader_id":"trader-other","limit":5}`)
var denied struct {
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(resp), &denied); err != nil {
t.Fatalf("unmarshal denied response: %v\nraw=%s", err, resp)
}
if denied.Error != "trader not found for current user" {
t.Fatalf("unexpected denied response: %+v", denied)
}
}
+183
View File
@@ -0,0 +1,183 @@
package agent
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"nofx/safe"
"strings"
"sync"
"time"
)
// Brain handles proactive intelligence: signals, news, market briefs.
type Brain struct {
agent *Agent
logger *slog.Logger
http *http.Client
stopCh chan struct{}
recentSignals sync.Map // debounce
}
func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
return &Brain{
agent: agent,
logger: logger,
http: &http.Client{Timeout: 15 * time.Second},
stopCh: make(chan struct{}),
}
}
func (b *Brain) Stop() { close(b.stopCh) }
// cleanStaleSignals removes debounce entries older than 30 minutes.
func (b *Brain) cleanStaleSignals() {
cutoff := time.Now().Add(-30 * time.Minute)
b.recentSignals.Range(func(key, value any) bool {
if t, ok := value.(time.Time); ok && t.Before(cutoff) {
b.recentSignals.Delete(key)
}
return true
})
}
func (b *Brain) HandleSignal(sig Signal) {
key := fmt.Sprintf("%s:%s", sig.Type, sig.Symbol)
if v, ok := b.recentSignals.Load(key); ok {
if time.Since(v.(time.Time)) < 10*time.Minute {
return
}
}
b.recentSignals.Store(key, time.Now())
emoji := map[string]string{"info": "️", "warning": "⚠️", "critical": "🚨"}
e := emoji[sig.Severity]
if e == "" { e = "📊" }
b.agent.notifyAll(fmt.Sprintf("%s *%s*\n\n%s", e, sig.Title, sig.Detail))
}
func (b *Brain) StartNewsScan(interval time.Duration) {
seen := make(map[string]bool)
safe.GoNamed("brain-news-scan", func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
cleanTick := 0
for {
select {
case <-b.stopCh: return
case <-ticker.C:
b.scanNews(seen)
cleanTick++
if cleanTick%6 == 0 { // every ~30 min
b.cleanStaleSignals()
}
}
}
})
}
func (b *Brain) scanNews(seen map[string]bool) {
resp, err := b.http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest")
if err != nil { return }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b.logger.Debug("news API non-200", "status", resp.StatusCode)
return
}
body, err := safe.ReadAllLimited(resp.Body, 1024*1024) // 1MB limit
if err != nil { return }
var result struct {
Data []struct {
Title string `json:"title"`
Source string `json:"source"`
URL string `json:"url"`
Body string `json:"body"`
Categories string `json:"categories"`
PublishedOn int64 `json:"published_on"`
} `json:"Data"`
}
if err := json.Unmarshal(body, &result); err != nil { return }
bullish := []string{"surge", "rally", "bullish", "breakout", "ath", "pump", "adoption"}
bearish := []string{"crash", "dump", "bearish", "sell-off", "plunge", "hack", "ban", "fraud"}
for _, d := range result.Data {
if seen[d.URL] { continue }
seen[d.URL] = true
if time.Since(time.Unix(d.PublishedOn, 0)) > 10*time.Minute { continue }
lower := strings.ToLower(d.Title + " " + d.Body)
bc, brc := 0, 0
for _, w := range bullish { if strings.Contains(lower, w) { bc++ } }
for _, w := range bearish { if strings.Contains(lower, w) { brc++ } }
if bc == 0 && brc == 0 { continue }
emoji := "📰"
sentiment := "NEUTRAL"
if bc > brc { emoji = "🟢"; sentiment = "BULLISH" }
if brc > bc { emoji = "🔴"; sentiment = "BEARISH" }
b.agent.notifyAll(fmt.Sprintf("%s *News*\n\n%s\n\n• Source: %s\n• Sentiment: %s",
emoji, d.Title, d.Source, sentiment))
}
// Evict ~half when seen map gets large (keep recent half to avoid re-notifying)
if len(seen) > 1000 {
i, half := 0, len(seen)/2
for k := range seen {
if i >= half { break }
delete(seen, k)
i++
}
}
}
func (b *Brain) StartMarketBriefs(hours []int) {
safe.GoNamed("brain-market-briefs", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
sent := make(map[string]bool)
for {
select {
case <-b.stopCh: return
case now := <-ticker.C:
key := now.Format("2006-01-02-15")
for _, h := range hours {
if now.Hour() == h && now.Minute() == 30 && !sent[key] {
sent[key] = true
b.sendBrief(h)
}
}
}
}
})
}
func (b *Brain) sendBrief(hour int) {
title := "☀️ *早间市场简报*"
if hour >= 18 { title = "🌙 *晚间市场简报*" }
// Fetch BTC/ETH prices for the brief
var btcPrice, ethPrice, btcChg, ethChg string
for _, sym := range []string{"BTCUSDT", "ETHUSDT"} {
resp, err := b.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym))
if err != nil { continue }
body, readErr := safe.ReadAllLimited(resp.Body, 64*1024) // 64KB limit
statusOK := resp.StatusCode == http.StatusOK
resp.Body.Close()
if readErr != nil || !statusOK { continue }
var t map[string]string
if err := json.Unmarshal(body, &t); err != nil { continue }
if sym == "BTCUSDT" { btcPrice = t["lastPrice"]; btcChg = t["priceChangePercent"] }
if sym == "ETHUSDT" { ethPrice = t["lastPrice"]; ethChg = t["priceChangePercent"] }
}
brief := fmt.Sprintf("%s\n\n• BTC: $%s (%s%%)\n• ETH: $%s (%s%%)\n\n_%s_",
title, btcPrice, btcChg, ethPrice, ethChg, time.Now().Format("2006-01-02 15:04"))
b.agent.notifyAll(brief)
}
+386
View File
@@ -0,0 +1,386 @@
package agent
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"nofx/mcp"
"nofx/store"
)
func newTestAgentWithStore(t *testing.T) *Agent {
t.Helper()
st, err := store.New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("create test store: %v", err)
}
t.Cleanup(func() {
_ = st.Close()
})
return &Agent{store: st}
}
func TestToolManageExchangeConfigLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true,
"testnet":true
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Exchange.AccountName != "Main" || created.Exchange.ExchangeType != "binance" {
t.Fatalf("unexpected exchange payload: %+v", created.Exchange)
}
updateResp := a.toolManageExchangeConfig("user-1", `{
"action":"update",
"exchange_id":"`+created.Exchange.ID+`",
"account_name":"Renamed",
"enabled":false
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Exchange.AccountName != "Renamed" || updated.Exchange.Enabled {
t.Fatalf("unexpected updated exchange payload: %+v", updated.Exchange)
}
deleteResp := a.toolManageExchangeConfig("user-1", `{
"action":"delete",
"exchange_id":"`+created.Exchange.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete response: %+v", deleted)
}
}
func TestToolManageModelConfigLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Model.Provider != "openai" || created.Model.CustomModelName != "gpt-5-mini" {
t.Fatalf("unexpected model payload: %+v", created.Model)
}
updateResp := a.toolManageModelConfig("user-1", `{
"action":"update",
"model_id":"`+created.Model.ID+`",
"enabled":false,
"custom_model_name":"gpt-5"
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Model.Enabled || updated.Model.CustomModelName != "gpt-5" {
t.Fatalf("unexpected updated model payload: %+v", updated.Model)
}
deleteResp := a.toolManageModelConfig("user-1", `{
"action":"delete",
"model_id":"`+created.Model.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete response: %+v", deleted)
}
}
func TestToolManageModelConfigRejectsEnableWithoutAPIKey(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":false,
"custom_model_name":"gpt-4o"
}`)
var created struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
updateResp := a.toolManageModelConfig("user-1", `{
"action":"update",
"model_id":"`+created.Model.ID+`",
"enabled":true
}`)
if !strings.Contains(updateResp, "cannot enable model config before API key is configured") {
t.Fatalf("expected enabling incomplete model to fail, got %s", updateResp)
}
}
func TestGetDefaultSkipsEnabledModelWithoutAPIKey(t *testing.T) {
a := newTestAgentWithStore(t)
incompleteCreate := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_model_name":"gpt-4o"
}`)
var incomplete struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(incompleteCreate), &incomplete); err != nil {
t.Fatalf("unmarshal incomplete create response: %v\nraw=%s", err, incompleteCreate)
}
completeCreate := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_model_name":"deepseek-chat"
}`)
var complete struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(completeCreate), &complete); err != nil {
t.Fatalf("unmarshal complete create response: %v\nraw=%s", err, completeCreate)
}
model, err := a.store.AIModel().GetDefault("user-1")
if err != nil {
t.Fatalf("GetDefault() error = %v", err)
}
if model.ID != complete.Model.ID {
t.Fatalf("expected GetDefault to skip incomplete enabled model and return %s, got %s", complete.Model.ID, model.ID)
}
}
func TestToolManageTraderLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
createResp := a.toolManageTrader("user-1", `{
"action":"create",
"name":"Momentum Trader",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"scan_interval_minutes":5
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create trader response: %+v", created)
}
if created.Trader.Name != "Momentum Trader" || created.Trader.ScanIntervalMinutes != 5 {
t.Fatalf("unexpected created trader: %+v", created.Trader)
}
listResp := a.toolManageTrader("user-1", `{"action":"list"}`)
var listed struct {
Count int `json:"count"`
Traders []safeTraderToolConfig `json:"traders"`
}
if err := json.Unmarshal([]byte(listResp), &listed); err != nil {
t.Fatalf("unmarshal list response: %v\nraw=%s", err, listResp)
}
if listed.Count != 1 || len(listed.Traders) != 1 {
t.Fatalf("unexpected trader list: %+v", listed)
}
updateResp := a.toolManageTrader("user-1", `{
"action":"update",
"trader_id":"`+created.Trader.ID+`",
"name":"Renamed Trader",
"scan_interval_minutes":8
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update trader response: %v\nraw=%s", err, updateResp)
}
if updated.Trader.Name != "Renamed Trader" || updated.Trader.ScanIntervalMinutes != 8 {
t.Fatalf("unexpected updated trader: %+v", updated.Trader)
}
deleteResp := a.toolManageTrader("user-1", `{
"action":"delete",
"trader_id":"`+created.Trader.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete trader response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete trader response: %+v", deleted)
}
}
func TestToolManageStrategyLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Strategy.Name != "激进" {
t.Fatalf("unexpected strategy payload: %+v", created.Strategy)
}
listResp := a.toolGetStrategies("user-1")
if !strings.Contains(listResp, "激进") {
t.Fatalf("expected created strategy in list, got %s", listResp)
}
updateResp := a.toolManageStrategy("user-1", `{
"action":"update",
"strategy_id":"`+created.Strategy.ID+`",
"description":"更新后的描述"
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Strategy.Description != "更新后的描述" {
t.Fatalf("unexpected updated strategy payload: %+v", updated.Strategy)
}
activateResp := a.toolManageStrategy("user-1", `{
"action":"activate",
"strategy_id":"`+created.Strategy.ID+`"
}`)
if !strings.Contains(activateResp, `"action":"activate"`) {
t.Fatalf("unexpected activate response: %s", activateResp)
}
deleteResp := a.toolManageStrategy("user-1", `{
"action":"delete",
"strategy_id":"`+created.Strategy.ID+`"
}`)
if !strings.Contains(deleteResp, `"action":"delete"`) {
t.Fatalf("unexpected delete response: %s", deleteResp)
}
}
func TestLoadAIClientFromStoreUserUsesUserSpecificEnabledModel(t *testing.T) {
a := newTestAgentWithStore(t)
if err := a.store.AIModel().Update("user-42", "openai", true, "sk-test", "https://api.openai.com/v1", "gpt-5-mini"); err != nil {
t.Fatalf("seed model: %v", err)
}
client, modelName, ok := a.loadAIClientFromStoreUser("user-42")
if !ok {
t.Fatal("expected AI client to load from user-specific model")
}
if client == nil {
t.Fatal("expected non-nil AI client")
}
if modelName != "gpt-5-mini" {
t.Fatalf("unexpected model name: %s", modelName)
}
if _, ok := client.(*mcp.Client); !ok {
t.Fatalf("expected *mcp.Client, got %T", client)
}
}
+339
View File
@@ -0,0 +1,339 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"time"
)
const (
executionStatusPlanning = "planning"
executionStatusRunning = "running"
executionStatusWaitingUser = "waiting_user"
executionStatusCompleted = "completed"
executionStatusFailed = "failed"
)
const (
planStepTypeTool = "tool"
planStepTypeReason = "reason"
planStepTypeAskUser = "ask_user"
planStepTypeRespond = "respond"
)
const (
planStepStatusPending = "pending"
planStepStatusRunning = "running"
planStepStatusCompleted = "completed"
planStepStatusFailed = "failed"
)
type ExecutionState struct {
SessionID string `json:"session_id"`
UserID int64 `json:"user_id"`
Goal string `json:"goal"`
Status string `json:"status"`
PlanID string `json:"plan_id"`
Steps []PlanStep `json:"steps,omitempty"`
CurrentStepID string `json:"current_step_id,omitempty"`
CurrentReferences *CurrentReferences `json:"current_references,omitempty"`
DynamicSnapshots []Observation `json:"dynamic_snapshots,omitempty"`
ExecutionLog []Observation `json:"execution_log,omitempty"`
SummaryNotes []Observation `json:"summary_notes,omitempty"`
Waiting *WaitingState `json:"waiting,omitempty"`
Observations []Observation `json:"observations,omitempty"`
FinalAnswer string `json:"final_answer,omitempty"`
LastError string `json:"last_error,omitempty"`
UpdatedAt string `json:"updated_at"`
}
type PlanStep struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolArgs map[string]any `json:"tool_args,omitempty"`
Instruction string `json:"instruction,omitempty"`
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
OutputSummary string `json:"output_summary,omitempty"`
Error string `json:"error,omitempty"`
}
type Observation struct {
StepID string `json:"step_id,omitempty"`
Kind string `json:"kind"`
Summary string `json:"summary"`
RawJSON string `json:"raw_json,omitempty"`
CreatedAt string `json:"created_at"`
}
type WaitingState struct {
Question string `json:"question,omitempty"`
Intent string `json:"intent,omitempty"`
PendingFields []string `json:"pending_fields,omitempty"`
ConfirmationTarget string `json:"confirmation_target,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
type EntityReference struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
}
type CurrentReferences struct {
Strategy *EntityReference `json:"strategy,omitempty"`
Trader *EntityReference `json:"trader,omitempty"`
Model *EntityReference `json:"model,omitempty"`
Exchange *EntityReference `json:"exchange,omitempty"`
}
type executionPlan struct {
Goal string `json:"goal"`
Steps []PlanStep `json:"steps"`
}
const (
executionLogMaxEntries = 8
summaryNotesMaxEntries = 4
)
func ExecutionStateConfigKey(userID int64) string {
return fmt.Sprintf("agent_execution_state_%d", userID)
}
func (a *Agent) getExecutionState(userID int64) ExecutionState {
if a.store == nil {
return ExecutionState{}
}
raw, err := a.store.GetSystemConfig(ExecutionStateConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load execution state", "error", err, "user_id", userID)
return ExecutionState{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return ExecutionState{}
}
var state ExecutionState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
a.logger.Warn("failed to parse execution state", "error", err, "user_id", userID)
return ExecutionState{}
}
return normalizeExecutionState(state)
}
func (a *Agent) saveExecutionState(state ExecutionState) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
state = normalizeExecutionState(state)
if state.SessionID == "" {
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), "")
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), string(data))
}
func (a *Agent) clearExecutionState(userID int64) {
if a.store == nil {
return
}
if err := a.store.SetSystemConfig(ExecutionStateConfigKey(userID), ""); err != nil {
a.logger.Warn("failed to clear execution state", "error", err, "user_id", userID)
}
}
func newExecutionState(userID int64, goal string) ExecutionState {
now := time.Now().UTC().Format(time.RFC3339)
return normalizeExecutionState(ExecutionState{
SessionID: fmt.Sprintf("sess_%d", time.Now().UTC().UnixNano()),
UserID: userID,
Goal: strings.TrimSpace(goal),
Status: executionStatusPlanning,
PlanID: fmt.Sprintf("plan_%d", time.Now().UTC().UnixNano()),
UpdatedAt: now,
})
}
func normalizeExecutionState(state ExecutionState) ExecutionState {
state.Goal = strings.TrimSpace(state.Goal)
state.Status = strings.TrimSpace(state.Status)
state.CurrentStepID = strings.TrimSpace(state.CurrentStepID)
state.FinalAnswer = strings.TrimSpace(state.FinalAnswer)
state.LastError = strings.TrimSpace(state.LastError)
state.CurrentReferences = normalizeCurrentReferences(state.CurrentReferences)
state.Waiting = normalizeWaitingState(state.Waiting)
if state.Status == "" && state.SessionID != "" {
state.Status = executionStatusPlanning
}
for i := range state.Steps {
state.Steps[i].ID = strings.TrimSpace(state.Steps[i].ID)
if state.Steps[i].ID == "" {
state.Steps[i].ID = fmt.Sprintf("step_%d", i+1)
}
state.Steps[i].Type = strings.TrimSpace(state.Steps[i].Type)
state.Steps[i].Title = strings.TrimSpace(state.Steps[i].Title)
state.Steps[i].ToolName = strings.TrimSpace(state.Steps[i].ToolName)
state.Steps[i].Instruction = strings.TrimSpace(state.Steps[i].Instruction)
state.Steps[i].OutputSummary = strings.TrimSpace(state.Steps[i].OutputSummary)
state.Steps[i].Error = strings.TrimSpace(state.Steps[i].Error)
if state.Steps[i].Status == "" {
state.Steps[i].Status = planStepStatusPending
}
}
if len(state.Observations) > 0 {
state.ExecutionLog = append(state.ExecutionLog, state.Observations...)
state.Observations = nil
}
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
state.ExecutionLog = normalizeObservationList(state.ExecutionLog)
state.SummaryNotes = normalizeObservationList(state.SummaryNotes)
state = compactExecutionLog(state)
if state.UpdatedAt == "" && state.SessionID != "" {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return state
}
func normalizeWaitingState(waiting *WaitingState) *WaitingState {
if waiting == nil {
return nil
}
waiting.Question = strings.TrimSpace(waiting.Question)
waiting.Intent = strings.TrimSpace(waiting.Intent)
waiting.PendingFields = cleanStringList(waiting.PendingFields)
waiting.ConfirmationTarget = strings.TrimSpace(waiting.ConfirmationTarget)
if waiting.CreatedAt == "" && (waiting.Question != "" || waiting.Intent != "" || len(waiting.PendingFields) > 0 || waiting.ConfirmationTarget != "") {
waiting.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
if waiting.Question == "" && waiting.Intent == "" && len(waiting.PendingFields) == 0 && waiting.ConfirmationTarget == "" {
return nil
}
return waiting
}
func normalizeEntityReference(ref *EntityReference) *EntityReference {
if ref == nil {
return nil
}
ref.ID = strings.TrimSpace(ref.ID)
ref.Name = strings.TrimSpace(ref.Name)
if ref.ID == "" && ref.Name == "" {
return nil
}
return ref
}
func normalizeCurrentReferences(refs *CurrentReferences) *CurrentReferences {
if refs == nil {
return nil
}
refs.Strategy = normalizeEntityReference(refs.Strategy)
refs.Trader = normalizeEntityReference(refs.Trader)
refs.Model = normalizeEntityReference(refs.Model)
refs.Exchange = normalizeEntityReference(refs.Exchange)
if refs.Strategy == nil && refs.Trader == nil && refs.Model == nil && refs.Exchange == nil {
return nil
}
return refs
}
func normalizeObservationList(values []Observation) []Observation {
if len(values) == 0 {
return nil
}
out := make([]Observation, 0, len(values))
for _, value := range values {
value.StepID = strings.TrimSpace(value.StepID)
value.Kind = strings.TrimSpace(value.Kind)
value.Summary = strings.TrimSpace(value.Summary)
value.RawJSON = strings.TrimSpace(value.RawJSON)
if value.Kind == "" && value.Summary == "" && value.RawJSON == "" {
continue
}
if value.CreatedAt == "" {
value.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
out = append(out, value)
}
if len(out) == 0 {
return nil
}
return out
}
func compactExecutionLog(state ExecutionState) ExecutionState {
if len(state.ExecutionLog) <= executionLogMaxEntries {
if len(state.SummaryNotes) > summaryNotesMaxEntries {
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
}
return state
}
overflow := state.ExecutionLog[:len(state.ExecutionLog)-executionLogMaxEntries]
state.ExecutionLog = state.ExecutionLog[len(state.ExecutionLog)-executionLogMaxEntries:]
summary := summarizeExecutionOverflow(overflow)
if summary != nil {
state.SummaryNotes = append(state.SummaryNotes, *summary)
if len(state.SummaryNotes) > summaryNotesMaxEntries {
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
}
}
return state
}
func summarizeExecutionOverflow(values []Observation) *Observation {
if len(values) == 0 {
return nil
}
summaries := make([]string, 0, len(values))
for _, value := range values {
label := value.Kind
if label == "" {
label = "observation"
}
if value.Summary != "" {
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.Summary))
} else if value.RawJSON != "" {
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.RawJSON))
}
}
if len(summaries) == 0 {
return nil
}
text := strings.Join(summaries, " | ")
if len(text) > 500 {
text = text[:500] + "..."
}
return &Observation{
Kind: "execution_summary",
Summary: text,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
}
func appendDynamicSnapshot(state *ExecutionState, obs Observation) {
state.DynamicSnapshots = append(state.DynamicSnapshots, obs)
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
}
func appendExecutionLog(state *ExecutionState, obs Observation) {
state.ExecutionLog = append(state.ExecutionLog, obs)
*state = normalizeExecutionState(*state)
}
func buildObservationContext(state ExecutionState) map[string]any {
state = normalizeExecutionState(state)
return map[string]any{
"current_references": state.CurrentReferences,
"dynamic_snapshots": state.DynamicSnapshots,
"execution_log": state.ExecutionLog,
"summary_notes": state.SummaryNotes,
}
}
+103
View File
@@ -0,0 +1,103 @@
package agent
import (
"sync"
"time"
)
// chatMessage represents a single message in conversation history.
type chatMessage struct {
Role string `json:"role"` // "user" or "assistant"
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
}
// chatHistory stores conversation history per user.
type chatHistory struct {
mu sync.RWMutex
sessions map[int64][]chatMessage
maxTurns int // hard safety cap in messages per user
}
func newChatHistory(maxTurns int) *chatHistory {
if maxTurns <= 0 {
maxTurns = 100 // default hard cap; recent-window trimming is handled separately
}
return &chatHistory{
sessions: make(map[int64][]chatMessage),
maxTurns: maxTurns,
}
}
// Add appends a message to the user's history.
func (h *chatHistory) Add(userID int64, role, content string) {
h.mu.Lock()
defer h.mu.Unlock()
h.sessions[userID] = append(h.sessions[userID], chatMessage{
Role: role,
Content: content,
Timestamp: time.Now(),
})
// Hard safety cap in case summarization is unavailable.
msgs := h.sessions[userID]
if len(msgs) > h.maxTurns {
h.sessions[userID] = msgs[len(msgs)-h.maxTurns:]
}
}
// Get returns the conversation history for a user.
func (h *chatHistory) Get(userID int64) []chatMessage {
h.mu.RLock()
defer h.mu.RUnlock()
msgs := h.sessions[userID]
if msgs == nil {
return nil
}
// Return a copy
result := make([]chatMessage, len(msgs))
copy(result, msgs)
return result
}
func (h *chatHistory) Replace(userID int64, msgs []chatMessage) {
h.mu.Lock()
defer h.mu.Unlock()
if len(msgs) == 0 {
delete(h.sessions, userID)
return
}
if len(msgs) > h.maxTurns {
msgs = msgs[len(msgs)-h.maxTurns:]
}
cloned := make([]chatMessage, len(msgs))
copy(cloned, msgs)
h.sessions[userID] = cloned
}
// Clear resets conversation history for a user.
func (h *chatHistory) Clear(userID int64) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.sessions, userID)
}
// CleanOld removes sessions older than the given duration.
func (h *chatHistory) CleanOld(maxAge time.Duration) {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
for uid, msgs := range h.sessions {
if len(msgs) > 0 {
lastMsg := msgs[len(msgs)-1]
if now.Sub(lastMsg.Timestamp) > maxAge {
delete(h.sessions, uid)
}
}
}
}
+86
View File
@@ -0,0 +1,86 @@
package agent
var i18nMessages = map[string]map[string]string{
"help": {
"zh": "🤖 *NOFXi — 你的 AI 交易 Agent*\n\n" +
"*交易:* /buy /sell /long /short + 交易对 数量 杠杆\n" +
"*查询:* /positions /balance /pnl /traders\n" +
"*分析:* /analyze BTC\n" +
"*监控:* /watch BTC · /unwatch BTC\n" +
"*策略:* /strategy\n" +
"*系统:* /status /help\n\n" +
"直接跟我说话就行,中英文都可以 💬",
"en": "🤖 *NOFXi — Your AI Trading Agent*\n\n" +
"*Trade:* /buy /sell /long /short + symbol qty leverage\n" +
"*Query:* /positions /balance /pnl /traders\n" +
"*Analyze:* /analyze BTC\n" +
"*Monitor:* /watch BTC · /unwatch BTC\n" +
"*Strategy:* /strategy\n" +
"*System:* /status /help\n\n" +
"Just talk to me in any language 💬",
},
"status": {
"zh": "📊 *NOFXi 状态*\n\n• Traders: %d/%d 运行中\n• 监控: %d 个交易对\n• AI: %s\n• 时间: %s",
"en": "📊 *NOFXi Status*\n\n• Traders: %d/%d running\n• Watching: %d symbols\n• AI: %s\n• Time: %s",
},
"no_traders": {
"zh": "📭 暂无 Trader。请在 Web UI 中创建和配置。",
"en": "📭 No traders configured. Create one in Web UI.",
},
"no_running_trader": {
"zh": "⚠️ 没有运行中的 Trader。请在 Web UI 中启动。",
"en": "⚠️ No running trader. Start one in Web UI.",
},
"no_positions": {
"zh": "📭 当前没有持仓。",
"en": "📭 No open positions.",
},
"positions_header": {
"zh": "📊 *当前持仓*\n\n",
"en": "📊 *Open Positions*\n\n",
},
"total_pnl": {
"zh": "💰 *总未实现盈亏: $%.2f*",
"en": "💰 *Total Unrealized P/L: $%.2f*",
},
"balance_header": {
"zh": "💰 *账户余额*\n\n",
"en": "💰 *Account Balances*\n\n",
},
"traders_header": {
"zh": "🤖 *Traders*\n\n",
"en": "🤖 *Traders*\n\n",
},
"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_qty": {
"zh": "❓ 无效数量: %s",
"en": "❓ Invalid quantity: %s",
},
"analysis_header": {
"zh": "🔍 *%s 市场分析*",
"en": "🔍 *%s Analysis*",
},
"sentinel_off": {
"zh": "⚠️ Sentinel 未启用。",
"en": "⚠️ Sentinel not enabled.",
},
"system_prompt": {
"zh": "你是 NOFXi,一个专业的 AI 交易 Agent。简洁、专业、用中文回复。使用交易相关 emoji。",
"en": "You are NOFXi, a professional AI trading agent. Be concise, professional. Use trading emojis.",
},
}
func (a *Agent) msg(lang, key string) string {
if m, ok := i18nMessages[key]; ok {
if s, ok := m[lang]; ok {
return s
}
if s, ok := m["en"]; ok {
return s
}
}
return key
}
+344
View File
@@ -0,0 +1,344 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
type llmSkillRouteDecision struct {
Route string `json:"route"`
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Filter string `json:"filter,omitempty"`
}
func (a *Agent) tryLLMSkillRoute(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
if a.aiClient == nil {
return "", false, nil
}
text = strings.TrimSpace(text)
if text == "" {
return "", false, nil
}
recentConversationCtx := a.buildRecentConversationContext(userID, text)
taskStateCtx := buildTaskStateContext(a.getTaskState(userID))
executionState := normalizeExecutionState(a.getExecutionState(userID))
executionJSON, _ := json.Marshal(executionState)
systemPrompt := `You are the lightweight skill router for NOFXi.
Decide whether the user's message should go to a structured skill or continue to the planner.
Return JSON only. Do not return markdown.
Use route "skill" only when the user intent is clear enough to send directly to one structured skill.
Use route "planner" for ambiguous, multi-step, open-ended, analytical, or diagnostic requests.
Available skills:
- trader_management
- exchange_management
- model_management
- strategy_management
- trader_diagnosis
- exchange_diagnosis
- model_diagnosis
- strategy_diagnosis
For management skills, choose one atomic action from:
- query_list
- query_detail
- query_running
- create
- update_name
- update_bindings
- update_status
- update_endpoint
- update_config
- update_prompt
- delete
- start
- stop
- activate
- duplicate
Set filter only when it is clearly implied by the user. Use values like:
- running_only
- stopped_only
- enabled_only
- disabled_only
- active_only
- default_only
Rules:
- Prefer route "planner" when uncertain.
- Prefer route "planner" for market analysis, broad advice, multi-step troubleshooting, or requests that need synthesis.
- Prefer route "skill" for straightforward management requests like listing, creating, starting, stopping, enabling, disabling, renaming, or deleting known entities.
- Questions like "当前有运行中的trader吗" and "有没有 trader 在跑" are trader_management with action "query_running".
- Questions about one entity's details, config, parameters, or prompt should prefer action "query_detail".
- Do not use route "skill" for casual chat.
- Consider Recent conversation, Task state, and Execution state JSON before deciding.
Return JSON with this exact shape:
{"route":"skill|planner","skill":"","action":"","filter":""}`
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nTask state:\n%s\n\nExecution state JSON:\n%s", lang, text, recentConversationCtx, taskStateCtx, string(executionJSON))
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return "", false, nil
}
decision, err := parseLLMSkillRouteDecision(raw)
if err != nil || decision.Route != "skill" {
return "", false, nil
}
outcome, ok := a.executeLLMSkillRoute(storeUserID, userID, lang, text, decision)
if !ok {
return "", false, nil
}
review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome)
if err != nil {
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
return "", false, nil
}
review = taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}
}
if review.Route == "replan" {
answer, planErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, fmt.Sprintf("Original user request:\n%s\n\nPrevious skill outcome JSON:\n%s", text, mustMarshalJSON(outcome)), onEvent)
return answer, true, planErr
}
answer := strings.TrimSpace(review.Answer)
if answer == "" {
answer = strings.TrimSpace(outcome.UserMessage)
}
if answer == "" {
return "", false, nil
}
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
label := "llm_skill_route"
if decision.Skill != "" {
label += ":" + decision.Skill
}
if decision.Action != "" {
label += ":" + decision.Action
}
onEvent(StreamEventTool, label)
onEvent(StreamEventDelta, answer)
}
return answer, true, nil
}
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision llmSkillRouteDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
return normalizeLLMSkillRouteDecision(decision), nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
return normalizeLLMSkillRouteDecision(decision), nil
}
}
return llmSkillRouteDecision{}, fmt.Errorf("invalid llm skill route json")
}
func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRouteDecision {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Skill = strings.TrimSpace(strings.ToLower(decision.Skill))
decision.Filter = strings.TrimSpace(strings.ToLower(decision.Filter))
if decision.Action == "query" && decision.Filter == "running_only" && decision.Skill == "trader_management" {
decision.Action = "query_running"
} else {
decision.Action = normalizeAtomicSkillAction(decision.Skill, decision.Action)
}
return decision
}
func (a *Agent) executeLLMSkillRoute(storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision) (skillOutcome, bool) {
session := skillSession{Name: decision.Skill, Action: decision.Action}
switch decision.Skill {
case "trader_management":
if decision.Action == "create" {
answer, handled := a.handleCreateTraderSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
}
answer, handled := a.handleTraderManagementSkill(storeUserID, userID, lang, text, session)
if handled && decision.Action == "query_running" {
answer = applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only")
}
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "exchange_management":
answer, handled := a.handleExchangeManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "model_management":
answer, handled := a.handleModelManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "strategy_management":
answer, handled := a.handleStrategyManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "model_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleModelDiagnosisSkill(storeUserID, lang, text),
}, true
case "exchange_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleExchangeDiagnosisSkill(storeUserID, lang, text),
}, true
case "trader_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleTraderDiagnosisSkill(storeUserID, lang, text),
}, true
case "strategy_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleStrategyDiagnosisSkill(storeUserID, lang, text),
}, true
default:
return skillOutcome{}, false
}
}
func skillDataForAction(storeUserID, skill, action string, a *Agent) map[string]any {
var raw string
switch skill {
case "trader_management":
if strings.HasPrefix(action, "query") {
raw = a.toolListTraders(storeUserID)
}
case "exchange_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetExchangeConfigs(storeUserID)
}
case "model_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetModelConfigs(storeUserID)
}
case "strategy_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetStrategies(storeUserID)
}
}
if strings.TrimSpace(raw) == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil
}
return data
}
func mustMarshalJSON(v any) string {
data, _ := json.Marshal(v)
return string(data)
}
func applyTraderQueryFilter(lang, fallback, raw, filter string) string {
filter = strings.TrimSpace(strings.ToLower(filter))
if filter == "" {
return fallback
}
var payload struct {
Traders []struct {
Name string `json:"name"`
IsRunning bool `json:"is_running"`
} `json:"traders"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fallback
}
switch filter {
case "running_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有运行中的交易员。"
}
return fmt.Sprintf("当前有 %d 个运行中的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no running traders right now."
}
return fmt.Sprintf("There are %d running traders right now: %s.", len(names), strings.Join(names, ", "))
case "stopped_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if !trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有已停止的交易员。"
}
return fmt.Sprintf("当前有 %d 个未运行的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no stopped traders right now."
}
return fmt.Sprintf("There are %d stopped traders right now: %s.", len(names), strings.Join(names, ", "))
default:
return fallback
}
}
+467
View File
@@ -0,0 +1,467 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nofx/mcp"
)
const (
recentConversationRounds = 3
recentConversationMessages = recentConversationRounds * 2
taskStateSummaryTokenLimit = 1200
shortTermCompressThreshold = 900
incrementalTaskStateMessages = 6
incrementalTaskStateTokenLimit = 500
)
type DecisionMemory struct {
Action string `json:"action,omitempty"`
Reason string `json:"reason,omitempty"`
StillValid bool `json:"still_valid,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
type TaskState struct {
CurrentGoal string `json:"current_goal,omitempty"`
ActiveFlow string `json:"active_flow,omitempty"`
// OpenLoops stores only high-level unresolved issues that still matter across turns.
// Step-level pending work belongs in ExecutionState, not here.
OpenLoops []string `json:"open_loops,omitempty"`
ImportantFacts []string `json:"important_facts,omitempty"`
LastDecision *DecisionMemory `json:"last_decision,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func TaskStateConfigKey(userID int64) string {
return fmt.Sprintf("agent_task_state_%d", userID)
}
func (a *Agent) getTaskState(userID int64) TaskState {
if a.store == nil {
return TaskState{}
}
raw, err := a.store.GetSystemConfig(TaskStateConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load task state", "error", err, "user_id", userID)
return TaskState{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return TaskState{}
}
var state TaskState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
a.logger.Warn("failed to parse task state", "error", err, "user_id", userID)
return TaskState{}
}
return normalizeTaskState(state)
}
func (a *Agent) saveTaskState(userID int64, state TaskState) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
state = normalizeTaskState(state)
if isZeroTaskState(state) {
return a.store.SetSystemConfig(TaskStateConfigKey(userID), "")
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return a.store.SetSystemConfig(TaskStateConfigKey(userID), string(data))
}
func (a *Agent) clearTaskState(userID int64) {
if a.store == nil {
return
}
if err := a.store.SetSystemConfig(TaskStateConfigKey(userID), ""); err != nil {
a.logger.Warn("failed to clear task state", "error", err, "user_id", userID)
}
}
func normalizeTaskState(state TaskState) TaskState {
state.CurrentGoal = strings.TrimSpace(state.CurrentGoal)
state.ActiveFlow = strings.TrimSpace(state.ActiveFlow)
state.OpenLoops = filterTaskStateOpenLoops(cleanStringList(state.OpenLoops))
state.ImportantFacts = cleanStringList(state.ImportantFacts)
if state.LastDecision != nil {
state.LastDecision.Action = strings.TrimSpace(state.LastDecision.Action)
state.LastDecision.Reason = strings.TrimSpace(state.LastDecision.Reason)
state.LastDecision.Timestamp = strings.TrimSpace(state.LastDecision.Timestamp)
if state.LastDecision.Timestamp == "" && (state.LastDecision.Action != "" || state.LastDecision.Reason != "") {
state.LastDecision.Timestamp = time.Now().UTC().Format(time.RFC3339)
}
if state.LastDecision.Action == "" && state.LastDecision.Reason == "" {
state.LastDecision = nil
}
}
if state.UpdatedAt == "" && !isZeroTaskState(state) {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return state
}
func isZeroTaskState(state TaskState) bool {
return state.CurrentGoal == "" &&
state.ActiveFlow == "" &&
len(state.OpenLoops) == 0 &&
len(state.ImportantFacts) == 0 &&
state.LastDecision == nil
}
func cleanStringList(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
key := strings.ToLower(v)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, v)
}
if len(out) == 0 {
return nil
}
return out
}
func filterTaskStateOpenLoops(values []string) []string {
if len(values) == 0 {
return nil
}
rejectedPrefixes := []string{
"wait for ",
"waiting for ",
"ask for ",
"call ",
"run ",
"execute ",
"invoke ",
"use tool",
"step ",
}
rejectedContains := []string{
"current step",
"tool call",
"api key",
"api secret",
"secret key",
"passphrase",
"model id",
"exchange id",
}
filtered := make([]string, 0, len(values))
for _, value := range values {
lower := strings.ToLower(strings.TrimSpace(value))
if lower == "" {
continue
}
if matchesAnyPrefix(lower, rejectedPrefixes) || matchesAnyContains(lower, rejectedContains) {
continue
}
filtered = append(filtered, value)
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func matchesAnyPrefix(value string, prefixes []string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}
func matchesAnyContains(value string, patterns []string) bool {
for _, pattern := range patterns {
if strings.Contains(value, pattern) {
return true
}
}
return false
}
func buildTaskStateContext(state TaskState) string {
state = normalizeTaskState(state)
if isZeroTaskState(state) {
return ""
}
var sb strings.Builder
sb.WriteString("[Structured Task State - durable, non-derivable context]\n")
if state.CurrentGoal != "" {
sb.WriteString("- Current goal: ")
sb.WriteString(state.CurrentGoal)
sb.WriteString("\n")
}
if state.ActiveFlow != "" {
sb.WriteString("- Active flow: ")
sb.WriteString(state.ActiveFlow)
sb.WriteString("\n")
}
for _, loop := range state.OpenLoops {
sb.WriteString("- High-level open loop: ")
sb.WriteString(loop)
sb.WriteString("\n")
}
for _, fact := range state.ImportantFacts {
sb.WriteString("- Important fact: ")
sb.WriteString(fact)
sb.WriteString("\n")
}
if state.LastDecision != nil {
sb.WriteString("- Last decision: ")
sb.WriteString(state.LastDecision.Action)
if state.LastDecision.Reason != "" {
sb.WriteString(" | reason: ")
sb.WriteString(state.LastDecision.Reason)
}
if state.LastDecision.StillValid {
sb.WriteString(" | still valid")
}
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
func estimateChatMessagesTokens(msgs []chatMessage) int {
total := 0
for _, msg := range msgs {
total += len([]rune(msg.Content))/3 + 10
}
return total
}
func formatChatMessagesForSummary(msgs []chatMessage) string {
var sb strings.Builder
for _, msg := range msgs {
if strings.TrimSpace(msg.Content) == "" {
continue
}
role := "User"
if msg.Role == "assistant" {
role = "Assistant"
}
sb.WriteString(role)
sb.WriteString(": ")
sb.WriteString(msg.Content)
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
func (a *Agent) maybeCompressHistory(ctx context.Context, userID int64) {
if a.aiClient == nil || a.history == nil {
return
}
msgs := a.history.Get(userID)
if len(msgs) <= recentConversationMessages {
return
}
if estimateChatMessagesTokens(msgs) <= shortTermCompressThreshold {
return
}
splitAt := len(msgs) - recentConversationMessages
if splitAt <= 0 {
return
}
oldPart := msgs[:splitAt]
recentPart := msgs[splitAt:]
existingState := a.getTaskState(userID)
updatedState, err := a.summarizeConversationToTaskState(ctx, userID, existingState, oldPart)
if err != nil {
a.logger.Warn("failed to compress chat history", "error", err, "user_id", userID)
return
}
if err := a.saveTaskState(userID, updatedState); err != nil {
a.log().Warn("failed to persist task state", "error", err, "user_id", userID)
return
}
a.history.Replace(userID, recentPart)
}
func (a *Agent) maybeUpdateTaskStateIncrementally(ctx context.Context, userID int64) {
if a.aiClient == nil || a.history == nil {
return
}
msgs := a.history.Get(userID)
if len(msgs) < 2 {
return
}
window := msgs
if len(window) > incrementalTaskStateMessages {
window = window[len(window)-incrementalTaskStateMessages:]
}
existingState := a.getTaskState(userID)
updatedState, err := a.summarizeRecentConversationToTaskState(ctx, userID, existingState, window)
if err != nil {
a.log().Warn("failed to incrementally update task state", "error", err, "user_id", userID)
return
}
if err := a.saveTaskState(userID, updatedState); err != nil {
a.log().Warn("failed to persist incremental task state", "error", err, "user_id", userID)
}
}
func (a *Agent) summarizeConversationToTaskState(ctx context.Context, userID int64, existing TaskState, oldPart []chatMessage) (TaskState, error) {
transcript := formatChatMessagesForSummary(oldPart)
if transcript == "" {
return normalizeTaskState(existing), nil
}
existingJSON, err := json.Marshal(normalizeTaskState(existing))
if err != nil {
return TaskState{}, err
}
systemPrompt := `You maintain structured task state for a trading assistant.
Update the task state using the existing state plus archived dialogue.
Return JSON only. Do not return markdown.
Rules:
- Keep only durable, non-derivable context useful for future turns.
- Do not store market prices, balances, positions, or anything tools can fetch again.
- Do not store chit-chat or repeated wording.
- current_goal: the user's active objective, if any.
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, or empty.
- open_loops: only high-level unresolved issues that still matter across turns.
- Do not put execution-step pending work into open_loops.
- Bad open_loops examples: "wait for API secret", "call get_exchange_configs", "run step 2", "ask user for exchange_id".
- Good open_loops examples: "finish trader setup after external configuration is ready", "user still wants to complete onboarding".
- important_facts: non-derivable facts worth remembering briefly.
- last_decision: keep only one current relevant decision; omit if none.
- Replace stale items instead of appending blindly.
- If a field is no longer relevant, return it empty or omit it.
- Never invent facts.`
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nArchived dialogue to compress:\n%s\n\nReturn the new task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
req := &mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: ctx,
MaxTokens: intPtr(taskStateSummaryTokenLimit),
}
resp, err := a.aiClient.CallWithRequest(req)
if err != nil {
return TaskState{}, err
}
state, err := parseTaskStateJSON(resp)
if err != nil {
return TaskState{}, err
}
state = normalizeTaskState(state)
a.log().Info("compressed chat history into task state", "user_id", userID, "archived_messages", len(oldPart))
return state, nil
}
func (a *Agent) summarizeRecentConversationToTaskState(ctx context.Context, userID int64, existing TaskState, recentPart []chatMessage) (TaskState, error) {
transcript := formatChatMessagesForSummary(recentPart)
if transcript == "" {
return normalizeTaskState(existing), nil
}
existingJSON, err := json.Marshal(normalizeTaskState(existing))
if err != nil {
return TaskState{}, err
}
systemPrompt := `You maintain structured task state for a trading assistant.
Update the task state incrementally using the existing state plus the latest conversation window.
Return JSON only. Do not return markdown.
Rules:
- Capture newly confirmed facts from the latest few turns immediately.
- Preserve important existing facts that still matter; replace stale items when contradicted.
- Keep only durable, non-derivable context useful for the next turns.
- current_goal: the user's active objective right now.
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, strategy_debugging, or empty.
- open_loops: only high-level unresolved issues that still matter across turns.
- important_facts: include recently confirmed concrete facts, such as the current trader under discussion, the reported runtime error, the user's claimed config value, or the environment where the issue occurs.
- Do not store execution-step pending work or tool instructions.
- Do not store market prices, balances, or anything tools can fetch again.
- Keep last_decision only if there is a current relevant decision; omit it otherwise.
- Never invent facts.`
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nLatest conversation window:\n%s\n\nReturn the updated task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
req := &mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: ctx,
MaxTokens: intPtr(incrementalTaskStateTokenLimit),
}
resp, err := a.aiClient.CallWithRequest(req)
if err != nil {
return TaskState{}, err
}
state, err := parseTaskStateJSON(resp)
if err != nil {
return TaskState{}, err
}
state = normalizeTaskState(state)
a.log().Info("incrementally refreshed task state", "user_id", userID, "window_messages", len(recentPart))
return state, nil
}
func parseTaskStateJSON(raw string) (TaskState, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var state TaskState
if err := json.Unmarshal([]byte(raw), &state); err == nil {
return state, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &state); err == nil {
return state, nil
}
}
return TaskState{}, fmt.Errorf("invalid task state json")
}
func intPtr(v int) *int {
return &v
}
+132
View File
@@ -0,0 +1,132 @@
package agent
import (
"context"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"nofx/mcp"
"nofx/store"
)
type fakeAIClient struct {
callCount int
}
func (f *fakeAIClient) SetAPIKey(string, string, string) {}
func (f *fakeAIClient) SetTimeout(time.Duration) {}
func (f *fakeAIClient) CallWithMessages(string, string) (string, error) {
return "", nil
}
func (f *fakeAIClient) CallWithRequest(req *mcp.Request) (string, error) {
f.callCount++
return `{"current_goal":"continue setup","active_flow":"onboarding","open_loops":["finish trader setup after external exchange/model configuration is ready"],"important_facts":["user selected OKX"],"last_decision":{"action":"paused setup","reason":"user asked a market question","still_valid":true},"updated_at":"2026-04-01T00:00:00Z"}`, nil
}
func (f *fakeAIClient) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
return "", nil
}
func (f *fakeAIClient) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
return nil, nil
}
func TestMaybeCompressHistoryKeepsRecentThreeRounds(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db"))
if err != nil {
t.Fatalf("store.New() error = %v", err)
}
fakeClient := &fakeAIClient{}
a := &Agent{
store: st,
logger: slog.Default(),
history: newChatHistory(100),
aiClient: fakeClient,
}
userID := int64(42)
payload := strings.Repeat("BTC ETH market context ", 20)
for i := 0; i < 6; i++ {
a.history.Add(userID, "user", "user turn #"+string(rune('0'+i))+" "+payload)
a.history.Add(userID, "assistant", "assistant turn #"+string(rune('0'+i))+" "+payload)
}
a.maybeCompressHistory(context.Background(), userID)
msgs := a.history.Get(userID)
if len(msgs) != recentConversationMessages {
t.Fatalf("expected %d recent messages, got %d", recentConversationMessages, len(msgs))
}
if fakeClient.callCount != 1 {
t.Fatalf("expected summarizer to be called once, got %d", fakeClient.callCount)
}
state := a.getTaskState(userID)
if state.CurrentGoal != "continue setup" {
t.Fatalf("expected persisted task state goal, got %#v", state)
}
if state.LastDecision == nil || state.LastDecision.Action != "paused setup" {
t.Fatalf("expected persisted last_decision, got %#v", state.LastDecision)
}
if len(state.OpenLoops) != 1 || state.OpenLoops[0] != "finish trader setup after external exchange/model configuration is ready" {
t.Fatalf("expected high-level open loop, got %#v", state.OpenLoops)
}
if strings.Contains(msgs[0].Content, "#0") {
t.Fatalf("expected oldest round to be compressed away, first recent message = %q", msgs[0].Content)
}
if !strings.Contains(msgs[0].Content, "#3") {
t.Fatalf("expected recent window to start from round #3, got %q", msgs[0].Content)
}
if !strings.Contains(msgs[len(msgs)-1].Content, "#5") {
t.Fatalf("expected latest round to remain in short-term history, got %q", msgs[len(msgs)-1].Content)
}
}
func TestNormalizeTaskStateDropsExecutionLevelOpenLoops(t *testing.T) {
state := normalizeTaskState(TaskState{
OpenLoops: []string{
"wait for API secret",
"call get_exchange_configs",
"finish trader setup after external configuration is ready",
},
})
if len(state.OpenLoops) != 1 {
t.Fatalf("expected only one high-level open loop to remain, got %#v", state.OpenLoops)
}
if state.OpenLoops[0] != "finish trader setup after external configuration is ready" {
t.Fatalf("unexpected open loop after normalization: %#v", state.OpenLoops)
}
}
func TestMaybeUpdateTaskStateIncrementallyPersistsShortConversationFacts(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db"))
if err != nil {
t.Fatalf("store.New() error = %v", err)
}
fakeClient := &fakeAIClient{}
a := &Agent{
store: st,
logger: slog.Default(),
history: newChatHistory(100),
aiClient: fakeClient,
}
userID := int64(7)
a.history.Add(userID, "user", "我是在运行测试1交易员时遇到的,错误是运行时出现的")
a.history.Add(userID, "assistant", "我会继续排查测试1交易员的运行时错误")
a.maybeUpdateTaskStateIncrementally(context.Background(), userID)
if fakeClient.callCount != 1 {
t.Fatalf("expected incremental summarizer to be called once, got %d", fakeClient.callCount)
}
state := a.getTaskState(userID)
if state.CurrentGoal != "continue setup" {
t.Fatalf("expected incrementally persisted task state, got %#v", state)
}
}
+595
View File
@@ -0,0 +1,595 @@
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)
setConfig(a.store, userID, "api_key", s.APIKey)
setConfig(a.store, userID, "api_secret", s.APISecret)
setConfig(a.store, userID, "passphrase", s.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)
setConfig(a.store, userID, "ai_key", s.AIKey)
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"} {
a.store.SetSystemConfig(fmt.Sprintf("setup_%s_%d", k, userID), "")
}
}
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
}
if modelID == state.AIProvider {
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
}
+25
View File
@@ -0,0 +1,25 @@
package agent
import "testing"
func TestIsDirectSetupCommand(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "setup", want: true},
{text: "/setup", want: true},
{text: "开始配置", want: false},
{text: "/开始配置", want: false},
{text: "创建全新的配置,杠杆你定", want: false},
{text: "帮我配置一个 deepseek 模型", want: false},
{text: "绑定交易所 okx", want: false},
{text: "配置", want: false},
}
for _, tc := range cases {
if got := isDirectSetupCommand(tc.text); got != tc.want {
t.Fatalf("isDirectSetupCommand(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
File diff suppressed because it is too large Load Diff
+807
View File
@@ -0,0 +1,807 @@
package agent
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
"time"
"nofx/mcp"
)
func TestIsConfigOrTraderIntent(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "帮我创建一个交易员", want: true},
{text: "我已经配置好了 OKX 和 DeepSeek", want: true},
{text: "List my traders", want: true},
{text: "BTC 接下来怎么看", want: false},
}
for _, tc := range cases {
if got := isConfigOrTraderIntent(tc.text); got != tc.want {
t.Fatalf("isConfigOrTraderIntent(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
func TestIsRealtimeAccountIntent(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "现在余额多少", want: true},
{text: "我的仓位还在吗", want: true},
{text: "show recent trade history", want: true},
{text: "帮我创建交易员", want: false},
}
for _, tc := range cases {
if got := isRealtimeAccountIntent(tc.text); got != tc.want {
t.Fatalf("isRealtimeAccountIntent(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
func TestDetectReadFastPath(t *testing.T) {
cases := []struct {
text string
want string
}{
{text: "/traders", want: "list_traders"},
{text: "/strategies", want: "get_strategies"},
{text: "/models", want: "get_model_configs"},
{text: "/exchanges", want: "get_exchange_configs"},
{text: "/balance", want: "get_balance"},
{text: "/positions", want: "get_positions"},
{text: "/history", want: "get_trade_history"},
{text: "/trades", want: "get_trade_history"},
{text: "列出我当前的策略", want: ""},
{text: "查看当前交易员", want: ""},
{text: "现在余额多少", want: ""},
{text: "我的仓位还在吗", want: ""},
{text: "我现在有哪些账户", want: ""},
{text: "我的余额", want: ""},
{text: "根据我的余额帮我分析我应该买什么", want: ""},
{text: "我的策略是AI100,但是No candidate coins available, cycle skipped", want: ""},
{text: "帮我创建一个 trader", want: ""},
}
for _, tc := range cases {
req := detectReadFastPath(tc.text)
got := ""
if req != nil {
got = req.Kind
}
if got != tc.want {
t.Fatalf("detectReadFastPath(%q) = %q, want %q", tc.text, got, tc.want)
}
}
}
func TestShouldResetExecutionStateForNewAttempt(t *testing.T) {
state := ExecutionState{
SessionID: "sess_1",
Status: executionStatusWaitingUser,
}
if !shouldResetExecutionStateForNewAttempt("我已经配置好了,继续创建交易员", state) {
t.Fatalf("expected retry-style config request to reset execution state")
}
if shouldResetExecutionStateForNewAttempt("BTC 价格多少", state) {
t.Fatalf("did not expect generic market query to reset execution state")
}
}
func TestLatestAskedQuestion(t *testing.T) {
state := ExecutionState{
Status: executionStatusWaitingUser,
Steps: []PlanStep{
{ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted},
{ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "需要我用正确的参数重试创建交易员 lky 吗?"},
},
}
got := latestAskedQuestion(state)
want := "需要我用正确的参数重试创建交易员 lky 吗?"
if got != want {
t.Fatalf("latestAskedQuestion() = %q, want %q", got, want)
}
}
func TestLatestAskedQuestionPrefersStructuredWaitingState(t *testing.T) {
state := ExecutionState{
Status: executionStatusWaitingUser,
Waiting: &WaitingState{
Question: "请确认是否继续创建交易员 lky",
Intent: "confirm_action",
},
Steps: []PlanStep{
{ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "旧问题"},
},
}
if got := latestAskedQuestion(state); got != "请确认是否继续创建交易员 lky" {
t.Fatalf("latestAskedQuestion() = %q, want structured waiting question", got)
}
}
func TestRefreshStateForDynamicRequestsAddsFreshSnapshots(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
_ = a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"Main",
"enabled":true
}`)
state := ExecutionState{
SessionID: "sess_1",
UserID: 1,
DynamicSnapshots: []Observation{
{Kind: "current_model_configs", Summary: "stale"},
},
ExecutionLog: []Observation{{Kind: "user_reply", Summary: "continue"}},
}
refreshed := a.refreshStateForDynamicRequests("user-1", "帮我创建交易员", state)
if len(refreshed.DynamicSnapshots) < 3 {
t.Fatalf("expected refreshed observations to include snapshots, got %+v", refreshed.DynamicSnapshots)
}
var foundModel, foundExchange, foundTraders bool
for _, obs := range refreshed.DynamicSnapshots {
switch obs.Kind {
case "current_model_configs":
foundModel = strings.Contains(obs.RawJSON, "openai")
case "current_exchange_configs":
foundExchange = strings.Contains(obs.RawJSON, "okx")
case "current_traders":
foundTraders = strings.Contains(obs.RawJSON, `"traders"`)
}
}
if !foundModel || !foundExchange || !foundTraders {
t.Fatalf("missing fresh snapshots: %+v", refreshed.DynamicSnapshots)
}
}
func TestRefreshStateForRealtimeAccountRequestsAddsFreshSnapshots(t *testing.T) {
a := newTestAgentWithStore(t)
state := ExecutionState{
SessionID: "sess_2",
UserID: 1,
DynamicSnapshots: []Observation{
{Kind: "current_balances", Summary: "stale balances"},
{Kind: "current_positions", Summary: "stale positions"},
},
ExecutionLog: []Observation{{Kind: "user_reply", Summary: "现在余额多少"}},
}
refreshed := a.refreshStateForDynamicRequests("user-1", "现在余额多少,我的仓位还在吗", state)
var keptBalances, keptPositions, foundHistory bool
for _, obs := range refreshed.DynamicSnapshots {
switch obs.Kind {
case "current_balances":
keptBalances = strings.Contains(obs.Summary, "stale balances")
case "current_positions":
keptPositions = strings.Contains(obs.Summary, "stale positions")
case "recent_trade_history":
foundHistory = obs.RawJSON != ""
}
}
if !keptBalances || !keptPositions || foundHistory {
t.Fatalf("expected realtime snapshots to stay untouched, got %+v", refreshed.DynamicSnapshots)
}
}
func TestThinkAndActNaturalLanguageReadCanBeHandledByHighLevelSkill(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") {
t.Fatalf("expected natural-language read to be handled by high-level skill, got %q", resp)
}
}
func TestNormalizeExecutionStateMigratesLegacyObservations(t *testing.T) {
state := normalizeExecutionState(ExecutionState{
SessionID: "sess_legacy",
UserID: 1,
Observations: []Observation{
{Kind: "tool_result", Summary: "legacy tool result"},
},
})
if len(state.Observations) != 0 {
t.Fatalf("expected legacy observations field to be cleared, got %+v", state.Observations)
}
if len(state.ExecutionLog) != 1 || state.ExecutionLog[0].Summary != "legacy tool result" {
t.Fatalf("expected legacy observations to migrate into execution log, got %+v", state.ExecutionLog)
}
}
func TestBuildWaitingStateForTraderConfirmation(t *testing.T) {
state := ExecutionState{Goal: "创建交易员 lky"}
step := PlanStep{
ID: "step_ask_1",
Type: planStepTypeAskUser,
Instruction: "需要我用正确的参数重试创建交易员 lky 吗?",
RequiresConfirmation: true,
}
waiting := buildWaitingState(state, step, step.Instruction)
if waiting == nil {
t.Fatal("expected waiting state")
}
if waiting.Intent != "confirm_action" {
t.Fatalf("unexpected waiting intent: %+v", waiting)
}
if waiting.ConfirmationTarget != "trader" {
t.Fatalf("unexpected confirmation target: %+v", waiting)
}
}
func TestNormalizeWaitingStateCleansFields(t *testing.T) {
state := normalizeExecutionState(ExecutionState{
SessionID: "sess_waiting",
UserID: 1,
Waiting: &WaitingState{
Question: " 请提供 strategy_id ",
Intent: " complete_trader_setup ",
PendingFields: []string{" strategy_id ", "strategy_id"},
ConfirmationTarget: " trader ",
},
})
if state.Waiting == nil {
t.Fatal("expected normalized waiting state")
}
if state.Waiting.Question != "请提供 strategy_id" {
t.Fatalf("unexpected normalized question: %+v", state.Waiting)
}
if len(state.Waiting.PendingFields) != 1 || state.Waiting.PendingFields[0] != "strategy_id" {
t.Fatalf("unexpected pending fields: %+v", state.Waiting)
}
if state.Waiting.ConfirmationTarget != "trader" {
t.Fatalf("unexpected confirmation target: %+v", state.Waiting)
}
}
func TestRefreshCurrentReferencesForUserTextMatchesStrategyName(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
state := newExecutionState(1, "帮我改一下激进这个策略")
a.refreshCurrentReferencesForUserText("user-1", "帮我改一下激进这个策略", &state)
if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil {
t.Fatalf("expected strategy reference, got %+v", state.CurrentReferences)
}
if state.CurrentReferences.Strategy.Name != "激进" {
t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy)
}
}
func TestUpdateCurrentReferencesFromToolResultTracksCreatedStrategy(t *testing.T) {
state := newExecutionState(1, "创建策略")
changed := updateCurrentReferencesFromToolResult(&state, "manage_strategy", `{
"status":"ok",
"action":"create",
"strategy":{"id":"strategy_1","name":"激进"}
}`)
if !changed {
t.Fatalf("expected reference update to report changed")
}
if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil {
t.Fatalf("expected strategy reference after tool result, got %+v", state.CurrentReferences)
}
if state.CurrentReferences.Strategy.ID != "strategy_1" {
t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy)
}
}
func TestShouldAttemptReplan(t *testing.T) {
state := ExecutionState{
Steps: []PlanStep{
{ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted},
{ID: "step_2", Type: planStepTypeRespond, Status: planStepStatusPending},
},
}
if !shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "manage_trader",
ToolArgs: map[string]any{"action": "create"},
OutputSummary: `{"status":"ok","action":"create"}`,
}, false) {
t.Fatalf("expected create trader step to trigger replan")
}
if shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "get_balance",
OutputSummary: `{"balances":[]}`,
}, false) {
t.Fatalf("did not expect read-only balance step to trigger replan")
}
if !shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "get_balance",
OutputSummary: `{"error":"ai_model_id is required"}`,
}, false) {
t.Fatalf("expected dependency/error result to trigger replan")
}
}
type failingAIClient struct{}
func (f *failingAIClient) SetAPIKey(string, string, string) {}
func (f *failingAIClient) SetTimeout(_ time.Duration) {}
func (f *failingAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (f *failingAIClient) CallWithRequest(*mcp.Request) (string, error) {
return "", errors.New("API returned error (status 402): insufficient balance")
}
func (f *failingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (f *failingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("API returned error (status 402): insufficient balance")
}
type capturePlannerAIClient struct {
systemPrompt string
userPrompt string
}
func (c *capturePlannerAIClient) SetAPIKey(string, string, string) {}
func (c *capturePlannerAIClient) SetTimeout(time.Duration) {}
func (c *capturePlannerAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (c *capturePlannerAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
c.systemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
c.userPrompt = req.Messages[1].Content
}
return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil
}
func (c *capturePlannerAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (c *capturePlannerAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
type blockingAIClient struct{}
func (b *blockingAIClient) SetAPIKey(string, string, string) {}
func (b *blockingAIClient) SetTimeout(time.Duration) {}
func (b *blockingAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (b *blockingAIClient) CallWithRequest(req *mcp.Request) (string, error) {
<-req.Ctx.Done()
return "", req.Ctx.Err()
}
func (b *blockingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (b *blockingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
type directReplyAIClient struct {
lastSystemPrompt string
lastUserPrompt string
routerPrompt string
skillRouterPrompt string
plannerPrompt string
}
func (d *directReplyAIClient) SetAPIKey(string, string, string) {}
func (d *directReplyAIClient) SetTimeout(time.Duration) {}
func (d *directReplyAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (d *directReplyAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
d.lastSystemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
d.lastUserPrompt = req.Messages[1].Content
}
if strings.Contains(d.lastSystemPrompt, "first-pass router for NOFXi") {
d.routerPrompt = d.lastSystemPrompt
if strings.Contains(d.lastUserPrompt, "你好") {
return `{"action":"direct_answer","answer":"你好,我在。想聊策略、配置还是排障?"}`, nil
}
return `{"action":"defer","answer":""}`, nil
}
if strings.Contains(d.lastSystemPrompt, "lightweight skill router for NOFXi") {
d.skillRouterPrompt = d.lastSystemPrompt
if strings.Contains(d.lastUserPrompt, "运行中的trader") || strings.Contains(d.lastUserPrompt, "有没有 trader 在跑") {
return `{"route":"skill","skill":"trader_management","action":"query","filter":"running_only"}`, nil
}
return `{"route":"planner","skill":"","action":"","filter":""}`, nil
}
if strings.Contains(d.lastSystemPrompt, "planning module for NOFXi") {
d.plannerPrompt = d.lastSystemPrompt
}
return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil
}
func (d *directReplyAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (d *directReplyAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
func TestThinkAndActLegacyReturnsProviderFailureInsteadOfNoAIFallback(t *testing.T) {
a := &Agent{
aiClient: &failingAIClient{},
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.thinkAndActLegacy(context.Background(), 42, "zh", "你好", nil)
if err != nil {
t.Fatalf("thinkAndActLegacy() error = %v", err)
}
if strings.Contains(resp, "发送 *开始配置* 配置 AI 模型") {
t.Fatalf("expected provider failure message, got fallback: %q", resp)
}
if !strings.Contains(resp, "AI 服务调用失败") {
t.Fatalf("expected provider failure message, got %q", resp)
}
}
func TestThinkAndActUsesDirectReplyGateForConversationalQuestion(t *testing.T) {
client := &directReplyAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 88, "zh", "你好")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "你好,我在") {
t.Fatalf("expected direct reply response, got %q", resp)
}
if !strings.Contains(client.routerPrompt, "first-pass router for NOFXi") {
t.Fatalf("expected direct reply router prompt, got %q", client.routerPrompt)
}
}
func TestThinkAndActDefersFromDirectReplyGateToHardSkill(t *testing.T) {
a := newTestAgentWithStore(t)
a.aiClient = &directReplyAIClient{}
resp, err := a.thinkAndAct(context.Background(), "user-1", 89, "zh", "帮我创建一个 DeepSeek 模型配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建模型配置") {
t.Fatalf("expected direct reply gate to defer to hard skill, got %q", resp)
}
}
func TestThinkAndActUsesLLMSkillRouterForNaturalLanguageTraderQuery(t *testing.T) {
client := &directReplyAIClient{}
a := newTestAgentWithStore(t)
a.aiClient = client
a.history = newChatHistory(10)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
createResp := a.toolManageTrader("user-1", `{
"action":"create",
"name":"Momentum Trader",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"scan_interval_minutes":5
}`)
var created struct {
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp)
}
if err := a.store.Trader().UpdateStatus("user-1", created.Trader.ID, true); err != nil {
t.Fatalf("update trader status: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 90, "zh", "当前有运行中的trader吗")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "运行中的交易员") || !strings.Contains(resp, "Momentum Trader") {
t.Fatalf("expected routed running-trader answer, got %q", resp)
}
if client.skillRouterPrompt == "" {
t.Fatal("expected lightweight skill router prompt to be used")
}
if client.plannerPrompt != "" {
t.Fatalf("expected planner to be skipped, got prompt %q", client.plannerPrompt)
}
}
func TestThinkAndActPrioritizesActiveExecutionStateOverDirectReply(t *testing.T) {
client := &directReplyAIClient{}
a := newTestAgentWithStore(t)
a.aiClient = client
a.history = newChatHistory(10)
a.logger = slog.Default()
userID := int64(90)
state := newExecutionState(userID, "继续完成当前任务")
state.Status = executionStatusWaitingUser
state.Waiting = &WaitingState{
Question: "请确认是否继续",
Intent: "confirm_action",
}
if err := a.saveExecutionState(state); err != nil {
t.Fatalf("saveExecutionState() error = %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "你好")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if strings.Contains(resp, "你好,我在") {
t.Fatalf("expected active execution state to bypass direct reply gate, got %q", resp)
}
if !strings.Contains(client.plannerPrompt, "planning module for NOFXi") {
t.Fatalf("expected planner prompt when execution state is active, got %q", client.plannerPrompt)
}
}
func TestThinkAndActInterruptsWaitingExecutionStateForNewTopic(t *testing.T) {
a := newTestAgentWithStore(t)
a.history = newChatHistory(10)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"lang":"zh"
}`)
userID := int64(91)
state := newExecutionState(userID, "创建交易员")
state.Status = executionStatusWaitingUser
state.Waiting = &WaitingState{
Question: "请告诉我交易员名称",
PendingFields: []string{"name"},
}
if err := a.saveExecutionState(state); err != nil {
t.Fatalf("saveExecutionState() error = %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") {
t.Fatalf("expected new topic to be handled, got %q", resp)
}
if got := a.getExecutionState(userID); got.SessionID != "" {
t.Fatalf("expected execution state to be cleared, got %+v", got)
}
}
func TestCreateExecutionPlanIncludesRecentConversation(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(42)
a.history.Add(userID, "user", "先帮我看一下当前trader")
a.history.Add(userID, "assistant", "当前只有测试1这个trader。")
a.history.Add(userID, "user", "好的,那就按当前trader来")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "好的,那就按当前trader来", newExecutionState(userID, "好的,那就按当前trader来"))
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "Recent conversation:") {
t.Fatalf("expected planner prompt to include recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") {
t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") {
t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt)
}
recentIdx := strings.Index(client.userPrompt, "Recent conversation:\n")
toolsIdx := strings.Index(client.userPrompt, "\n\nAvailable tools JSON:")
if recentIdx == -1 || toolsIdx == -1 || toolsIdx <= recentIdx {
t.Fatalf("expected recent conversation block boundaries, got %q", client.userPrompt)
}
recentBlock := client.userPrompt[recentIdx:toolsIdx]
if strings.Contains(recentBlock, "好的,那就按当前trader来") {
t.Fatalf("expected current user text to stay out of recent conversation block, got %q", recentBlock)
}
if !strings.Contains(client.systemPrompt, "Memory priority order:") {
t.Fatalf("expected planner system prompt to include memory priority guidance, got %q", client.systemPrompt)
}
if !strings.Contains(client.systemPrompt, "Execution state JSON = current operational truth") {
t.Fatalf("expected planner system prompt to prioritize execution state, got %q", client.systemPrompt)
}
if !strings.Contains(client.systemPrompt, "Do not ask the user to repeat a fact") {
t.Fatalf("expected planner system prompt to forbid unnecessary repeated questions, got %q", client.systemPrompt)
}
}
func TestCreateExecutionPlanIncludesRecentConversationForFreshRequest(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(99)
a.history.Add(userID, "user", "先帮我看一下当前trader")
a.history.Add(userID, "assistant", "当前只有测试1这个trader。")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "帮我分析一下比特币", ExecutionState{})
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "Recent conversation:") {
t.Fatalf("expected fresh request to still include recent conversation block, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") {
t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") {
t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt)
}
}
func TestCreateExecutionPlanIncludesQuotedEarlierAssistantClaim(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(100)
a.history.Add(userID, "user", "配置页怎么只有三个交易所")
a.history.Add(userID, "assistant", "目前你看到的是三个交易所。")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "你前面也跟我说只有三个交易所", ExecutionState{})
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "目前你看到的是三个交易所") {
t.Fatalf("expected planner prompt to include earlier assistant claim, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "配置页怎么只有三个交易所") {
t.Fatalf("expected planner prompt to include earlier user complaint, got %q", client.userPrompt)
}
}
func TestRunPlannedAgentReturnsTimeoutMessageOnPlannerTimeout(t *testing.T) {
oldTimeout := plannerCreateTimeout
plannerCreateTimeout = 10 * time.Millisecond
defer func() { plannerCreateTimeout = oldTimeout }()
a := &Agent{
aiClient: &blockingAIClient{},
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.runPlannedAgent(context.Background(), "default", 7, "zh", "帮我分析一下当前市场", nil)
if err != nil {
t.Fatalf("runPlannedAgent() error = %v", err)
}
if !strings.Contains(resp, "处理超时") {
t.Fatalf("expected timeout message, got %q", resp)
}
}
func TestHandleMessageForStoreUserBypassesPlannerForTradeConfirmation(t *testing.T) {
a := &Agent{
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
pending: newPendingTrades(),
}
resp, err := a.handleMessageForStoreUser(context.Background(), "default", 1, "确认 trade_missing")
if err != nil {
t.Fatalf("handleMessageForStoreUser() error = %v", err)
}
if !strings.Contains(resp, "交易已过期或不存在") {
t.Fatalf("expected direct trade confirmation handling, got %q", resp)
}
}
func TestResolveModelRuntimeConfigUsesProviderDefaults(t *testing.T) {
url, model := resolveModelRuntimeConfig("deepseek", "", "", "user_deepseek")
if url != "https://api.deepseek.com/v1" {
t.Fatalf("unexpected deepseek default url: %q", url)
}
if model != "deepseek-chat" {
t.Fatalf("unexpected deepseek default model: %q", model)
}
url, model = resolveModelRuntimeConfig("deepseek", "", "deepseek1", "user_deepseek")
if url != "https://api.deepseek.com/v1" {
t.Fatalf("unexpected resolved url: %q", url)
}
if model != "deepseek1" {
t.Fatalf("expected existing custom model name to win, got %q", model)
}
}
+161
View File
@@ -0,0 +1,161 @@
package agent
import (
"encoding/json"
"fmt"
"hash/fnv"
"strings"
"time"
)
// PersistentPreference is a durable user instruction shown in the UI and
// injected into the agent context for future conversations.
type PersistentPreference struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at,omitempty"`
}
func NewPersistentPreference(text string) (PersistentPreference, error) {
text = strings.TrimSpace(text)
if text == "" {
return PersistentPreference{}, fmt.Errorf("text required")
}
now := time.Now().UTC()
return PersistentPreference{
ID: now.Format("20060102150405.000000000"),
Text: text,
CreatedAt: now.Format(time.RFC3339),
}, nil
}
// SessionUserIDFromKey maps a stable user key (for example a UUID string from
// auth) to the int64 session id expected by the current agent implementation.
func SessionUserIDFromKey(userKey string) int64 {
if strings.TrimSpace(userKey) == "" {
return 1
}
h := fnv.New64a()
_, _ = h.Write([]byte(userKey))
sum := h.Sum64() & 0x7fffffffffffffff
if sum == 0 {
return 1
}
return int64(sum)
}
func PreferencesConfigKey(userID int64) string {
return fmt.Sprintf("agent_preferences_%d", userID)
}
func (a *Agent) getPersistentPreferences(userID int64) []PersistentPreference {
if a.store == nil {
return nil
}
raw, err := a.store.GetSystemConfig(PreferencesConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return nil
}
var prefs []PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
a.logger.Warn("failed to parse persistent preferences", "error", err, "user_id", userID)
return nil
}
return prefs
}
func (a *Agent) savePersistentPreferences(userID int64, prefs []PersistentPreference) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
data, err := json.Marshal(prefs)
if err != nil {
return err
}
return a.store.SetSystemConfig(PreferencesConfigKey(userID), string(data))
}
func (a *Agent) addPersistentPreference(userID int64, text string) ([]PersistentPreference, PersistentPreference, error) {
created, err := NewPersistentPreference(text)
if err != nil {
return nil, PersistentPreference{}, err
}
prefs := a.getPersistentPreferences(userID)
prefs = append([]PersistentPreference{created}, prefs...)
if len(prefs) > 20 {
prefs = prefs[:20]
}
if err := a.savePersistentPreferences(userID, prefs); err != nil {
return nil, PersistentPreference{}, err
}
return prefs, created, nil
}
func (a *Agent) updatePersistentPreference(userID int64, match, replacement string) ([]PersistentPreference, *PersistentPreference, error) {
match = strings.TrimSpace(match)
replacement = strings.TrimSpace(replacement)
if match == "" || replacement == "" {
return nil, nil, fmt.Errorf("match and replacement are required")
}
prefs := a.getPersistentPreferences(userID)
for i := range prefs {
if prefs[i].ID == match || strings.Contains(strings.ToLower(prefs[i].Text), strings.ToLower(match)) {
prefs[i].Text = replacement
if err := a.savePersistentPreferences(userID, prefs); err != nil {
return nil, nil, err
}
return prefs, &prefs[i], nil
}
}
return prefs, nil, fmt.Errorf("preference not found")
}
func (a *Agent) deletePersistentPreference(userID int64, match string) ([]PersistentPreference, *PersistentPreference, error) {
match = strings.TrimSpace(match)
if match == "" {
return nil, nil, fmt.Errorf("match required")
}
prefs := a.getPersistentPreferences(userID)
filtered := make([]PersistentPreference, 0, len(prefs))
var removed *PersistentPreference
for i := range prefs {
p := prefs[i]
if removed == nil && (p.ID == match || strings.Contains(strings.ToLower(p.Text), strings.ToLower(match))) {
cp := p
removed = &cp
continue
}
filtered = append(filtered, p)
}
if removed == nil {
return prefs, nil, fmt.Errorf("preference not found")
}
if err := a.savePersistentPreferences(userID, filtered); err != nil {
return nil, nil, err
}
return filtered, removed, nil
}
func (a *Agent) buildPersistentPreferencesContext(userID int64) string {
prefs := a.getPersistentPreferences(userID)
if len(prefs) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("[Persistent User Preferences - follow unless the user explicitly overrides them]\n")
for _, pref := range prefs {
if strings.TrimSpace(pref.Text) == "" {
continue
}
sb.WriteString("- ")
sb.WriteString(pref.Text)
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
+31
View File
@@ -0,0 +1,31 @@
package agent
import (
"strings"
"testing"
)
func TestNewPersistentPreference(t *testing.T) {
pref, err := NewPersistentPreference(" Always answer in Chinese. ")
if err != nil {
t.Fatalf("expected preference to be created, got error: %v", err)
}
if pref.ID == "" {
t.Fatal("expected non-empty preference id")
}
if pref.Text != "Always answer in Chinese." {
t.Fatalf("expected trimmed text, got %q", pref.Text)
}
if pref.CreatedAt == "" {
t.Fatal("expected created_at to be set")
}
if strings.Contains(pref.ID, "Always") {
t.Fatalf("expected generated id, got %q", pref.ID)
}
}
func TestNewPersistentPreferenceRejectsEmptyText(t *testing.T) {
if _, err := NewPersistentPreference(" "); err == nil {
t.Fatal("expected empty text to be rejected")
}
}
+105
View File
@@ -0,0 +1,105 @@
package agent
import (
"context"
"fmt"
"log/slog"
"nofx/safe"
"strings"
"time"
)
type Scheduler struct {
agent *Agent
logger *slog.Logger
stopCh chan struct{}
}
func NewScheduler(a *Agent, l *slog.Logger) *Scheduler {
return &Scheduler{agent: a, logger: l, stopCh: make(chan struct{})}
}
func (s *Scheduler) Start(ctx context.Context) {
safe.GoNamed("agent-scheduler", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
lastReport := time.Time{}
lastCheck := time.Time{}
for {
select {
case <-ctx.Done(): return
case <-s.stopCh: return
case now := <-ticker.C:
// Daily report at 21:00
if now.Hour() == 21 && now.Sub(lastReport) > 12*time.Hour {
s.dailyReport()
lastReport = now
}
// Position risk check every 4h
if now.Sub(lastCheck) > 4*time.Hour {
s.riskCheck()
lastCheck = now
}
// Clean expired pending trades every hour.
if now.Minute() == 0 {
if s.agent.pending != nil {
s.agent.pending.CleanExpired()
}
}
}
}
})
}
func (s *Scheduler) Stop() { close(s.stopCh) }
func (s *Scheduler) dailyReport() {
if s.agent.traderManager == nil { return }
traders := s.agent.traderManager.GetAllTraders()
if len(traders) == 0 { return }
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📊 *NOFXi 每日报告 — %s*\n\n", time.Now().Format("2006-01-02")))
totalPnL := 0.0
for _, t := range traders {
info, err := t.GetAccountInfo()
if err != nil { continue }
equity := toFloat(info["total_equity"])
pnl := toFloat(info["unrealized_pnl"])
sb.WriteString(fmt.Sprintf("• %s: $%.2f (P/L: $%.2f)\n", t.GetName(), equity, pnl))
totalPnL += pnl
}
e := "📈"
if totalPnL < 0 { e = "📉" }
sb.WriteString(fmt.Sprintf("\n%s Total P/L: $%.2f", e, totalPnL))
s.agent.notifyAll(sb.String())
}
func (s *Scheduler) riskCheck() {
if s.agent.traderManager == nil { return }
var alerts []string
for _, t := range s.agent.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil { continue }
for _, p := range positions {
pnl := toFloat(p["unrealizedPnl"])
size := toFloat(p["size"])
if size == 0 { continue }
entry := toFloat(p["entryPrice"])
if entry > 0 {
pnlPct := (pnl / (entry * size)) * 100
if pnlPct < -5 {
alerts = append(alerts, fmt.Sprintf("⚠️ *%s* %s: %.1f%% ($%.2f)",
p["symbol"], p["side"], pnlPct, pnl))
}
}
}
}
if len(alerts) > 0 {
s.agent.notifyAll("🚨 *持仓风险提醒*\n\n" + strings.Join(alerts, "\n"))
}
}
+172
View File
@@ -0,0 +1,172 @@
package agent
import (
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"nofx/safe"
"strconv"
"strings"
"sync"
"time"
)
type SignalType string
const (
SignalPriceBreakout SignalType = "price_breakout"
SignalVolumeSpike SignalType = "volume_spike"
SignalFundingRate SignalType = "funding_rate"
)
type Signal struct {
Type SignalType
Symbol string
Severity string
Title string
Detail string
Price float64
Change float64
}
type SignalCallback func(Signal)
type Sentinel struct {
mu sync.RWMutex
symbols []string
history map[string][]pricePt
onSignal SignalCallback
http *http.Client
logger *slog.Logger
stopCh chan struct{}
}
type pricePt struct {
Price float64
Volume float64
Time time.Time
}
func NewSentinel(symbols []string, cb SignalCallback, logger *slog.Logger) *Sentinel {
return &Sentinel{
symbols: symbols,
history: make(map[string][]pricePt),
onSignal: cb,
http: &http.Client{Timeout: 10 * time.Second},
logger: logger,
stopCh: make(chan struct{}),
}
}
func (s *Sentinel) Start() {
safe.GoNamed("sentinel", func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
s.scan()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.scan()
}
}
})
}
func (s *Sentinel) Stop() { close(s.stopCh) }
func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) }
func (s *Sentinel) AddSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for _, x := range s.symbols { if x == sym { return } }; s.symbols = append(s.symbols, sym) }
func (s *Sentinel) RemoveSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for i, x := range s.symbols { if x == sym { s.symbols = append(s.symbols[:i], s.symbols[i+1:]...); return } } }
func (s *Sentinel) FormatWatchlist(L string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.symbols) == 0 {
if L == "zh" { return "📭 监控列表为空。用 `/watch BTC` 添加。" }
return "📭 Watchlist empty. Use `/watch BTC` to add."
}
var sb strings.Builder
if L == "zh" { sb.WriteString("👁️ *监控列表*\n\n") } else { sb.WriteString("👁️ *Watchlist*\n\n") }
for _, sym := range s.symbols {
if pts, ok := s.history[sym]; ok && len(pts) > 0 {
last := pts[len(pts)-1]
sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n", sym, last.Price, last.Time.Format("15:04")))
} else {
sb.WriteString(fmt.Sprintf("• *%s*: waiting...\n", sym))
}
}
return sb.String()
}
func (s *Sentinel) scan() {
s.mu.RLock()
syms := make([]string, len(s.symbols))
copy(syms, s.symbols)
s.mu.RUnlock()
for _, sym := range syms {
s.check(sym)
}
}
func (s *Sentinel) check(symbol string) {
resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
if err != nil { return }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
s.logger.Debug("sentinel ticker non-200", "symbol", symbol, "status", resp.StatusCode)
return
}
body, err := safe.ReadAllLimited(resp.Body, 256*1024) // 256KB limit
if err != nil { return }
var t map[string]interface{}
if err := json.Unmarshal(body, &t); err != nil { return }
price, _ := strconv.ParseFloat(fmt.Sprint(t["lastPrice"]), 64)
vol, _ := strconv.ParseFloat(fmt.Sprint(t["quoteVolume"]), 64)
chg, _ := strconv.ParseFloat(fmt.Sprint(t["priceChangePercent"]), 64)
pt := pricePt{Price: price, Volume: vol, Time: time.Now()}
s.mu.Lock()
h := s.history[symbol]
h = append(h, pt)
if len(h) > 60 { h = h[len(h)-60:] }
s.history[symbol] = h
s.mu.Unlock()
if len(h) < 5 { return }
// Price breakout (>3% in 5 min)
old := h[len(h)-5]
pct := ((price - old.Price) / old.Price) * 100
if math.Abs(pct) >= 3.0 {
sev := "warning"
if math.Abs(pct) >= 6.0 { sev = "critical" }
dir := "📈 拉升"
if pct < 0 { dir = "📉 下跌" }
s.emit(Signal{Type: SignalPriceBreakout, Symbol: symbol, Severity: sev,
Title: fmt.Sprintf("%s %s %.1f%%", symbol, dir, math.Abs(pct)),
Detail: fmt.Sprintf("5min: $%.2f → $%.2f (24h: %.1f%%)", old.Price, price, chg),
Price: price, Change: pct})
}
// Volume spike (>3x avg)
if len(h) >= 10 {
var avg float64
for i := 0; i < len(h)-1; i++ { avg += h[i].Volume }
avg /= float64(len(h) - 1)
if avg > 0 && vol > avg*3 {
s.emit(Signal{Type: SignalVolumeSpike, Symbol: symbol, Severity: "warning",
Title: fmt.Sprintf("%s 成交量异常 %.1fx", symbol, vol/avg),
Detail: fmt.Sprintf("Price: $%.2f (24h: %.1f%%)", price, chg),
Price: price, Change: chg})
}
}
}
func (s *Sentinel) emit(sig Signal) {
s.logger.Info("signal", "type", sig.Type, "symbol", sig.Symbol, "title", sig.Title)
if s.onSignal != nil { s.onSignal(sig) }
}
+97
View File
@@ -0,0 +1,97 @@
package agent
func skillCatalogPrompt(lang string) string {
if lang == "zh" {
return `## 多轮与 Skill-First 工作模式
- 对于高频已知任务,优先按 skill 执行,不要每次从零规划
- 如果用户仍在同一任务里,继续当前 flow,不要重新路由
- 只追问继续执行所需的最少必要字段,不要让用户重复已确认信息
- 高风险动作(删除、启动实盘、停止运行中 trader、覆盖关键配置)必须单独确认
- 对诊断类问题,优先做“问题归类 -> 可能原因 -> 核查项 -> 下一步建议”
## 当前重点技能
### 1. 模型配置与诊断
- ` + "`skill_model_api_setup`" + `:用户问某个大模型的 API key 去哪申请、base URL 怎么填、model name 怎么填时,给步骤化指导
- ` + "`skill_model_config_diagnosis`" + `:当用户遇到模型配置失败、调用失败、保存后不可用时,优先检查:
1. 是否已启用模型
2. API Key 是否为空
3. custom_api_url 是否为合法 HTTPS 地址
4. custom_model_name 是否为空或填错
5. 保存后是否需要重新加载 trader
- 已知事实:
- 系统会拒绝非 HTTPS 的 custom_api_url
- 已启用模型如果缺少 API Key 或 custom_api_url,会导致 agent 不可用
### 2. 交易所配置与诊断
- ` + "`skill_exchange_api_setup`" + `:指导用户创建交易所 API,明确需要哪些权限、哪些权限不要开、哪些交易所需要额外字段
- ` + "`skill_exchange_api_diagnosis`" + `:用户遇到 invalid signature、timestamp、permission denied、IP not allowed 时,优先排查:
1. 系统时间是否同步
2. API Key / Secret 是否填反或过期
3. IP 白名单是否包含服务器 IP
4. 是否启用了合约/交易权限
5. OKX 是否遗漏 passphrase
- 已知事实:
- OKX 除 API Key 和 Secret 外还需要 passphrase
- invalid signature / timestamp 常见根因是时间不同步或密钥不匹配
### 3. Trader 启动与运行诊断
- ` + "`skill_trader_start_diagnosis`" + `:当用户说 trader 启动不了、启动后不交易、没有持仓、没有决策时,优先排查:
1. 是否存在可用且启用的模型配置
2. 是否存在可用且启用的交易所配置
3. trader 绑定的 strategy / exchange / model 是否齐全
4. 账户余额和权限是否满足下单要求
5. AI 是否一直返回 wait / hold
- 如果用户问“为什么没有开仓”,要明确区分:
- 系统没启动
- 启动了但 AI 决策为 wait
- 有信号但下单失败
### 4. 交易行为异常诊断
- ` + "`skill_order_execution_diagnosis`" + `:当用户问仓位开不出来、只开单边、杠杆报错时,优先排查:
1. 是否为交易所模式问题(例如 Binance One-way / Hedge Mode
2. 是否为子账户杠杆限制
3. 是否为合约权限或 symbol 不可交易
4. 是否为余额不足或保证金占用过高
- 已知事实:
- Binance 若不是 Hedge Mode,可能出现 position side mismatch 或只开单边
- 某些子账户杠杆受限,超过限制会直接报错
### 5. 策略与提示词诊断
- ` + "`skill_strategy_diagnosis`" + `:当用户说策略没生效、提示词不对、预览和实际不一致时,优先建议:
1. 查看当前 strategy 配置
2. 区分策略模板本身和 trader 上的 custom prompt
3. 必要时预览 prompt 或读取当前保存值后再判断
## 回答格式要求
- 诊断类问题尽量按“现象 / 原因 / 先检查什么 / 怎么修复”回答
- 配置指导类问题尽量按步骤回答
- 如果已有工具能验证当前状态,先查再下结论
- 如果结论是推测,必须明确说是“更可能”或“优先怀疑”`
}
return `## Multi-turn and Skill-First Operating Mode
- For high-frequency known tasks, prefer stable skills instead of replanning from scratch
- If the user is still in the same task, continue the active flow
- Ask only for the minimum missing fields required to proceed
- Require explicit confirmation for destructive or financially sensitive actions
- For diagnostic requests, use: issue class -> likely causes -> checks -> next steps
## Priority Skills
- skill_model_api_setup / skill_model_config_diagnosis
- skill_exchange_api_setup / skill_exchange_api_diagnosis
- skill_trader_start_diagnosis
- skill_order_execution_diagnosis
- skill_strategy_diagnosis
Known facts:
- custom_api_url must be a valid HTTPS URL
- OKX requires passphrase in addition to API key and secret
- invalid signature / timestamp often means clock skew or mismatched credentials
- missing enabled model or exchange config can block trader startup
- Binance position-side issues are often caused by One-way Mode vs Hedge Mode
Response style:
- Diagnostics: symptom -> cause -> checks -> fix
- Setup guidance: step-by-step
- Verify with tools when possible before concluding`
}
+35
View File
@@ -0,0 +1,35 @@
package agent
import (
"log/slog"
"strings"
"testing"
)
func TestSkillCatalogPromptZHIncludesDiagnosisSkills(t *testing.T) {
got := skillCatalogPrompt("zh")
for _, want := range []string{
"多轮与 Skill-First 工作模式",
"skill_model_config_diagnosis",
"skill_exchange_api_diagnosis",
"skill_trader_start_diagnosis",
} {
if !strings.Contains(got, want) {
t.Fatalf("skillCatalogPrompt(zh) missing %q\n%s", want, got)
}
}
}
func TestBuildSystemPromptIncludesSkillCatalog(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
got := a.buildSystemPrompt("zh")
for _, want := range []string{
"多轮与 Skill-First 工作模式",
"skill_exchange_api_setup",
"skill_order_execution_diagnosis",
} {
if !strings.Contains(got, want) {
t.Fatalf("buildSystemPrompt(zh) missing %q", want)
}
}
}
+277
View File
@@ -0,0 +1,277 @@
package agent
import "strings"
type SkillDAG struct {
SkillName string
Action string
Steps []SkillDAGStep
}
type SkillDAGStep struct {
ID string
Kind string
RequiredFields []string
OptionalFields []string
Next []string
Terminal bool
}
var skillDAGRegistry = buildSkillDAGRegistry()
func buildSkillDAGRegistry() map[string]SkillDAG {
dags := []SkillDAG{
{
SkillName: "trader_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"resolve_exchange"}},
{ID: "resolve_exchange", Kind: "collect_slot", RequiredFields: []string{"exchange_id"}, OptionalFields: []string{"exchange_name"}, Next: []string{"resolve_model"}},
{ID: "resolve_model", Kind: "collect_slot", RequiredFields: []string{"model_id"}, OptionalFields: []string{"model_name"}, Next: []string{"resolve_strategy"}},
{ID: "resolve_strategy", Kind: "collect_slot", RequiredFields: []string{"strategy_id"}, OptionalFields: []string{"strategy_name"}, Next: []string{"maybe_confirm_start"}},
{ID: "maybe_confirm_start", Kind: "branch", OptionalFields: []string{"auto_start"}, Next: []string{"await_start_confirmation", "execute_create_only"}},
{ID: "await_start_confirmation", Kind: "confirm", RequiredFields: []string{"auto_start"}, Next: []string{"execute_create_and_start", "execute_create_only"}},
{ID: "execute_create_only", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, Terminal: true},
{ID: "execute_create_and_start", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, OptionalFields: []string{"auto_start"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "update_bindings",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}},
{ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "start",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_start"}},
{ID: "execute_start", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "stop",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_stop"}},
{ID: "execute_stop", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_prompt",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_prompt"}},
{ID: "collect_prompt", Kind: "collect_slot", RequiredFields: []string{"prompt"}, Next: []string{"load_config"}},
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "prompt"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_config",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"resolve_config_field"}},
{ID: "resolve_config_field", Kind: "collect_slot", RequiredFields: []string{"config_field"}, Next: []string{"resolve_config_value"}},
{ID: "resolve_config_value", Kind: "collect_slot", RequiredFields: []string{"config_value"}, Next: []string{"load_config"}},
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"apply_field_update"}},
{ID: "apply_field_update", Kind: "transform", RequiredFields: []string{"config_field", "config_value"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "config_field", "config_value"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "duplicate",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_duplicate"}},
{ID: "execute_duplicate", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "activate",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"execute_activate"}},
{ID: "execute_activate", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_provider", Kind: "collect_slot", RequiredFields: []string{"provider"}, Next: []string{"collect_optional_fields"}},
{ID: "collect_optional_fields", Kind: "collect_slot", OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"provider"}, OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_status",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_endpoint",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_api_url"}},
{ID: "collect_custom_api_url", Kind: "collect_slot", RequiredFields: []string{"custom_api_url"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_api_url"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_model_name"}},
{ID: "collect_custom_model_name", Kind: "collect_slot", RequiredFields: []string{"custom_model_name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_model_name"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_exchange_type", Kind: "collect_slot", RequiredFields: []string{"exchange_type"}, Next: []string{"collect_account_name"}},
{ID: "collect_account_name", Kind: "collect_slot", OptionalFields: []string{"account_name"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"exchange_type"}, OptionalFields: []string{"account_name"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_account_name"}},
{ID: "collect_account_name", Kind: "collect_slot", RequiredFields: []string{"account_name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "account_name"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "update_status",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
}
registry := make(map[string]SkillDAG, len(dags))
for _, dag := range dags {
dag = normalizeSkillDAG(dag)
if dag.SkillName == "" || dag.Action == "" {
continue
}
registry[skillDAGKey(dag.SkillName, dag.Action)] = dag
}
return registry
}
func normalizeSkillDAG(dag SkillDAG) SkillDAG {
dag.SkillName = strings.TrimSpace(dag.SkillName)
dag.Action = strings.TrimSpace(dag.Action)
steps := make([]SkillDAGStep, 0, len(dag.Steps))
for _, step := range dag.Steps {
step.ID = strings.TrimSpace(step.ID)
step.Kind = strings.TrimSpace(step.Kind)
step.RequiredFields = cleanStringList(step.RequiredFields)
step.OptionalFields = cleanStringList(step.OptionalFields)
step.Next = cleanStringList(step.Next)
if step.ID == "" {
continue
}
steps = append(steps, step)
}
dag.Steps = steps
return dag
}
func skillDAGKey(skillName, action string) string {
return strings.TrimSpace(skillName) + ":" + strings.TrimSpace(action)
}
func getSkillDAG(skillName, action string) (SkillDAG, bool) {
dag, ok := skillDAGRegistry[skillDAGKey(skillName, action)]
return dag, ok
}
func listSkillDAGs() []SkillDAG {
out := make([]SkillDAG, 0, len(skillDAGRegistry))
for _, dag := range skillDAGRegistry {
out = append(out, dag)
}
return out
}
+51
View File
@@ -0,0 +1,51 @@
package agent
const skillDAGStepField = "_dag_step"
func currentSkillDAGStep(session skillSession) (SkillDAGStep, bool) {
dag, ok := getSkillDAG(session.Name, session.Action)
if !ok || len(dag.Steps) == 0 {
return SkillDAGStep{}, false
}
stepID := fieldValue(session, skillDAGStepField)
if stepID == "" {
return dag.Steps[0], true
}
for _, step := range dag.Steps {
if step.ID == stepID {
return step, true
}
}
return dag.Steps[0], true
}
func setSkillDAGStep(session *skillSession, stepID string) {
ensureSkillFields(session)
if stepID == "" {
delete(session.Fields, skillDAGStepField)
return
}
session.Fields[skillDAGStepField] = stepID
}
func clearSkillDAGStep(session *skillSession) {
if session == nil || session.Fields == nil {
return
}
delete(session.Fields, skillDAGStepField)
}
func advanceSkillDAGStep(session *skillSession, currentStepID string) {
dag, ok := getSkillDAG(session.Name, session.Action)
if !ok {
return
}
for _, step := range dag.Steps {
if step.ID != currentStepID || len(step.Next) == 0 {
continue
}
setSkillDAGStep(session, step.Next[0])
return
}
}
+27
View File
@@ -0,0 +1,27 @@
package agent
import "testing"
func TestCurrentSkillDAGStepDefaultsToFirstStep(t *testing.T) {
session := skillSession{Name: "strategy_management", Action: "update_config"}
step, ok := currentSkillDAGStep(session)
if !ok {
t.Fatal("expected dag step")
}
if step.ID != "resolve_target" {
t.Fatalf("expected first step resolve_target, got %s", step.ID)
}
}
func TestAdvanceSkillDAGStepMovesToNextStep(t *testing.T) {
session := skillSession{Name: "strategy_management", Action: "update_config"}
setSkillDAGStep(&session, "resolve_config_field")
advanceSkillDAGStep(&session, "resolve_config_field")
step, ok := currentSkillDAGStep(session)
if !ok {
t.Fatal("expected dag step")
}
if step.ID != "resolve_config_value" {
t.Fatalf("expected resolve_config_value, got %s", step.ID)
}
}
+67
View File
@@ -0,0 +1,67 @@
package agent
import "testing"
func TestGetSkillDAGForStructuredActions(t *testing.T) {
tests := []struct {
skill string
action string
}{
{skill: "trader_management", action: "create"},
{skill: "trader_management", action: "update_bindings"},
{skill: "strategy_management", action: "update_config"},
{skill: "strategy_management", action: "update_prompt"},
{skill: "model_management", action: "update_status"},
{skill: "exchange_management", action: "update_name"},
}
for _, tt := range tests {
dag, ok := getSkillDAG(tt.skill, tt.action)
if !ok {
t.Fatalf("expected DAG for %s/%s", tt.skill, tt.action)
}
if dag.SkillName != tt.skill || dag.Action != tt.action {
t.Fatalf("unexpected dag identity: %+v", dag)
}
if len(dag.Steps) == 0 {
t.Fatalf("expected DAG steps for %s/%s", tt.skill, tt.action)
}
}
}
func TestStructuredDAGsHaveTerminalStep(t *testing.T) {
for _, dag := range listSkillDAGs() {
hasTerminal := false
for _, step := range dag.Steps {
if step.Terminal {
hasTerminal = true
break
}
}
if !hasTerminal {
t.Fatalf("expected terminal step for %s/%s", dag.SkillName, dag.Action)
}
}
}
func TestStrategyUpdateConfigDAGMatchesCurrentAtomicFlow(t *testing.T) {
dag, ok := getSkillDAG("strategy_management", "update_config")
if !ok {
t.Fatal("missing strategy update_config dag")
}
if len(dag.Steps) != 6 {
t.Fatalf("expected 6 steps, got %d", len(dag.Steps))
}
if dag.Steps[0].ID != "resolve_target" {
t.Fatalf("expected first step resolve_target, got %s", dag.Steps[0].ID)
}
if dag.Steps[1].ID != "resolve_config_field" {
t.Fatalf("expected second step resolve_config_field, got %s", dag.Steps[1].ID)
}
if dag.Steps[2].ID != "resolve_config_value" {
t.Fatalf("expected third step resolve_config_value, got %s", dag.Steps[2].ID)
}
if dag.Steps[5].ID != "execute_update" || !dag.Steps[5].Terminal {
t.Fatalf("expected final terminal execute step, got %+v", dag.Steps[5])
}
}
File diff suppressed because it is too large Load Diff
+828
View File
@@ -0,0 +1,828 @@
package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"nofx/mcp"
)
func TestCreateTraderSkillCollectsMissingFieldsAndCreatesTrader(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
if strings.Contains(modelResp, `"error"`) {
t.Fatalf("failed to create model: %s", modelResp)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"主账户",
"enabled":true
}`)
if strings.Contains(exchangeResp, `"error"`) {
t.Fatalf("failed to create exchange: %s", exchangeResp)
}
strategyResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"趋势策略",
"lang":"zh"
}`)
if strings.Contains(strategyResp, `"error"`) {
t.Fatalf("failed to create strategy: %s", strategyResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "还缺这些信息") || !strings.Contains(resp, "名称") {
t.Fatalf("expected missing-field prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 1, "zh", "叫 波段一号")
if err != nil {
t.Fatalf("thinkAndAct() second turn error = %v", err)
}
if !strings.Contains(resp, "已创建交易员") || !strings.Contains(resp, "波段一号") {
t.Fatalf("expected trader creation confirmation, got %q", resp)
}
listResp := a.toolListTraders("user-1")
if !strings.Contains(listResp, "波段一号") {
t.Fatalf("expected created trader in list, got %s", listResp)
}
}
func TestCreateTraderSkillReportsAllMissingPrerequisitesAtOnce(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 11, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
for _, want := range []string{"名称", "交易所", "模型", "策略"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention %q, got %q", want, resp)
}
}
for _, want := range []string{"当前还没有可用交易所配置", "当前还没有可用模型配置", "当前还没有可用策略"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention prerequisite %q, got %q", want, resp)
}
}
}
func TestActiveSkillSessionYieldsToNewTopic(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"测试策略",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 13, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "还缺这些信息") {
t.Fatalf("expected trader creation flow prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 13, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() interrupt error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "测试策略") {
t.Fatalf("expected new topic to be handled, got %q", resp)
}
if a.hasActiveSkillSession(13) {
t.Fatal("expected skill session to be cleared after interruption")
}
}
func TestCreateTraderSkillRequestsStartConfirmation(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5"
}`)
_ = a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"保守策略",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 2, "zh", "创建一个叫“实盘一号”的交易员并启动")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "高风险动作") || !strings.Contains(resp, "确认") {
t.Fatalf("expected start confirmation prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 2, "zh", "先不用")
if err != nil {
t.Fatalf("thinkAndAct() confirmation error = %v", err)
}
if !strings.Contains(resp, "已创建交易员") || strings.Contains(resp, "已创建并启动") {
t.Fatalf("expected create-without-start response, got %q", resp)
}
}
func TestModelDiagnosisSkillHandledWithoutAIClient(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 3, "zh", "为什么我的模型配置失败了")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "模型配置") {
t.Fatalf("expected model diagnosis response, got %q", resp)
}
}
func TestExchangeDiagnosisSkillHandledWithoutAIClient(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 4, "zh", "交易所 API 报 invalid signature 怎么办")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "invalid signature") && !strings.Contains(resp, "签名") {
t.Fatalf("expected exchange diagnosis response, got %q", resp)
}
}
func TestExchangeManagementCreateAndQuerySkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 5, "zh", "帮我创建一个 OKX 交易所配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建交易所配置") {
t.Fatalf("expected exchange create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 5, "zh", "列出我的交易所配置")
if err != nil {
t.Fatalf("thinkAndAct() query error = %v", err)
}
if !strings.Contains(resp, "当前交易所配置") && !strings.Contains(resp, "Default") {
t.Fatalf("expected exchange query response, got %q", resp)
}
}
func TestModelManagementCreateSkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 6, "zh", "帮我创建一个 DeepSeek 模型配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建模型配置") {
t.Fatalf("expected model create response, got %q", resp)
}
}
func TestStrategyManagementCreateAndActivateSkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 7, "zh", "创建一个叫“趋势策略B”的策略")
if err != nil {
t.Fatalf("thinkAndAct() create error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 7, "zh", "激活趋势策略B")
if err != nil {
t.Fatalf("thinkAndAct() activate error = %v", err)
}
if !strings.Contains(resp, "已激活策略") {
t.Fatalf("expected strategy activate response, got %q", resp)
}
}
func TestStrategyManagementQueryCanExplainStrategyDetails(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 12, "zh", "创建一个叫“激进的”的策略")
if err != nil {
t.Fatalf("thinkAndAct() create error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 12, "zh", "这个策略里面的参数和prompt分别是什么样的")
if err != nil {
t.Fatalf("thinkAndAct() detail query error = %v", err)
}
for _, want := range []string{"策略“激进的”概览", "K线周期", "仓位风险", "Prompt"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention %q, got %q", want, resp)
}
}
}
func TestTraderManagementQueryAndDiagnosisSkill(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"测试策略",
"lang":"zh"
}`)
_ = a.toolManageTrader("user-1", `{
"action":"create",
"name":"测试交易员",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"strategy_id":""
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 8, "zh", "查看我的交易员")
if err != nil {
t.Fatalf("thinkAndAct() query error = %v", err)
}
if !strings.Contains(resp, "当前交易员") && !strings.Contains(resp, "测试交易员") {
t.Fatalf("expected trader query response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 8, "zh", "为什么我的交易员不交易")
if err != nil {
t.Fatalf("thinkAndAct() diagnosis error = %v", err)
}
if !strings.Contains(resp, "交易员运行诊断") {
t.Fatalf("expected trader diagnosis response, got %q", resp)
}
}
func TestExchangeManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"主账户",
"enabled":true
}`)
var created struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 14, "zh", "更新交易所,把主账户改名为备用账户")
if err != nil {
t.Fatalf("rename exchange error = %v", err)
}
if !strings.Contains(resp, "已更新交易所配置") {
t.Fatalf("expected exchange update response, got %q", resp)
}
raw := a.toolGetExchangeConfigs("user-1")
if !strings.Contains(raw, "备用账户") {
t.Fatalf("expected renamed exchange in list, got %s", raw)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 14, "zh", "禁用这个交易所配置")
if err != nil {
t.Fatalf("disable exchange error = %v", err)
}
if !strings.Contains(resp, "已更新交易所配置") {
t.Fatalf("expected exchange status update response, got %q", resp)
}
raw = a.toolGetExchangeConfigs("user-1")
if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, "备用账户") {
t.Fatalf("expected exchange to be disabled, got %s", raw)
}
}
func TestModelManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
var created struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把模型名称改成 deepseek-reasoner")
if err != nil {
t.Fatalf("rename model error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把接口地址改成 https://api.deepseek.com/beta")
if err != nil {
t.Fatalf("update model endpoint error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model endpoint update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "禁用这个模型配置")
if err != nil {
t.Fatalf("disable model error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model status update response, got %q", resp)
}
raw := a.toolGetModelConfigs("user-1")
if !strings.Contains(raw, "deepseek-reasoner") || !strings.Contains(raw, "https://api.deepseek.com/beta") {
t.Fatalf("expected updated model fields, got %s", raw)
}
if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, created.Model.ID) {
t.Fatalf("expected model to be disabled, got %s", raw)
}
}
func TestStrategyManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 16, "zh", "创建一个叫“激进策略C”的策略")
if err != nil {
t.Fatalf("create strategy error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略的prompt,把提示词改成“优先观察BTC和ETH,信号不一致时不要开仓”")
if err != nil {
t.Fatalf("update strategy prompt error = %v", err)
}
if !strings.Contains(resp, "已更新策略 prompt") {
t.Fatalf("expected strategy prompt update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略参数,把最大持仓改成2,最低置信度改成80,主周期改成15m,并使用15m 1h 4h")
if err != nil {
t.Fatalf("update strategy config error = %v", err)
}
if !strings.Contains(resp, "已更新策略参数") {
t.Fatalf("expected strategy config update response, got %q", resp)
}
listRaw := a.toolGetStrategies("user-1")
if !strings.Contains(listRaw, "优先观察BTC和ETH") || !strings.Contains(listRaw, `"max_positions":2`) || !strings.Contains(listRaw, `"min_confidence":80`) || !strings.Contains(listRaw, `"primary_timeframe":"15m"`) {
t.Fatalf("expected updated strategy config, got %s", listRaw)
}
}
func TestTraderManagementAtomicBindingUpdate(t *testing.T) {
a := newTestAgentWithStore(t)
modelOpenAI := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var openAI struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelOpenAI), &openAI); err != nil {
t.Fatalf("unmarshal openai model: %v", err)
}
modelDeepSeek := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
var deepSeek struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelDeepSeek), &deepSeek); err != nil {
t.Fatalf("unmarshal deepseek model: %v", err)
}
exchangeBinance := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Binance 主账户",
"enabled":true
}`)
var binance struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeBinance), &binance); err != nil {
t.Fatalf("unmarshal binance exchange: %v", err)
}
exchangeOKX := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"OKX 主账户",
"enabled":true
}`)
var okx struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeOKX), &okx); err != nil {
t.Fatalf("unmarshal okx exchange: %v", err)
}
strategyA := a.toolManageStrategy("user-1", `{"action":"create","name":"策略A","lang":"zh"}`)
var stA struct {
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(strategyA), &stA); err != nil {
t.Fatalf("unmarshal strategy A: %v", err)
}
strategyB := a.toolManageStrategy("user-1", `{"action":"create","name":"策略B","lang":"zh"}`)
var stB struct {
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(strategyB), &stB); err != nil {
t.Fatalf("unmarshal strategy B: %v", err)
}
createTrader := a.toolManageTrader("user-1", `{
"action":"create",
"name":"实盘一号",
"ai_model_id":"`+openAI.Model.ID+`",
"exchange_id":"`+binance.Exchange.ID+`",
"strategy_id":"`+stA.Strategy.ID+`"
}`)
var trader struct {
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createTrader), &trader); err != nil {
t.Fatalf("unmarshal trader: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 17, "zh", "更新交易员绑定,把实盘一号换成 deepseek-chat、OKX 主账户 和 策略B")
if err != nil {
t.Fatalf("update trader bindings error = %v", err)
}
if !strings.Contains(resp, "已更新交易员绑定") {
t.Fatalf("expected trader binding update response, got %q", resp)
}
listRaw := a.toolListTraders("user-1")
if !strings.Contains(listRaw, deepSeek.Model.ID) || !strings.Contains(listRaw, okx.Exchange.ID) || !strings.Contains(listRaw, stB.Strategy.ID) {
t.Fatalf("expected trader bindings to change, got %s", listRaw)
}
}
func TestStrategyManagementDeleteAllUserStrategies(t *testing.T) {
a := newTestAgentWithStore(t)
for _, name := range []string{"趋势策略A", "趋势策略B"} {
resp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"`+name+`",
"lang":"zh"
}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("failed to create strategy %q: %s", name, resp)
}
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 21, "zh", "现在把所有的策略全部删除")
if err != nil {
t.Fatalf("thinkAndAct() bulk delete start error = %v", err)
}
if !strings.Contains(resp, "确认") || !strings.Contains(resp, "全部自定义策略") {
t.Fatalf("expected bulk delete confirmation, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 21, "zh", "确认")
if err != nil {
t.Fatalf("thinkAndAct() bulk delete confirm error = %v", err)
}
if !strings.Contains(resp, "成功删除 2 个") {
t.Fatalf("expected bulk delete success summary, got %q", resp)
}
listResp := a.toolGetStrategies("user-1")
if strings.Contains(listResp, "趋势策略A") || strings.Contains(listResp, "趋势策略B") {
t.Fatalf("expected created strategies to be deleted, got %s", listResp)
}
}
func TestCreateTraderSkillRejectsDisabledExchangeWithClearPrompt(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
enabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"test",
"enabled":true
}`)
if strings.Contains(enabledExchange, `"error"`) {
t.Fatalf("failed to create enabled exchange: %s", enabledExchange)
}
anotherEnabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"lky",
"enabled":true
}`)
if strings.Contains(anotherEnabledExchange, `"error"`) {
t.Fatalf("failed to create second enabled exchange: %s", anotherEnabledExchange)
}
disabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"new",
"enabled":false
}`)
if strings.Contains(disabledExchange, `"error"`) {
t.Fatalf("failed to create disabled exchange: %s", disabledExchange)
}
_ = a.toolManageStrategy("user-1", `{"action":"create","name":"激进","lang":"zh"}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 24, "zh", "给我创建一个trader")
if err != nil {
t.Fatalf("create trader start error = %v", err)
}
if !strings.Contains(resp, "new(已禁用)") {
t.Fatalf("expected disabled exchange to be labelled, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 24, "zh", "名称叫test,交易所用new、策略用激进")
if err != nil {
t.Fatalf("disabled exchange selection error = %v", err)
}
if !strings.Contains(resp, "当前已禁用") {
t.Fatalf("expected disabled exchange warning, got %q", resp)
}
}
func TestCancelReplyExitsExchangeUpdateFlow(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"test",
"enabled":true
}`)
if strings.Contains(exchangeResp, `"error"`) {
t.Fatalf("failed to create exchange: %s", exchangeResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 25, "zh", "把test这个交易所改一下")
if err != nil {
t.Fatalf("enter exchange update flow error = %v", err)
}
if !strings.Contains(resp, "请告诉我你要改什么") {
t.Fatalf("expected exchange update prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 25, "zh", "不改")
if err != nil {
t.Fatalf("cancel exchange flow error = %v", err)
}
if !strings.Contains(resp, "已取消当前流程") {
t.Fatalf("expected flow cancellation, got %q", resp)
}
}
func TestClassifySkillSessionInputInterruptsOnDeflection(t *testing.T) {
session := skillSession{Name: "exchange_management", Action: "update"}
a := &Agent{}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" {
t.Fatalf("expected diagnosis deflection to interrupt current skill flow, got %q", got)
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "换话题了大哥"); got != "cancel" {
t.Fatalf("expected topic shift to cancel current skill flow, got %q", got)
}
}
type skillSessionClassifierAIClient struct {
lastSystemPrompt string
lastUserPrompt string
response string
}
func (c *skillSessionClassifierAIClient) SetAPIKey(string, string, string) {}
func (c *skillSessionClassifierAIClient) SetTimeout(time.Duration) {}
func (c *skillSessionClassifierAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (c *skillSessionClassifierAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
c.lastSystemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
c.lastUserPrompt = req.Messages[1].Content
}
return c.response, nil
}
func (c *skillSessionClassifierAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (c *skillSessionClassifierAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
func TestClassifySkillSessionInputUsesSlotExpectationWithoutLLM(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`}
a := &Agent{aiClient: client}
session := skillSession{
Name: "strategy_management",
Action: "update_config",
Fields: map[string]string{
skillDAGStepField: "resolve_config_value",
"config_field": "min_confidence",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "70"); got != "continue" {
t.Fatalf("expected numeric slot fill to continue, got %q", got)
}
if client.lastSystemPrompt != "" {
t.Fatalf("expected no LLM call for direct slot expectation, got prompt %q", client.lastSystemPrompt)
}
}
func TestClassifySkillSessionInputUsesLLMOnlyForAmbiguousDeflection(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`}
a := &Agent{
aiClient: client,
history: newChatHistory(10),
}
session := skillSession{
Name: "exchange_management",
Action: "update",
Fields: map[string]string{
skillDAGStepField: "collect_account_name",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" {
t.Fatalf("expected ambiguous deflection to interrupt, got %q", got)
}
if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") {
t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt)
}
}
func TestClassifySkillSessionInputUsesLLMForUnmatchedActiveSessionInput(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"continue"}`}
a := &Agent{
aiClient: client,
history: newChatHistory(10),
}
session := skillSession{
Name: "model_management",
Action: "create",
Fields: map[string]string{
skillDAGStepField: "collect_optional_fields",
"provider": "openai",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "新增一个"); got != "continue" {
t.Fatalf("expected unmatched active-session input to follow LLM decision, got %q", got)
}
if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") {
t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt)
}
}
func TestStrategyManagementCanDescribeDefaultConfig(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 22, "zh", "看一下默认配置")
if err != nil {
t.Fatalf("thinkAndAct() default config error = %v", err)
}
if !strings.Contains(resp, "默认策略模板") || !strings.Contains(resp, "最低置信度") {
t.Fatalf("expected default strategy config response, got %q", resp)
}
}
func TestStrategyManagementSupportsMultiFieldConfigUpdate(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
createResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"趋势策略A",
"lang":"zh"
}`)
if strings.Contains(createResp, `"error"`) {
t.Fatalf("failed to create strategy: %s", createResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 23, "zh", "把趋势策略A的最小置信度改成70,核心指标都全选")
if err != nil {
t.Fatalf("thinkAndAct() multi-field update error = %v", err)
}
if !strings.Contains(resp, "最小置信度") || !strings.Contains(resp, "EMA") {
t.Fatalf("expected multi-field update confirmation, got %q", resp)
}
strategiesRaw := a.toolGetStrategies("user-1")
if !strings.Contains(strategiesRaw, `"min_confidence":70`) ||
!strings.Contains(strategiesRaw, `"enable_ema":true`) ||
!strings.Contains(strategiesRaw, `"enable_macd":true`) ||
!strings.Contains(strategiesRaw, `"enable_rsi":true`) ||
!strings.Contains(strategiesRaw, `"enable_atr":true`) ||
!strings.Contains(strategiesRaw, `"enable_boll":true`) {
t.Fatalf("expected strategy config to include updated confidence and indicators, got %s", strategiesRaw)
}
}
File diff suppressed because it is too large Load Diff
+931
View File
@@ -0,0 +1,931 @@
package agent
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"nofx/store"
)
var urlPattern = regexp.MustCompile(`https://[^\s"'<>]+`)
func detectTraderManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{"交易员", "trader", "agent"}) &&
containsAny(lower, []string{"修改", "编辑", "更新", "改", "改一下", "删除", "删了", "启动", "停止", "查看", "查询", "列出", "rename", "update", "delete", "start", "stop", "list", "show"})
}
func detectExchangeManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{"交易所", "exchange", "okx", "binance", "bybit", "gate", "kucoin", "hyperliquid"}) &&
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "删除", "删了", "查询", "查看", "列出", "启用", "禁用", "改名", "rename", "create", "update", "delete", "list", "show", "enable", "disable"})
}
func detectModelManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{"模型", "model", "provider", "deepseek", "openai", "claude", "gemini", "qwen", "kimi", "grok", "minimax"}) &&
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "删除", "删了", "查询", "查看", "列出", "启用", "禁用", "改名", "rename", "create", "update", "delete", "list", "show", "enable", "disable"})
}
func detectStrategyManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
if wantsDefaultStrategyConfig(text) {
return true
}
return containsAny(lower, []string{"策略", "strategy"}) &&
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "改成", "改为", "删除", "删了", "查询", "查看", "列出", "激活", "复制", "参数", "配置", "详情", "详细", "prompt", "提示词", "什么样", "怎么样", "create", "update", "delete", "list", "show", "activate", "duplicate", "detail", "details", "config", "configuration", "parameter", "prompt", "what kind"})
}
func detectTraderDiagnosisSkill(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
return containsAny(lower, []string{"交易员", "trader"}) &&
containsAny(lower, []string{"启动失败", "不交易", "没开仓", "无法启动", "异常", "失败", "diagnose", "error", "not trading"})
}
func detectStrategyDiagnosisSkill(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
return containsAny(lower, []string{"策略", "strategy", "prompt"}) &&
containsAny(lower, []string{"不生效", "没生效", "异常", "失败", "不一致", "失效", "diagnose", "error"})
}
func detectManagementAction(text string, domain string) string {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return ""
}
hasUpdateVerb := containsAny(lower, []string{"修改", "编辑", "更新", "改", "rename", "update", "切换", "换成", "换到"})
switch {
case containsAny(lower, []string{"删除", "删掉", "删了", "remove", "delete"}):
return "delete"
case containsAny(lower, []string{"启动", "开始", "run", "start"}) && domain == "trader":
return "start"
case containsAny(lower, []string{"停止", "停掉", "stop", "pause"}) && domain == "trader":
return "stop"
case containsAny(lower, []string{"激活", "activate"}) && domain == "strategy":
return "activate"
case containsAny(lower, []string{"复制", "duplicate"}) && domain == "strategy":
return "duplicate"
case containsAny(lower, []string{"改名", "重命名", "rename"}):
return "update_name"
case domain == "trader" && containsAny(lower, []string{"换模型", "换交易所", "换策略", "绑定", "切换模型", "切换交易所", "切换策略"}):
return "update_bindings"
case (domain == "exchange" || domain == "model") && containsAny(lower, []string{"启用", "禁用", "enable", "disable"}):
return "update_status"
case domain == "model" && hasUpdateVerb && containsAny(lower, []string{"url", "endpoint", "地址", "接口"}):
return "update_endpoint"
case domain == "strategy" && hasUpdateVerb && containsAny(lower, []string{"prompt", "提示词"}):
return "update_prompt"
case domain == "strategy" && hasUpdateVerb && containsAny(lower, []string{
"参数", "配置", "config", "configuration", "parameter",
"最大持仓", "最小置信度", "最低置信度", "主周期", "多周期", "时间框架",
"btc/eth杠杆", "btc eth杠杆", "山寨币杠杆",
"核心指标", "ema", "macd", "rsi", "atr", "boll", "bollinger", "布林",
}):
return "update_config"
case containsAny(lower, []string{"修改", "编辑", "更新", "改", "rename", "update"}):
return "update"
case domain == "trader" && containsAny(lower, []string{"运行中的", "在跑", "running"}):
return "query_running"
case !containsAny(lower, []string{"创建", "新建", "create", "new"}) &&
containsAny(lower, []string{"详情", "详细", "prompt", "提示词", "什么样", "怎么样", "detail", "details", "what kind"}):
return "query_detail"
case containsAny(lower, []string{"查询", "查看", "列出", "list", "show", "有哪些"}):
return "query_list"
case containsAny(lower, []string{"创建", "新建", "加一个", "create", "new"}):
return "create"
default:
return ""
}
}
func exchangeTypeFromText(text string) string {
lower := strings.ToLower(text)
candidates := []string{"binance", "okx", "bybit", "gate", "kucoin", "hyperliquid", "aster", "lighter"}
for _, candidate := range candidates {
if strings.Contains(lower, candidate) {
return candidate
}
}
switch {
case strings.Contains(text, "币安"):
return "binance"
case strings.Contains(text, "欧易"):
return "okx"
case strings.Contains(text, "库币"):
return "kucoin"
default:
return ""
}
}
func providerFromText(text string) string {
lower := strings.ToLower(text)
candidates := []string{"openai", "deepseek", "claude", "gemini", "qwen", "kimi", "grok", "minimax"}
for _, candidate := range candidates {
if strings.Contains(lower, candidate) {
return candidate
}
}
if strings.Contains(text, "通义") {
return "qwen"
}
return ""
}
func extractURL(text string) string {
return strings.TrimSpace(urlPattern.FindString(text))
}
func extractPostKeywordName(text string, keywords []string) string {
trimmed := strings.TrimSpace(text)
for _, keyword := range keywords {
if idx := strings.Index(trimmed, keyword); idx >= 0 {
name := strings.TrimSpace(trimmed[idx+len(keyword):])
name = strings.Trim(name, "“”\"': ")
if name != "" && len([]rune(name)) <= 50 {
return name
}
}
}
return ""
}
func setField(session *skillSession, key, value string) {
ensureSkillFields(session)
value = strings.TrimSpace(value)
if value == "" {
return
}
session.Fields[key] = value
}
func fieldValue(session skillSession, key string) string {
if session.Fields == nil {
return ""
}
return strings.TrimSpace(session.Fields[key])
}
func textMeansAllTargets(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{
"全部", "所有", "全都", "全部策略", "所有策略",
"all", "all strategies", "every strategy",
})
}
func supportsBulkTargetSelection(skillName, action string) bool {
return skillName == "strategy_management" && action == "delete"
}
func resolveTargetFromText(text string, options []traderSkillOption, existing *EntityReference) *EntityReference {
if existing != nil && (existing.ID != "" || existing.Name != "") {
return existing
}
if match := pickMentionedOption(text, options); match != nil {
return &EntityReference{ID: match.ID, Name: match.Name}
}
if choice := choosePreferredOption(options); choice != nil {
return &EntityReference{ID: choice.ID, Name: choice.Name}
}
return nil
}
func (a *Agent) handleTraderManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "trader")
if session.Name == "trader_management" && session.Action != "" {
action = session.Action
}
if action == "" || action == "create" {
return "", false
}
if action == "query_running" {
answer := formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID))
return applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only"), true
}
if action == "query_detail" {
options := a.loadTraderOptions(storeUserID)
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeTrader(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID)), true
}
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "trader_management", action, a.loadTraderOptions(storeUserID))
}
func (a *Agent) handleExchangeManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "exchange")
if session.Name == "exchange_management" && session.Action != "" {
action = session.Action
}
if action == "" {
return "", false
}
options := a.loadExchangeOptions(storeUserID)
switch action {
case "query_list":
return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true
case "query_detail":
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeExchange(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true
case "create":
return a.handleExchangeCreateSkill(storeUserID, userID, lang, text, session), true
default:
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "exchange_management", action, options)
}
}
func (a *Agent) handleModelManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "model")
if session.Name == "model_management" && session.Action != "" {
action = session.Action
}
if action == "" {
return "", false
}
options := a.loadEnabledModelOptions(storeUserID)
switch action {
case "query_list":
return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true
case "query_detail":
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeModel(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true
case "create":
return a.handleModelCreateSkill(storeUserID, userID, lang, text, session), true
default:
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "model_management", action, options)
}
}
func (a *Agent) handleStrategyManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "strategy")
if session.Name == "strategy_management" && session.Action != "" {
action = session.Action
}
if action == "" && wantsStrategyDetails(text) {
action = "query_detail"
}
if action == "" {
return "", false
}
options := a.loadStrategyOptions(storeUserID)
switch action {
case "query_detail":
if wantsDefaultStrategyConfig(text) {
return a.describeDefaultStrategyConfig(lang), true
}
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeStrategy(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true
case "query_list":
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true
case "create":
return a.handleStrategyCreateSkill(storeUserID, userID, lang, text, session), true
default:
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "strategy_management", action, options)
}
}
func wantsStrategyDetails(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{
"什么样", "怎么样", "详情", "详细", "参数", "配置", "prompt", "提示词",
"what kind", "details", "detail", "config", "configuration", "parameter", "prompt",
})
}
func wantsDefaultStrategyConfig(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{
"默认配置", "默认策略", "默认模板", "模板配置",
"default config", "default strategy", "default template",
})
}
func (a *Agent) describeStrategy(storeUserID, lang string, target *EntityReference) (string, bool) {
if a.store == nil {
return "", false
}
var strategy *store.Strategy
var err error
if target != nil && strings.TrimSpace(target.ID) != "" {
strategy, err = a.store.Strategy().Get(storeUserID, strings.TrimSpace(target.ID))
} else if target != nil && strings.TrimSpace(target.Name) != "" {
strategies, listErr := a.store.Strategy().List(storeUserID)
if listErr != nil {
return "", false
}
for _, item := range strategies {
if item != nil && strings.EqualFold(strings.TrimSpace(item.Name), strings.TrimSpace(target.Name)) {
strategy = item
break
}
}
} else {
strategies, listErr := a.store.Strategy().List(storeUserID)
if listErr != nil || len(strategies) != 1 {
return "", false
}
strategy = strategies[0]
}
if err != nil || strategy == nil {
return "", false
}
var cfg store.StrategyConfig
if strings.TrimSpace(strategy.Config) != "" {
_ = json.Unmarshal([]byte(strategy.Config), &cfg)
}
return formatStrategyDetailResponse(lang, strategy, cfg), true
}
func formatStrategyDetailResponse(lang string, strategy *store.Strategy, cfg store.StrategyConfig) string {
name := strings.TrimSpace(strategy.Name)
if name == "" {
name = strings.TrimSpace(strategy.ID)
}
sourceBits := make([]string, 0, 4)
if strings.TrimSpace(cfg.CoinSource.SourceType) != "" {
sourceBits = append(sourceBits, cfg.CoinSource.SourceType)
}
if cfg.CoinSource.UseAI500 {
sourceBits = append(sourceBits, fmt.Sprintf("AI500=%d", cfg.CoinSource.AI500Limit))
}
if cfg.CoinSource.UseOITop {
sourceBits = append(sourceBits, fmt.Sprintf("OITop=%d", cfg.CoinSource.OITopLimit))
}
if cfg.CoinSource.UseOILow {
sourceBits = append(sourceBits, fmt.Sprintf("OILow=%d", cfg.CoinSource.OILowLimit))
}
if len(cfg.CoinSource.StaticCoins) > 0 {
sourceBits = append(sourceBits, "static="+strings.Join(cfg.CoinSource.StaticCoins, ","))
}
timeframes := append([]string(nil), cfg.Indicators.Klines.SelectedTimeframes...)
if len(timeframes) == 0 {
timeframes = cleanStringList([]string{cfg.Indicators.Klines.PrimaryTimeframe, cfg.Indicators.Klines.LongerTimeframe})
}
indicatorBits := make([]string, 0, 8)
if cfg.Indicators.EnableRawKlines {
indicatorBits = append(indicatorBits, "raw_klines")
}
if cfg.Indicators.EnableVolume {
indicatorBits = append(indicatorBits, "volume")
}
if cfg.Indicators.EnableOI {
indicatorBits = append(indicatorBits, "oi")
}
if cfg.Indicators.EnableFundingRate {
indicatorBits = append(indicatorBits, "funding_rate")
}
if cfg.Indicators.EnableEMA {
indicatorBits = append(indicatorBits, "ema")
}
if cfg.Indicators.EnableMACD {
indicatorBits = append(indicatorBits, "macd")
}
if cfg.Indicators.EnableRSI {
indicatorBits = append(indicatorBits, "rsi")
}
if cfg.Indicators.EnableATR {
indicatorBits = append(indicatorBits, "atr")
}
if cfg.Indicators.EnableBOLL {
indicatorBits = append(indicatorBits, "boll")
}
sort.Strings(indicatorBits)
promptBits := make([]string, 0, 5)
if strings.TrimSpace(cfg.PromptSections.RoleDefinition) != "" {
promptBits = append(promptBits, "role_definition")
}
if strings.TrimSpace(cfg.PromptSections.TradingFrequency) != "" {
promptBits = append(promptBits, "trading_frequency")
}
if strings.TrimSpace(cfg.PromptSections.EntryStandards) != "" {
promptBits = append(promptBits, "entry_standards")
}
if strings.TrimSpace(cfg.PromptSections.DecisionProcess) != "" {
promptBits = append(promptBits, "decision_process")
}
customPrompt := strings.TrimSpace(cfg.CustomPrompt)
customPromptPreview := customPrompt
if len([]rune(customPromptPreview)) > 120 {
runes := []rune(customPromptPreview)
customPromptPreview = string(runes[:120]) + "..."
}
if lang == "zh" {
lines := []string{
fmt.Sprintf("策略“%s”概览:", name),
fmt.Sprintf("- 类型:%s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")),
fmt.Sprintf("- 语言:%s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "zh")),
}
if strings.TrimSpace(strategy.Description) != "" {
lines = append(lines, fmt.Sprintf("- 描述:%s", strings.TrimSpace(strategy.Description)))
}
if len(sourceBits) > 0 {
lines = append(lines, "- 标的来源:"+strings.Join(sourceBits, " | "))
}
if len(timeframes) > 0 {
lines = append(lines, "- K线周期:"+strings.Join(timeframes, " / "))
}
lines = append(lines, fmt.Sprintf("- 仓位风险:最多持仓 %d,BTC/ETH 最大杠杆 %d,山寨最大杠杆 %d,最低置信度 %d",
cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence))
if len(indicatorBits) > 0 {
lines = append(lines, "- 已启用指标:"+strings.Join(indicatorBits, "、"))
}
if len(promptBits) > 0 {
lines = append(lines, "- Prompt 模块:"+strings.Join(promptBits, "、"))
}
if customPromptPreview != "" {
lines = append(lines, "- 自定义 Prompt"+customPromptPreview)
} else {
lines = append(lines, "- 自定义 Prompt:当前为空,主要使用策略模板内置 prompt sections。")
}
lines = append(lines, "- 如果你要,我还可以继续展开这条策略的完整参数 JSON,或者逐段解释它的 prompt。")
return strings.Join(lines, "\n")
}
lines := []string{
fmt.Sprintf("Strategy %q overview:", name),
fmt.Sprintf("- Type: %s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")),
fmt.Sprintf("- Language: %s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "en")),
}
if strings.TrimSpace(strategy.Description) != "" {
lines = append(lines, fmt.Sprintf("- Description: %s", strings.TrimSpace(strategy.Description)))
}
if len(sourceBits) > 0 {
lines = append(lines, "- Coin source: "+strings.Join(sourceBits, " | "))
}
if len(timeframes) > 0 {
lines = append(lines, "- Timeframes: "+strings.Join(timeframes, " / "))
}
lines = append(lines, fmt.Sprintf("- Risk: max positions %d, BTC/ETH max leverage %d, alt max leverage %d, min confidence %d",
cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence))
if len(indicatorBits) > 0 {
lines = append(lines, "- Enabled indicators: "+strings.Join(indicatorBits, ", "))
}
if len(promptBits) > 0 {
lines = append(lines, "- Prompt modules: "+strings.Join(promptBits, ", "))
}
if customPromptPreview != "" {
lines = append(lines, "- Custom prompt: "+customPromptPreview)
} else {
lines = append(lines, "- Custom prompt: empty right now; it mainly uses the built-in prompt sections from the strategy template.")
}
lines = append(lines, "- I can also expand the full strategy config JSON or walk through the prompt section by section.")
return strings.Join(lines, "\n")
}
func (a *Agent) describeDefaultStrategyConfig(lang string) string {
if lang != "zh" {
lang = "en"
}
cfg := store.GetDefaultStrategyConfig(lang)
name := "Default Strategy Template"
description := "System default strategy configuration template"
if lang == "zh" {
name = "默认策略模板"
description = "系统默认策略配置模板"
}
return formatStrategyDetailResponse(lang, &store.Strategy{
ID: "default_strategy_template",
Name: name,
Description: description,
}, cfg)
}
func (a *Agent) describeTrader(storeUserID, lang string, target *EntityReference) (string, bool) {
raw := a.toolListTraders(storeUserID)
var payload struct {
Traders []safeTraderToolConfig `json:"traders"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "", false
}
trader := findTraderByReference(payload.Traders, target)
if trader == nil {
if len(payload.Traders) != 1 {
return "", false
}
trader = &payload.Traders[0]
}
if lang == "zh" {
status := "未运行"
if trader.IsRunning {
status = "运行中"
}
return fmt.Sprintf("交易员“%s”详情:\n- 状态:%s\n- 模型:%s\n- 交易所:%s\n- 策略:%s\n- 扫描间隔:%d 分钟\n- 初始余额:%.2f",
trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "未绑定"), trader.ScanIntervalMinutes, trader.InitialBalance), true
}
status := "stopped"
if trader.IsRunning {
status = "running"
}
return fmt.Sprintf("Trader %q details:\n- Status: %s\n- Model: %s\n- Exchange: %s\n- Strategy: %s\n- Scan interval: %d minutes\n- Initial balance: %.2f",
trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "none"), trader.ScanIntervalMinutes, trader.InitialBalance), true
}
func (a *Agent) describeExchange(storeUserID, lang string, target *EntityReference) (string, bool) {
raw := a.toolGetExchangeConfigs(storeUserID)
var payload struct {
ExchangeConfigs []safeExchangeToolConfig `json:"exchange_configs"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "", false
}
exchange := findExchangeByReference(payload.ExchangeConfigs, target)
if exchange == nil {
if len(payload.ExchangeConfigs) != 1 {
return "", false
}
exchange = &payload.ExchangeConfigs[0]
}
if lang == "zh" {
return fmt.Sprintf("交易所配置“%s”详情:\n- 交易所:%s\n- 已启用:%t\n- API Key%t\n- Secret%t\n- Passphrase%t\n- Testnet%t",
defaultIfEmpty(exchange.AccountName, exchange.ID), exchange.ExchangeType, exchange.Enabled, exchange.HasAPIKey, exchange.HasSecretKey, exchange.HasPassphrase, exchange.Testnet), true
}
return fmt.Sprintf("Exchange config %q details:\n- Exchange: %s\n- Enabled: %t\n- API key present: %t\n- Secret present: %t\n- Passphrase present: %t\n- Testnet: %t",
defaultIfEmpty(exchange.AccountName, exchange.ID), exchange.ExchangeType, exchange.Enabled, exchange.HasAPIKey, exchange.HasSecretKey, exchange.HasPassphrase, exchange.Testnet), true
}
func (a *Agent) describeModel(storeUserID, lang string, target *EntityReference) (string, bool) {
raw := a.toolGetModelConfigs(storeUserID)
var payload struct {
ModelConfigs []safeModelToolConfig `json:"model_configs"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "", false
}
model := findModelByReference(payload.ModelConfigs, target)
if model == nil {
if len(payload.ModelConfigs) != 1 {
return "", false
}
model = &payload.ModelConfigs[0]
}
if lang == "zh" {
return fmt.Sprintf("模型配置“%s”详情:\n- Provider%s\n- 已启用:%t\n- API Key%t\n- URL%s\n- Model Name%s",
defaultIfEmpty(model.Name, model.ID), model.Provider, model.Enabled, model.HasAPIKey, defaultIfEmpty(model.CustomAPIURL, "未设置"), defaultIfEmpty(model.CustomModelName, "未设置")), true
}
return fmt.Sprintf("Model config %q details:\n- Provider: %s\n- Enabled: %t\n- API key present: %t\n- URL: %s\n- Model name: %s",
defaultIfEmpty(model.Name, model.ID), model.Provider, model.Enabled, model.HasAPIKey, defaultIfEmpty(model.CustomAPIURL, "not set"), defaultIfEmpty(model.CustomModelName, "not set")), true
}
func findTraderByReference(items []safeTraderToolConfig, target *EntityReference) *safeTraderToolConfig {
if target == nil {
return nil
}
for i := range items {
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
return &items[i]
}
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) {
return &items[i]
}
}
return nil
}
func findExchangeByReference(items []safeExchangeToolConfig, target *EntityReference) *safeExchangeToolConfig {
if target == nil {
return nil
}
for i := range items {
name := defaultIfEmpty(items[i].AccountName, items[i].Name)
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
return &items[i]
}
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(target.Name)) {
return &items[i]
}
}
return nil
}
func findModelByReference(items []safeModelToolConfig, target *EntityReference) *safeModelToolConfig {
if target == nil {
return nil
}
for i := range items {
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
return &items[i]
}
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) {
return &items[i]
}
}
return nil
}
func (a *Agent) loadTraderOptions(storeUserID string) []traderSkillOption {
if a.store == nil {
return nil
}
traders, err := a.store.Trader().List(storeUserID)
if err != nil {
return nil
}
out := make([]traderSkillOption, 0, len(traders))
for _, trader := range traders {
out = append(out, traderSkillOption{ID: trader.ID, Name: trader.Name, Enabled: trader.IsRunning})
}
return out
}
func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.Name == "" {
session = skillSession{Name: "exchange_management", Action: "create", Phase: "collecting"}
}
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "resolve_exchange_type")
}
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前创建交易所配置流程。"
}
return "Cancelled the current exchange creation flow."
}
if v := exchangeTypeFromText(text); fieldValue(session, "exchange_type") == "" && v != "" {
setField(&session, "exchange_type", v)
}
if v := extractTraderName(text); fieldValue(session, "account_name") == "" && v != "" {
setField(&session, "account_name", v)
}
exType := fieldValue(session, "exchange_type")
if actionRequiresSlot("exchange_management", "create", "exchange_type") && exType == "" {
setSkillDAGStep(&session, "resolve_exchange_type")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "要创建交易所配置,我还需要:" + slotDisplayName("exchange_type", lang) + "。例如:OKX、Binance、Bybit。"
}
return "To create an exchange config, tell me which exchange to use, for example OKX, Binance, or Bybit."
}
accountName := fieldValue(session, "account_name")
if accountName == "" {
accountName = "Default"
}
setSkillDAGStep(&session, "execute_create")
args := map[string]any{
"action": "create",
"exchange_type": exType,
"account_name": accountName,
}
raw, _ := json.Marshal(args)
resp := a.toolManageExchangeConfig(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "创建交易所配置失败:" + errMsg
}
return "Failed to create exchange config: " + errMsg
}
a.clearSkillSession(userID)
if lang == "zh" {
return fmt.Sprintf("已创建交易所配置:%s(%s)。如需继续补 API Key、Secret 或 Passphrase,可以直接继续说。", accountName, exType)
}
return fmt.Sprintf("Created exchange config %s (%s). You can continue by adding API key, secret, or passphrase.", accountName, exType)
}
func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.Name == "" {
session = skillSession{Name: "model_management", Action: "create", Phase: "collecting"}
}
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "resolve_provider")
}
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前创建模型配置流程。"
}
return "Cancelled the current model creation flow."
}
if v := providerFromText(text); fieldValue(session, "provider") == "" && v != "" {
setField(&session, "provider", v)
}
if v := extractTraderName(text); fieldValue(session, "name") == "" && v != "" {
setField(&session, "name", v)
}
if v := extractURL(text); fieldValue(session, "custom_api_url") == "" && v != "" {
setField(&session, "custom_api_url", v)
}
provider := fieldValue(session, "provider")
if actionRequiresSlot("model_management", "create", "provider") && provider == "" {
setSkillDAGStep(&session, "resolve_provider")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "要创建模型配置,我还需要:" + slotDisplayName("provider", lang) + ",例如:OpenAI、DeepSeek、Claude、Gemini。"
}
return "To create a model config, I need the provider first, for example OpenAI, DeepSeek, Claude, or Gemini."
}
setSkillDAGStep(&session, "execute_create")
args := map[string]any{
"action": "create",
"provider": provider,
"name": defaultIfEmpty(fieldValue(session, "name"), provider),
"custom_api_url": fieldValue(session, "custom_api_url"),
"custom_model_name": fieldValue(session, "custom_model_name"),
}
raw, _ := json.Marshal(args)
resp := a.toolManageModelConfig(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "创建模型配置失败:" + errMsg
}
return "Failed to create model config: " + errMsg
}
a.clearSkillSession(userID)
if lang == "zh" {
return fmt.Sprintf("已创建模型配置:%s。你后续还可以继续补 API Key、URL 或模型名。", provider)
}
return fmt.Sprintf("Created model config for %s. You can continue by adding API key, URL, or model name.", provider)
}
func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.Name == "" {
session = skillSession{Name: "strategy_management", Action: "create", Phase: "collecting"}
}
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "resolve_name")
}
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前创建策略流程。"
}
return "Cancelled the current strategy creation flow."
}
name := fieldValue(session, "name")
if name == "" {
name = extractTraderName(text)
if name == "" {
name = extractPostKeywordName(text, []string{"叫", "名为", "策略叫", "strategy called"})
}
if name != "" {
setField(&session, "name", name)
}
}
if actionRequiresSlot("strategy_management", "create", "name") && name == "" {
setSkillDAGStep(&session, "resolve_name")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "要创建策略,我还需要:" + slotDisplayName("name", lang) + "。你可以直接说:创建一个叫“趋势策略A”的策略。"
}
return "To create a strategy, I need a strategy name. You can say: create a strategy called 'Trend A'."
}
setSkillDAGStep(&session, "execute_create")
args := map[string]any{"action": "create", "name": name, "lang": "zh"}
raw, _ := json.Marshal(args)
resp := a.toolManageStrategy(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "创建策略失败:" + errMsg
}
return "Failed to create strategy: " + errMsg
}
a.clearSkillSession(userID)
if lang == "zh" {
return fmt.Sprintf("已创建策略“%s”。默认配置已就绪,你后续可以继续让我帮你改细节。", name)
}
return fmt.Sprintf("Created strategy %q with the default configuration.", name)
}
func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, options []traderSkillOption) (string, bool) {
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前流程。", true
}
return "Cancelled the current flow.", true
}
if session.Name == "" {
session = skillSession{Name: skillName, Action: action, Phase: "collecting"}
}
if session.Name != skillName || session.Action != action {
return "", false
}
if dag, ok := getSkillDAG(skillName, action); ok && len(dag.Steps) > 0 {
currentStep, _ := currentSkillDAGStep(session)
if currentStep.ID == "resolve_target" {
if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) {
setField(&session, "bulk_scope", "all")
advanceSkillDAGStep(&session, currentStep.ID)
} else {
session.TargetRef = resolveTargetFromText(text, options, session.TargetRef)
}
if session.TargetRef == nil {
if !(supportsBulkTargetSelection(skillName, action) && fieldValue(session, "bulk_scope") == "all") {
setSkillDAGStep(&session, "resolve_target")
a.saveSkillSession(userID, session)
label := "可选对象:"
if lang != "zh" {
label = "Available targets:"
}
optionList := formatOptionList(label, options)
if lang == "zh" {
reply := "当前这一步需要先确定目标对象。请告诉我你要操作哪一个。"
if optionList != "" {
reply += "\n" + optionList
}
return reply, true
}
reply := "This step needs a target object first. Tell me which one to operate on."
if optionList != "" {
reply += "\n" + optionList
}
return reply, true
}
}
if fieldValue(session, skillDAGStepField) == currentStep.ID {
advanceSkillDAGStep(&session, currentStep.ID)
}
}
} else {
if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) {
setField(&session, "bulk_scope", "all")
} else {
session.TargetRef = resolveTargetFromText(text, options, session.TargetRef)
}
if session.TargetRef == nil && fieldValue(session, "bulk_scope") != "all" && action != "query" && action != "query_list" && action != "query_detail" && action != "query_running" {
a.saveSkillSession(userID, session)
label := formatOptionList("可选对象:", options)
if lang == "zh" {
reply := "我还需要你明确要操作的是哪一个对象。"
if label != "" {
reply += "\n" + label
}
return reply, true
}
reply := "I still need you to specify which object to operate on."
if label != "" {
reply += "\n" + label
}
return reply, true
}
}
switch skillName {
case "trader_management":
return a.executeTraderManagementAction(storeUserID, userID, lang, text, session), true
case "exchange_management":
return a.executeExchangeManagementAction(storeUserID, userID, lang, text, session), true
case "model_management":
return a.executeModelManagementAction(storeUserID, userID, lang, text, session), true
case "strategy_management":
return a.executeStrategyManagementAction(storeUserID, userID, lang, text, session), true
default:
return "", false
}
}
func defaultIfEmpty(value, fallback string) string {
value = strings.TrimSpace(value)
if value == "" {
return strings.TrimSpace(fallback)
}
return value
}
+180
View File
@@ -0,0 +1,180 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
const (
skillOutcomeSuccess = "success"
skillOutcomeNeedMoreInfo = "need_more_info"
skillOutcomeRecoverableError = "recoverable_error"
skillOutcomeFatalError = "fatal_error"
skillOutcomeNotHandled = "not_handled"
)
type skillOutcome struct {
Skill string `json:"skill"`
Action string `json:"action"`
Status string `json:"status"`
GoalAchieved bool `json:"goal_achieved"`
UserMessage string `json:"user_message,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
Error string `json:"error,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
type taskReviewDecision struct {
Route string `json:"route"`
Answer string `json:"answer,omitempty"`
}
func normalizeAtomicSkillAction(skill, action string) string {
action = strings.TrimSpace(strings.ToLower(action))
switch skill {
case "trader_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_running":
return "query_running"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_bindings":
return action
}
case "exchange_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_status":
return action
}
case "model_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_endpoint", "update_status":
return action
}
case "strategy_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_config", "update_prompt":
return action
}
}
return action
}
func inferSkillOutcome(skill, action, answer string, activeSession skillSession, data map[string]any) skillOutcome {
outcome := skillOutcome{
Skill: skill,
Action: action,
Status: skillOutcomeSuccess,
UserMessage: strings.TrimSpace(answer),
Data: data,
}
if activeSession.Name != "" {
outcome.Status = skillOutcomeNeedMoreInfo
outcome.GoalAchieved = false
return outcome
}
lower := strings.ToLower(strings.TrimSpace(answer))
switch {
case lower == "":
outcome.Status = skillOutcomeNotHandled
case strings.Contains(lower, "失败") || strings.Contains(lower, "failed") || strings.Contains(lower, "error"):
outcome.Status = skillOutcomeRecoverableError
outcome.Error = strings.TrimSpace(answer)
default:
outcome.GoalAchieved = true
}
return outcome
}
func parseTaskReviewDecision(raw string) (taskReviewDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision taskReviewDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Answer = strings.TrimSpace(decision.Answer)
return decision, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Answer = strings.TrimSpace(decision.Answer)
return decision, nil
}
}
return taskReviewDecision{}, fmt.Errorf("invalid task review json")
}
func (a *Agent) reviewTaskCompletion(ctx context.Context, userID int64, lang, text string, outcome skillOutcome) (taskReviewDecision, error) {
if a.aiClient == nil {
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
return taskReviewDecision{Route: "replan"}, nil
}
return taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}, nil
}
recentConversationCtx := a.buildRecentConversationContext(userID, text)
outcomeJSON, _ := json.Marshal(outcome)
systemPrompt := `You are the task-level Plan-Execute-Review supervisor for NOFXi.
You are reviewing the JSON result returned by one structured skill execution.
Return JSON only. Do not return markdown.
Rules:
- Decide whether the OVERALL user task is finished, not whether the skill itself ran successfully.
- Use route "complete" only when the user's task is now complete or the best next message is a final user-facing reply.
- Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome.
- Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help.
- If you choose "complete", produce the final user-facing answer in the user's language.
Return JSON with this exact shape:
{"route":"complete|replan","answer":""}`
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nSkill outcome JSON:\n%s", lang, text, recentConversationCtx, string(outcomeJSON))
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return taskReviewDecision{}, err
}
return parseTaskReviewDecision(raw)
}
+119
View File
@@ -0,0 +1,119 @@
package agent
import (
"embed"
"encoding/json"
"fmt"
"sort"
"strings"
)
//go:embed skills/*.json
var embeddedSkillDefinitions embed.FS
type SkillDefinition struct {
Name string `json:"name"`
Kind string `json:"kind"`
Domain string `json:"domain"`
Description string `json:"description"`
Intents []string `json:"intents,omitempty"`
Actions map[string]SkillActionDefinition `json:"actions,omitempty"`
ToolMapping map[string]string `json:"tool_mapping,omitempty"`
}
type SkillActionDefinition struct {
Description string `json:"description,omitempty"`
RequiredSlots []string `json:"required_slots,omitempty"`
OptionalSlots []string `json:"optional_slots,omitempty"`
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
}
var skillRegistry = mustLoadSkillRegistry()
func mustLoadSkillRegistry() map[string]SkillDefinition {
registry, err := loadSkillRegistry()
if err != nil {
panic(err)
}
return registry
}
func loadSkillRegistry() (map[string]SkillDefinition, error) {
entries, err := embeddedSkillDefinitions.ReadDir("skills")
if err != nil {
return nil, err
}
registry := make(map[string]SkillDefinition, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
raw, err := embeddedSkillDefinitions.ReadFile("skills/" + entry.Name())
if err != nil {
return nil, err
}
var def SkillDefinition
if err := json.Unmarshal(raw, &def); err != nil {
return nil, fmt.Errorf("parse skill definition %s: %w", entry.Name(), err)
}
def = normalizeSkillDefinition(def)
if def.Name == "" {
return nil, fmt.Errorf("skill definition %s has empty name", entry.Name())
}
registry[def.Name] = def
}
return registry, nil
}
func normalizeSkillDefinition(def SkillDefinition) SkillDefinition {
def.Name = strings.TrimSpace(def.Name)
def.Kind = strings.TrimSpace(def.Kind)
def.Domain = strings.TrimSpace(def.Domain)
def.Description = strings.TrimSpace(def.Description)
def.Intents = cleanStringList(def.Intents)
if len(def.Actions) > 0 {
normalized := make(map[string]SkillActionDefinition, len(def.Actions))
for key, action := range def.Actions {
key = strings.TrimSpace(key)
if key == "" {
continue
}
action.Description = strings.TrimSpace(action.Description)
action.RequiredSlots = cleanStringList(action.RequiredSlots)
action.OptionalSlots = cleanStringList(action.OptionalSlots)
normalized[key] = action
}
def.Actions = normalized
}
if len(def.ToolMapping) > 0 {
normalized := make(map[string]string, len(def.ToolMapping))
for key, value := range def.ToolMapping {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
normalized[key] = value
}
def.ToolMapping = normalized
}
return def
}
func getSkillDefinition(name string) (SkillDefinition, bool) {
def, ok := skillRegistry[strings.TrimSpace(name)]
return def, ok
}
func listSkillNames() []string {
names := make([]string, 0, len(skillRegistry))
for name := range skillRegistry {
names = append(names, name)
}
sort.Strings(names)
return names
}
+55
View File
@@ -0,0 +1,55 @@
package agent
import "testing"
func TestSkillRegistryLoadsDefinitions(t *testing.T) {
names := listSkillNames()
if len(names) < 4 {
t.Fatalf("expected skill registry to load definitions, got %v", names)
}
for _, name := range []string{
"trader_management",
"exchange_management",
"model_management",
"strategy_management",
"exchange_diagnosis",
"model_diagnosis",
} {
if _, ok := getSkillDefinition(name); !ok {
t.Fatalf("missing skill definition %q", name)
}
}
}
func TestTraderManagementDefinitionHasCreateAction(t *testing.T) {
def, ok := getSkillDefinition("trader_management")
if !ok {
t.Fatalf("missing trader_management definition")
}
action, ok := def.Actions["create"]
if !ok {
t.Fatalf("missing create action in trader_management")
}
if len(action.RequiredSlots) == 0 {
t.Fatalf("expected required slots for trader_management create action")
}
}
func TestActionNeedsConfirmationUsesSkillDefinition(t *testing.T) {
if !actionNeedsConfirmation("exchange_management", "delete") {
t.Fatalf("expected exchange_management delete to require confirmation")
}
if actionNeedsConfirmation("exchange_management", "query") {
t.Fatalf("did not expect exchange_management query to require confirmation")
}
}
func TestActionRequiresSlotUsesSkillDefinition(t *testing.T) {
if !actionRequiresSlot("model_management", "create", "provider") {
t.Fatalf("expected model_management create to require provider")
}
if actionRequiresSlot("model_management", "create", "target_ref") {
t.Fatalf("did not expect model_management create to require target_ref")
}
}
+144
View File
@@ -0,0 +1,144 @@
package agent
import (
"fmt"
"strings"
)
type skillActionRuntime struct {
Skill SkillDefinition
Name string
Action SkillActionDefinition
}
func getSkillActionRuntime(skillName, action string) (skillActionRuntime, bool) {
def, ok := getSkillDefinition(skillName)
if !ok {
return skillActionRuntime{}, false
}
action = strings.TrimSpace(action)
if action == "" {
return skillActionRuntime{Skill: def}, true
}
actionDef, ok := def.Actions[action]
if !ok {
return skillActionRuntime{}, false
}
return skillActionRuntime{
Skill: def,
Name: action,
Action: actionDef,
}, true
}
func actionNeedsConfirmation(skillName, action string) bool {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return false
}
return runtime.Action.NeedsConfirmation
}
func actionRequiresSlot(skillName, action, slot string) bool {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return false
}
slot = strings.TrimSpace(slot)
for _, candidate := range runtime.Action.RequiredSlots {
if candidate == slot {
return true
}
}
return false
}
func slotDisplayName(slot, lang string) string {
slot = strings.TrimSpace(slot)
if lang != "zh" {
switch slot {
case "target_ref":
return "target"
case "name":
return "name"
case "exchange":
return "exchange"
case "model":
return "model"
case "strategy":
return "strategy"
case "exchange_type":
return "exchange type"
case "provider":
return "provider"
default:
return slot
}
}
switch slot {
case "target_ref":
return "目标对象"
case "name":
return "名称"
case "exchange":
return "交易所"
case "model":
return "模型"
case "strategy":
return "策略"
case "exchange_type":
return "交易所类型"
case "provider":
return "provider"
default:
return slot
}
}
func formatAwaitConfirmationMessage(lang, action, targetLabel string) string {
actionLabel := action
if lang == "zh" {
switch action {
case "start":
actionLabel = "启动"
case "stop":
actionLabel = "停止"
case "delete":
actionLabel = "删除"
case "activate":
actionLabel = "激活"
default:
actionLabel = action
}
return fmt.Sprintf("即将%s“%s”。这是需要确认的操作,请回复“确认”继续,回复“取消”终止。", actionLabel, targetLabel)
}
return fmt.Sprintf("You are about to %s %q. Please reply 'confirm' to continue or 'cancel' to stop.", actionLabel, targetLabel)
}
func formatStillWaitingConfirmationMessage(lang string) string {
if lang == "zh" {
return "当前流程仍在等待你确认。回复“确认”继续,或“取消”终止。"
}
return "This flow is still waiting for your confirmation."
}
func beginConfirmationIfNeeded(userID int64, lang string, session *skillSession, targetLabel string) (string, bool) {
if session == nil || !actionNeedsConfirmation(session.Name, session.Action) {
return "", false
}
if session.Phase != "await_confirmation" {
session.Phase = "await_confirmation"
return formatAwaitConfirmationMessage(lang, session.Action, targetLabel), true
}
return "", false
}
func awaitingConfirmationButNotApproved(lang string, session skillSession, text string) (string, bool) {
if !actionNeedsConfirmation(session.Name, session.Action) || session.Phase != "await_confirmation" {
return "", false
}
if isYesReply(text) {
return "", false
}
return formatStillWaitingConfirmationMessage(lang), true
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "exchange_diagnosis",
"kind": "diagnosis",
"domain": "exchange",
"description": "当用户反馈交易所 API 连接失败、签名错误、timestamp 异常、权限不足、IP 白名单限制、账户不可用等问题时调用。适用于用户在手动配置或运行交易员时遇到的交易所接入故障。不用于创建、修改、删除或查询交易所配置这类管理操作。"
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "exchange_management",
"kind": "management",
"domain": "exchange",
"description": "当用户想创建、查看、修改或删除交易所账户配置时调用。适用于用户提到交易所账户、API Key、Secret、Passphrase、测试网开关、启用状态等配置管理需求。不用于排查 invalid signature、timestamp、权限不足、白名单限制等连接或鉴权诊断问题。",
"actions": {
"create": {
"description": "创建新的交易所配置。",
"required_slots": ["exchange_type"],
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "testnet"]
},
"update": {
"description": "更新已有交易所配置。",
"required_slots": ["target_ref"],
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "enabled", "testnet"]
},
"delete": {
"description": "删除交易所配置。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"query": {
"description": "查询交易所配置。"
}
},
"tool_mapping": {
"create": "manage_exchange_config:create",
"update": "manage_exchange_config:update",
"delete": "manage_exchange_config:delete",
"query": "get_exchange_configs"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "model_diagnosis",
"kind": "diagnosis",
"domain": "model",
"description": "当用户反馈模型配置失败、API Key 无效、Base URL 非法、模型名不匹配、调用返回错误、模型不可用等问题时调用。适用于用户在接入或测试大模型时遇到的配置与兼容性故障。不用于创建、修改、删除或查询模型配置这类管理操作。"
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "model_management",
"kind": "management",
"domain": "model",
"description": "当用户想创建、查看、修改或删除 AI 模型配置时调用。适用于用户提到 provider、API Key、Base URL、模型名称、启用状态等配置管理需求。不用于排查模型调用失败、接口不兼容、鉴权错误、模型不存在等诊断问题。",
"actions": {
"create": {
"description": "创建新的模型配置。",
"required_slots": ["provider"],
"optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"]
},
"update": {
"description": "更新已有模型配置。",
"required_slots": ["target_ref"],
"optional_slots": ["api_key", "custom_api_url", "custom_model_name", "enabled"]
},
"delete": {
"description": "删除模型配置。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"query": {
"description": "查询模型配置。"
}
},
"tool_mapping": {
"create": "manage_model_config:create",
"update": "manage_model_config:update",
"delete": "manage_model_config:delete",
"query": "get_model_configs"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "strategy_diagnosis",
"kind": "diagnosis",
"domain": "strategy",
"description": "当用户反馈策略未生效、策略输出异常、提示词或配置结果与预期不一致、策略执行表现异常时调用。适用于策略内容和执行效果相关的排障与解释。不用于创建、修改、删除、激活、复制或查询策略模板这类管理操作。"
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "strategy_management",
"kind": "management",
"domain": "strategy",
"description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。适用于用户提到策略名称、策略配置、描述、语言、激活状态、复制新版本等管理需求。不用于排查策略未生效、策略输出异常、执行结果异常等诊断问题。",
"actions": {
"create": {
"description": "创建策略模板。",
"required_slots": ["name"],
"optional_slots": ["config", "description", "lang"]
},
"update": {
"description": "更新策略模板。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "config", "description"]
},
"delete": {
"description": "删除策略模板。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"activate": {
"description": "激活策略模板。",
"required_slots": ["target_ref"]
},
"duplicate": {
"description": "复制策略模板。",
"required_slots": ["target_ref", "name"]
},
"query": {
"description": "查询策略模板。"
}
},
"tool_mapping": {
"create": "manage_strategy:create",
"update": "manage_strategy:update",
"delete": "manage_strategy:delete",
"activate": "manage_strategy:activate",
"duplicate": "manage_strategy:duplicate",
"query": "get_strategies"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "trader_diagnosis",
"kind": "diagnosis",
"domain": "trader",
"description": "当用户反馈交易员无法启动、启动后不交易、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。"
}
+52
View File
@@ -0,0 +1,52 @@
{
"name": "trader_management",
"kind": "management",
"domain": "trader",
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。适用于用户提到交易员名称、绑定交易所、绑定模型、绑定策略、扫描频率、自定义提示词、运行状态等管理需求。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。",
"intents": [
"创建交易员",
"修改交易员",
"删除交易员",
"启动交易员",
"停止交易员",
"查询交易员"
],
"actions": {
"create": {
"description": "创建新的交易员。",
"required_slots": ["name", "exchange", "model"],
"optional_slots": ["strategy", "auto_start"]
},
"update": {
"description": "更新已有交易员。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "exchange", "model", "strategy", "scan_interval_minutes", "custom_prompt"]
},
"delete": {
"description": "删除交易员。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"start": {
"description": "启动交易员。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"stop": {
"description": "停止交易员。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"query": {
"description": "查询交易员列表或状态。"
}
},
"tool_mapping": {
"create": "manage_trader:create",
"update": "manage_trader:update",
"delete": "manage_trader:delete",
"start": "manage_trader:start",
"stop": "manage_trader:stop",
"query": "manage_trader:list"
}
}
+444
View File
@@ -0,0 +1,444 @@
package agent
import (
"nofx/safe"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// stockHTTPClient is a shared HTTP client for stock API requests.
// Reused across calls for connection pooling.
var stockHTTPClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 90 * time.Second,
},
}
// StockQuote holds real-time stock data.
type StockQuote struct {
Name string
Code string
Market string // "A股", "港股", "美股"
Currency string // "CNY", "HKD", "USD"
Open float64
PrevClose float64
Price float64
High float64
Low float64
Volume float64
Turnover float64
Date string
Time string
Change float64
ChangePct float64
// 盘前盘后 (美股)
ExtPrice float64 // 盘前/盘后价格
ExtChangePct float64 // 盘前/盘后涨跌幅%
ExtChange float64 // 盘前/盘后涨跌额
ExtTime string // 盘前/盘后时间
IsExtHours bool // 是否在盘前盘后时段
}
// knownStocks maps Chinese names to stock codes.
var knownStocks = map[string]string{
// A股
"拓维信息": "sz002261", "比亚迪": "sz002594", "宁德时代": "sz300750",
"贵州茅台": "sh600519", "中国平安": "sh601318", "招商银行": "sh600036",
"中芯国际": "sh688981", "工商银行": "sh601398", "建设银行": "sh601939",
"中国银行": "sh601988", "农业银行": "sh601288", "中信证券": "sh600030",
"海康威视": "sz002415", "立讯精密": "sz002475", "东方财富": "sz300059",
"隆基绿能": "sh601012", "长城汽车": "sh601633", "科大讯飞": "sz002230",
"三六零": "sh601360", "中兴通讯": "sz000063",
// 港股
"腾讯": "hk00700", "阿里巴巴": "hk09988", "美团": "hk03690",
"小米": "hk01810", "京东": "hk09618", "网易": "hk09999",
"百度": "hk09888", "快手": "hk01024", "哔哩哔哩": "hk09626",
"理想汽车": "hk02015", "蔚来": "hk09866", "小鹏汽车": "hk09868",
// 华为 is not publicly listed — removed incorrect Tencent fallback
// 美股
"苹果": "gb_aapl", "特斯拉": "gb_tsla", "英伟达": "gb_nvda",
"微软": "gb_msft", "谷歌": "gb_googl", "亚马逊": "gb_amzn",
"meta": "gb_meta", "奈飞": "gb_nflx", "台积电": "gb_tsm",
"拼多多": "gb_pdd", "蔚来汽车": "gb_nio",
}
// US stock ticker mapping
var usTickerMap = map[string]string{
"AAPL": "gb_aapl", "TSLA": "gb_tsla", "NVDA": "gb_nvda", "MSFT": "gb_msft",
"GOOGL": "gb_googl", "AMZN": "gb_amzn", "META": "gb_meta", "NFLX": "gb_nflx",
"TSM": "gb_tsm", "PDD": "gb_pdd", "NIO": "gb_nio", "BABA": "gb_baba",
"JD": "gb_jd", "BIDU": "gb_bidu", "AMD": "gb_amd", "INTC": "gb_intc",
"COIN": "gb_coin", "MARA": "gb_mara", "RIOT": "gb_riot",
}
func resolveStockCode(text string) (string, string) {
// Known Chinese names
for name, code := range knownStocks {
if strings.Contains(text, name) {
return code, name
}
}
// US ticker symbols (uppercase)
upper := strings.ToUpper(text)
for ticker, code := range usTickerMap {
if strings.Contains(upper, ticker) {
return code, ticker
}
}
// 6-digit A-share code
for _, w := range strings.Fields(text) {
w = strings.TrimSpace(w)
if len(w) == 6 {
if _, err := strconv.Atoi(w); err == nil {
prefix := "sz"
if w[0] == '6' || w[0] == '9' { prefix = "sh" }
return prefix + w, w
}
}
// 5-digit HK code
if len(w) == 5 {
if _, err := strconv.Atoi(w); err == nil {
return "hk" + w, w
}
}
}
return "", ""
}
// SearchResult represents a stock search result from Sina suggest API.
type SearchResult struct {
Name string // Display name
Code string // Sina-style code (e.g. sz300750, hk00700, gb_tsla)
Ticker string // Raw ticker (e.g. 300750, 00700, tsla)
Type string // Market type code: 11=A股, 31=港股, 41=美股
Market string // "A股", "港股", "美股"
}
// searchStock queries Sina's suggest API for dynamic stock search.
// Returns matching stocks across A-share, HK, and US markets.
func searchStock(keyword string) ([]SearchResult, error) {
// type=11 (A股), 31 (港股), 41 (美股)
u := fmt.Sprintf("https://suggest3.sinajs.cn/suggest/type=11,31,41&key=%s&name=suggestdata",
url.QueryEscape(keyword))
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("Referer", "https://finance.sina.com.cn")
resp, err := stockHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("stock search API returned status %d", resp.StatusCode)
}
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
body, err := safe.ReadAllLimited(reader)
if err != nil {
return nil, err
}
line := string(body)
// Parse: var suggestdata="item1;item2;..."
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start == -1 || end <= start {
return nil, fmt.Errorf("invalid suggest response")
}
data := line[start+1 : end]
if data == "" {
return nil, nil // no results
}
var results []SearchResult
items := strings.Split(data, ";")
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
fields := strings.Split(item, ",")
if len(fields) < 5 {
continue
}
// fields: [0]=name, [1]=type, [2]=ticker, [3]=sinaCode, [4]=displayName
typeCode := fields[1]
ticker := fields[2]
sinaCode := fields[3]
displayName := fields[4]
if displayName == "" {
displayName = fields[0]
}
var mkt, code string
switch typeCode {
case "11": // A股
mkt = "A股"
code = sinaCode // already like sz300750, sh600519
if code == "" {
// Build from ticker
prefix := "sz"
if len(ticker) == 6 && (ticker[0] == '6' || ticker[0] == '9') {
prefix = "sh"
}
code = prefix + ticker
}
case "31": // 港股
mkt = "港股"
code = "hk" + ticker
case "41": // 美股
mkt = "美股"
code = "gb_" + ticker
default:
continue // skip funds (201), indices, etc.
}
results = append(results, SearchResult{
Name: displayName,
Code: code,
Ticker: ticker,
Type: typeCode,
Market: mkt,
})
}
return results, nil
}
// resolveStockCodeDynamic tries local map first, then falls back to Sina search API.
func resolveStockCodeDynamic(text string) (string, string) {
// First try the static map
code, name := resolveStockCode(text)
if code != "" {
return code, name
}
// Fall back to Sina search API
// Extract a meaningful search keyword from the text
keyword := extractStockKeyword(text)
if keyword == "" {
return "", ""
}
results, err := searchStock(keyword)
if err != nil || len(results) == 0 {
return "", ""
}
// Return the first (best) result
return results[0].Code, results[0].Name
}
// extractStockKeyword extracts a likely stock name/ticker from user text.
func extractStockKeyword(text string) string {
// Remove common prefixes/suffixes that aren't stock names
text = strings.TrimSpace(text)
// If the text itself is short enough, use it directly
// (e.g. "中远海控" or "AAPL")
if len([]rune(text)) <= 10 {
return text
}
// Try to extract quoted terms first: 「xxx」 or "xxx"
quotePairs := [][2]string{
{"「", "」"},
{"\u201c", "\u201d"},
{"\u2018", "\u2019"},
{"\"", "\""},
}
for _, pair := range quotePairs {
if s := strings.Index(text, pair[0]); s >= 0 {
if e := strings.Index(text[s+len(pair[0]):], pair[1]); e >= 0 {
return text[s+len(pair[0]) : s+len(pair[0])+e]
}
}
}
// Look for patterns like "查 XXX", "搜索 XXX", "查一下 XXX"
for _, prefix := range []string{"查一下", "搜索", "查询", "看看", "搜一下", "查", "看", "search ", "find "} {
if idx := strings.Index(text, prefix); idx >= 0 {
rest := strings.TrimSpace(text[idx+len(prefix):])
// Take the first "word" (either Chinese characters or English word)
words := strings.Fields(rest)
if len(words) > 0 {
return words[0]
}
}
}
// Last resort: use first few words
words := strings.Fields(text)
if len(words) > 0 {
return words[0]
}
return ""
}
func fetchStockQuote(code string) (*StockQuote, error) {
url := fmt.Sprintf("https://hq.sinajs.cn/list=%s", code)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Referer", "https://finance.sina.com.cn")
resp, err := stockHTTPClient.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("stock quote API returned status %d", resp.StatusCode)
}
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
body, err := safe.ReadAllLimited(reader)
if err != nil { return nil, err }
line := string(body)
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start == -1 || end <= start { return nil, fmt.Errorf("invalid response") }
data := line[start+1 : end]
if data == "" { return nil, fmt.Errorf("empty data for %s", code) }
if strings.HasPrefix(code, "sh") || strings.HasPrefix(code, "sz") {
return parseAShare(code, data)
} else if strings.HasPrefix(code, "hk") {
return parseHKShare(code, data)
} else if strings.HasPrefix(code, "gb_") {
return parseUSShare(code, data)
}
return nil, fmt.Errorf("unsupported market: %s", code)
}
func parseAShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 32 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[0], Code: code, Market: "A股", Currency: "CNY"}
q.Open, _ = strconv.ParseFloat(f[1], 64)
q.PrevClose, _ = strconv.ParseFloat(f[2], 64)
q.Price, _ = strconv.ParseFloat(f[3], 64)
q.High, _ = strconv.ParseFloat(f[4], 64)
q.Low, _ = strconv.ParseFloat(f[5], 64)
q.Volume, _ = strconv.ParseFloat(f[8], 64)
q.Turnover, _ = strconv.ParseFloat(f[9], 64)
q.Date = f[30]; q.Time = f[31]
if q.PrevClose > 0 { q.Change = q.Price - q.PrevClose; q.ChangePct = (q.Change / q.PrevClose) * 100 }
return q, nil
}
func parseHKShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 18 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[1], Code: code, Market: "港股", Currency: "HKD"}
q.PrevClose, _ = strconv.ParseFloat(f[3], 64)
q.Open, _ = strconv.ParseFloat(f[2], 64)
q.High, _ = strconv.ParseFloat(f[4], 64)
q.Low, _ = strconv.ParseFloat(f[5], 64)
q.Price, _ = strconv.ParseFloat(f[6], 64)
q.Change, _ = strconv.ParseFloat(f[7], 64)
q.ChangePct, _ = strconv.ParseFloat(f[8], 64)
q.Turnover, _ = strconv.ParseFloat(f[10], 64)
q.Volume, _ = strconv.ParseFloat(f[11], 64)
if len(f) > 17 { q.Date = f[17]; q.Time = f[17] }
return q, nil
}
func parseUSShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 30 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[0], Code: code, Market: "美股", Currency: "USD"}
q.Price, _ = strconv.ParseFloat(f[1], 64)
q.ChangePct, _ = strconv.ParseFloat(f[2], 64)
q.Change, _ = strconv.ParseFloat(f[4], 64)
q.Open, _ = strconv.ParseFloat(f[5], 64)
q.High, _ = strconv.ParseFloat(f[6], 64)
q.Low, _ = strconv.ParseFloat(f[7], 64)
// 52wk high/low
high52, _ := strconv.ParseFloat(f[8], 64)
low52, _ := strconv.ParseFloat(f[9], 64)
q.Volume, _ = strconv.ParseFloat(f[10], 64)
q.Turnover, _ = strconv.ParseFloat(f[11], 64)
if len(f) > 25 { q.Date = f[25]; q.Time = f[26] }
q.PrevClose = q.Price - q.Change
_ = high52; _ = low52
// 盘前盘后数据 (字段21=价格, 22=涨跌幅%, 23=涨跌额, 24=时间)
if len(f) > 24 {
extPrice, _ := strconv.ParseFloat(f[21], 64)
extPct, _ := strconv.ParseFloat(f[22], 64)
extChg, _ := strconv.ParseFloat(f[23], 64)
if extPrice > 0 {
q.ExtPrice = extPrice
q.ExtChangePct = extPct
q.ExtChange = extChg
q.ExtTime = strings.TrimSpace(f[24])
q.IsExtHours = true
}
}
return q, nil
}
func formatStockQuote(q *StockQuote) string {
emoji := "🟢"
if q.ChangePct < 0 { emoji = "🔴" }
sym := "¥"
if q.Currency == "USD" { sym = "$" }
if q.Currency == "HKD" { sym = "HK$" }
volStr := fmt.Sprintf("%.0f", q.Volume)
if q.Volume > 1000000 { volStr = fmt.Sprintf("%.1f万", q.Volume/10000) }
if q.Volume > 100000000 { volStr = fmt.Sprintf("%.2f亿", q.Volume/100000000) }
turnStr := fmt.Sprintf("%.0f", q.Turnover)
if q.Turnover > 100000000 { turnStr = fmt.Sprintf("%.2f亿", q.Turnover/100000000) }
result := fmt.Sprintf(`%s *%s* (%s · %s)
💰 现价: %s%.2f (%+.2f%%)
📊 开盘: %s%.2f | 昨收: %s%.2f
📈 最高: %s%.2f | 最低: %s%.2f
📦 成交: %s | 额: %s
🕐 %s`,
emoji, q.Name, q.Code, q.Market,
sym, q.Price, q.ChangePct,
sym, q.Open, sym, q.PrevClose,
sym, q.High, sym, q.Low,
volStr, turnStr,
q.Date)
// 盘前盘后数据
if q.IsExtHours && q.ExtPrice > 0 {
extEmoji := "🟢"
if q.ExtChangePct < 0 { extEmoji = "🔴" }
extLabel := "🌙 盘后"
if strings.Contains(strings.ToLower(q.ExtTime), "am") {
extLabel = "🌅 盘前"
}
result += fmt.Sprintf("\n%s %s: %s%.2f (%+.2f%%) %s",
extLabel, extEmoji, sym, q.ExtPrice, q.ExtChangePct, q.ExtTime)
}
return result
}
+2242
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
package agent
import "testing"
func TestIsStockSymbol(t *testing.T) {
tests := []struct {
sym string
want bool
}{
// Known crypto base symbols — must NOT be detected as stock
{"BTC", false},
{"ETH", false},
{"SOL", false},
{"BNB", false},
{"XRP", false},
{"DOGE", false},
{"ADA", false},
{"AVAX", false},
{"DOT", false},
{"LINK", false},
{"PEPE", false},
{"SHIB", false},
{"TRUMP", false},
{"USDT", false},
{"USDC", false},
{"W", false}, // single letter crypto
// Crypto pairs — must NOT be stock
{"BTCUSDT", false},
{"ETHUSDT", false},
{"SOLUSDT", false},
{"DOGEUSDT", false},
// Real stock tickers — must be detected as stock
{"AAPL", true},
{"TSLA", true},
{"NVDA", true},
{"MSFT", true},
{"GOOGL", true},
{"AMZN", true},
{"META", true},
{"AMD", true},
{"PLTR", true},
{"BA", true},
{"F", true}, // Ford — 1 letter
{"GM", true}, // 2 letters
{"JPM", true}, // 3 letters
// Mixed / edge cases
{"btc", false}, // lowercase crypto
{"aapl", true}, // lowercase stock (uppercased internally)
{"BTC123", false}, // not pure letters
{"123456", false}, // digits
{"", false},
}
for _, tt := range tests {
t.Run(tt.sym, func(t *testing.T) {
got := isStockSymbol(tt.sym)
if got != tt.want {
t.Errorf("isStockSymbol(%q) = %v, want %v", tt.sym, got, tt.want)
}
})
}
}
+342
View File
@@ -0,0 +1,342 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
)
// TradeAction represents a parsed trade intent from the LLM or user.
type TradeAction struct {
ID string `json:"id"`
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short"
Symbol string `json:"symbol"` // e.g. "BTCUSDT"
Quantity float64 `json:"quantity"` // amount
Leverage int `json:"leverage"` // leverage multiplier
TraderID string `json:"trader_id"` // which trader to use
Status string `json:"status"` // "pending", "confirmed", "executed", "failed", "expired"
CreatedAt int64 `json:"created_at"`
Error string `json:"error,omitempty"`
}
// pendingTrades stores pending trade confirmations.
type pendingTrades struct {
mu sync.RWMutex
trades map[string]*TradeAction // id -> trade
}
func newPendingTrades() *pendingTrades {
return &pendingTrades{trades: make(map[string]*TradeAction)}
}
func (p *pendingTrades) Add(t *TradeAction) {
p.mu.Lock()
defer p.mu.Unlock()
p.trades[t.ID] = t
}
func (p *pendingTrades) Get(id string) *TradeAction {
p.mu.RLock()
defer p.mu.RUnlock()
return p.trades[id]
}
func (p *pendingTrades) Remove(id string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.trades, id)
}
// CleanExpired removes trades older than 5 minutes.
func (p *pendingTrades) CleanExpired() {
p.mu.Lock()
defer p.mu.Unlock()
cutoff := time.Now().Add(-5 * time.Minute).Unix()
for id, t := range p.trades {
if t.CreatedAt < cutoff {
delete(p.trades, id)
}
}
}
// parseTradeCommand parses natural language trade commands.
// Returns nil if the message is not a trade command.
func parseTradeCommand(text string) *TradeAction {
upper := strings.ToUpper(strings.TrimSpace(text))
// Pattern: "做多 BTC 0.01" / "做空 ETH 0.1" / "long BTC 0.01" / "short ETH 0.1"
// Also: "平多 BTC" / "平空 ETH" / "close long BTC" / "close short ETH"
var action, symbol string
var quantity float64
var leverage int
words := strings.Fields(upper)
if len(words) < 2 {
return nil
}
switch words[0] {
case "做多", "LONG", "BUY":
action = "open_long"
case "做空", "SHORT", "SELL":
action = "open_short"
case "平多":
action = "close_long"
case "平空":
action = "close_short"
case "CLOSE":
if len(words) >= 3 {
switch words[1] {
case "LONG":
action = "close_long"
words = append(words[:1], words[2:]...) // remove "LONG"
case "SHORT":
action = "close_short"
words = append(words[:1], words[2:]...) // remove "SHORT"
}
}
if action == "" {
return nil
}
default:
return nil
}
// Parse symbol
if len(words) < 2 {
return nil
}
symbol = words[1]
// Only append USDT for crypto symbols, not stock tickers
if !isStockSymbol(symbol) && !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
// Parse quantity (optional)
if len(words) >= 3 {
fmt.Sscanf(words[2], "%f", &quantity)
}
// Parse leverage (optional, "x10" or "10x")
if len(words) >= 4 {
lev := strings.TrimSuffix(strings.TrimPrefix(words[3], "X"), "X")
fmt.Sscanf(lev, "%d", &leverage)
}
if action == "" || symbol == "" {
return nil
}
return &TradeAction{
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
Action: action,
Symbol: symbol,
Quantity: quantity,
Leverage: leverage,
Status: "pending",
CreatedAt: time.Now().Unix(),
}
}
// executeTrade performs the actual trade execution via TraderManager.
func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
if a.traderManager == nil {
return fmt.Errorf("no trader manager available")
}
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 {
return fmt.Errorf("no traders configured")
}
// Determine if this is a stock trade to route to the right exchange
wantStock := isStockSymbol(trade.Symbol)
// Find a running trader's underlying exchange interface
var underlyingTrader interface {
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
}
for _, t := range traders {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
if running {
ut := t.GetUnderlyingTrader()
if ut == nil {
continue
}
// Route stock symbols to alpaca traders, crypto to others
exchange := t.GetExchange()
isAlpaca := exchange == "alpaca"
if wantStock && !isAlpaca {
continue // Skip non-stock traders for stock symbols
}
if !wantStock && isAlpaca {
continue // Skip stock traders for crypto symbols
}
underlyingTrader = ut
break
}
}
if underlyingTrader == nil {
if wantStock {
return fmt.Errorf("no running stock trader (Alpaca) found — configure one to trade stocks")
}
return fmt.Errorf("no running trader supports trade execution")
}
switch trade.Action {
case "open_long":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
_, err := underlyingTrader.OpenLong(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "open_short":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
_, err := underlyingTrader.OpenShort(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "close_long":
_, err := underlyingTrader.CloseLong(trade.Symbol, trade.Quantity)
return err
case "close_short":
_, err := underlyingTrader.CloseShort(trade.Symbol, trade.Quantity)
return err
default:
return fmt.Errorf("unknown action: %s", trade.Action)
}
}
// formatTradeConfirmation creates a confirmation message for a pending trade.
func formatTradeConfirmation(trade *TradeAction, lang string) string {
actionNames := map[string]string{
"open_long": "做多 (Long)",
"open_short": "做空 (Short)",
"close_long": "平多 (Close Long)",
"close_short": "平空 (Close Short)",
}
symbol := trade.Symbol
if strings.HasSuffix(symbol, "USDT") {
symbol = strings.TrimSuffix(symbol, "USDT")
}
actionName := actionNames[trade.Action]
if actionName == "" {
actionName = trade.Action
}
if lang == "zh" {
msg := fmt.Sprintf("⚠️ **交易确认**\n\n"+
"操作: %s\n"+
"品种: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("数量: %.4f\n", trade.Quantity)
}
if trade.Leverage > 0 {
msg += fmt.Sprintf("杠杆: %dx\n", trade.Leverage)
}
msg += fmt.Sprintf("\n发送 `确认 %s` 执行交易,或忽略取消。", trade.ID)
return msg
}
msg := fmt.Sprintf("⚠️ **Trade Confirmation**\n\n"+
"Action: %s\n"+
"Symbol: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("Quantity: %.4f\n", trade.Quantity)
}
if trade.Leverage > 0 {
msg += fmt.Sprintf("Leverage: %dx\n", trade.Leverage)
}
msg += fmt.Sprintf("\nSend `confirm %s` to execute, or ignore to cancel.", trade.ID)
return msg
}
// handleTradeConfirmation processes a trade confirmation message.
func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, lang string) (string, bool) {
upper := strings.ToUpper(strings.TrimSpace(text))
var tradeID string
if strings.HasPrefix(upper, "确认 ") || strings.HasPrefix(upper, "CONFIRM ") {
parts := strings.Fields(text)
if len(parts) >= 2 {
tradeID = parts[1]
}
}
if tradeID == "" {
return "", false
}
if a.pending == nil {
return "", false
}
trade := a.pending.Get(tradeID)
if trade == nil {
if lang == "zh" {
return "❌ 交易已过期或不存在。", true
}
return "❌ Trade expired or not found.", true
}
a.pending.Remove(tradeID)
trade.Status = "confirmed"
a.logger.Info("executing trade",
slog.String("id", trade.ID),
slog.String("action", trade.Action),
slog.String("symbol", trade.Symbol),
slog.Float64("quantity", trade.Quantity),
)
err := a.executeTrade(ctx, trade)
if err != nil {
trade.Status = "failed"
trade.Error = err.Error()
if lang == "zh" {
return fmt.Sprintf("❌ 交易执行失败: %s", err.Error()), true
}
return fmt.Sprintf("❌ Trade execution failed: %s", err.Error()), true
}
trade.Status = "executed"
symbol := trade.Symbol
if strings.HasSuffix(symbol, "USDT") {
symbol = strings.TrimSuffix(symbol, "USDT")
}
actionEmoji := "📈"
if strings.Contains(trade.Action, "short") {
actionEmoji = "📉"
}
if strings.Contains(trade.Action, "close") {
actionEmoji = "✅"
}
qtyStr := ""
if trade.Quantity > 0 {
qtyStr = fmt.Sprintf(" %.4f", trade.Quantity)
}
if lang == "zh" {
return fmt.Sprintf("%s 交易已执行!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
return fmt.Sprintf("%s Trade executed!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
// marshals trade action to JSON for embedding in responses
func marshalTradeAction(trade *TradeAction) string {
b, _ := json.Marshal(trade)
return string(b)
}
+343
View File
@@ -0,0 +1,343 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"nofx/safe"
"regexp"
"time"
)
type storeUserIDContextKey struct{}
// WithStoreUserID annotates an HTTP request context with the authenticated store user ID.
func WithStoreUserID(ctx context.Context, storeUserID string) context.Context {
return context.WithValue(ctx, storeUserIDContextKey{}, storeUserID)
}
func storeUserIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(storeUserIDContextKey{}).(string); ok && v != "" {
return v
}
return "default"
}
// validSymbolRe matches only alphanumeric trading symbols (e.g. BTCUSDT, ETH-USD).
var validSymbolRe = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,20}$`)
// validIntervalRe matches only valid kline intervals (e.g. 1m, 5m, 1h, 4h, 1d, 1w).
var validIntervalRe = regexp.MustCompile(`^[0-9]{1,2}[mhHdDwWM]$`)
// binanceClient is a shared HTTP client for proxying Binance API requests.
// Reused across requests to benefit from connection pooling.
var binanceClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// WebHandler provides HTTP endpoints for the NOFXi agent.
type WebHandler struct {
agent *Agent
logger *slog.Logger
}
func NewWebHandler(agent *Agent, logger *slog.Logger) *WebHandler {
return &WebHandler{agent: agent, logger: logger}
}
// HandleHealth handles GET /api/agent/health.
func (w *WebHandler) HandleHealth(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
}
// HandleChat handles POST /api/agent/chat.
func (w *WebHandler) HandleChat(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"`
UserKey string `json:"user_key"`
Lang string `json:"lang"`
}
// Limit request body to 64KB to prevent abuse
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 = SessionUserIDFromKey(req.UserKey)
}
msg := req.Message
if req.Lang != "" {
msg = "[lang:" + req.Lang + "] " + msg
}
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
defer cancel()
resp, err := w.agent.HandleMessageForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg)
if err != nil {
w.logger.Error("agent HandleMessage failed", "error", err, "user_id", req.UserID)
writeJSON(rw, 500, map[string]string{"error": "Failed to process message. Please try again."})
return
}
writeJSON(rw, 200, map[string]string{"response": resp})
}
// HandleChatStream handles POST /api/agent/chat/stream — SSE streaming chat.
// Sends server-sent events with types including planning, plan, step_start,
// step_complete, replan, 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"`
UserKey string `json:"user_key"`
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 = SessionUserIDFromKey(req.UserKey)
}
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.HandleMessageStreamForStoreUser(ctx, storeUserIDFromContext(r.Context()), 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.
func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
interval := r.URL.Query().Get("interval")
if interval == "" {
interval = "1h"
}
if !validSymbolRe.MatchString(symbol) {
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
return
}
if !validIntervalRe.MatchString(interval) {
writeJSON(rw, 400, map[string]string{"error": "invalid interval"})
return
}
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval))
}
// HandleTicker proxies ticker data from Binance.
func (w *WebHandler) HandleTicker(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
if !validSymbolRe.MatchString(symbol) {
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
return
}
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
}
// HandleTickers handles GET /api/agent/tickers?symbols=BTCUSDT,ETHUSDT,SOLUSDT
// Batch endpoint: fetches multiple tickers concurrently, returns array.
func (w *WebHandler) HandleTickers(rw http.ResponseWriter, r *http.Request) {
symbolsParam := r.URL.Query().Get("symbols")
if symbolsParam == "" {
symbolsParam = "BTCUSDT,ETHUSDT,SOLUSDT"
}
// Validate symbols
var symbols []string
for _, s := range splitComma(symbolsParam) {
if validSymbolRe.MatchString(s) {
symbols = append(symbols, s)
}
}
if len(symbols) == 0 {
writeJSON(rw, 400, map[string]string{"error": "no valid symbols"})
return
}
if len(symbols) > 20 {
writeJSON(rw, 400, map[string]string{"error": "max 20 symbols"})
return
}
// Fetch all tickers concurrently with context propagation
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
type result struct {
idx int
data json.RawMessage
}
results := make(chan result, len(symbols))
for i, sym := range symbols {
idx, s := i, sym
safe.GoNamed("ticker-fetch-"+s, func() {
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", s), nil)
if err != nil {
results <- result{idx: idx}
return
}
resp, err := binanceClient.Do(req)
if err != nil {
results <- result{idx: idx}
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
results <- result{idx: idx}
return
}
body, err := safe.ReadAllLimited(resp.Body, 16*1024)
if err != nil {
results <- result{idx: idx}
return
}
results <- result{idx: idx, data: body}
})
}
// Collect results in order
ordered := make([]json.RawMessage, len(symbols))
for range symbols {
r := <-results
if r.data != nil {
ordered[r.idx] = r.data
}
}
// Filter out nil entries and write response
out := make([]json.RawMessage, 0, len(ordered))
for _, d := range ordered {
if d != nil {
out = append(out, d)
}
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(out)
}
// commaRe is pre-compiled for splitComma — avoids recompiling on every call.
var commaRe = regexp.MustCompile(`\s*,\s*`)
// splitComma splits a comma-separated string, trims whitespace, skips empty.
func splitComma(s string) []string {
var parts []string
for _, p := range commaRe.Split(s, -1) {
if p != "" {
parts = append(parts, p)
}
}
return parts
}
func proxyBinance(rw http.ResponseWriter, ctx context.Context, url string) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
writeJSON(rw, 500, map[string]string{"error": "failed to create request"})
return
}
resp, err := binanceClient.Do(req)
if err != nil {
// Distinguish client cancellation from upstream failures
if ctx.Err() != nil {
return // Client disconnected, no point writing response
}
writeJSON(rw, 502, map[string]string{"error": "upstream request failed"})
return
}
defer resp.Body.Close()
// Forward upstream error status codes instead of silently proxying bad data
if resp.StatusCode != http.StatusOK {
writeJSON(rw, 502, map[string]string{"error": fmt.Sprintf("upstream returned status %d", resp.StatusCode)})
return
}
rw.Header().Set("Content-Type", "application/json")
// CORS is handled by the gin middleware — no need to set it here
// Limit response body to 2MB to prevent memory exhaustion
io.Copy(rw, io.LimitReader(resp.Body, 2*1024*1024))
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
// CORS is handled by the gin middleware — no need to set it here
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
+521
View File
@@ -0,0 +1,521 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nofx/mcp"
)
const (
workflowTaskPending = "pending"
workflowTaskRunning = "running"
workflowTaskCompleted = "completed"
workflowTaskFailed = "failed"
)
type WorkflowTask struct {
ID string `json:"id,omitempty"`
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Request string `json:"request,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
}
type WorkflowSession struct {
UserID int64 `json:"user_id"`
OriginalRequest string `json:"original_request,omitempty"`
Tasks []WorkflowTask `json:"tasks,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type workflowDecomposition struct {
Tasks []WorkflowTask `json:"tasks"`
}
func workflowSessionConfigKey(userID int64) string {
return fmt.Sprintf("agent_workflow_session_%d", userID)
}
func normalizeWorkflowSession(session WorkflowSession) WorkflowSession {
session.OriginalRequest = strings.TrimSpace(session.OriginalRequest)
normalized := make([]WorkflowTask, 0, len(session.Tasks))
for i, task := range session.Tasks {
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = fmt.Sprintf("task_%d", i+1)
}
task.Skill = strings.TrimSpace(task.Skill)
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
task.Request = strings.TrimSpace(task.Request)
task.DependsOn = cleanStringList(task.DependsOn)
task.Status = strings.TrimSpace(task.Status)
if task.Status == "" {
task.Status = workflowTaskPending
}
task.Error = strings.TrimSpace(task.Error)
if task.Skill == "" || task.Action == "" || task.Request == "" {
continue
}
normalized = append(normalized, task)
}
session.Tasks = normalized
if len(session.Tasks) == 0 {
return WorkflowSession{}
}
if session.UpdatedAt == "" {
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return session
}
func (a *Agent) getWorkflowSession(userID int64) WorkflowSession {
if a.store == nil {
return WorkflowSession{}
}
raw, err := a.store.GetSystemConfig(workflowSessionConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return WorkflowSession{}
}
var session WorkflowSession
if err := json.Unmarshal([]byte(raw), &session); err != nil {
return WorkflowSession{}
}
return normalizeWorkflowSession(session)
}
func (a *Agent) saveWorkflowSession(userID int64, session WorkflowSession) {
if a.store == nil {
return
}
session = normalizeWorkflowSession(session)
if len(session.Tasks) == 0 {
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
return
}
session.UserID = userID
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
data, err := json.Marshal(session)
if err != nil {
return
}
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), string(data))
}
func (a *Agent) clearWorkflowSession(userID int64) {
if a.store == nil {
return
}
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
}
func hasActiveWorkflowSession(session WorkflowSession) bool {
if len(session.Tasks) == 0 {
return false
}
for _, task := range session.Tasks {
if task.Status == workflowTaskPending || task.Status == workflowTaskRunning {
return true
}
}
return false
}
func nextRunnableWorkflowTask(session WorkflowSession) (WorkflowTask, int, bool) {
for i, task := range session.Tasks {
if task.Status != workflowTaskPending && task.Status != workflowTaskRunning {
continue
}
depsReady := true
for _, dep := range task.DependsOn {
ok := false
for _, candidate := range session.Tasks {
if candidate.ID == dep && candidate.Status == workflowTaskCompleted {
ok = true
break
}
}
if !ok {
depsReady = false
break
}
}
if depsReady {
return task, i, true
}
}
return WorkflowTask{}, -1, false
}
func supportedWorkflowSkill(skill, action string) bool {
skill = strings.TrimSpace(skill)
action = normalizeAtomicSkillAction(skill, action)
if skill == "" || action == "" {
return false
}
if _, ok := getSkillDAG(skill, action); ok {
return true
}
switch skill {
case "trader_management", "strategy_management", "model_management", "exchange_management":
switch action {
case "create", "query_list", "query_detail", "query_running", "activate":
return true
}
}
return false
}
func (a *Agent) tryWorkflowIntent(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
if session := a.getWorkflowSession(userID); hasActiveWorkflowSession(session) {
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
decomposition, err := a.decomposeWorkflowIntent(ctx, userID, lang, text)
if err != nil || len(decomposition.Tasks) <= 1 {
return "", false, err
}
session := WorkflowSession{
UserID: userID,
OriginalRequest: text,
Tasks: decomposition.Tasks,
}
a.saveWorkflowSession(userID, session)
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
func (a *Agent) handleWorkflowSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
if isExplicitFlowAbort(text) {
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
if lang == "zh" {
return "已取消当前任务流。", true, nil
}
return "Cancelled the current workflow.", true, nil
}
if activeSkill := a.getSkillSession(userID); strings.TrimSpace(activeSkill.Name) != "" {
answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent)
if !handled {
return "", false, nil
}
session = a.getWorkflowSession(userID)
if hasActiveWorkflowSession(session) && strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
a.saveWorkflowSession(userID, session)
if final, done, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); done || err != nil {
if final != "" && answer != "" {
return answer + "\n\n" + final, true, err
}
if answer != "" {
return answer, true, err
}
return final, true, err
}
}
return answer, true, nil
}
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
}
func (a *Agent) maybeAdvanceWorkflow(ctx context.Context, storeUserID string, userID int64, lang string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
task, index, ok := nextRunnableWorkflowTask(session)
if !ok {
summary := a.generateWorkflowSummary(ctx, userID, lang, session)
a.clearWorkflowSession(userID)
if summary == "" {
if lang == "zh" {
summary = "已完成当前任务流。"
} else {
summary = "Completed the current workflow."
}
}
if onEvent != nil {
onEvent(StreamEventPlan, summary)
onEvent(StreamEventDelta, summary)
}
return summary, true, nil
}
session.Tasks[index].Status = workflowTaskRunning
a.saveWorkflowSession(userID, session)
taskSession := skillSession{Name: task.Skill, Action: task.Action, Phase: "collecting"}
a.saveSkillSession(userID, taskSession)
if onEvent != nil {
onEvent(StreamEventPlan, a.formatWorkflowStatus(lang, session))
onEvent(StreamEventTool, "workflow:"+task.Skill+":"+task.Action)
}
answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, task.Request, onEvent)
if !handled {
session.Tasks[index].Status = workflowTaskFailed
session.Tasks[index].Error = "task_not_handled"
a.saveWorkflowSession(userID, session)
return "", false, nil
}
if strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
session = a.getWorkflowSession(userID)
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
a.saveWorkflowSession(userID, session)
if more, ok, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); ok || err != nil {
if answer != "" && more != "" {
return answer + "\n\n" + more, true, err
}
if answer != "" {
return answer, true, err
}
return more, true, err
}
}
return answer, true, nil
}
func markCurrentWorkflowTask(session WorkflowSession, status, errMsg string) WorkflowSession {
for i := range session.Tasks {
if session.Tasks[i].Status == workflowTaskRunning {
session.Tasks[i].Status = status
session.Tasks[i].Error = strings.TrimSpace(errMsg)
return session
}
}
return session
}
func (a *Agent) formatWorkflowStatus(lang string, session WorkflowSession) string {
parts := make([]string, 0, len(session.Tasks))
for _, task := range session.Tasks {
label := task.Request
if label == "" {
label = task.Skill + ":" + task.Action
}
switch task.Status {
case workflowTaskCompleted:
label = "✓ " + label
case workflowTaskRunning:
label = "→ " + label
default:
label = "· " + label
}
parts = append(parts, label)
}
if lang == "zh" {
return "任务流:" + strings.Join(parts, " | ")
}
return "Workflow: " + strings.Join(parts, " | ")
}
func (a *Agent) generateWorkflowSummary(ctx context.Context, userID int64, lang string, session WorkflowSession) string {
completed := make([]string, 0, len(session.Tasks))
for _, task := range session.Tasks {
if task.Status == workflowTaskCompleted {
completed = append(completed, task.Request)
}
}
if len(completed) == 0 {
return ""
}
if a.aiClient == nil {
if lang == "zh" {
return "已完成这些任务:" + strings.Join(completed, "")
}
return "Completed these tasks: " + strings.Join(completed, "; ")
}
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
systemPrompt := `You are summarizing a finished workflow for NOFXi.
Return one short user-facing summary in the user's language.
Do not mention internal DAG, scheduler, or JSON.`
userPrompt := fmt.Sprintf("Language: %s\nOriginal request: %s\nCompleted tasks:\n- %s", lang, session.OriginalRequest, strings.Join(completed, "\n- "))
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
if lang == "zh" {
return "已完成这些任务:" + strings.Join(completed, "")
}
return "Completed these tasks: " + strings.Join(completed, "; ")
}
return strings.TrimSpace(raw)
}
func (a *Agent) decomposeWorkflowIntent(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
if !looksLikeMultiTaskIntent(text) {
return workflowDecomposition{}, nil
}
if a.aiClient != nil {
if dec, err := a.decomposeWorkflowIntentWithLLM(ctx, userID, lang, text); err == nil && len(dec.Tasks) > 1 {
return dec, nil
}
}
return a.decomposeWorkflowIntentFallback(text), nil
}
func looksLikeMultiTaskIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
connectors := []string{"", ",", "然后", "再", "并且", "并", "同时", "and", "then"}
count := 0
for _, c := range connectors {
if strings.Contains(lower, c) {
count++
}
}
return count > 0
}
func (a *Agent) decomposeWorkflowIntentWithLLM(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
systemPrompt := `You decompose one NOFXi user request into a small task graph.
Return JSON only. No markdown.
Only use these skills: trader_management, strategy_management, model_management, exchange_management.
Only use one atomic action per task.
Each task must include:
- id
- skill
- action
- request
- depends_on (array, may be empty)
If the request is effectively a single task, return one task only.`
userPrompt := fmt.Sprintf("Language: %s\nUser request: %s", lang, text)
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return workflowDecomposition{}, err
}
return parseWorkflowDecomposition(raw)
}
func parseWorkflowDecomposition(raw string) (workflowDecomposition, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var out workflowDecomposition
if err := json.Unmarshal([]byte(raw), &out); err == nil {
out = normalizeWorkflowDecomposition(out)
return out, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &out); err == nil {
out = normalizeWorkflowDecomposition(out)
return out, nil
}
}
return workflowDecomposition{}, fmt.Errorf("invalid workflow json")
}
func normalizeWorkflowDecomposition(out workflowDecomposition) workflowDecomposition {
normalized := make([]WorkflowTask, 0, len(out.Tasks))
for i, task := range out.Tasks {
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = fmt.Sprintf("task_%d", i+1)
}
task.Skill = strings.TrimSpace(task.Skill)
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
task.Request = strings.TrimSpace(task.Request)
task.DependsOn = cleanStringList(task.DependsOn)
if !supportedWorkflowSkill(task.Skill, task.Action) || task.Request == "" {
continue
}
task.Status = workflowTaskPending
normalized = append(normalized, task)
}
out.Tasks = normalized
return out
}
func (a *Agent) decomposeWorkflowIntentFallback(text string) workflowDecomposition {
segments := splitWorkflowSegments(text)
tasks := make([]WorkflowTask, 0, len(segments))
for i, segment := range segments {
task, ok := classifyWorkflowTask(segment)
if !ok {
continue
}
task.ID = fmt.Sprintf("task_%d", i+1)
task.Status = workflowTaskPending
if len(tasks) > 0 {
task.DependsOn = []string{tasks[len(tasks)-1].ID}
}
tasks = append(tasks, task)
}
return workflowDecomposition{Tasks: tasks}
}
func splitWorkflowSegments(text string) []string {
parts := []string{strings.TrimSpace(text)}
separators := []string{"", ",", "然后", "再", "并且", "同时", " and then ", " then ", " and "}
for _, sep := range separators {
next := make([]string, 0, len(parts))
for _, part := range parts {
split := strings.Split(part, sep)
for _, candidate := range split {
candidate = strings.TrimSpace(candidate)
if candidate != "" {
next = append(next, candidate)
}
}
}
parts = next
}
return parts
}
func classifyWorkflowTask(text string) (WorkflowTask, bool) {
segment := strings.TrimSpace(text)
if segment == "" {
return WorkflowTask{}, false
}
switch {
case detectCreateTraderSkill(segment):
return WorkflowTask{Skill: "trader_management", Action: "create", Request: segment}, true
case detectTraderManagementIntent(segment):
action := normalizeAtomicSkillAction("trader_management", detectManagementAction(segment, "trader"))
if supportedWorkflowSkill("trader_management", action) {
return WorkflowTask{Skill: "trader_management", Action: action, Request: segment}, true
}
case detectExchangeManagementIntent(segment):
action := normalizeAtomicSkillAction("exchange_management", detectManagementAction(segment, "exchange"))
if supportedWorkflowSkill("exchange_management", action) {
return WorkflowTask{Skill: "exchange_management", Action: action, Request: segment}, true
}
case detectModelManagementIntent(segment):
action := normalizeAtomicSkillAction("model_management", detectManagementAction(segment, "model"))
if supportedWorkflowSkill("model_management", action) {
return WorkflowTask{Skill: "model_management", Action: action, Request: segment}, true
}
case detectStrategyManagementIntent(segment):
action := normalizeAtomicSkillAction("strategy_management", detectManagementAction(segment, "strategy"))
if action == "" && wantsStrategyDetails(segment) {
action = "query_detail"
}
if supportedWorkflowSkill("strategy_management", action) {
return WorkflowTask{Skill: "strategy_management", Action: action, Request: segment}, true
}
}
return WorkflowTask{}, false
}
+37
View File
@@ -0,0 +1,37 @@
package agent
import "testing"
func TestSplitWorkflowSegments(t *testing.T) {
got := splitWorkflowSegments("把策略删了,再把交易所改名")
if len(got) != 2 {
t.Fatalf("expected 2 segments, got %d: %#v", len(got), got)
}
}
func TestClassifyWorkflowTask(t *testing.T) {
task, ok := classifyWorkflowTask("把策略删了")
if !ok {
t.Fatal("expected task")
}
if task.Skill != "strategy_management" || task.Action != "delete" {
t.Fatalf("unexpected task: %+v", task)
}
}
func TestFallbackWorkflowDecompositionBuildsTwoTasks(t *testing.T) {
a := &Agent{}
out := a.decomposeWorkflowIntentFallback("把策略删了,再把交易所改名")
if len(out.Tasks) != 2 {
t.Fatalf("expected 2 tasks, got %d", len(out.Tasks))
}
if out.Tasks[0].Skill != "strategy_management" {
t.Fatalf("unexpected first task: %+v", out.Tasks[0])
}
if out.Tasks[1].Skill != "exchange_management" {
t.Fatalf("unexpected second task: %+v", out.Tasks[1])
}
if len(out.Tasks[1].DependsOn) != 1 || out.Tasks[1].DependsOn[0] != out.Tasks[0].ID {
t.Fatalf("expected dependency on first task, got %+v", out.Tasks[1].DependsOn)
}
}
+922
View File
@@ -0,0 +1,922 @@
# NOFXi 交易智能助手规范
## 使命
NOFXi 交易智能助手不是通用闲聊机器人,而是一个面向交易场景的操作与决策辅助助手。
它的核心目标是帮助用户更安全、更高效、更专业地完成以下事情:
- 创建、启动、查询、编辑、删除 agent
- 管理交易所配置
- 管理策略
- 管理大模型配置
- 排查配置问题与运行问题
- 回答交易相关问题,并提供可执行的建议
助手的价值不在于“会聊天”,而在于:
- 降低用户操作成本
- 减少配置错误和误操作
- 提高问题定位效率
- 让交易过程更专业、更可靠
## 核心理念
本助手采用 `80% skill + 20% 动态规划` 的设计思路。
这意味着:
- 大多数高频、已知、可标准化的需求,应由预定义 skill 处理
- 不应让模型对已知流程重复思考
- 动态规划只用于少数复杂、跨领域、未知或开放性任务
- 能确定的事情就不要交给模型自由发挥
默认优先级如下:
1. 优先匹配 skill
2. 如果用户仍在当前任务中,则继续当前 skill
3. 只有当没有合适 skill 时,才进入动态规划
## 设计原则
### 1. 以 Skill 为主,不以自由推理为主
对于高频任务和高风险任务,必须优先使用 skill,而不是通用 agent 自行规划。
尤其是以下场景:
- 创建 agent
- 启动或停止 agent
- 新增或修改交易所配置
- 新增或修改策略
- 新增或修改模型配置
- 常见报错排查
- API 配置指导
这些任务都应有稳定、明确、可重复执行的处理路径。
### 2. 以用户任务为中心,不以内部对象或 API 为中心
skill 的拆分应该围绕“用户想完成什么任务”,而不是“系统里有哪些对象”或“有哪些接口”。
好的拆分方式:
- 创建一个 agent
- 启动或停止一个 agent
- 排查交易所 API 连接失败
- 指导用户配置某个模型的 API
- 解释某条报错并给出下一步
不好的拆分方式:
- exchange skill
- strategy 对象 skill
- 通用 REST 调用 skill
- 纯接口包装型 skill
用户关注的是任务结果,不是内部实现。
### 3. 多轮对话的目标是推进任务,不是维持聊天感
多轮对话的本质,不是“让助手显得更像人”,而是让任务从模糊走向完成。
每一轮都应围绕以下问题展开:
- 当前正在处理什么任务
- 当前任务已经确认了哪些信息
- 还缺什么关键信息
- 下一步最合理的推进动作是什么
### 4. 只追问必要信息
当任务可以继续推进时,不要提出宽泛、发散、无助于执行的问题。
助手只应追问:
- 当前任务必需但缺失的字段
- 影响结果的重要选择项
- 涉及风险、删除、替换、启动、停止等动作时的确认信息
不要要求用户重复已经确认过的信息。
### 5. 尽量减少不必要的思考
对于已有稳定处理路径的任务,直接按既定流程执行,不进行自由规划。
不要把模型能力浪费在这些事情上:
- 猜测标准流程
- 重新设计高频任务执行顺序
- 对常见配置问题进行开放式发散分析
- 对结构化任务做不必要的“创造性理解”
### 6. 高风险动作优先保证安全
任何可能造成损失、误操作、难以回滚或影响实盘的动作,都必须谨慎处理。
以下动作通常需要明确确认:
- 删除 agent
- 删除交易所配置
- 删除策略
- 覆盖已有配置
- 启动实盘 agent
- 停止正在运行的 agent
- 修改可能影响下单行为的关键参数
当用户意图不够明确时,宁可先确认,不要直接执行。
### 7. 回答要以可执行为目标
当用户提问、排障、求指导时,回答应优先提供清晰的下一步,而不是停留在抽象概念。
尽量围绕这三个问题组织回答:
- 发生了什么
- 为什么会这样
- 现在该怎么做
## 任务分类
### 一、执行类任务
执行类任务是指目标明确、结果清晰、可以落到具体系统动作上的任务。
例如:
- 创建 agent
- 编辑 agent
- 启动 agent
- 停止 agent
- 删除 agent
- 创建交易所配置
- 修改交易所配置
- 删除交易所配置
- 创建策略
- 编辑策略
- 激活策略
- 复制策略
- 删除策略
- 创建模型配置
- 修改模型配置
- 删除模型配置
这类任务应优先通过 skill 实现,避免自由规划。
### 二、诊断类任务
诊断类任务是指用户遇到了问题,需要助手帮助识别原因、缩小范围、给出修复步骤。
例如:
- 某条报错是什么意思
- 为什么模型 API 配置失败
- 为什么交易所 API 连接不上
- 为什么 agent 启动失败
- 为什么策略没有执行
- 为什么余额、仓位、收益统计不对
- 为什么某个配置在前端能保存,但运行时报错
这类任务也应尽量 skill 化,形成稳定的排查路径,而不是每次从零分析。
### 三、指导类任务
指导类任务是指用户需要完成某项配置、接入、理解或选择,但不一定立刻触发系统动作。
例如:
- 某个模型的 API key 去哪里申请
- 某个模型的 base URL 和 model name 怎么填
- 某个交易所 API key 怎么创建
- 某个交易所权限应该怎么勾选
- 某种策略适合什么市场环境
- 某些交易指标怎么理解
这类任务应提供步骤化、实操型指导。
### 四、动态规划类任务
动态规划不是默认模式,而是兜底模式。
只有在以下情况下,才允许进入动态规划:
- 用户请求跨越多个 skill
- 用户描述模糊,需要先探索再判断
- 用户提出的是开放式交易问题
- 用户的问题不属于已有 skill 覆盖范围
- 需要组合查询、分析、判断和建议
动态规划可以存在,但必须受控,不能覆盖主路径。
## 多轮对话策略
### 一、优先延续当前任务
如果用户仍然在处理同一个任务,就继续当前任务,不要重新规划或重新路由。
例如:
- 用户:帮我创建一个新的 BTC agent
- 助手:请提供交易所和模型配置
- 用户:用我刚配的 DeepSeek
这时应继续“创建 agent”这个任务,而不是重新理解成一个新的需求。
### 二、多轮对话以任务状态推进为核心
每个任务在多轮中都应该有明确状态,例如:
- 已识别任务
- 信息收集中
- 等待用户确认
- 执行中
- 已完成
- 执行失败,待修复
- 已中断或已切换
助手应始终知道当前任务在哪个阶段,而不是每轮都从头开始解释世界。
### 三、只补齐缺失参数,不重复收集已有信息
如果一个 skill 已经定义了所需字段,那么多轮中的追问应只围绕缺失字段展开。
例如创建 agent 时,可能需要:
- 名称
- 交易所
- 策略
- 模型
- 是否立即启动
如果其中三个字段已经确认,就不要重新追问这三个字段。
### 四、允许用户中途切换任务
如果用户明显改变了目标,助手应允许当前任务中断,并切换到新任务。
例如:
- 当前任务:创建 agent
- 用户突然说:为什么我的交易所 API 报 invalid signature
这时应切换到诊断类任务,而不是强行把用户拉回创建流程。
### 五、允许短暂插问,但尽量回到主任务
如果用户在当前任务中插入一个简短问题,助手可以先简要回答,再视情况回到主任务。
例如:
- 用户正在创建策略
- 中途问:逐仓和全仓有什么区别
助手可以先给简洁解释,再继续原任务。
### 六、对高风险动作单独确认
即使任务流程已经基本完成,只要最后一步属于高风险动作,也要在执行前单独确认。
例如:
- 删除策略前确认
- 启动实盘前确认
- 覆盖已有配置前确认
## 记忆策略
### 一、记住对当前任务有用的信息
当前会话中,应保留以下内容:
- 当前活跃任务
- 已确认的参数
- 用户明确表达过的选择
- 仍然缺失的关键字段
- 当前排障上下文
- 最近一次确认结果
### 二、不把猜测当成记忆
以下内容不应被高强度依赖:
- 助手自行推断但用户未确认的偏好
- 早前对话中的过时信息
- 与当前任务无关的旧上下文
- 仅基于模糊表达做出的假设
如果有不确定性,应明确标注为“推测”或重新确认。
### 三、敏感信息只在必要范围内使用
对于 API key、密钥、凭证、账户等敏感信息:
- 不要在回答中完整复述
- 不要在无关任务中再次提起
- 仅在当前任务确有需要时使用
- 默认进行脱敏展示
## Skill 设计规范
每个 skill 都应服务于一个真实、完整、可交付的用户任务。
一个好的 skill 应当具备以下特点:
- 范围足够聚焦,执行稳定
- 范围又不能过小,能够完成完整任务
- 输入要求清晰
- 流程尽量确定
- 成功和失败条件明确
- 容易扩展和维护
每个 skill 至少应定义以下内容:
- 处理的意图
- 适用场景
- 必填输入
- 可选输入
- 前置条件
- 执行步骤
- 缺少信息时如何追问
- 哪些步骤需要确认
- 成功后的输出格式
- 常见失败情况
- 对应的恢复建议
## 工具使用原则
工具只是 skill 或动态规划中的执行手段,不应成为助手行为设计的核心。
助手不应表现为:
- 一个通用 API 调用器
- 一个只会函数路由的壳
- 一个对常规任务也反复规划的自治代理
默认顺序应为:
1. 先判断是否有合适 skill
2. 在 skill 内部调用所需工具
3. 如果没有 skill,再进入受限动态规划
4. 最后才考虑通用探索式工具调用
## Skill 与 Tool 的分层原则
Skill 和 tool 不是同一层概念。
tool 是底层执行能力,skill 是面向用户任务的稳定流程。
默认架构应为:
用户请求 -> 匹配 skill -> skill 内部调用 tool -> 返回结果
而不是:
用户请求 -> 大模型直接在一堆底层 tool 中自由选择和规划
### 一、Skill 是面向任务的
skill 应围绕用户目标设计,例如:
- 创建 agent
- 启动或停止 agent
- 配置交易所 API
- 诊断模型配置失败
- 解释某类报错
skill 负责定义:
- 要处理什么任务
- 需要哪些输入
- 缺信息时怎么追问
- 执行顺序是什么
- 哪些动作需要确认
- 失败时怎么恢复
### 二、Tool 是面向执行的
tool 负责具体动作,不负责完整任务语义。
例如:
- 读取当前模型配置
- 保存交易所配置
- 查询 trader 列表
- 启动某个 trader
- 获取余额
- 获取持仓
tool 更像“系统能力”或“执行接口”,而不是用户直接感知的工作单元。
### 三、优先把底层 tool 收敛到 skill 内部
在 skill-first 架构下,不应默认把大量底层 tool 直接暴露给大模型。
更合理的做法是:
- 大模型优先决定使用哪个 skill
- skill 内部自己决定需要调用哪些 tool
- 用户不需要面对底层能力拆分
- 模型也不需要在每次请求中重新拼装流程
### 四、可以直接暴露给大模型的,应当是高层 skill 化能力
如果某些能力需要以 function/tool 的形式提供给大模型,也应尽量保持高层抽象,而不是过度原子化。
较好的直接暴露方式:
- `manage_trader`
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
- `diagnose_trader_start_failure`
较差的直接暴露方式:
- `get_model_list_then_find_enabled_one`
- `read_exchange_then_patch_field`
- `generic_api_request`
- 纯粹的 CRUD 原子碎片接口
也就是说,即使最终在技术实现上仍然使用 tool calling,这些 tool 也应该尽量表现为 skill,而不是裸露的底层零件。
### 五、只有在以下情况,才允许直接使用底层 tool
- 当前请求没有匹配 skill
- 请求属于探索式、一次性、低频问题
- 需要动态组合多个能力处理未知问题
- 当前是在做诊断型探索,而不是执行标准流程
即使如此,也应优先限制范围,避免进入无边界的自由调用。
### 六、设计目标
引入 skill 的目的,不是让系统层次变复杂,而是让大模型少思考那些不需要思考的事情。
因此分层目标应是:
- 高频任务由 skill 固化
- 低层动作沉到 skill 内部
- 大模型少接触原子化 tool
- 只有少数未知问题才进入动态规划
## 交易场景下的行为要求
交易助手必须让整体体验显得专业、谨慎、清晰。
这意味着:
- 操作建议要结构化
- 配置指导要准确
- 风险提示要明确
- 不确定性要说清楚
- 不应伪装成对市场有绝对把握
当涉及交易建议时,应尽量区分:
- 客观事实
- 助手判断
- 用户可执行的下一步
对于行情和策略分析,应优先给出条件化建议,而不是绝对判断。
例如应更倾向于:
- 如果你是震荡思路,可以考虑……
- 如果当前目标是降低回撤,优先检查……
- 这个现象更像是配置问题,不一定是策略本身失效
而不是:
- 这个市场一定会涨
- 你应该马上开多
- 这个策略就是最优解
## 默认处理流程
当用户发来请求时,助手默认按以下顺序处理:
1. 先判断这是不是一个已知高频任务
2. 如果是,直接进入对应 skill
3. 如果任务信息不完整,只追问继续执行所需的最少字段
4. 如果属于诊断问题,先判断问题类型,再进入对应排查路径
5. 如果属于开放式问题或跨 skill 问题,才进入动态规划
6. 如果涉及高风险动作,在执行前单独确认
7. 完成后给出简洁、明确、可执行的结果反馈
## 总结原则
本助手的核心不是“尽可能多地思考”,而是“在正确的地方思考”。
应当 skill 化的事情,就不要交给模型自由发挥。
应当标准化的流程,就不要每次重新规划。
应当确认的风险动作,就不要直接执行。
多轮对话的价值,在于持续推进任务、减少用户负担、提升交易操作质量。
## 当前落地状态
第一批诊断与配置类 skill 已开始沉淀,见:
- `docs/agent-skills/diagnostic-skills.zh-CN.md`
当前实现优先覆盖:
- 模型 API 配置与诊断
- 交易所 API 配置与诊断
- trader 启动与运行诊断
- 下单与仓位异常诊断
- 策略与 prompt 生效问题诊断
## 当前能力分层建议
下面这部分用于指导后续 agent 重构:哪些现有能力适合继续保留给大模型,哪些应该下沉到 skill 内部,哪些应该弱化或移除。
### 一、建议保留为高层 skill 的能力
这些能力已经接近“用户任务”粒度,适合继续保留为高层入口。
- `manage_trader`
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
- `execute_trade`
- `get_positions`
- `get_balance`
- `get_trade_history`
- `search_stock`
原因:
- 用户会直接表达这类任务
- 这些能力已经具备较完整的业务语义
- 它们天然适合作为 skill 或 skill-like tool
后续建议:
- 保持这些能力对外稳定
- 在其上继续补充确认规则、缺参追问规则和诊断分支
### 二、建议下沉到 skill 内部的能力
这些能力可以继续存在,但不应作为主要交互层暴露给大模型自由组合。
- 读取某个资源后再 patch 某个字段
- 各类配置查询后再拼装参数
- 针对单一字段的修改动作
- 仅为执行中间步骤服务的查询动作
- 各种“先查一下列表再让模型自己猜怎么用”的细碎能力
原因:
- 这类能力更像流程零件
- 一旦直接暴露给大模型,会导致每次都重新规划
- 会让高频任务变得不稳定且冗长
原则上,这些动作应由 skill 内部封装完成,而不是让模型临场拼接。
### 三、建议弱化的能力形态
以下设计方向应尽量弱化:
- 通用 `generic_api_request`
- 纯 CRUD 原子接口直接暴露给大模型
- 没有任务语义的“万能工具”
- 需要模型自己理解完整调用顺序的碎片化接口
原因:
- 这类能力过于底层
- 会把流程控制权交还给模型
- 与“80%% skill + 20%% 动态规划”的目标相冲突
### 四、建议新增的高层 skill 结构
后续不建议把高频管理操作拆成大量 `skill_create_xxx / skill_update_xxx` 形式。
更合理的方式是按“资源管理域”收敛为少量 management skill
- `trader_management`
- `exchange_management`
- `model_management`
- `strategy_management`
这些 management skill 可以在内部继续复用现有:
- `manage_trader`
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
也就是说,现有高层管理工具可以作为 management skill 的执行底座,但不应继续承担全部对话策略。
#### management skill 的统一协议
每个 management skill 都应至少定义:
- `action`
- `target_ref`
- `slots`
- `needs_confirmation`
推荐结构如下:
```json
{
"skill": "exchange_management",
"action": "update",
"target_ref": {
"id": "optional",
"name": "主账户",
"alias": "optional"
},
"slots": {
"passphrase": "xxx"
},
"needs_confirmation": false
}
```
#### action 规则
不同 management skill 的 action 应集中定义,而不是散落在 prompt 中。
- `trader_management`
- `create`
- `update`
- `delete`
- `start`
- `stop`
- `query`
- `exchange_management`
- `create`
- `update`
- `delete`
- `query`
- `model_management`
- `create`
- `update`
- `delete`
- `query`
- `strategy_management`
- `create`
- `update`
- `delete`
- `activate`
- `duplicate`
- `query`
#### reference 规则
management skill 不应要求用户总是提供精确 id,而应支持分层定位目标:
1. 优先使用 `id`
2. 其次使用 `name`
3. 再其次使用 alias / 最近上下文引用
4. 若命中多个对象,则要求用户明确选择
5. 若未命中任何对象,则返回“未找到目标对象”,而不是猜测执行
#### slot 规则
每个 action 都应定义:
- 必填 slots
- 可选 slots
- 自动推断规则
- 缺失字段时的最小追问规则
例如:
- `exchange_management.create`
- 必填:`exchange_type`
- 常见必填:`account_name`、凭证字段
- `exchange_management.update`
- 必填:`target_ref`
- 其余只需要用户明确要改的字段
- `trader_management.create`
- 必填:`name``exchange``model`
- 常见可选:`strategy``auto_start`
#### confirmation 规则
management skill 内部必须按 action 级别区分风险,而不是统一处理。
- `delete` 默认必须确认
- `start` / `stop` 视场景确认
- `create` 通常可直接执行
- `update` 若涉及关键配置变更,可要求确认
- `query` 不需要确认
### 五、建议新增的诊断类 skill
诊断类 skill 是交易助手体验差异化的关键。
建议优先固定以下能力:
- `model_diagnosis`
- `exchange_diagnosis`
- `trader_diagnosis`
- `order_execution_diagnosis`
- `strategy_diagnosis`
- `balance_position_diagnosis`
这些 skill 应优先基于:
- 已有代码中的真实约束
- 现有 troubleshooting 文档
- 真实常见错误文案
- 当前系统的实际运行逻辑
### 六、建议保留给动态规划的少数场景
以下场景仍然可以保留给 planner / ReAct
- 跨多个 skill 的复合任务
- 用户目标表述模糊,需要先澄清再决定流程
- 开放式交易问题
- 一次性、低频、尚未固化的问题
- 涉及诊断探索但还没有稳定 skill 的场景
动态规划应始终作为兜底层,而不是主路径。
### 七、最终目标分层
理想结构如下:
1. 用户表达需求
2. 系统先判断是否命中高频 skill
3. 若命中,则进入对应 skill 流程
4. skill 内部调用现有管理类能力或查询能力
5. 只有未命中 skill 时,才进入 planner
长期目标不是“让 planner 更聪明”,而是“让 planner 更少出场”。
## `agent/tools.go` 重构清单
当前 `agent/tools.go` 中主要暴露了以下工具:
- `get_preferences`
- `manage_preferences`
- `get_exchange_configs`
- `manage_exchange_config`
- `get_model_configs`
- `manage_model_config`
- `get_strategies`
- `manage_strategy`
- `manage_trader`
- `search_stock`
- `execute_trade`
- `get_positions`
- `get_balance`
- `get_market_price`
- `get_trade_history`
下面给出按当前设计目标的建议分类。
### 一、建议继续保留为高层入口的工具
这些工具已经具备较完整的任务语义,短期内可以继续作为高层 skill-like tool 保留。
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
- `manage_trader`
- `execute_trade`
原因:
- 它们都对应明确的用户任务
- 内部已经承载了一定业务语义
- 后续可以直接继续向 skill 演进,而不是推倒重来
重构建议:
- 保持接口稳定
- 在 planner / prompt 层优先把它们当作 management skill 的执行底座使用
- 后续逐步把对话语义前移到 `xxx_management`
### 二、建议保留为“只读能力”但弱化对外存在感的工具
这些工具适合继续保留,但主要作为查询型能力存在,不应成为复杂任务的主流程控制中心。
- `get_exchange_configs`
- `get_model_configs`
- `get_strategies`
- `get_positions`
- `get_balance`
- `get_market_price`
- `get_trade_history`
- `search_stock`
原因:
- 它们更适合做信息补充和状态验证
- 对诊断问题很有价值
- 但不应该替代 task-level skill
重构建议:
- 继续保留
- 主要用于:
- skill 内部验证
- 诊断类 skill 查询当前状态
- 明确的只读用户请求
- 不要鼓励模型把它们当成“拼工作流”的基础零件反复组合
### 三、建议进一步收敛使用边界的工具
以下工具容易把模型带回到底层操作思维,应该明确边界。
- `get_preferences`
- `manage_preferences`
原因:
- 长期偏好记忆是辅助能力,不是交易任务主线
- 如果让模型频繁自由改偏好,容易污染上下文
重构建议:
- 仅在用户明确表达“记住/修改/删除长期偏好”时使用
- 不要把偏好系统混进交易执行和排障主流程
### 四、建议前移为 management / diagnosis skill 的现有高层工具
下面这些现有高层工具虽然可用,但语义仍然过宽,建议后续逐步前移为 management / diagnosis skill。
#### 1. `manage_trader`
建议逐步前移为:
- `trader_management`
- `trader_diagnosis`
原因:
- 创建、修改、启动、停止、删除虽然动作不同,但属于同一资源管理域
- 诊断路径和执行路径应分开
#### 2. `manage_exchange_config`
建议逐步前移为:
- `exchange_management`
- `exchange_diagnosis`
原因:
- CRUD / query 属于同一资源管理域
- invalid signature / timestamp / IP 白名单问题需要单独诊断路径
#### 3. `manage_model_config`
建议逐步前移为:
- `model_management`
- `model_diagnosis`
原因:
- 模型对象管理应集中到一个 management skill
- provider 配置失败和运行失败应集中到 diagnosis skill
#### 4. `manage_strategy`
建议逐步前移为:
- `strategy_management`
- `strategy_diagnosis`
原因:
- 策略模板管理和策略问题排查是两类不同任务
- create / update / activate / duplicate / delete / query 可以统一在 management skill 内处理
### 五、当前最适合直接做成硬 skill 的第一批对象
如果后续开始从“prompt 约束”走向“真正 dispatcher + skill runner”,建议优先落以下几类:
1. `create_trader`
2. `trader_management`
3. `exchange_management`
4. `model_management`
5. `exchange_diagnosis`
6. `model_diagnosis`
7. `trader_diagnosis`
原因:
- 这些最常见
- 多轮价值最高
- 失败成本高
- 用户对稳定性的感知最强
### 六、最终目标
`agent/tools.go` 中的工具未来应逐步承担“skill 的执行底座”角色,而不是直接承担全部对话策略。
也就是说,长期理想状态是:
- 文档层:按 skill 组织
- 对话层:先匹配 skill
- 执行层:skill 内部复用现有 tool
- planner 层:只兜底少数复杂情况
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"nofx/agent"
"github.com/gin-gonic/gin"
)
type agentPreferencePayload struct {
Text string `json:"text"`
}
func (s *Server) handleGetAgentPreferences(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(uid))
if err != nil || strings.TrimSpace(raw) == "" {
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
return
}
var prefs []agent.PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
func (s *Server) handleCreateAgentPreference(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
var req agentPreferencePayload
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Text) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "text required"})
return
}
created, err := agent.NewPersistentPreference(req.Text)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prefs := s.loadAgentPreferences(uid)
prefs = append([]agent.PersistentPreference{created}, prefs...)
if len(prefs) > 20 {
prefs = prefs[:20]
}
if err := s.saveAgentPreferences(uid, prefs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference"})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
func (s *Server) handleDeleteAgentPreference(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
prefs := s.loadAgentPreferences(uid)
filtered := prefs[:0]
for _, pref := range prefs {
if pref.ID != id {
filtered = append(filtered, pref)
}
}
if err := s.saveAgentPreferences(uid, filtered); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preference"})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": filtered})
}
func (s *Server) loadAgentPreferences(userID int64) []agent.PersistentPreference {
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return []agent.PersistentPreference{}
}
var prefs []agent.PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
return []agent.PersistentPreference{}
}
return prefs
}
func (s *Server) saveAgentPreferences(userID int64, prefs []agent.PersistentPreference) error {
data, err := json.Marshal(prefs)
if err != nil {
return err
}
return s.store.SetSystemConfig(agent.PreferencesConfigKey(userID), string(data))
}
+26
View File
@@ -0,0 +1,26 @@
package api
import (
"nofx/agent"
"github.com/gin-gonic/gin"
)
// RegisterAgentHandler registers NOFXi agent API routes on the main router.
// Chat endpoint requires authentication; market data endpoints are public.
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
// Chat requires auth — can trigger trades and access account data
s.router.POST("/api/agent/chat", s.authMiddleware(), func(c *gin.Context) {
req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")))
h.HandleChat(c.Writer, req)
})
s.router.POST("/api/agent/chat/stream", s.authMiddleware(), func(c *gin.Context) {
req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")))
h.HandleChatStream(c.Writer, req)
})
// Public endpoints — read-only market data
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/ticker", gin.WrapF(h.HandleTicker))
s.router.GET("/api/agent/tickers", gin.WrapF(h.HandleTickers))
}
+17 -12
View File
@@ -30,6 +30,7 @@ type SafeModelConfig struct {
Name string `json:"name"` Name string `json:"name"`
Provider string `json:"provider"` Provider string `json:"provider"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
HasAPIKey bool `json:"has_api_key"`
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive) CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive) CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
WalletAddress string `json:"walletAddress,omitempty"` WalletAddress string `json:"walletAddress,omitempty"`
@@ -60,14 +61,14 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
if len(models) == 0 { if len(models) == 0 {
logger.Infof("⚠️ No AI models in database, returning defaults") logger.Infof("⚠️ No AI models in database, returning defaults")
defaultModels := []SafeModelConfig{ defaultModels := []SafeModelConfig{
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false}, {ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false},
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false}, {ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false}, {ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false}, {ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false}, {ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false}, {ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false}, {ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false}, {ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
} }
c.JSON(http.StatusOK, defaultModels) c.JSON(http.StatusOK, defaultModels)
return return
@@ -83,6 +84,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
Name: model.Name, Name: model.Name,
Provider: model.Provider, Provider: model.Provider,
Enabled: model.Enabled, Enabled: model.Enabled,
HasAPIKey: model.APIKey != "",
CustomAPIURL: model.CustomAPIURL, CustomAPIURL: model.CustomAPIURL,
CustomModelName: model.CustomModelName, CustomModelName: model.CustomModelName,
} }
@@ -171,7 +173,8 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
if modelData.CustomAPIURL != "" { if modelData.CustomAPIURL != "" {
cleanURL := strings.TrimSuffix(modelData.CustomAPIURL, "#") cleanURL := strings.TrimSuffix(modelData.CustomAPIURL, "#")
if err := security.ValidateURL(cleanURL); err != nil { if err := security.ValidateURL(cleanURL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: %s", modelID, err.Error())}) logger.Warnf("Invalid custom_api_url for model %s: %v", modelID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: URL must be a valid HTTPS endpoint", modelID)})
return return
} }
} }
@@ -214,11 +217,13 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"}, {"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"}, {"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"}, {"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3.1-pro"}, {"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"}, {"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"}, {"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"}, {"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.5"},
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "glm-5"}, {"id": "blockrun-base", "name": "BlockRun (Base Wallet)", "provider": "blockrun-base", "defaultModel": "auto"},
{"id": "blockrun-sol", "name": "BlockRun (Solana Wallet)", "provider": "blockrun-sol", "defaultModel": "auto"},
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek"},
} }
c.JSON(http.StatusOK, supportedModels) c.JSON(http.StatusOK, supportedModels)
+3
View File
@@ -127,6 +127,9 @@ func (s *Server) setupRoutes() {
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout) s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding) s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding)
s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet) s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet)
s.route(protected, "GET", "/agent/preferences", "Get persistent agent preferences", s.handleGetAgentPreferences)
s.route(protected, "POST", "/agent/preferences", "Create persistent agent preference", s.handleCreateAgentPreference)
s.route(protected, "DELETE", "/agent/preferences/:id", "Delete persistent agent preference", s.handleDeleteAgentPreference)
// User account management // User account management
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password", s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
@@ -0,0 +1,203 @@
# NOFXi 诊断与配置 Skills(第一批)
这份文档用于沉淀交易智能助手的第一批高频诊断与配置 skill。
目标不是让模型“更会想”,而是让它面对常见问题时,优先走稳定、可复用的排查路径。
## 设计原则
- 优先按 skill 回答,不要对高频问题重复自由规划
- 先归类问题,再给出原因、检查项和修复建议
- 能通过工具验证当前状态时,先查再下结论
- 敏感信息只指导填写,不完整回显
- 对结论不确定时,要明确标注为“更可能”或“优先怀疑”
## skill_model_api_setup
### 适用场景
- 用户问某个大模型的 API key 去哪里申请
- 用户问 base URL 怎么填
- 用户问 model name 怎么填
- 用户问 OpenAI / Claude / Gemini / DeepSeek / Qwen / Kimi / Grok / MiniMax 怎么接入
### 处理策略
1. 先确认用户要配置哪个 provider
2. 告诉用户需要准备的最少字段:
- provider
- API key
- custom_api_url
- custom_model_name
3. 如果系统已有默认地址和默认模型名,优先给推荐值
4. 回答按步骤组织,不要泛泛解释概念
### 已知实现事实
- 系统内置 provider 默认运行配置,见 `agent.resolveModelRuntimeConfig(...)`
- 常见 provider 已有默认 URL 和默认 model name
## skill_model_config_diagnosis
### 适用场景
- 模型保存成功但 agent 仍然不可用
- 提示 AI unavailable
- 提示模型没启用
- 提示 custom_api_url 不合法
- 配置后 trader 不生效
### 优先排查
1. 是否存在已启用模型
2. API key 是否为空
3. custom_api_url 是否为合法 HTTPS 地址
4. custom_model_name 是否为空或不匹配
5. 当前 trader 是否绑定了这个模型
6. 更新模型后是否已触发 trader reload
### 已知实现事实
- 非 HTTPS 的 `custom_api_url` 会被后端拒绝,见 `api/handler_ai_model.go`
- 已启用模型如果缺少 API Key 或 URL,会导致 agent 无法就绪,见 `agent.ensureAIClientForStoreUser(...)`
- 更新模型配置后,系统会尝试移除并重载相关 trader,使新配置立即生效
### 输出格式
- 现象
- 更可能原因
- 先检查什么
- 下一步怎么修复
## skill_exchange_api_setup
### 适用场景
- 用户要新建交易所 API
- 用户不知道交易所需要哪些权限
- 用户问 API key / secret / passphrase 分别填什么
### 通用处理策略
1. 先确认交易所类型
2. 告知必须权限与禁止权限
3. 告知是否需要额外字段
4. 强调 IP 白名单与权限配置
5. 引导用户回到系统内完成绑定
### 特殊规则
- OKX 除 API Key 和 Secret 外,还需要 passphrase
- Bybit 永续/合约交易需要合约权限
- 不建议开启提现权限
### 参考文档
- `docs/getting-started/okx-api.md`
- `docs/getting-started/bybit-api.md`
## skill_exchange_api_diagnosis
### 适用场景
- `invalid signature`
- `timestamp` 错误
- `IP not allowed`
- `permission denied`
- 交易所连接不上
### 优先排查
1. 系统时间是否同步
2. API Key / Secret 是否正确
3. 是否遗漏额外字段,如 OKX passphrase
4. IP 白名单是否包含当前服务器
5. 是否启用了交易或合约权限
6. 密钥是否过期或已重建
### 已知实现事实
- 时间不同步是 `invalid signature` / `timestamp` 的高频根因,见 `docs/guides/TROUBLESHOOTING.zh-CN.md`
- OKX 的 passphrase 缺失会导致签名相关问题,见 `docs/getting-started/okx-api.md`
### 输出格式
- 报错现象
- 最常见根因
- 优先检查顺序
- 修复步骤
## skill_trader_start_diagnosis
### 适用场景
- trader 启动不了
- trader 启动了但没开始交易
- 页面显示已启动但一直没有动作
- 用户怀疑 strategy / model / exchange 绑定有问题
### 优先排查
1. 是否有已启用的模型配置
2. 是否有已启用的交易所配置
3. trader 是否绑定了 exchange_id / strategy_id / ai_model_id
4. 交易所余额和权限是否满足下单条件
5. AI 最近的决策到底是 wait、hold 还是下单失败
### 回答原则
- 要区分“没启动”“启动了但 AI 选择不交易”“尝试下单但失败”这三类
- 不要把“没开仓”直接等同于“系统故障”
## skill_order_execution_diagnosis
### 适用场景
- 下单失败
- 只开空不开户 / 只开单边
- 杠杆报错
- position side mismatch
### 优先排查
1. 账户模式是否匹配,例如 Binance 是否为 Hedge Mode
2. 是否为子账户杠杆限制
3. 合约权限是否开启
4. 余额、保证金、可交易 symbol 是否满足条件
### 已知实现事实
- Binance 在 One-way Mode 下,可能出现 `position side mismatch` 或单边行为
- 某些子账户杠杆上限较低,超过限制会直接失败
- 这些问题在 `docs/guides/TROUBLESHOOTING.md` 已有明确说明
## skill_strategy_diagnosis
### 适用场景
- 用户说策略没生效
- 用户说 prompt 预览和实际不一致
- 用户说修改策略后 trader 行为没有变化
### 优先排查
1. 当前编辑的是策略模板,还是 trader 的 custom prompt
2. 策略是否真的保存成功
3. 是否需要重新读取当前配置做对比
4. 用户说的“没生效”是指未保存、未绑定,还是运行结果与预期不一致
### 回答原则
- 先明确“对象”再排查:strategy template / trader / prompt override
- 如果能读取当前保存值,就不要凭印象判断
## 后续扩展方向
下一批可以继续补:
- `skill_balance_and_position_diagnosis`
- `skill_market_data_diagnosis`
- `skill_prompt_generation_diagnosis`
- `skill_strategy_test_run_diagnosis`
- `skill_exchange_specific_setup_<exchange>`
- `skill_model_provider_setup_<provider>`
@@ -0,0 +1,613 @@
# NOFXi Agent 当前设计说明
## 目的
本文描述当前 NOFXi Agent 的实际设计,而不是早期版本的理想设计。重点回答这些问题:
- 用户消息从哪里进入
- 什么请求会进入 planner
- 当前有哪些记忆层
- planner 如何生成与执行 plan
- tool 现在是怎么设计的
- 动态快照和当前引用分别解决什么问题
- 为什么某些问题会出现“看起来有历史,但模型还是会追问”
本文对应的主要实现文件:
- `agent/agent.go`
- `agent/web.go`
- `api/agent_routes.go`
- `agent/planner_runtime.go`
- `agent/execution_state.go`
- `agent/memory.go`
- `agent/history.go`
- `agent/tools.go`
## 一句话总览
当前 Agent 的运行模型可以概括为:
1. 前端把消息发到 `/api/agent/chat/stream`
2. 后端把登录用户身份放进 context
3. Agent 除 `/clear``/status` 外,其他消息全部进入 planner
4. planner 结合多层记忆、动态快照和 tool schema 生成 plan
5. 执行 plan 中的 `tool / reason / ask_user / respond`
6. 在执行过程中持续更新执行态、短期原话、长期摘要和当前对象引用
## 请求入口
### 前端入口
前端 Agent 页面在:
- `web/src/pages/AgentChatPage.tsx`
当前聊天使用:
- `POST /api/agent/chat/stream`
请求体里会传:
- `message`
- `lang`
- `user_key`
### 后端路由入口
路由注册在:
- `api/agent_routes.go`
这里会:
1. 经过 `authMiddleware`
2. 从登录态里取出 `user_id`
3. 通过 `agent.WithStoreUserID(...)` 写入 request context
### Agent Web Handler
真正的 HTTP handler 在:
- `agent/web.go`
主要入口:
- `HandleChat(...)`
- `HandleChatStream(...)`
再往下进入:
- `HandleMessageForStoreUser(...)`
- `HandleMessageStreamForStoreUser(...)`
## 最外层分流
当前外层分流已经被收口。
`agent/agent.go` 中,除了这两个命令之外,其他输入全部交给 planner:
- `/clear`
- `/status`
也就是说,现在这些都不再在外层直接处理:
- setup flow
- trade confirmation
- direct trade regex
- 自然语言配置流程
- 自然语言策略创建
这些都统一进入 planner。
这是当前设计里一个很重要的原则:
- 外层分流越少,行为边界越清晰
- 自然语言理解尽量统一交给 planner + tool
## 当前的 5 层记忆
当前不是 3 层,也不是 4 层,而是 5 层:
1. `chatHistory`
2. `TaskState`
3. `ExecutionState`
4. `CurrentReferences`
5. `Persistent Preferences`
### 1. chatHistory
定义位置:
- `agent/history.go`
作用:
- 保存最近几轮用户 / assistant 原始消息
- 给模型保留最近原话上下文
- 为后续摘要成 `TaskState` 提供原始素材
特点:
- 只保留短期原话
- 内存态
- `/clear` 时清空
适合存:
- 最近几轮对话原文
- 用户的最新措辞
- 刚刚的自然语言上下文
不适合存:
- 长期真相
- 当前外部系统状态
- 当前流程精确执行位置
### 2. TaskState
定义位置:
- `agent/memory.go`
作用:
- 保存跨轮次仍然有意义的高层摘要
- 注入 planner / reasoning / final response
持久化 key
- `agent_task_state_<userID>`
字段:
- `CurrentGoal`
- `ActiveFlow`
- `OpenLoops`
- `ImportantFacts`
- `LastDecision`
- `UpdatedAt`
适合存:
- 当前高层目标
- 跨轮次仍然成立的未闭环事项
- 关键事实
- 最近一次重要决策及其原因
不适合存:
- step 级待办
- “下一步调用哪个 tool”
- 动态余额、持仓、配置存在性
- 任何可以通过 tool 重新读取的实时状态
### 3. ExecutionState
定义位置:
- `agent/execution_state.go`
作用:
- 保存当前 plan 的执行态
- 支持 `ask_user` 之后继续执行
- 保存 plan、当前步骤、执行日志、等待状态等
持久化 key
- `agent_execution_state_<userID>`
当前关键字段:
- `SessionID`
- `Goal`
- `Status`
- `PlanID`
- `Steps`
- `CurrentStepID`
- `DynamicSnapshots`
- `ExecutionLog`
- `SummaryNotes`
- `Waiting`
- `CurrentReferences`
- `FinalAnswer`
- `LastError`
### 4. CurrentReferences
定义位置:
- `agent/execution_state.go`
作用:
- 记录当前对话里“这个 / 那个 / 刚才那个”到底指的是谁
当前支持的引用对象:
- `strategy`
- `trader`
- `model`
- `exchange`
这是为了解决一种常见问题:
- 用户明明前一轮刚说过“激进策略”
- 下一轮说“改一下这个策略”
- 如果没有结构化引用,模型虽然有聊天历史,也容易重新追问
`CurrentReferences` 不是系统状态快照,而是:
- 当前对话焦点对象
- 当前代词绑定对象
### 5. Persistent Preferences
对应工具:
- `get_preferences`
- `manage_preferences`
作用:
- 保存用户长期偏好
适合存:
- 默认中文回复
- 偏好激进风格
- 更关注 BTC / ETH
- 不喜欢高频
- 每天固定时间简报
它和 `TaskState` 的区别是:
- `TaskState` 偏向当前任务摘要
- `Persistent Preferences` 偏向长期用户画像
## DynamicSnapshots 是什么
`DynamicSnapshots` 是当前真实系统状态的快照。
它不是历史,也不是长期记忆,而是 planner 在规划前或执行中插入的“当前事实”。
当前会进入快照的典型信息包括:
- 当前模型配置列表
- 当前交易所配置列表
- 当前策略列表
- 当前 trader 列表
- 当前余额
- 当前持仓
- 最近交易历史
作用:
- 防止 planner 盲信旧结论
- 避免“之前没配置,现在其实已经配好了却还说没有”
- 避免“之前余额是 A,现在拿旧 observation 继续回答”
一句话:
- `DynamicSnapshots` = 当前世界里真实有什么
## CurrentReferences 和 DynamicSnapshots 的区别
这两个容易混淆,但职责完全不同。
`DynamicSnapshots`
- 当前系统状态快照
- 是候选集合 / 当前事实
- 例如当前有两个策略:`激进``新策略`
`CurrentReferences`
- 当前对话焦点对象
- 是“这个”到底指谁
- 例如用户现在说的“这个策略”就是 `激进`
可以这样理解:
- `DynamicSnapshots` 是地图
- `CurrentReferences` 是你手指现在指着地图上的哪个点
## Planner 的输入
planner 主逻辑在:
- `agent/planner_runtime.go`
生成计划时,当前会把这些东西一起送给模型:
- 当前用户请求
- tool schema
- `Persistent Preferences`
- `TaskState`
- `ExecutionState`
- `Resume context`
- `Structured waiting state`
- `Observation context`
其中 observation context 不是旧版单数组,而是分层后的:
- `dynamic_snapshots`
- `execution_log`
- `summary_notes`
## Plan 的结构
当前 planner 只允许这 4 类 step
- `tool`
- `reason`
- `ask_user`
- `respond`
这意味着现在的 Agent 不是一个“自由发挥的回复器”,而是:
- 先规划
- 再执行步骤
- 必要时重规划
## 步骤执行流程
`executePlan(...)` 的核心逻辑是:
1. 找下一个 pending step
2. 标记 step 为 running
3. 执行对应类型
4. 写回 `ExecutionState`
5. 必要时触发 replanning
不同 step 类型行为如下:
### tool
- 调内部 tool
- 把结果写入 `ExecutionLog`
- 根据结果更新 `CurrentReferences`
- 必要时触发 replanner
### reason
- 发起一次短 reasoning 调用
- 生成一段简短中间推理
- 写入 `ExecutionLog`
### ask_user
- 进入 `waiting_user`
- 保存 `WaitingState`
- 把问题直接回给用户
### respond
- 生成最终回答
- 标记当前执行完成
## WaitingState 是什么
`WaitingState` 用来解决:
- 用户回复 `是`
- 用户回复 `继续`
- 用户回复 `那个就行`
这类短回复如果没有结构化等待状态,很容易丢上下文。
当前字段包括:
- `Question`
- `Intent`
- `PendingFields`
- `ConfirmationTarget`
- `CreatedAt`
它的作用是:
- 告诉 planner 上一轮到底在等什么
- 让这轮短回复更容易被理解成“对上一问的回答”
## CurrentReferences 如何更新
当前是双路径更新:
### 1. 用户消息命中对象名时更新
如果用户说:
- `修改激进策略`
- `停止 lky`
- `用 DeepSeek`
系统会去当前用户的策略 / trader / model / exchange 列表里尝试匹配名称或 ID。
匹配成功后,更新 `CurrentReferences`
### 2. tool 成功返回对象时更新
比如:
- `manage_strategy(create/update/activate)`
- `manage_trader(create/update)`
- `manage_model_config(update)`
- `manage_exchange_config(update)`
只要 tool 返回了具体对象,系统就会把对应 ID / name 写回当前引用。
## Tool 设计
当前 tool 是“资源型 tool”设计,不是“页面动作型 tool”。
### 当前主要工具
配置资源:
- `get_exchange_configs`
- `manage_exchange_config`
- `get_model_configs`
- `manage_model_config`
策略资源:
- `get_strategies`
- `manage_strategy`
trader 资源:
- `manage_trader`
交易 / 查询资源:
- `search_stock`
- `execute_trade`
- `get_positions`
- `get_balance`
- `get_market_price`
- `get_trade_history`
### 为什么这么设计
优点:
- tool schema 稳定
- 行为边界清晰
- planner 更容易学会
- 资源增删改查统一
当前 `manage_strategy` 支持:
- `list`
- `get_default_config`
- `create`
- `update`
- `delete`
- `activate`
- `duplicate`
当前 `manage_trader` 支持:
- `list`
- `create`
- `update`
- `delete`
- `start`
- `stop`
## 为什么“创建策略”不该默认依赖交易所和模型
当前设计里,策略模板应该是独立资源:
- `strategy`
而运行态对象是:
- `trader`
更合理的边界是:
- 创建策略模板:用 `manage_strategy`
- 把策略跑起来:用 `manage_trader`
也就是说:
- 策略不默认依赖交易所和模型
- 只有当用户要求“运行 / 部署 / 创建 trader”时,才需要进一步关联 exchange / model / trader
## 当前一个完整例子
用户输入:
`帮我创建一个新的激进策略模板,名字就叫激进。创建完后,再把这个策略绑定到 trader lky。`
当前大致流程:
1. 前端请求 `/api/agent/chat/stream`
2. 后端注入 `store_user_id`
3. Agent 进入 planner
4. planner 刷新动态快照:
- 当前策略
- 当前 trader
5. 生成 plan,例如:
- `get_strategies`
- `manage_strategy(create)`
- `manage_trader(update)`
- `respond`
6. 执行 `manage_strategy(create)` 后:
- 写入 `ExecutionLog`
- 更新 `CurrentReferences.strategy`
7. 执行 `manage_trader(update)` 时:
- 直接使用刚创建策略的 ID
8. 输出最终回复
如果此后用户继续说:
`把这个策略的 prompt 改激进一点`
系统会优先从 `CurrentReferences.strategy` 理解“这个策略”。
## 为什么看起来“有历史”,模型还是会追问
因为“有聊天历史”不等于“有结构化对象绑定”。
如果没有 `CurrentReferences`
- 模型只能依赖原话文本推断“这个策略”是谁
- 一旦中间插入多条消息,或者有多个候选策略
- 就容易重新追问
所以当前设计里,`CurrentReferences` 是补齐这一块的关键。
## 当前已知限制
### 1. 外层虽然已经大幅收口,但仍然不是纯 graph runtime
现在比之前更统一,但整体仍然是:
- Agent 主入口
- Planner
- Tool 执行
而不是完整 node-graph 引擎。
### 2. ExecutionState 仍然是按 userID 单槽位
这意味着:
- 同一用户的多个并行任务仍然可能相互影响
更彻底的方向应该是:
- 按 thread / session 多实例存储
### 3. CurrentReferences 目前还是轻量实现
当前只覆盖:
- strategy
- trader
- model
- exchange
后面如果要更强,需要考虑:
- 多候选冲突消解
- 昵称映射
- 跨更长会话的稳定实体绑定
## 当前设计的核心思想
一句话总结:
- `chatHistory` 记原话
- `Persistent Preferences` 记长期偏好
- `TaskState` 记高层摘要
- `ExecutionState` 记当前流程
- `DynamicSnapshots` 记当前事实
- `CurrentReferences` 记当前指代对象
- `planner` 决定步骤
- `tools` 执行落地动作
这就是当前 NOFXi Agent 的实际运行设计。
@@ -0,0 +1,454 @@
# NOFXi Agent Memory And Planning Design
## Purpose
This document explains how the current NOFXi agent handles:
- short-term conversation memory
- durable task memory
- durable execution / planning state
- planner execution and replanning
- state reset and resume behavior
The implementation described here is primarily in:
- `agent/history.go`
- `agent/memory.go`
- `agent/execution_state.go`
- `agent/planner_runtime.go`
- `agent/agent.go`
## High-Level Model
The current agent uses three different layers of state:
1. `chatHistory`
Recent in-memory user/assistant turns for the live conversation.
2. `TaskState`
Durable summarized context that should survive beyond recent turns.
3. `ExecutionState`
Durable workflow state for the currently running or recently blocked plan.
These three layers serve different purposes and should not be treated as the same thing.
## State Layers
### 1. `chatHistory`
Defined in `agent/history.go`.
Role:
- stores recent `user` / `assistant` messages in memory
- keyed by `userID`
- used as short-term conversational context
- acts as the source material for later compression into `TaskState`
Characteristics:
- in-memory only
- capped by `maxTurns`
- cleared by `/clear`
- not suitable as durable truth
Typical contents:
- the last few user questions
- the last few assistant replies
- temporary conversational wording
### 2. `TaskState`
Defined in `agent/memory.go`.
Role:
- stores durable, structured, non-derivable context
- persisted through `system_config`
- injected into planning and reasoning prompts
Storage key:
- `agent_task_state_<userID>`
Fields:
- `CurrentGoal`
- `ActiveFlow`
- `OpenLoops`
- `ImportantFacts`
- `LastDecision`
- `UpdatedAt`
Intended contents:
- user goal that still matters across turns
- high-level unresolved issues that still matter across turns
- facts that tools cannot cheaply re-fetch
- latest important decision summary
Explicitly not intended for:
- step-level pending items such as "wait for API key"
- execution actions such as "call get_exchange_configs"
- live balances
- current positions
- current market prices
- mutable configuration availability
Those should be checked from tools at planning time instead of being trusted from old summaries.
### 3. `ExecutionState`
Defined in `agent/execution_state.go`.
Role:
- stores the current execution workflow
- allows the agent to resume after `ask_user`
- persists plan steps, observations, and completion status
Storage key:
- `agent_execution_state_<userID>`
Fields:
- `SessionID`
- `UserID`
- `Goal`
- `Status`
- `PlanID`
- `Steps`
- `CurrentStepID`
- `Observations`
- `FinalAnswer`
- `LastError`
- `UpdatedAt`
This is the planner's working state, not a general memory store.
## Data Flow
### Request Entry
Entry points:
- `HandleMessage(...)`
- `HandleMessageStream(...)`
Flow:
1. user message enters `agent`
2. slash commands and explicit direct branches are handled first
3. all other requests go into planner flow via `thinkAndAct(...)` / `thinkAndActStream(...)`
### Planner Flow
The planner pipeline in `agent/planner_runtime.go` is:
1. append user message into `chatHistory`
2. emit `planning` SSE event
3. load `ExecutionState`
4. optionally reset stale `ExecutionState`
5. optionally refresh dynamic configuration snapshots
6. create a fresh execution plan with the LLM
7. execute steps one by one
8. persist `ExecutionState` after important transitions
9. append assistant answer into `chatHistory`
10. maybe compress old conversation into `TaskState`
## Short-Term vs Durable Memory
### What lives in `chatHistory`
Good fits:
- raw recent messages
- conversational wording
- latest assistant phrasing
Bad fits:
- long-lived truths
- current external system state
### What lives in `TaskState`
Good fits:
- durable goal
- high-level unfinished work that remains relevant across turns
- important facts the user stated
- previous decisions and why they were made
Bad fits:
- pending steps inside the current plan
- execution-level reminders such as "wait for a field" or "call a tool"
- old conclusions about whether tools exist
- old conclusions about whether model/exchange config is present
- live operational state that can change outside the chat
### What lives in `ExecutionState`
Good fits:
- current plan steps
- observations from tool calls
- blocked-on-user-input status
- exact current workflow state
- step-level pending work and block reasons
Bad fits:
- evergreen user profile
- long-term semantic memory
## Planning Logic
### Plan Creation
`createExecutionPlan(...)` sends the following into the planner model:
- available tool definitions
- persistent preferences
- `TaskState` context
- `ExecutionState` JSON
- current user request
The planner must return JSON only with step types:
- `tool`
- `reason`
- `ask_user`
- `respond`
### Step Execution
`executePlan(...)` executes the plan loop:
- `tool`
call tool and append observation
- `reason`
run reasoning sub-call and append observation
- `ask_user`
save `waiting_user` state and return question
- `respond`
generate final answer and mark completed
After each completed step, `replanAfterStep(...)` may:
- continue
- replace remaining steps
- ask user
- finish
## Resume Behavior
When `ExecutionState.Status == waiting_user`, the next user turn is treated as a reply to the pending question.
Current safeguards:
- latest asked question is extracted from the stored plan
- the user reply is appended as a `user_reply` observation
- planner prompt receives explicit `Resume context`
This prevents short replies like `是` from being misread as unrelated fresh intents as often as before.
## Dynamic State Refresh
Configuration and trader management requests are dynamic by nature. Their truth can change outside the current chat, for example:
- user configures exchange in the UI
- user adds model in another tab
- user creates trader elsewhere
Because of that, configuration/trader requests should not trust stale model conclusions.
Current protection in `planner_runtime.go`:
- detects config / trader intent with `isConfigOrTraderIntent(...)`
- clears `TaskState` context from the planner prompt for these requests
- refreshes `ExecutionState.Observations` with fresh snapshots from:
- `toolGetModelConfigs(...)`
- `toolGetExchangeConfigs(...)`
- `toolListTraders(...)`
This makes the planner rely more on current system state and less on older narrative memory.
## Reset Strategy
The system currently resets or weakens stale execution state when:
- user says retry-like phrases such as `再试`, `继续`, `try again`, `continue`
- request is config / trader related and old execution state is failed / completed / waiting
Reset scope:
- `ExecutionState` may be cleared
- `TaskState` is not globally deleted, but it is intentionally ignored for config/trader planning
Manual reset:
- `/clear`
This clears:
- short-term chat history
- task state
- execution state
## Compression Design
`maybeCompressHistory(...)` moves older short-term chat content into `TaskState` when:
- recent message count exceeds the configured window
- estimated token count exceeds the threshold
Compression strategy:
1. keep recent conversation in `chatHistory`
2. summarize older turns into structured `TaskState`
3. persist new `TaskState`
4. replace `chatHistory` with recent slice
Important design rule:
- `TaskState` should keep durable context only
- it should not become a stale copy of mutable operational state
## Current Architecture Diagram
```mermaid
flowchart TD
U[User Message] --> A[HandleMessage / HandleMessageStream]
A --> B{Direct command?}
B -->|Yes| C[Direct branch or slash command]
B -->|No| D[thinkAndAct / thinkAndActStream]
D --> E[Append user turn to chatHistory]
D --> F[Load ExecutionState]
F --> G{waiting_user?}
G -->|Yes| H[Attach user_reply observation]
G -->|No| I[Create fresh ExecutionState]
H --> J[Refresh dynamic snapshots if config/trader intent]
I --> J
J --> K[createExecutionPlan via LLM]
K --> L[Execution plan]
L --> M[executePlan loop]
M --> N[tool step]
M --> O[reason step]
M --> P[ask_user step]
M --> Q[respond step]
N --> R[Append Observation]
O --> R
R --> S[replanAfterStep]
S --> M
P --> T[Persist waiting_user ExecutionState]
T --> UQ[Return question to user]
Q --> V[Persist completed ExecutionState]
V --> W[Append assistant turn to chatHistory]
W --> X[maybeCompressHistory]
X --> Y[Persist TaskState]
Y --> Z[Final response]
```
## Memory Relationship Diagram
```mermaid
flowchart LR
CH[chatHistory\nin-memory\nrecent turns]
TS[TaskState\npersisted summary\nsystem_config]
ES[ExecutionState\npersisted workflow\nsystem_config]
PL[Planner Prompt]
CH -->|recent raw turns| PL
ES -->|current workflow JSON| PL
TS -->|durable structured context| PL
CH -->|old turns compressed| TS
PL -->|plan / observations / status| ES
```
## State Transition Diagram
```mermaid
stateDiagram-v2
[*] --> planning
planning --> running: plan created
running --> waiting_user: ask_user step
waiting_user --> planning: user replies
running --> completed: respond step finished
running --> failed: step error
failed --> planning: retry / continue / config-trader reset
completed --> planning: new relevant request or retry flow
```
## Known Design Tradeoffs
### Strengths
- separates short-term chat from durable task summary
- allows blocked flows to resume
- supports replanning after every meaningful step
- can recover from stale assumptions better for dynamic config/trader requests
### Weaknesses
- `TaskState` is still summary-driven, so summarization quality matters
- planner still depends on model compliance for some transitions
- `ExecutionState` is single-track per user, not multiple concurrent workflows
- config/trader intent detection is heuristic and keyword-based
## Practical Guidance
### When to trust `TaskState`
Trust it for:
- user intent continuity
- open loops
- durable facts
Do not trust it for:
- whether current exchange/model/trader config exists now
- whether a specific operational action is currently possible
### When to trust `ExecutionState`
Trust it for:
- current plan continuity
- exact blocked step
- latest observation chain
Do not trust it blindly when:
- user has changed configuration outside the chat
- the system capabilities changed after deployment
### When to fetch live state again
Always prefer fresh tool snapshots before answering about:
- existing model configs
- existing exchange configs
- existing traders
- whether trader creation can proceed
## Suggested Future Improvements
- add workflow versioning so capability changes invalidate stale `ExecutionState`
- separate `waiting_user_confirmation` from generic `waiting_user`
- introduce code-level handling for short confirmations such as `是`, `好`, `继续`
- move dynamic state refresh from heuristic to explicit planner preflight stage
- support multiple concurrent execution sessions per user if needed
@@ -0,0 +1,453 @@
# NOFXi Agent 记忆与规划设计
## 目的
本文说明当前 NOFXi agent 是如何处理以下能力的:
- 短期对话记忆
- 持久化任务记忆
- 持久化执行态 / 规划态
- planner 的执行与重规划
- 状态重置与恢复
本文主要对应以下实现文件:
- `agent/history.go`
- `agent/memory.go`
- `agent/execution_state.go`
- `agent/planner_runtime.go`
- `agent/agent.go`
## 总体模型
当前 agent 使用三层不同的状态:
1. `chatHistory`
用于保存当前会话最近几轮的原始用户/助手对话,驻留内存。
2. `TaskState`
用于保存跨轮次仍然有价值的结构化摘要,持久化存储。
3. `ExecutionState`
用于保存当前规划流程的执行态,支持流程中断后的继续执行。
这三层职责不同,不能混为一谈。
## 三层状态
### 1. `chatHistory`
定义位置:`agent/history.go`
作用:
- 按 `userID` 保存最近的 `user` / `assistant` 消息
- 作为短期对话上下文
- 作为后续压缩进 `TaskState` 的原始素材
特性:
- 仅在内存中存在
- 有 `maxTurns` 上限
- `/clear` 时会清空
- 不适合作为长期真相来源
典型内容:
- 最近几轮用户问题
- 最近几轮助手回答
- 临时措辞与上下文表达
### 2. `TaskState`
定义位置:`agent/memory.go`
作用:
- 保存持久化、结构化、不可轻易从工具重新推导出的上下文
- 通过 `system_config` 持久化
- 注入到 planner / reasoning prompt 中
存储 key
- `agent_task_state_<userID>`
字段:
- `CurrentGoal`
- `ActiveFlow`
- `OpenLoops`
- `ImportantFacts`
- `LastDecision`
- `UpdatedAt`
适合存放:
- 当前仍有效的用户目标
- 跨轮次仍然成立的高层未闭环问题
- 无法简单通过工具重新读取的重要事实
- 最近一次关键决策及原因
不适合存放:
- “等用户提供 API Key” 这类 step 级待办
- “调用 get_exchange_configs” 这类执行动作
- 实时余额
- 当前持仓
- 当前行情价格
- 是否存在某个配置这类会变化的状态
这些动态信息应该在规划阶段通过工具重新检查,而不是相信旧摘要。
### 3. `ExecutionState`
定义位置:`agent/execution_state.go`
作用:
- 保存当前执行中的工作流状态
- 支持 `ask_user` 之后恢复执行
- 持久化保存计划步骤、观察结果和最终状态
存储 key
- `agent_execution_state_<userID>`
字段:
- `SessionID`
- `UserID`
- `Goal`
- `Status`
- `PlanID`
- `Steps`
- `CurrentStepID`
- `Observations`
- `FinalAnswer`
- `LastError`
- `UpdatedAt`
它是 planner 的“工作态”,不是通用记忆仓库。
## 数据流
### 请求入口
入口函数:
- `HandleMessage(...)`
- `HandleMessageStream(...)`
流程:
1. 用户消息进入 `agent`
2. 优先处理 slash command 和显式直达分支
3. 其余请求进入 planner 流程:`thinkAndAct(...)` / `thinkAndActStream(...)`
### Planner 主流程
`agent/planner_runtime.go` 中的 planner 管线如下:
1. 把用户消息加入 `chatHistory`
2. 发出 `planning` SSE 事件
3. 加载 `ExecutionState`
4. 视情况重置过期的 `ExecutionState`
5. 视情况刷新动态配置快照
6. 调用 LLM 生成新的执行计划
7. 按步骤执行计划
8. 在关键状态变化后持久化 `ExecutionState`
9. 把助手回答加入 `chatHistory`
10. 视情况把旧对话压缩进 `TaskState`
## 短期记忆 vs 持久记忆
### `chatHistory` 里应该放什么
适合:
- 最近原始消息
- 对话措辞
- 最近一轮助手的表达方式
不适合:
- 长期真相
- 外部系统当前状态
### `TaskState` 里应该放什么
适合:
- 持续目标
- 跨轮次仍有意义的高层未闭环事项
- 用户明确讲过的重要事实
- 历史关键决策和原因
不适合:
- 当前 plan 中尚未执行的步骤
- “等待某个字段”“调用某个 tool” 这类执行级待办
- “系统有没有这个工具” 这种过时结论
- “当前有没有模型/交易所配置” 这种可变化状态
- 可以通过工具重新查询到的动态状态
### `ExecutionState` 里应该放什么
适合:
- 当前计划步骤
- 工具调用观察结果
- 当前是否卡在等用户补充信息
- 当前工作流的精确执行位置
- step 级待办和阻塞原因
不适合:
- 长期用户画像
- 通用长期语义记忆
## 规划逻辑
### 计划生成
`createExecutionPlan(...)` 会把以下信息送给 planner 模型:
- 当前可用 tool 定义
- 持久化用户偏好
- `TaskState` 上下文
- `ExecutionState` JSON
- 当前用户请求
planner 必须返回 JSON,且步骤类型只能是:
- `tool`
- `reason`
- `ask_user`
- `respond`
### 步骤执行
`executePlan(...)` 的执行循环如下:
- `tool`
调用工具并写入 observation
- `reason`
发起 reasoning 子调用并写入 observation
- `ask_user`
保存 `waiting_user` 状态并把问题返回给用户
- `respond`
生成最终回答并标记完成
每个步骤结束后,`replanAfterStep(...)` 还可以决定:
- continue
- replace_remaining
- ask_user
- finish
## 恢复执行
`ExecutionState.Status == waiting_user` 时,下一条用户消息会被视为对上一轮追问的回复。
当前保护机制:
- 从已有 plan 中提取最近一次追问内容
- 将用户回复作为 `user_reply` observation 追加
- 在 planner prompt 中注入显式的 `Resume context`
这样可以减少用户只回复 `是` 这类短消息时,被错误理解成全新意图的情况。
## 动态状态刷新
配置类与 trader 管理类请求本质上是动态请求,它们的真相可能在聊天之外发生变化,例如:
- 用户在 Web UI 中配置了交易所
- 用户在另一个页面新增了模型
- 用户在别处创建了 trader
因此,这类请求不能依赖旧的模型结论。
当前在 `planner_runtime.go` 中的保护措施:
- 通过 `isConfigOrTraderIntent(...)` 检测配置 / trader 意图
- 这类请求在 planner prompt 中不再注入旧 `TaskState`
- 同时刷新 `ExecutionState.Observations` 中的实时快照:
- `toolGetModelConfigs(...)`
- `toolGetExchangeConfigs(...)`
- `toolListTraders(...)`
这样 planner 会更多依赖当前系统状态,而不是依赖旧记忆中的描述。
## 重置策略
当前系统在以下场景会重置或弱化旧执行态:
- 用户说了类似 `再试``继续``try again``continue`
- 当前请求是配置 / trader 相关,并且旧 `ExecutionState` 已经失败 / 完成 / 正在等待用户
重置范围:
- `ExecutionState` 可能会被清空
- `TaskState` 不会整体删除,但在配置 / trader 请求中会被主动忽略
手动清理:
- `/clear`
这条命令会清掉:
- 短期 chat history
- task state
- execution state
## 压缩设计
`maybeCompressHistory(...)` 会在以下条件满足时把旧的短期对话压缩进 `TaskState`
- 最近消息数超过窗口
- 估算 token 数超过阈值
压缩流程:
1. 保留最近若干轮对话在 `chatHistory`
2. 把更早的内容总结成结构化 `TaskState`
3. 持久化新的 `TaskState`
4. 用最近消息切片替换 `chatHistory`
重要设计原则:
- `TaskState` 只保留长期有效上下文
- 不能把它变成动态运营状态的陈旧副本
## 当前架构图
```mermaid
flowchart TD
U[用户消息] --> A[HandleMessage / HandleMessageStream]
A --> B{是否命中直达分支?}
B -->|是| C[直接处理 slash command 或快捷分支]
B -->|否| D[thinkAndAct / thinkAndActStream]
D --> E[写入 chatHistory]
D --> F[加载 ExecutionState]
F --> G{是否 waiting_user?}
G -->|是| H[追加 user_reply observation]
G -->|否| I[创建新的 ExecutionState]
H --> J[若为配置或 trader 请求则刷新动态快照]
I --> J
J --> K[createExecutionPlan 调用 LLM]
K --> L[得到 execution plan]
L --> M[executePlan 循环执行]
M --> N[tool step]
M --> O[reason step]
M --> P[ask_user step]
M --> Q[respond step]
N --> R[写入 Observation]
O --> R
R --> S[replanAfterStep]
S --> M
P --> T[持久化 waiting_user ExecutionState]
T --> UQ[向用户返回追问]
Q --> V[持久化 completed ExecutionState]
V --> W[把 assistant 回复写入 chatHistory]
W --> X[maybeCompressHistory]
X --> Y[持久化 TaskState]
Y --> Z[返回最终回答]
```
## 记忆关系图
```mermaid
flowchart LR
CH[chatHistory\n内存态\n最近对话]
TS[TaskState\n持久化摘要\nsystem_config]
ES[ExecutionState\n持久化执行态\nsystem_config]
PL[Planner Prompt]
CH -->|最近原始对话| PL
ES -->|当前工作流 JSON| PL
TS -->|长期结构化上下文| PL
CH -->|旧消息压缩| TS
PL -->|计划 / 观察 / 状态| ES
```
## 状态转换图
```mermaid
stateDiagram-v2
[*] --> planning
planning --> running: plan created
running --> waiting_user: ask_user step
waiting_user --> planning: user replies
running --> completed: respond step finished
running --> failed: step error
failed --> planning: retry / continue / config-trader reset
completed --> planning: new relevant request or retry flow
```
## 当前设计的取舍
### 优点
- 将短期对话与长期摘要分离
- 支持在 `ask_user` 之后恢复执行
- 每个关键步骤后都支持重规划
- 对配置 / 创建 trader 这类动态请求,已经能更好抵抗旧结论污染
### 缺点
- `TaskState` 的质量仍然依赖总结效果
- 某些恢复逻辑仍依赖模型是否听话
- 每个用户当前只有一条 `ExecutionState`,不支持多个并发工作流
- 配置 / trader 意图识别目前仍是关键词启发式
## 实践建议
### 什么时候该相信 `TaskState`
应该相信它用于:
- 延续用户目标
- 跟踪未完成事项
- 保留长期有效事实
不应该相信它用于:
- 当前是否存在模型 / 交易所 / trader 配置
- 当前是否能够执行某个操作
### 什么时候该相信 `ExecutionState`
应该相信它用于:
- 当前工作流是否仍然连续
- 当前阻塞在哪一步
- 最近的 observation 链条
不应该盲信它用于:
- 用户在聊天外已经修改过配置的场景
- 系统能力或工具集发生变化后的旧结论
### 什么时候必须重新获取实时状态
以下场景应该优先重新通过工具获取:
- 当前模型配置
- 当前交易所配置
- 当前 trader 列表
- 当前是否满足 trader 创建条件
## 后续建议
- 为 `ExecutionState` 增加版本号或能力签名,能力变化时自动失效
- 将 `waiting_user_confirmation` 与通用 `waiting_user` 分开
- 对 `是``好``继续` 这类短确认增加代码级识别
- 将动态快照刷新从启发式升级为显式 planner 预检查阶段
- 如果后续需要,支持一个用户多条并发执行会话
+19 -1
View File
@@ -1,13 +1,15 @@
package main package main
import ( import (
"log/slog"
"nofx/api" "nofx/api"
nofxiagent "nofx/agent"
"nofx/auth" "nofx/auth"
"nofx/config" "nofx/config"
"nofx/crypto" "nofx/crypto"
"nofx/telemetry"
"nofx/logger" "nofx/logger"
"nofx/manager" "nofx/manager"
"nofx/telemetry"
_ "nofx/mcp/payment" _ "nofx/mcp/payment"
_ "nofx/mcp/provider" _ "nofx/mcp/provider"
"nofx/store" "nofx/store"
@@ -141,6 +143,14 @@ func main() {
} }
}() }()
// Start the NOFXi web agent on top of the current dev branch services.
nofxiAgent := nofxiagent.New(traderManager, st, nil, slog.Default())
nofxiAgent.Start()
defer nofxiAgent.Stop()
agentWeb := nofxiagent.NewWebHandler(nofxiAgent, slog.Default())
server.RegisterAgentHandler(agentWeb)
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured) // Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
go telegram.Start(cfg, st, telegramReloadCh) go telegram.Start(cfg, st, telegramReloadCh)
@@ -154,6 +164,14 @@ func main() {
<-quit <-quit
logger.Info("📴 Shutdown signal received, closing system...") logger.Info("📴 Shutdown signal received, closing system...")
if err := server.Shutdown(); err != nil {
logger.Warnf("⚠️ HTTP server shutdown error: %v", err)
}
logger.Info("✅ HTTP server stopped")
nofxiAgent.Stop()
logger.Info("✅ NOFXi agent stopped")
// Stop all traders // Stop all traders
traderManager.StopAll() traderManager.StopAll()
logger.Info("✅ System shut down safely") logger.Info("✅ System shut down safely")
+16 -9
View File
@@ -11,6 +11,13 @@ import (
"time" "time"
) )
func traderLogTag(traderID, traderName string) string {
if traderName != "" {
return fmt.Sprintf("[trader_id=%s trader_name=%s]", traderID, traderName)
}
return fmt.Sprintf("[trader_id=%s]", traderID)
}
// CompetitionCache competition data cache // CompetitionCache competition data cache
type CompetitionCache struct { type CompetitionCache struct {
data map[string]interface{} data map[string]interface{}
@@ -88,9 +95,9 @@ func (tm *TraderManager) StartAll() {
logger.Info("🚀 Starting all traders...") logger.Info("🚀 Starting all traders...")
for id, t := range tm.traders { for id, t := range tm.traders {
go func(traderID string, at *trader.AutoTrader) { go func(traderID string, at *trader.AutoTrader) {
logger.Infof("▶️ Starting %s...", at.GetName()) logger.Infof("%s ▶️ Starting trader runtime", traderLogTag(traderID, at.GetName()))
if err := at.Run(); err != nil { if err := at.Run(); err != nil {
logger.Infof("%s runtime error: %v", at.GetName(), err) logger.Warnf("%s runtime error: %v", traderLogTag(traderID, at.GetName()), err)
} }
}(id, t) }(id, t)
} }
@@ -136,9 +143,9 @@ func (tm *TraderManager) AutoStartRunningTraders(st *store.Store) {
for id, t := range tm.traders { for id, t := range tm.traders {
if runningTraderIDs[id] { if runningTraderIDs[id] {
go func(traderID string, at *trader.AutoTrader) { go func(traderID string, at *trader.AutoTrader) {
logger.Infof("▶️ Auto-restoring %s...", at.GetName()) logger.Infof("%s ▶️ Auto-restoring trader runtime", traderLogTag(traderID, at.GetName()))
if err := at.Run(); err != nil { if err := at.Run(); err != nil {
logger.Infof("%s runtime error: %v", at.GetName(), err) logger.Warnf("%s runtime error: %v", traderLogTag(traderID, at.GetName()), err)
} }
}(id, t) }(id, t)
startedCount++ startedCount++
@@ -487,7 +494,7 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
logger.Infof("📦 Loading trader %s (AI Model: %s, Exchange: %s/%s, Strategy ID: %s)", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ExchangeType, exchangeCfg.AccountName, traderCfg.StrategyID) logger.Infof("📦 Loading trader %s (AI Model: %s, Exchange: %s/%s, Strategy ID: %s)", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ExchangeType, exchangeCfg.AccountName, traderCfg.StrategyID)
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st) err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
if err != nil { if err != nil {
logger.Infof("❌ Failed to load trader %s: %v", traderCfg.Name, err) logger.Warnf("%s failed to load trader: %v", traderLogTag(traderCfg.ID, traderCfg.Name), err)
// Save error for later retrieval // Save error for later retrieval
tm.loadErrors[traderCfg.ID] = err tm.loadErrors[traderCfg.ID] = err
} else { } else {
@@ -592,7 +599,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
// Add to TraderManager (ai500APIURL/oiTopAPIURL already obtained from strategy config) // Add to TraderManager (ai500APIURL/oiTopAPIURL already obtained from strategy config)
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st) err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
if err != nil { if err != nil {
logger.Infof("❌ Failed to add trader %s: %v", traderCfg.Name, err) logger.Warnf("%s failed to add trader: %v", traderLogTag(traderCfg.ID, traderCfg.Name), err)
continue continue
} }
} }
@@ -727,17 +734,17 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
// Auto-start if trader was running before shutdown // Auto-start if trader was running before shutdown
if traderCfg.IsRunning { if traderCfg.IsRunning {
logger.Infof("🔄 Auto-starting trader '%s' (was running before shutdown)...", traderCfg.Name) logger.Infof("%s 🔄 Auto-starting trader (was running before shutdown)...", traderLogTag(traderCfg.ID, traderCfg.Name))
go func(trader *trader.AutoTrader, traderName, traderID, userID string) { go func(trader *trader.AutoTrader, traderName, traderID, userID string) {
if err := trader.Run(); err != nil { if err := trader.Run(); err != nil {
logger.Warnf("⚠️ Trader '%s' stopped with error: %v", traderName, err) logger.Warnf("%s trader stopped with error: %v", traderLogTag(traderID, traderName), err)
// Update database to reflect stopped state // Update database to reflect stopped state
if st != nil { if st != nil {
_ = st.Trader().UpdateStatus(userID, traderID, false) _ = st.Trader().UpdateStatus(userID, traderID, false)
} }
} }
}(at, traderCfg.Name, traderCfg.ID, traderCfg.UserID) }(at, traderCfg.Name, traderCfg.ID, traderCfg.UserID)
logger.Infof("✅ Trader '%s' auto-started successfully", traderCfg.Name) logger.Infof("%s ✅ Trader auto-started successfully", traderLogTag(traderCfg.ID, traderCfg.Name))
} }
return nil return nil
+5
View File
@@ -1,5 +1,7 @@
package mcp package mcp
import "context"
// Message represents a conversation message. // Message represents a conversation message.
// Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls), // Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls),
// and tool result messages (Role="tool", ToolCallID, Content). // and tool result messages (Role="tool", ToolCallID, Content).
@@ -62,6 +64,9 @@ type Request struct {
// Advanced features // Advanced features
Tools []Tool `json:"tools,omitempty"` // Available tools list Tools []Tool `json:"tools,omitempty"` // Available tools list
ToolChoice string `json:"tool_choice,omitempty"` // Tool choice strategy ("auto", "none", {"type": "function", "function": {"name": "xxx"}}) ToolChoice string `json:"tool_choice,omitempty"` // Tool choice strategy ("auto", "none", {"type": "function", "function": {"name": "xxx"}})
// Context for cancellation; not serialized.
Ctx context.Context `json:"-"`
} }
// NewMessage creates a message // NewMessage creates a message
+59
View File
@@ -0,0 +1,59 @@
// Package safe provides panic-recovery wrappers for goroutines.
// A panic in any bare goroutine tears down the entire process.
// Use safe.Go instead of `go func()` in long-running or critical paths.
package safe
import (
"fmt"
"nofx/logger"
"runtime/debug"
)
// Go launches fn in a new goroutine with automatic panic recovery.
// If fn panics, the panic is logged (with stack trace) but the process
// continues running. An optional onPanic callback receives the recovered value.
func Go(fn func(), onPanic ...func(recovered interface{})) {
go func() {
defer func() {
if r := recover(); r != nil {
stack := string(debug.Stack())
logger.Errorf("🔥 goroutine panic recovered: %v\n%s", r, stack)
for _, cb := range onPanic {
func() {
defer func() {
if r2 := recover(); r2 != nil {
logger.Errorf("🔥 onPanic callback itself panicked: %v", r2)
}
}()
cb(r)
}()
}
}
}()
fn()
}()
}
// GoNamed is like Go but tags the log line with a human-readable name.
func GoNamed(name string, fn func(), onPanic ...func(recovered interface{})) {
Go(func() {
fn()
}, append([]func(interface{}){
func(r interface{}) {
logger.Errorf("🔥 [%s] goroutine panicked: %v", name, r)
},
}, onPanic...)...)
}
// Must converts a panic into an error. Useful inside goroutines where you
// want to handle panics as errors in the caller's recovery flow.
func Must(fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v\n%s", r, debug.Stack())
}
}()
fn()
return nil
}
+29
View File
@@ -0,0 +1,29 @@
// Package safe provides safe I/O helpers.
package safe
import (
"fmt"
"io"
)
// MaxResponseBody is the default maximum size for HTTP response bodies (10MB).
const MaxResponseBody = 10 * 1024 * 1024
// ReadAllLimited reads all bytes from r up to maxBytes.
// If maxBytes <= 0, it defaults to MaxResponseBody (10MB).
// Returns an error if the response exceeds the limit.
func ReadAllLimited(r io.Reader, maxBytes ...int64) ([]byte, error) {
limit := int64(MaxResponseBody)
if len(maxBytes) > 0 && maxBytes[0] > 0 {
limit = maxBytes[0]
}
lr := io.LimitReader(r, limit+1)
data, err := io.ReadAll(lr)
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("response body exceeds %d bytes limit", limit)
}
return data, nil
}
+17 -4
View File
@@ -131,7 +131,7 @@ func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
if userID == "" { if userID == "" {
userID = "default" userID = "default"
} }
model, err := s.firstEnabled(userID) model, err := s.firstEnabledUsable(userID)
if err == nil { if err == nil {
return model, nil return model, nil
} }
@@ -139,14 +139,14 @@ func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
return nil, err return nil, err
} }
if userID != "default" { if userID != "default" {
return s.firstEnabled("default") return s.firstEnabledUsable("default")
} }
return nil, fmt.Errorf("please configure an available AI model in the system first") return nil, fmt.Errorf("please configure an available AI model in the system first")
} }
func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) { func (s *AIModelStore) firstEnabledUsable(userID string) (*AIModel, error) {
var model AIModel var model AIModel
err := s.db.Where("user_id = ? AND enabled = ?", userID, true). err := s.db.Where("user_id = ? AND enabled = ? AND api_key != ''", userID, true).
Order("updated_at DESC, id ASC"). Order("updated_at DESC, id ASC").
First(&model).Error First(&model).Error
if err != nil { if err != nil {
@@ -303,3 +303,16 @@ func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, a
// Use FirstOrCreate to ignore if already exists // Use FirstOrCreate to ignore if already exists
return s.db.Where("id = ?", id).FirstOrCreate(model).Error return s.db.Where("id = ?", id).FirstOrCreate(model).Error
} }
// Delete removes a user-owned AI model configuration.
func (s *AIModelStore) Delete(userID, id string) error {
result := s.db.Where("user_id = ? AND id = ?", userID, id).Delete(&AIModel{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("ai model not found: id=%s, userID=%s", id, userID)
}
logger.Infof("🗑️ Deleted AI model: id=%s, userID=%s", id, userID)
return nil
}
+59 -18
View File
@@ -24,6 +24,31 @@ import (
"time" "time"
) )
func (at *AutoTrader) logTag() string {
if at == nil {
return "[trader_id=unknown]"
}
if at.name != "" {
return fmt.Sprintf("[trader_id=%s trader_name=%s]", at.id, at.name)
}
return fmt.Sprintf("[trader_id=%s]", at.id)
}
func (at *AutoTrader) logInfof(format string, args ...interface{}) {
values := append([]interface{}{at.logTag()}, args...)
logger.Infof("%s "+format, values...)
}
func (at *AutoTrader) logWarnf(format string, args ...interface{}) {
values := append([]interface{}{at.logTag()}, args...)
logger.Warnf("%s "+format, values...)
}
func (at *AutoTrader) logErrorf(format string, args ...interface{}) {
values := append([]interface{}{at.logTag()}, args...)
logger.Errorf("%s "+format, values...)
}
// AutoTraderConfig auto trading configuration (simplified version - AI makes all decisions) // AutoTraderConfig auto trading configuration (simplified version - AI makes all decisions)
type AutoTraderConfig struct { type AutoTraderConfig struct {
// Trader identification // Trader identification
@@ -381,8 +406,8 @@ func (at *AutoTrader) Run() error {
at.startTime = time.Now() at.startTime = time.Now()
logger.Info("🚀 AI-driven automatic trading system started") logger.Info("🚀 AI-driven automatic trading system started")
logger.Infof("💰 Initial balance: %.2f USDT", at.initialBalance) at.logInfof("💰 Initial balance: %.2f USDT", at.initialBalance)
logger.Infof("⚙️ Scan interval: %v", at.config.ScanInterval) at.logInfof("⚙️ Scan interval: %v", at.config.ScanInterval)
logger.Info("🤖 AI will make full decisions on leverage, position size, stop loss/take profit, etc.") logger.Info("🤖 AI will make full decisions on leverage, position size, stop loss/take profit, etc.")
// Pre-launch checks for claw402 users // Pre-launch checks for claw402 users
@@ -397,7 +422,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "lighter" { if at.exchange == "lighter" {
if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil { if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil {
lighterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) lighterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name) at.logInfof("🔄 Lighter order+position sync enabled (every 30s)")
} }
} }
@@ -405,7 +430,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "hyperliquid" { if at.exchange == "hyperliquid" {
if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil { if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil {
hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name) at.logInfof("🔄 Hyperliquid order+position sync enabled (every 30s)")
} }
} }
@@ -413,7 +438,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "bybit" { if at.exchange == "bybit" {
if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil { if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil {
bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name) at.logInfof("🔄 Bybit order+position sync enabled (every 30s)")
} }
} }
@@ -421,7 +446,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "okx" { if at.exchange == "okx" {
if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil { if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil {
okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name) at.logInfof("🔄 OKX order+position sync enabled (every 30s)")
} }
} }
@@ -429,7 +454,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "bitget" { if at.exchange == "bitget" {
if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil { if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil {
bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name) at.logInfof("🔄 Bitget order+position sync enabled (every 30s)")
} }
} }
@@ -437,7 +462,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "aster" { if at.exchange == "aster" {
if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil { if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil {
asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name) at.logInfof("🔄 Aster order+position sync enabled (every 30s)")
} }
} }
@@ -445,7 +470,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "binance" { if at.exchange == "binance" {
if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil { if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil {
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name) at.logInfof("🔄 Binance order+position sync enabled (every 30s)")
} }
} }
@@ -453,7 +478,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "gate" { if at.exchange == "gate" {
if gateTrader, ok := at.trader.(*gate.GateTrader); ok && at.store != nil { if gateTrader, ok := at.trader.(*gate.GateTrader); ok && at.store != nil {
gateTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) gateTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Gate order+position sync enabled (every 30s)", at.name) at.logInfof("🔄 Gate order+position sync enabled (every 30s)")
} }
} }
@@ -461,7 +486,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "kucoin" { if at.exchange == "kucoin" {
if kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil { if kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil {
kucoinTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) kucoinTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] KuCoin order+position sync enabled (every 30s)", at.name) at.logInfof("🔄 KuCoin order+position sync enabled (every 30s)")
} }
} }
@@ -471,9 +496,9 @@ func (at *AutoTrader) Run() error {
// Check if this is a grid trading strategy // Check if this is a grid trading strategy
isGridStrategy := at.IsGridStrategy() isGridStrategy := at.IsGridStrategy()
if isGridStrategy { if isGridStrategy {
logger.Infof("🔲 [%s] Grid trading strategy detected, initializing grid...", at.name) at.logInfof("🔲 Grid trading strategy detected, initializing grid...")
if err := at.InitializeGrid(); err != nil { if err := at.InitializeGrid(); err != nil {
logger.Errorf("❌ [%s] Failed to initialize grid: %v", at.name, err) at.logErrorf("❌ Failed to initialize grid: %v", err)
return fmt.Errorf("grid initialization failed: %w", err) return fmt.Errorf("grid initialization failed: %w", err)
} }
} }
@@ -481,11 +506,11 @@ func (at *AutoTrader) Run() error {
// Execute immediately on first run // Execute immediately on first run
if isGridStrategy { if isGridStrategy {
if err := at.RunGridCycle(); err != nil { if err := at.RunGridCycle(); err != nil {
logger.Infof("❌ Grid execution failed: %v", err) at.logErrorf("❌ Grid execution failed: %v", err)
} }
} else { } else {
if err := at.runCycle(); err != nil { if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err) at.logErrorf("❌ Execution failed: %v", err)
} }
} }
@@ -502,15 +527,15 @@ func (at *AutoTrader) Run() error {
case <-ticker.C: case <-ticker.C:
if isGridStrategy { if isGridStrategy {
if err := at.RunGridCycle(); err != nil { if err := at.RunGridCycle(); err != nil {
logger.Infof("❌ Grid execution failed: %v", err) at.logErrorf("❌ Grid execution failed: %v", err)
} }
} else { } else {
if err := at.runCycle(); err != nil { if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err) at.logErrorf("❌ Execution failed: %v", err)
} }
} }
case <-at.stopMonitorCh: case <-at.stopMonitorCh:
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name) at.logInfof("⏹ Stop signal received, exiting automatic trading main loop")
return nil return nil
} }
} }
@@ -590,6 +615,22 @@ func (at *AutoTrader) GetSystemPromptTemplate() string {
return "strategy" return "strategy"
} }
// GetCandidateCoins returns the current candidate coin set from the trader's strategy engine.
func (at *AutoTrader) GetCandidateCoins() ([]kernel.CandidateCoin, error) {
if at.strategyEngine == nil {
return nil, fmt.Errorf("strategy engine not configured")
}
return at.strategyEngine.GetCandidateCoins()
}
// GetStrategyConfig returns the current strategy config used by the trader.
func (at *AutoTrader) GetStrategyConfig() *store.StrategyConfig {
if at.strategyEngine == nil {
return at.config.StrategyConfig
}
return at.strategyEngine.GetConfig()
}
// GetStore gets data store (for external access to decision records, etc.) // GetStore gets data store (for external access to decision records, etc.)
func (at *AutoTrader) GetStore() *store.Store { func (at *AutoTrader) GetStore() *store.Store {
return at.store return at.store
+30 -30
View File
@@ -24,7 +24,7 @@ func (at *AutoTrader) runCycle() error {
running := at.isRunning running := at.isRunning
at.isRunningMutex.RUnlock() at.isRunningMutex.RUnlock()
if !running { if !running {
logger.Infof("⏹ Trader is stopped, aborting cycle #%d", at.callCount) at.logInfof("⏹ Trader is stopped, aborting cycle #%d", at.callCount)
return nil return nil
} }
@@ -42,7 +42,7 @@ func (at *AutoTrader) runCycle() error {
// 1. Check if trading needs to be stopped // 1. Check if trading needs to be stopped
if time.Now().Before(at.stopUntil) { if time.Now().Before(at.stopUntil) {
remaining := at.stopUntil.Sub(time.Now()) remaining := at.stopUntil.Sub(time.Now())
logger.Infof("⏸ Risk control: Trading paused, remaining %.0f minutes", remaining.Minutes()) at.logWarnf("⏸ Risk control: Trading paused, remaining %.0f minutes", remaining.Minutes())
record.Success = false record.Success = false
record.ErrorMessage = fmt.Sprintf("Risk control paused, remaining %.0f minutes", remaining.Minutes()) record.ErrorMessage = fmt.Sprintf("Risk control paused, remaining %.0f minutes", remaining.Minutes())
at.saveDecision(record) at.saveDecision(record)
@@ -59,6 +59,7 @@ func (at *AutoTrader) runCycle() error {
// 4. Collect trading context // 4. Collect trading context
ctx, err := at.buildTradingContext() ctx, err := at.buildTradingContext()
if err != nil { if err != nil {
at.logErrorf("failed to build trading context: %v", err)
record.Success = false record.Success = false
record.ErrorMessage = fmt.Sprintf("Failed to build trading context: %v", err) record.ErrorMessage = fmt.Sprintf("Failed to build trading context: %v", err)
at.saveDecision(record) at.saveDecision(record)
@@ -71,7 +72,7 @@ func (at *AutoTrader) runCycle() error {
// If no candidate coins available, log but do not error // If no candidate coins available, log but do not error
if len(ctx.CandidateCoins) == 0 { if len(ctx.CandidateCoins) == 0 {
logger.Infof(" No candidate coins available, skipping this cycle") at.logInfof("️ No candidate coins available, skipping this cycle")
record.Success = true // Not an error, just no candidate coins record.Success = true // Not an error, just no candidate coins
record.ExecutionLog = append(record.ExecutionLog, "No candidate coins available, cycle skipped") record.ExecutionLog = append(record.ExecutionLog, "No candidate coins available, cycle skipped")
record.AccountState = store.AccountSnapshot{ record.AccountState = store.AccountSnapshot{
@@ -90,16 +91,16 @@ func (at *AutoTrader) runCycle() error {
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol) record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
} }
logger.Infof("📊 Account equity: %.2f USDT | Available: %.2f USDT | Positions: %d", at.logInfof("📊 Account equity: %.2f USDT | Available: %.2f USDT | Positions: %d",
ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount) ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount)
// 5. Use strategy engine to call AI for decision // 5. Use strategy engine to call AI for decision
logger.Infof("🤖 Requesting AI analysis and decision... [Strategy Engine]") at.logInfof("🤖 Requesting AI analysis and decision... [Strategy Engine]")
aiDecision, err := kernel.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced") aiDecision, err := kernel.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced")
if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 { if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
record.AIRequestDurationMs = aiDecision.AIRequestDurationMs record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
logger.Infof("⏱️ AI call duration: %.2f seconds", float64(record.AIRequestDurationMs)/1000) at.logInfof("⏱️ AI call duration: %.2f seconds", float64(record.AIRequestDurationMs)/1000)
record.ExecutionLog = append(record.ExecutionLog, record.ExecutionLog = append(record.ExecutionLog,
fmt.Sprintf("AI call duration: %d ms", record.AIRequestDurationMs)) fmt.Sprintf("AI call duration: %d ms", record.AIRequestDurationMs))
} }
@@ -119,7 +120,7 @@ func (at *AutoTrader) runCycle() error {
// Record AI charge (track cost regardless of decision outcome) // Record AI charge (track cost regardless of decision outcome)
if aiDecision != nil && at.store != nil { if aiDecision != nil && at.store != nil {
if chargeErr := at.store.AICharge().Record(at.id, at.aiModel, at.config.AIModel); chargeErr != nil { if chargeErr := at.store.AICharge().Record(at.id, at.aiModel, at.config.AIModel); chargeErr != nil {
logger.Warnf("⚠️ Failed to record AI charge: %v", chargeErr) at.logWarnf("⚠️ Failed to record AI charge: %v", chargeErr)
} }
} }
@@ -132,10 +133,9 @@ func (at *AutoTrader) runCycle() error {
if at.consecutiveAIFailures >= 3 && !at.safeMode { if at.consecutiveAIFailures >= 3 && !at.safeMode {
at.safeMode = true at.safeMode = true
at.safeModeReason = fmt.Sprintf("AI failed %d consecutive times: %v", at.consecutiveAIFailures, err) at.safeModeReason = fmt.Sprintf("AI failed %d consecutive times: %v", at.consecutiveAIFailures, err)
logger.Errorf("🛡️ [%s] SAFE MODE ACTIVATED — AI failed %d times in a row. No new positions will be opened. Existing positions are protected with current stop-loss settings.", at.logErrorf("🛡️ SAFE MODE ACTIVATED — AI failed %d times in a row. No new positions will be opened. Existing positions are protected with current stop-loss settings.", at.consecutiveAIFailures)
at.name, at.consecutiveAIFailures) at.logErrorf("🛡️ Reason: %v", err)
logger.Errorf("🛡️ [%s] Reason: %v", at.name, err) at.logErrorf("🛡️ Action: Will keep trying AI each cycle. Safe mode auto-deactivates when AI recovers.")
logger.Errorf("🛡️ [%s] Action: Will keep trying AI each cycle. Safe mode auto-deactivates when AI recovers.", at.name)
} }
// Print system prompt and AI chain of thought (output even with errors for debugging) // Print system prompt and AI chain of thought (output even with errors for debugging)
@@ -159,7 +159,7 @@ func (at *AutoTrader) runCycle() error {
// In safe mode, don't return error — keep the loop running to retry next cycle // In safe mode, don't return error — keep the loop running to retry next cycle
if at.safeMode { if at.safeMode {
logger.Warnf("🛡️ [%s] Safe mode: skipping this cycle, will retry in %v", at.name, at.config.ScanInterval) at.logWarnf("🛡️ Safe mode: skipping this cycle, will retry in %v", at.config.ScanInterval)
return nil return nil
} }
@@ -168,11 +168,11 @@ func (at *AutoTrader) runCycle() error {
// AI succeeded — reset failure counter and deactivate safe mode // AI succeeded — reset failure counter and deactivate safe mode
if at.consecutiveAIFailures > 0 { if at.consecutiveAIFailures > 0 {
logger.Infof("✅ [%s] AI recovered after %d consecutive failures", at.name, at.consecutiveAIFailures) at.logInfof("✅ AI recovered after %d consecutive failures", at.consecutiveAIFailures)
} }
at.consecutiveAIFailures = 0 at.consecutiveAIFailures = 0
if at.safeMode { if at.safeMode {
logger.Infof("🛡️ [%s] SAFE MODE DEACTIVATED — AI is working again. Resuming normal trading.", at.name) at.logInfof("🛡️ SAFE MODE DEACTIVATED — AI is working again. Resuming normal trading.")
at.safeMode = false at.safeMode = false
at.safeModeReason = "" at.safeModeReason = ""
} }
@@ -219,7 +219,7 @@ func (at *AutoTrader) runCycle() error {
running = at.isRunning running = at.isRunning
at.isRunningMutex.RUnlock() at.isRunningMutex.RUnlock()
if !running { if !running {
logger.Infof("⏹ Trader stopped before decision execution, aborting cycle #%d", at.callCount) at.logInfof("⏹ Trader stopped before decision execution, aborting cycle #%d", at.callCount)
return nil return nil
} }
@@ -228,14 +228,14 @@ func (at *AutoTrader) runCycle() error {
filtered := make([]kernel.Decision, 0) filtered := make([]kernel.Decision, 0)
for _, d := range sortedDecisions { for _, d := range sortedDecisions {
if d.Action == "open_long" || d.Action == "open_short" { if d.Action == "open_long" || d.Action == "open_short" {
logger.Warnf("🛡️ [%s] Safe mode: BLOCKED %s %s (no new positions allowed)", at.name, d.Action, d.Symbol) at.logWarnf("🛡️ Safe mode: BLOCKED %s %s (no new positions allowed)", d.Action, d.Symbol)
continue continue
} }
filtered = append(filtered, d) filtered = append(filtered, d)
} }
sortedDecisions = filtered sortedDecisions = filtered
if len(sortedDecisions) == 0 { if len(sortedDecisions) == 0 {
logger.Infof("🛡️ [%s] Safe mode: all decisions were open positions, nothing to execute", at.name) at.logInfof("🛡️ Safe mode: all decisions were open positions, nothing to execute")
} }
} }
@@ -246,7 +246,7 @@ func (at *AutoTrader) runCycle() error {
running = at.isRunning running = at.isRunning
at.isRunningMutex.RUnlock() at.isRunningMutex.RUnlock()
if !running { if !running {
logger.Infof("⏹ Trader stopped during decision execution, aborting remaining decisions") at.logInfof("⏹ Trader stopped during decision execution, aborting remaining decisions")
break break
} }
@@ -265,7 +265,7 @@ func (at *AutoTrader) runCycle() error {
} }
if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil { if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil {
logger.Infof("❌ Failed to execute decision (%s %s): %v", d.Symbol, d.Action, err) at.logErrorf("❌ Failed to execute decision (%s %s): %v", d.Symbol, d.Action, err)
actionRecord.Error = err.Error() actionRecord.Error = err.Error()
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("❌ %s %s failed: %v", d.Symbol, d.Action, err)) record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("❌ %s %s failed: %v", d.Symbol, d.Action, err))
} else { } else {
@@ -280,7 +280,7 @@ func (at *AutoTrader) runCycle() error {
// 9. Save decision record // 9. Save decision record
if err := at.saveDecision(record); err != nil { if err := at.saveDecision(record); err != nil {
logger.Infof("⚠ Failed to save decision record: %v", err) at.logWarnf("⚠ Failed to save decision record: %v", err)
} }
return nil return nil
@@ -417,12 +417,12 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// 3. Use strategy engine to get candidate coins (must have strategy engine) // 3. Use strategy engine to get candidate coins (must have strategy engine)
var candidateCoins []kernel.CandidateCoin var candidateCoins []kernel.CandidateCoin
if at.strategyEngine == nil { if at.strategyEngine == nil {
logger.Infof("⚠️ [%s] No strategy engine configured, skipping candidate coins", at.name) at.logWarnf("⚠️ No strategy engine configured, skipping candidate coins")
} else { } else {
coins, err := at.strategyEngine.GetCandidateCoins() coins, err := at.strategyEngine.GetCandidateCoins()
if err != nil { if err != nil {
// Log warning but don't fail - equity snapshot should still be saved // Log warning but don't fail - equity snapshot should still be saved
logger.Infof("⚠️ [%s] Failed to get candidate coins: %v (will use empty list)", at.name, err) at.logWarnf("⚠️ Failed to get candidate coins: %v (will use empty list)", err)
} else { } else {
candidateCoins = coins candidateCoins = coins
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins)) logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
@@ -473,7 +473,7 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// Get recent 10 closed trades for AI context // Get recent 10 closed trades for AI context
recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10) recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10)
if err != nil { if err != nil {
logger.Infof("⚠️ [%s] Failed to get recent trades: %v", at.name, err) at.logWarnf("⚠️ Failed to get recent trades: %v", err)
} else { } else {
logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades)) logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades))
for _, trade := range recentTrades { for _, trade := range recentTrades {
@@ -503,11 +503,11 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// Get trading statistics for AI context // Get trading statistics for AI context
stats, err := at.store.Position().GetFullStats(at.id) stats, err := at.store.Position().GetFullStats(at.id)
if err != nil { if err != nil {
logger.Infof("⚠️ [%s] Failed to get trading stats: %v", at.name, err) at.logWarnf("⚠️ Failed to get trading stats: %v", err)
} else if stats == nil { } else if stats == nil {
logger.Infof("⚠️ [%s] GetFullStats returned nil", at.name) at.logWarnf("⚠️ GetFullStats returned nil")
} else if stats.TotalTrades == 0 { } else if stats.TotalTrades == 0 {
logger.Infof("⚠️ [%s] GetFullStats returned 0 trades (traderID=%s)", at.name, at.id) at.logWarnf("⚠️ GetFullStats returned 0 trades")
} else { } else {
ctx.TradingStats = &kernel.TradingStats{ ctx.TradingStats = &kernel.TradingStats{
TotalTrades: stats.TotalTrades, TotalTrades: stats.TotalTrades,
@@ -523,7 +523,7 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
at.name, stats.TotalTrades, stats.WinRate, stats.ProfitFactor, stats.SharpeRatio, stats.MaxDrawdownPct) at.name, stats.TotalTrades, stats.WinRate, stats.ProfitFactor, stats.SharpeRatio, stats.MaxDrawdownPct)
} }
} else { } else {
logger.Infof("⚠️ [%s] Store is nil, cannot get recent trades", at.name) at.logWarnf("⚠️ Store is nil, cannot get recent trades")
} }
// 8. Get quantitative data (if enabled in strategy config) // 8. Get quantitative data (if enabled in strategy config)
@@ -630,15 +630,15 @@ func (at *AutoTrader) checkClaw402Balance() {
if at.claw402WalletAddr != "" { if at.claw402WalletAddr != "" {
balance, err := wallet.QueryUSDCBalance(at.claw402WalletAddr) balance, err := wallet.QueryUSDCBalance(at.claw402WalletAddr)
if err != nil { if err != nil {
logger.Warnf("⚠️ [%s] Failed to query USDC balance: %v", at.name, err) at.logWarnf("⚠️ Failed to query USDC balance: %v", err)
return return
} }
if balance < 1.0 { if balance < 1.0 {
logger.Warnf("⚠️ [%s] Low USDC balance: $%.2f — AI may stop soon!", at.name, balance) at.logWarnf("⚠️ Low USDC balance: $%.2f — AI may stop soon!", balance)
} }
if balance <= 0 { if balance <= 0 {
logger.Errorf("🚨 [%s] USDC balance is ZERO — AI calls will fail!", at.name) at.logErrorf("🚨 USDC balance is ZERO — AI calls will fail!")
} }
runway := float64(0) runway := float64(0)
+104
View File
@@ -0,0 +1,104 @@
interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
interface AgentStepPanelProps {
steps?: AgentStep[]
visible?: boolean
}
const statusStyles: Record<AgentStep['status'], { dot: string; text: string }> = {
planning: { dot: '#7c3aed', text: '#c4b5fd' },
pending: { dot: 'rgba(255,255,255,0.18)', text: '#818198' },
running: { dot: '#F0B90B', text: '#f6d67a' },
completed: { dot: '#00e5a0', text: '#9cf5d5' },
replanned: { dot: '#38bdf8', text: '#9bdcf7' },
}
export function AgentStepPanel({ steps, visible }: AgentStepPanelProps) {
if (!visible || !steps || steps.length === 0) {
return null
}
return (
<div
style={{
marginBottom: 12,
padding: '10px 12px',
borderRadius: 12,
background: 'linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015))',
border: '1px solid rgba(255,255,255,0.06)',
}}
>
<div
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#7b7b91',
marginBottom: 10,
}}
>
Live Run
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{steps.map((step) => {
const style = statusStyles[step.status]
return (
<div
key={step.id}
style={{
display: 'grid',
gridTemplateColumns: '14px 1fr',
gap: 8,
alignItems: 'start',
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: 999,
marginTop: 5,
background: style.dot,
boxShadow:
step.status === 'running'
? '0 0 0 4px rgba(240,185,11,0.08)'
: 'none',
}}
/>
<div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: style.text,
fontWeight: step.status === 'running' ? 600 : 500,
}}
>
{step.label}
</div>
{step.detail && (
<div
style={{
fontSize: 11.5,
lineHeight: 1.45,
color: '#6e6e86',
marginTop: 2,
}}
>
{step.detail}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
+154
View File
@@ -0,0 +1,154 @@
import { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react'
import { ArrowUp } from 'lucide-react'
export interface ChatInputHandle {
focus: () => void
clear: () => void
getValue: () => string
}
interface ChatInputProps {
language: string
loading: boolean
onSend: (text: string) => void
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
function ChatInput({ language, loading, onSend }, ref) {
const [input, setInput] = useState('')
const [composing, setComposing] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
setInput('')
if (inputRef.current) inputRef.current.style.height = 'auto'
},
getValue: () => input,
}))
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value)
const el = e.target
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
},
[]
)
const handleSend = () => {
const msg = input.trim()
if (!msg || loading) return
setInput('')
if (inputRef.current) inputRef.current.style.height = 'auto'
onSend(msg)
inputRef.current?.focus()
}
// Keyboard shortcut: Cmd+K to focus
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
inputRef.current?.focus()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<div
style={{
padding: '12px 16px 20px',
borderTop: '1px solid rgba(255,255,255,0.04)',
background: 'linear-gradient(to top, #09090b 80%, transparent)',
}}
>
<div
className="chat-input-wrapper"
style={{
maxWidth: 720,
margin: '0 auto',
display: 'flex',
gap: 8,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 18,
padding: '4px 4px 4px 16px',
alignItems: 'flex-end',
transition: 'all 0.2s ease',
}}
>
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !composing) {
e.preventDefault()
handleSend()
}
}}
placeholder={
language === 'zh'
? '跟 NOFXi 聊点什么... ⌘K'
: 'Ask NOFXi anything... ⌘K'
}
rows={1}
style={{
flex: 1,
background: 'none',
border: 'none',
color: '#eaeaf0',
fontSize: 13.5,
outline: 'none',
padding: '10px 0',
fontFamily: 'inherit',
resize: 'none',
lineHeight: 1.5,
maxHeight: 150,
}}
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
style={{
width: 36,
height: 36,
borderRadius: 12,
border: 'none',
background:
loading || !input.trim()
? 'rgba(255,255,255,0.04)'
: 'linear-gradient(135deg, #F0B90B, #d4a30a)',
color: loading || !input.trim() ? '#3c3c52' : '#000',
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
<ArrowUp size={16} strokeWidth={2.5} />
</button>
</div>
<div
style={{
maxWidth: 720,
margin: '6px auto 0',
textAlign: 'center',
fontSize: 10,
color: '#1e1e32',
}}
>
NOFXi may make mistakes. Always verify trading decisions.
</div>
</div>
)
}
)
+151
View File
@@ -0,0 +1,151 @@
import { forwardRef } from 'react'
import { motion } from 'framer-motion'
import { AgentStepPanel } from './AgentStepPanel'
import { renderMessageContent } from './MessageRenderer'
interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
interface Message {
id: string
role: 'user' | 'bot'
text: string
time: string
streaming?: boolean
steps?: AgentStep[]
}
interface ChatMessagesProps {
messages: Message[]
}
function hasMeaningfulExecutionSteps(steps?: AgentStep[]) {
if (!steps || steps.length === 0) return false
return steps.some((step) => step.status !== 'planning')
}
export const ChatMessages = forwardRef<HTMLDivElement, ChatMessagesProps>(
function ChatMessages({ messages }, ref) {
return (
<div style={{ maxWidth: 720, margin: '0 auto', padding: '0 20px' }}>
{messages.map((m) => (
<motion.div
key={m.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
style={{
display: 'flex',
gap: 12,
marginBottom: 24,
flexDirection: m.role === 'user' ? 'row-reverse' : 'row',
}}
>
{/* Avatar */}
<div
style={{
width: 30,
height: 30,
borderRadius: 10,
display: 'grid',
placeItems: 'center',
fontSize: 14,
flexShrink: 0,
marginTop: 2,
background:
m.role === 'user'
? 'linear-gradient(135deg, rgba(139,92,246,.12), rgba(139,92,246,.04))'
: 'linear-gradient(135deg, rgba(240,185,11,.08), rgba(0,229,160,.04))',
border:
'1px solid ' +
(m.role === 'user'
? 'rgba(139,92,246,.15)'
: 'rgba(240,185,11,.1)'),
}}
>
{m.role === 'user' ? '👤' : '⚡'}
</div>
{/* Message content */}
<div style={{ maxWidth: '78%', minWidth: 0 }}>
{m.role === 'user' ? (
<div
style={{
padding: '10px 16px',
borderRadius: 18,
borderTopRightRadius: 4,
fontSize: 13.5,
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
color: '#fff',
}}
>
{m.text}
</div>
) : (
<div
style={{
padding: '12px 16px',
borderRadius: 18,
borderTopLeftRadius: 4,
fontSize: 13.5,
lineHeight: 1.7,
wordBreak: 'break-word',
background: 'rgba(255,255,255,0.03)',
color: '#dcdce8',
border: '1px solid rgba(255,255,255,0.05)',
}}
>
<AgentStepPanel steps={m.steps} visible={hasMeaningfulExecutionSteps(m.steps)} />
{renderMessageContent(m.text)}
{m.streaming && m.text === '' && (
<div style={{ display: 'flex', gap: 4, padding: '4px 0' }}>
<span className="typing-dot" style={{ animationDelay: '0ms' }} />
<span className="typing-dot" style={{ animationDelay: '150ms' }} />
<span className="typing-dot" style={{ animationDelay: '300ms' }} />
</div>
)}
{m.streaming && m.text !== '' && (
<span
style={{
display: 'inline-block',
width: 2,
height: 15,
background: '#F0B90B',
marginLeft: 1,
borderRadius: 1,
animation: 'blink 0.8s infinite',
verticalAlign: 'text-bottom',
}}
/>
)}
</div>
)}
{m.time && !m.streaming && (
<div
style={{
fontSize: 10,
color: '#2c2c42',
marginTop: 4,
textAlign: m.role === 'user' ? 'right' : 'left',
paddingLeft: m.role === 'bot' ? 4 : 0,
paddingRight: m.role === 'user' ? 4 : 0,
}}
>
{m.role === 'bot' && 'NOFXi · '}{m.time}
</div>
)}
</div>
</motion.div>
))}
<div ref={ref} />
</div>
)
}
)
+178
View File
@@ -0,0 +1,178 @@
import { useState, useEffect } from 'react'
// icons reserved for future use
interface TickerData {
symbol: string
lastPrice: string
priceChangePercent: string
highPrice: string
lowPrice: string
volume: string
}
const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT']
const SYMBOL_ICONS: Record<string, string> = {
BTC: '₿',
ETH: 'Ξ',
SOL: '◎',
}
export function MarketTicker() {
const [tickers, setTickers] = useState<Record<string, TickerData>>({})
const [loading, setLoading] = useState(true)
const fetchTickers = async () => {
try {
// Batch fetch: single API call for all symbols
const res = await fetch(`/api/agent/tickers?symbols=${SYMBOLS.join(',')}`)
const data = await res.json()
const map: Record<string, TickerData> = {}
if (Array.isArray(data)) {
data.forEach((r: TickerData) => {
if (r.lastPrice && r.symbol) map[r.symbol] = r
})
}
setTickers(map)
} catch {
// ignore — will retry on next interval
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTickers()
const interval = setInterval(fetchTickers, 15000)
return () => clearInterval(interval)
}, [])
const formatPrice = (price: string) => {
const n = parseFloat(price)
if (n >= 1000) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (n >= 1) return n.toFixed(2)
return n.toFixed(4)
}
const formatVolume = (vol: string) => {
const n = parseFloat(vol)
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
return n.toFixed(0)
}
if (loading) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SYMBOLS.map((sym) => (
<div
key={sym}
style={{
padding: '12px',
background: 'rgba(255,255,255,0.02)',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.04)',
height: 56,
}}
>
<div style={{
width: '60%',
height: 10,
background: 'rgba(255,255,255,0.04)',
borderRadius: 4,
animation: 'pulse 1.5s infinite',
}} />
</div>
))}
<style>{`
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
`}</style>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SYMBOLS.map((sym) => {
const t = tickers[sym]
if (!t) return null
const pct = parseFloat(t.priceChangePercent)
const isUp = pct > 0
const isDown = pct < 0
const color = isUp ? '#00e5a0' : isDown ? '#F6465D' : '#6c6c82'
const bgColor = isUp ? 'rgba(0,229,160,0.06)' : isDown ? 'rgba(246,70,93,0.06)' : 'rgba(108,108,130,0.06)'
const label = sym.replace('USDT', '')
const icon = SYMBOL_ICONS[label] || label[0]
return (
<div
key={sym}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 11px',
background: 'rgba(255,255,255,0.02)',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.04)',
transition: 'all 0.15s ease',
cursor: 'default',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)'
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.02)'
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.04)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 8,
background: bgColor,
display: 'grid',
placeItems: 'center',
fontSize: 13,
fontWeight: 700,
color: color,
fontFamily: 'system-ui',
}}
>
{icon}
</div>
<div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', letterSpacing: '-0.01em' }}>
{label}
</div>
<div style={{ fontSize: 10, color: '#4c4c62' }}>
Vol {formatVolume(t.volume)}
</div>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', fontFamily: '"IBM Plex Mono", monospace', letterSpacing: '-0.02em' }}>
${formatPrice(t.lastPrice)}
</div>
<div style={{
fontSize: 10.5,
fontWeight: 600,
color,
fontFamily: '"IBM Plex Mono", monospace',
}}>
{isUp ? '+' : ''}{pct.toFixed(2)}%
</div>
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,187 @@
/**
* MessageRenderer markdown-to-JSX renderer for agent chat messages.
* Supports: headers, bold, italic, inline code, code blocks, lists, links, HR.
*/
// Inline formatting: bold, italic, code, links
export function renderInline(text: string): (string | JSX.Element)[] {
const parts = text.split(/(```[\s\S]*?```|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\(([^)]+)\))/g)
const result: (string | JSX.Element)[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (!part) continue
if (part.startsWith('`') && part.endsWith('`') && !part.startsWith('```')) {
result.push(
<code
key={i}
style={{
background: 'rgba(240,185,11,0.08)',
padding: '2px 6px',
borderRadius: 5,
fontSize: '0.88em',
fontFamily: '"IBM Plex Mono", monospace',
color: '#F0B90B',
border: '1px solid rgba(240,185,11,0.12)',
}}
>
{part.slice(1, -1)}
</code>
)
} else if (part.startsWith('**') && part.endsWith('**')) {
result.push(
<strong key={i} style={{ fontWeight: 600, color: '#f0f0f8' }}>
{part.slice(2, -2)}
</strong>
)
} else if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) {
result.push(
<em key={i} style={{ fontStyle: 'italic', color: '#d0d0e0' }}>
{part.slice(1, -1)}
</em>
)
} else if (part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)) {
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (match) {
const href = match[2]
// Only allow http/https links to prevent javascript: XSS
const safeHref = /^https?:\/\//i.test(href) ? href : '#'
result.push(
<a
key={i}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#F0B90B', textDecoration: 'underline', textUnderlineOffset: 2 }}
>
{match[1]}
</a>
)
}
} else {
result.push(part)
}
}
return result
}
// Enhanced markdown renderer: headers, bold, italic, code, lists, links
export function renderMessageContent(text: string) {
const lines = text.split('\n')
const elements: JSX.Element[] = []
let inCodeBlock = false
let codeContent = ''
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Code block toggle
if (line.startsWith('```')) {
if (inCodeBlock) {
elements.push(
<pre
key={`code-${i}`}
style={{
background: '#0a0a12',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 10,
padding: '12px 14px',
fontSize: 12,
overflowX: 'auto',
margin: '8px 0',
fontFamily: '"IBM Plex Mono", monospace',
color: '#c0c0d0',
lineHeight: 1.6,
}}
>
{codeContent.trim()}
</pre>
)
codeContent = ''
inCodeBlock = false
} else {
inCodeBlock = true
}
continue
}
if (inCodeBlock) {
codeContent += (codeContent ? '\n' : '') + line
continue
}
// Headers
if (line.startsWith('### ')) {
elements.push(
<div key={i} style={{ fontSize: 14, fontWeight: 700, color: '#f0f0f8', margin: '12px 0 6px', letterSpacing: '-0.01em' }}>
{renderInline(line.slice(4))}
</div>
)
continue
}
if (line.startsWith('## ')) {
elements.push(
<div key={i} style={{ fontSize: 15, fontWeight: 700, color: '#f0f0f8', margin: '14px 0 6px', letterSpacing: '-0.01em' }}>
{renderInline(line.slice(3))}
</div>
)
continue
}
if (line.startsWith('# ')) {
elements.push(
<div key={i} style={{ fontSize: 16, fontWeight: 700, color: '#f0f0f8', margin: '16px 0 8px', letterSpacing: '-0.02em' }}>
{renderInline(line.slice(2))}
</div>
)
continue
}
// Bullet lists
if (line.match(/^[-•*]\s/)) {
elements.push(
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
<span style={{ color: '#F0B90B', flexShrink: 0, fontSize: 8, marginTop: 7 }}></span>
<span>{renderInline(line.replace(/^[-•*]\s/, ''))}</span>
</div>
)
continue
}
// Numbered lists
if (line.match(/^\d+\.\s/)) {
const num = line.match(/^(\d+)\./)?.[1]
elements.push(
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
<span style={{ color: '#8a8aa0', flexShrink: 0, fontSize: 12, fontWeight: 600, minWidth: 16, fontFamily: '"IBM Plex Mono", monospace' }}>{num}.</span>
<span>{renderInline(line.replace(/^\d+\.\s/, ''))}</span>
</div>
)
continue
}
// Horizontal rule
if (line.match(/^---+$/)) {
elements.push(
<hr key={i} style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.06)', margin: '12px 0' }} />
)
continue
}
// Empty line → small gap
if (line.trim() === '') {
elements.push(<div key={i} style={{ height: 6 }} />)
continue
}
// Regular paragraph
elements.push(
<div key={i} style={{ lineHeight: 1.7, padding: '1px 0' }}>
{renderInline(line)}
</div>
)
}
return elements
}
+154
View File
@@ -0,0 +1,154 @@
import useSWR from 'swr'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { ArrowUpRight, ArrowDownRight, Wallet } from 'lucide-react'
import type { Position, TraderInfo } from '../../types'
export function PositionsPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
user && token ? 'agent-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
// Get first running trader's positions
const runningTrader = traders?.find((t) => t.is_running)
const traderId = runningTrader?.trader_id
const { data: positions } = useSWR<Position[]>(
traderId ? `agent-positions-${traderId}` : null,
() => api.getPositions(traderId),
{ refreshInterval: 15000, shouldRetryOnError: false }
)
if (!user || !token) {
return (
<div
style={{
padding: '20px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
<Wallet size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<div>Login to view positions</div>
</div>
)
}
const openPositions = positions?.filter((p) => p.quantity !== 0) || []
if (openPositions.length === 0) {
return (
<div
style={{
padding: '16px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
No open positions
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{openPositions.map((pos, i) => {
const pnl = pos.unrealized_pnl
const isProfit = pnl >= 0
const color = isProfit ? '#00e5a0' : '#F6465D'
const side = pos.side?.toUpperCase() || (pos.quantity > 0 ? 'LONG' : 'SHORT')
const rawSymbol = pos.symbol || ''
// Stock symbols are pure letters (1-5 chars), crypto has USDT suffix
const isStock = /^[A-Z]{1,5}$/.test(rawSymbol) && !rawSymbol.endsWith('USDT')
const symbol = isStock ? rawSymbol : rawSymbol.replace('USDT', '')
const currencyPrefix = isStock ? '$' : ''
return (
<div
key={i}
style={{
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: '#eaeaf0',
}}
>
{symbol}
</span>
{isStock && (
<span style={{ fontSize: 10, color: '#8b8ba0' }}>🇺🇸</span>
)}
<span
style={{
fontSize: 10,
fontWeight: 600,
padding: '1px 5px',
borderRadius: 4,
background:
side === 'LONG'
? 'rgba(0,229,160,0.12)'
: 'rgba(246,70,93,0.12)',
color: side === 'LONG' ? '#00e5a0' : '#F6465D',
}}
>
{isStock ? (side === 'LONG' ? 'HOLD' : 'SHORT') : side}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 3,
color,
fontSize: 12,
fontWeight: 600,
}}
>
{isProfit ? (
<ArrowUpRight size={12} />
) : (
<ArrowDownRight size={12} />
)}
{isProfit ? '+' : ''}
{currencyPrefix}{pnl.toFixed(2)}
</div>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: '#5c5c72',
}}
>
<span>{isStock ? 'Shares' : 'Qty'}: {pos.quantity}</span>
<span>Entry: {currencyPrefix}{pos.entry_price.toFixed(2)}</span>
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,110 @@
import useSWR from 'swr'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { Activity, CircleOff, Bot } from 'lucide-react'
import type { TraderInfo } from '../../types'
export function TraderStatusPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
user && token ? 'agent-sidebar-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
if (!user || !token) {
return (
<div
style={{
padding: '20px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
<Bot size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<div>Login to view traders</div>
</div>
)
}
if (!traders || traders.length === 0) {
return (
<div
style={{
padding: '16px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
No traders configured
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{traders.map((trader) => (
<div
key={trader.trader_id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 7,
background: trader.is_running
? 'rgba(0,229,160,0.08)'
: 'rgba(92,92,114,0.08)',
display: 'grid',
placeItems: 'center',
}}
>
{trader.is_running ? (
<Activity size={14} color="#00e5a0" />
) : (
<CircleOff size={14} color="#5c5c72" />
)}
</div>
<div>
<div
style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}
>
{trader.trader_name}
</div>
<div style={{ fontSize: 10, color: '#5c5c72' }}>
{trader.trader_id.slice(0, 8)}...
</div>
</div>
</div>
<div
style={{
fontSize: 10,
fontWeight: 600,
padding: '3px 8px',
borderRadius: 6,
background: trader.is_running
? 'rgba(0,229,160,0.12)'
: 'rgba(92,92,114,0.12)',
color: trader.is_running ? '#00e5a0' : '#5c5c72',
}}
>
{trader.is_running ? 'RUNNING' : 'STOPPED'}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,222 @@
import { useEffect, useState } from 'react'
interface Preference {
id: string
text: string
created_at?: string
}
interface Props {
token: string | null
language: string
}
export function UserPreferencesPanel({ token, language }: Props) {
const [preferences, setPreferences] = useState<Preference[]>([])
const [draft, setDraft] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadPreferences = async () => {
if (!token) {
setPreferences([])
setLoading(false)
return
}
setLoading(true)
try {
const res = await fetch('/api/agent/preferences', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to load preferences')
const data = await res.json()
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
setError(null)
} catch {
setError(language === 'zh' ? '加载偏好失败' : 'Failed to load')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!token) {
setPreferences([])
setLoading(false)
return
}
let cancelled = false
void (async () => {
await loadPreferences()
})()
const handleRefresh = () => {
if (!cancelled) void loadPreferences()
}
window.addEventListener('agent-preferences-refresh', handleRefresh)
return () => {
cancelled = true
window.removeEventListener('agent-preferences-refresh', handleRefresh)
}
}, [token, language])
const addPreference = async () => {
const text = draft.trim()
if (!text || !token || saving) return
setSaving(true)
try {
const res = await fetch('/api/agent/preferences', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'save failed')
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
setDraft('')
setError(null)
} catch {
setError(language === 'zh' ? '保存偏好失败' : 'Failed to save')
} finally {
setSaving(false)
}
}
const removePreference = async (id: string) => {
if (!token || saving) return
setSaving(true)
try {
const res = await fetch(`/api/agent/preferences/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'delete failed')
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
setError(null)
} catch {
setError(language === 'zh' ? '删除偏好失败' : 'Failed to delete')
} finally {
setSaving(false)
}
}
return (
<div
id="agent-preferences-panel"
style={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.05)',
borderRadius: 12,
padding: 10,
}}
>
<div style={{ marginBottom: 8 }}>
<div style={{ color: '#d7d7e0', fontSize: 12, fontWeight: 600 }}>
{language === 'zh' ? '长期偏好' : 'Persistent Preferences'}
</div>
<div style={{ color: '#77778d', fontSize: 11, lineHeight: 1.5, marginTop: 4 }}>
{language === 'zh'
? '把长期偏好固定下来,比如“默认用中文回答”或“优先关注 BTC 和 ETH”。'
: 'Pin durable preferences the agent should keep in mind, like answering in Chinese or focusing on BTC and ETH.'}
</div>
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
<input
data-agent-preferences-input="true"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void addPreference()
}}
placeholder={language === 'zh' ? '例如:默认用中文回答,优先关注 BTC、ETH' : 'Example: Answer in Chinese and focus on BTC, ETH'}
style={{
flex: 1,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
color: '#e8e8f0',
borderRadius: 8,
padding: '8px 10px',
fontSize: 12,
outline: 'none',
}}
/>
<button
onClick={() => void addPreference()}
disabled={!draft.trim() || saving}
style={{
background: draft.trim() && !saving ? 'rgba(240,185,11,0.12)' : 'rgba(255,255,255,0.04)',
color: draft.trim() && !saving ? '#F0B90B' : '#6d6d82',
border: '1px solid rgba(240,185,11,0.14)',
borderRadius: 8,
padding: '0 10px',
fontSize: 12,
cursor: draft.trim() && !saving ? 'pointer' : 'default',
}}
>
{language === 'zh' ? '添加' : 'Add'}
</button>
</div>
{error && (
<div style={{ color: '#f08a8a', fontSize: 11, marginBottom: 8 }}>{error}</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{loading ? (
<div style={{ color: '#77778d', fontSize: 11 }}>
{language === 'zh' ? '加载中...' : 'Loading...'}
</div>
) : preferences.length === 0 ? (
<div style={{ color: '#77778d', fontSize: 11, lineHeight: 1.5 }}>
{language === 'zh'
? '还没有长期偏好。你可以把关注标的、风险倾向、回答习惯放在这里。'
: 'No persistent preferences yet. Add watchlists, risk preferences, or response habits here.'}
</div>
) : (
preferences.map((pref) => (
<div
key={pref.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: 8,
borderRadius: 10,
background: 'rgba(255,255,255,0.025)',
border: '1px solid rgba(255,255,255,0.04)',
}}
>
<div style={{ flex: 1, color: '#d7d7e0', fontSize: 12, lineHeight: 1.5 }}>
{pref.text}
</div>
<button
onClick={() => void removePreference(pref.id)}
disabled={saving}
style={{
background: 'transparent',
border: 'none',
color: '#8b8ba0',
fontSize: 11,
cursor: saving ? 'default' : 'pointer',
padding: 0,
}}
>
{language === 'zh' ? '删除' : 'Delete'}
</button>
</div>
))
)}
</div>
</div>
)
}
+138
View File
@@ -0,0 +1,138 @@
import { motion } from 'framer-motion'
import {
Zap,
BarChart3,
Lightbulb,
Search,
} from 'lucide-react'
interface SuggestionCard {
icon: JSX.Element
title: string
subtitle: string
cmd: string
}
interface WelcomeScreenProps {
language: string
onSend: (cmd: string) => void
}
export function WelcomeScreen({ language, onSend }: WelcomeScreenProps) {
const suggestions: SuggestionCard[] = language === 'zh'
? [
{ icon: <BarChart3 size={18} />, title: '分析 BTC 走势', subtitle: '技术分析 + 市场情绪', cmd: '分析一下 BTC 的走势' },
{ icon: <Zap size={18} />, title: '做多 ETH', subtitle: 'Agent 帮你自动下单', cmd: '帮我做多 ETH 0.01 手' },
{ icon: <Search size={18} />, title: '搜索股票', subtitle: '输入名称或代码即可', cmd: '搜索一下中远海控' },
{ icon: <Lightbulb size={18} />, title: '策略建议', subtitle: '根据当前市场给出建议', cmd: '当前市场适合什么策略?' },
]
: [
{ icon: <BarChart3 size={18} />, title: 'Analyze BTC', subtitle: 'Technical analysis + sentiment', cmd: 'Analyze BTC price action' },
{ icon: <Zap size={18} />, title: 'Trade ETH', subtitle: 'Agent executes for you', cmd: 'Open a long position on ETH 0.01' },
{ icon: <Search size={18} />, title: 'Search Stocks', subtitle: 'Enter name or ticker', cmd: 'Search for NVIDIA stock' },
{ icon: <Lightbulb size={18} />, title: 'Strategy Ideas', subtitle: 'Market-based suggestions', cmd: 'What strategy fits the current market?' },
]
return (
<div style={{
maxWidth: 640,
margin: '0 auto',
padding: '0 20px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 400,
}}>
{/* Logo / greeting */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
style={{ textAlign: 'center', marginBottom: 40 }}
>
<div style={{
width: 56,
height: 56,
borderRadius: 16,
background: 'linear-gradient(135deg, rgba(240,185,11,0.12), rgba(0,229,160,0.06))',
border: '1px solid rgba(240,185,11,0.15)',
display: 'grid',
placeItems: 'center',
margin: '0 auto 16px',
fontSize: 24,
}}>
</div>
<h1 style={{
fontSize: 22,
fontWeight: 700,
color: '#f0f0f8',
margin: '0 0 8px',
letterSpacing: '-0.02em',
}}>
{language === 'zh' ? '跟 NOFXi 聊点什么' : 'What can I help with?'}
</h1>
<p style={{
fontSize: 13.5,
color: '#5c5c72',
margin: 0,
lineHeight: 1.5,
}}>
{language === 'zh'
? '分析行情、执行交易、搜索股票 — 用自然语言就行'
: 'Analyze markets, execute trades, search stocks — just ask'}
</p>
</motion.div>
{/* Suggestion cards grid */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1, ease: 'easeOut' }}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 10,
width: '100%',
maxWidth: 520,
}}
>
{suggestions.map((s, i) => (
<button
key={i}
onClick={() => onSend(s.cmd)}
className="suggestion-card"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 6,
padding: '16px 14px',
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 14,
cursor: 'pointer',
textAlign: 'left',
fontFamily: 'inherit',
transition: 'all 0.2s ease',
}}
>
<div style={{ color: '#F0B90B', opacity: 0.7 }}>
{s.icon}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#d0d0e0', marginBottom: 2 }}>
{s.title}
</div>
<div style={{ fontSize: 11.5, color: '#5c5c72' }}>
{s.subtitle}
</div>
</div>
</button>
))}
</motion.div>
</div>
)
}
+12
View File
@@ -109,6 +109,12 @@ export default function HeaderBar({
label: string label: string
requiresAuth: boolean requiresAuth: boolean
}[] = [ }[] = [
{
page: 'agent',
path: ROUTES.agent,
label: 'Agent',
requiresAuth: false,
},
{ {
page: 'data', page: 'data',
path: ROUTES.data, path: ROUTES.data,
@@ -431,6 +437,12 @@ export default function HeaderBar({
label: string label: string
requiresAuth: boolean requiresAuth: boolean
}[] = [ }[] = [
{
page: 'agent',
path: ROUTES.agent,
label: 'Agent',
requiresAuth: false,
},
{ {
page: 'data', page: 'data',
path: ROUTES.data, path: ROUTES.data,
+64 -521
View File
@@ -7,7 +7,6 @@ import type {
CreateTraderRequest, CreateTraderRequest,
AIModel, AIModel,
Exchange, Exchange,
ExchangeAccountState,
} from '../../types' } from '../../types'
import { useLanguage } from '../../contexts/LanguageContext' import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations' import { t } from '../../i18n/translations'
@@ -19,17 +18,13 @@ import { TelegramConfigModal } from './TelegramConfigModal'
import { ModelConfigModal } from './ModelConfigModal' import { ModelConfigModal } from './ModelConfigModal'
import { ConfigStatusGrid } from './ConfigStatusGrid' import { ConfigStatusGrid } from './ConfigStatusGrid'
import { TradersList } from './TradersList' import { TradersList } from './TradersList'
import { BeginnerGuideCards } from './BeginnerGuideCards' import {
import { AlertTriangle, Bot, Plus, MessageCircle } from 'lucide-react' Bot,
Plus,
MessageCircle,
} from 'lucide-react'
import { confirmToast } from '../../lib/notify' import { confirmToast } from '../../lib/notify'
import { toast } from 'sonner' import { toast } from 'sonner'
import {
getBeginnerWalletAddress,
getUserMode,
setBeginnerWalletAddress as persistBeginnerWalletAddress,
} from '../../lib/onboarding'
import type { Strategy } from '../../types'
import { ApiError } from '../../lib/httpClient'
interface AITradersPageProps { interface AITradersPageProps {
onTraderSelect?: (traderId: string) => void onTraderSelect?: (traderId: string) => void
@@ -50,288 +45,34 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [allModels, setAllModels] = useState<AIModel[]>([]) const [allModels, setAllModels] = useState<AIModel[]>([])
const [allExchanges, setAllExchanges] = useState<Exchange[]>([]) const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
const [supportedModels, setSupportedModels] = useState<AIModel[]>([]) const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState< const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
Set<string> const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<
Set<string>
>(new Set())
const [copiedId, setCopiedId] = useState<string | null>(null) const [copiedId, setCopiedId] = useState<string | null>(null)
const [quickSetupLoading, setQuickSetupLoading] = useState(false)
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<
string | null
>(() => getBeginnerWalletAddress())
const isBeginnerMode = getUserMode() === 'beginner'
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message.trim() !== '') {
return error.message
}
return fallback
}
const formatActionableDescriptionByKey = (
errorKey: string,
params: Record<string, string> = {},
fallback: string
) => {
const traderName = params.trader_name || params.traderName || 'this trader'
const modelName = params.model_name || params.modelName || 'selected model'
const exchangeName =
params.exchange_name || params.exchangeName || 'selected exchange account'
const reason = localizeTraderReason(
params.reason_key,
params.reason || fallback
)
const symbol = params.symbol || ''
const zh = language === 'zh' const loadConfigs = async () => {
if (!user || !token) {
switch (errorKey) { const models = await api.getSupportedModels()
case 'trader.create.invalid_request': setSupportedModels(models)
return zh
? '提交的信息不完整,或者格式不正确。请检查后重新提交。'
: 'The submitted information is incomplete or invalid. Please review it and try again.'
case 'trader.create.invalid_btc_eth_leverage':
return zh
? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。'
: 'BTC/ETH leverage must be between 1x and 50x.'
case 'trader.create.invalid_altcoin_leverage':
return zh
? '山寨币杠杆倍数需要在 1 到 20 倍之间。'
: 'Altcoin leverage must be between 1x and 20x.'
case 'trader.create.invalid_symbol':
return zh
? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。`
: `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.`
case 'trader.create.model_not_found':
return zh
? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。'
: 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.'
case 'trader.create.model_disabled':
return zh
? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。`
: `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.`
case 'trader.create.model_missing_credentials':
return zh
? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。`
: `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.`
case 'trader.create.strategy_required':
return zh
? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。'
: 'No trading strategy is selected yet. Please choose a strategy before creating a trader.'
case 'trader.create.strategy_not_found':
return zh
? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。'
: 'The selected strategy no longer exists. Please choose another available strategy.'
case 'trader.create.exchange_not_found':
return zh
? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。'
: 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.'
case 'trader.create.exchange_disabled':
return zh
? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。`
: `Exchange account "${exchangeName}" is currently disabled. Please enable it first.`
case 'trader.create.exchange_missing_fields':
return zh
? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。`
: `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.`
case 'trader.create.exchange_unsupported':
return zh
? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。`
: `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.`
case 'trader.create.exchange_probe_failed':
return zh
? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}`
: `Exchange account "${exchangeName}" failed initialization checks: ${reason}`
case 'trader.start.strategy_missing':
return zh
? `机器人「${traderName}」缺少有效的交易策略配置。`
: `Trader "${traderName}" does not have a valid strategy configuration.`
case 'trader.start.model_not_found':
return zh
? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。`
: `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.`
case 'trader.start.model_disabled':
return zh
? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。`
: `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.`
case 'trader.start.exchange_not_found':
return zh
? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。`
: `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.`
case 'trader.start.exchange_disabled':
return zh
? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。`
: `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.`
case 'trader.start.setup_invalid':
case 'trader.start.load_failed':
return zh
? `机器人「${traderName}」暂时还不能启动,原因是:${reason}`
: `Trader "${traderName}" cannot be started yet because ${reason}`
default:
return fallback
}
}
const localizeTraderReason = (reasonKey?: string, fallback?: string) => {
const zh = language === 'zh'
switch (reasonKey) {
case 'trader.reason.strategy_config_invalid':
return zh
? '当前策略配置内容已损坏,系统暂时无法解析'
: 'the current strategy configuration is corrupted and cannot be parsed'
case 'trader.reason.strategy_missing':
return zh
? '当前机器人缺少有效的交易策略配置'
: 'the trader is missing a valid strategy configuration'
case 'trader.reason.private_key_invalid':
return zh
? '私钥格式不正确,系统无法识别'
: 'the private key format is invalid and cannot be recognized'
case 'trader.reason.hyperliquid_init_failed':
return zh
? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确'
: 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration'
case 'trader.reason.aster_init_failed':
return zh
? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确'
: 'Aster account initialization failed. Please verify the Aster User, Signer, and private key'
case 'trader.reason.exchange_meta_unavailable':
return zh
? '系统暂时无法从交易所读取账户元信息'
: 'the system could not read account metadata from the exchange'
case 'trader.reason.hyperliquid_agent_balance_too_high':
return zh
? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求'
: 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements'
case 'trader.reason.exchange_account_init_failed':
return zh
? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配'
: 'exchange account initialization failed. Please verify that the wallet address and API key match'
case 'trader.reason.exchange_unsupported':
return zh
? '当前交易所类型暂不支持机器人初始化'
: 'the selected exchange type is not currently supported for trader initialization'
case 'trader.reason.exchange_balance_unavailable':
return zh
? '系统暂时无法从交易所读取账户余额'
: 'the system could not read the account balance from the exchange'
case 'trader.reason.exchange_service_unreachable':
return zh
? '系统暂时无法连接交易所服务'
: 'the system could not reach the exchange service right now'
default:
return (
fallback ||
(zh
? '系统返回了一个未知错误'
: 'an unknown error was returned by the system')
)
}
}
const normalizeActionableDescription = (
error: unknown,
message: string,
title: string
) => {
if (error instanceof ApiError && error.errorKey) {
return formatActionableDescriptionByKey(
error.errorKey,
error.errorParams,
message
)
}
const prefixes = [
'这次未能创建机器人:',
'机器人创建失败:',
'这次未能更新机器人:',
'机器人更新失败:',
'这次未能启动机器人:',
'Failed to create trader:',
'Failed to update trader:',
'Unable to create trader:',
'Unable to update trader:',
'Unable to start trader:',
]
let description = message.trim()
if (description === title) return ''
for (const prefix of prefixes) {
if (description.startsWith(prefix)) {
description = description.slice(prefix.length).trim()
break
}
}
return description
}
const showActionableError = (title: string, error: unknown) => {
const message = getErrorMessage(error, title)
const description = normalizeActionableDescription(error, message, title)
if (description === '') {
toast.error(title)
return return
} }
toast.error(title, { const [
description, modelConfigs,
}) exchangeConfigs,
} models,
const parseBalanceUsdc = (balance?: string) => { ] = await Promise.all([
if (!balance) return null api.getModelConfigs(),
const parsed = Number.parseFloat(balance) api.getExchangeConfigs(),
return Number.isFinite(parsed) ? parsed : null api.getSupportedModels(),
} ])
const getClaw402BalanceMessage = (balance: number, blocking: boolean) => { setAllModels(modelConfigs)
if (language === 'zh') { setAllExchanges(exchangeConfigs)
return blocking setSupportedModels(models)
? `当前 Claw402 钱包余额为 ${balance.toFixed(6)} USDC,AI 调用无法执行。请先为这个钱包充值,再重新点击启动。`
: `当前 Claw402 钱包余额仅剩 ${balance.toFixed(6)} USDC,虽然还能尝试启动,但很快可能因为 AI 调用费用不足而停止。建议先补一点 USDC。`
}
return blocking
? `Your Claw402 wallet balance is ${balance.toFixed(6)} USDC. AI calls cannot run with zero balance. Please top up this wallet before starting again.`
: `Your Claw402 wallet balance is only ${balance.toFixed(6)} USDC. You can still try to start, but AI calls may stop soon due to insufficient funds.`
}
const getClaw402BalanceIssue = (traderId: string) => {
const trader = traders?.find((item) => item.trader_id === traderId)
if (!trader) return null
const model =
allModels.find((item) => item.id === trader.ai_model) ||
allModels.find((item) => item.provider === trader.ai_model)
if (!model || model.provider !== 'claw402') return null
const balance = parseBalanceUsdc(model.balanceUsdc)
if (balance === null) return null
if (balance <= 0) {
return {
blocking: true,
title: language === 'zh' ? '启动失败' : 'Start failed',
description: getClaw402BalanceMessage(balance, true),
}
}
if (balance < 1) {
return {
blocking: false,
title: language === 'zh' ? 'Claw402 余额偏低' : 'Low Claw402 balance',
description: getClaw402BalanceMessage(balance, false),
}
}
return null
}
const navigateInApp = (path: string) => {
navigate(path)
} }
// Toggle wallet address visibility for a trader // Toggle wallet address visibility for a trader
const toggleTraderAddressVisibility = (traderId: string) => { const toggleTraderAddressVisibility = (traderId: string) => {
setVisibleTraderAddresses((prev) => { setVisibleTraderAddresses(prev => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(traderId)) { if (next.has(traderId)) {
next.delete(traderId) next.delete(traderId)
@@ -344,7 +85,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
// Toggle wallet address visibility for an exchange // Toggle wallet address visibility for an exchange
const toggleExchangeAddressVisibility = (exchangeId: string) => { const toggleExchangeAddressVisibility = (exchangeId: string) => {
setVisibleExchangeAddresses((prev) => { setVisibleExchangeAddresses(prev => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(exchangeId)) { if (next.has(exchangeId)) {
next.delete(exchangeId) next.delete(exchangeId)
@@ -366,64 +107,27 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
} }
} }
const { const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
data: traders, user && token ? 'traders' : null,
mutate: mutateTraders, api.getTraders,
isLoading: isTradersLoading, { refreshInterval: 5000 }
} = useSWR<TraderInfo[]>(user && token ? 'traders' : null, api.getTraders, {
refreshInterval: 5000,
})
const {
data: exchangeAccountStateData,
mutate: mutateExchangeAccountStates,
isLoading: isExchangeAccountStatesLoading,
} = useSWR<{ states: Record<string, ExchangeAccountState> }>(
user && token ? 'exchange-account-state' : null,
api.getExchangeAccountState,
{
refreshInterval: 30000,
shouldRetryOnError: false,
}
)
const { data: strategies } = useSWR<Strategy[]>(
user && token ? 'strategies' : null,
api.getStrategies,
{ refreshInterval: 30000 }
) )
useEffect(() => { useEffect(() => {
const loadConfigs = async () => {
if (!user || !token) {
try {
const models = await api.getSupportedModels()
setSupportedModels(models)
} catch (err) {
console.error('Failed to load supported configs:', err)
}
return
}
try {
const [modelConfigs, exchangeConfigs, models] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getSupportedModels(),
])
setAllModels(modelConfigs)
const clawWalletAddress =
modelConfigs.find((model) => model.provider === 'claw402')
?.walletAddress || null
if (clawWalletAddress) {
setBeginnerWalletAddress(clawWalletAddress)
persistBeginnerWalletAddress(clawWalletAddress)
}
setAllExchanges(exchangeConfigs)
setSupportedModels(models)
} catch (error) {
console.error('Failed to load configs:', error)
}
}
loadConfigs() loadConfigs()
.catch((error) => {
console.error('Failed to load configs:', error)
})
}, [user, token])
useEffect(() => {
const handleRefresh = () => {
loadConfigs().catch((error) => {
console.error('Failed to refresh configs:', error)
})
}
window.addEventListener('agent-config-refresh', handleRefresh)
return () => window.removeEventListener('agent-config-refresh', handleRefresh)
}, [user, token]) }, [user, token])
const configuredModels = const configuredModels =
@@ -443,31 +147,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}) || [] }) || []
const enabledModels = allModels?.filter((m) => m.enabled) || [] const enabledModels = allModels?.filter((m) => m.enabled) || []
const enabledClaw402Model =
enabledModels.find((model) => model.provider === 'claw402') || null
const enabledClaw402Balance = parseBalanceUsdc(
enabledClaw402Model?.balanceUsdc
)
const claw402BalanceAlert =
enabledClaw402Model &&
enabledClaw402Balance !== null &&
enabledClaw402Balance < 1
? {
blocking: enabledClaw402Balance <= 0,
title:
language === 'zh'
? enabledClaw402Balance <= 0
? 'Claw402 钱包余额为 0'
: 'Claw402 钱包余额偏低'
: enabledClaw402Balance <= 0
? 'Claw402 wallet balance is zero'
: 'Claw402 wallet balance is low',
description: getClaw402BalanceMessage(
enabledClaw402Balance,
enabledClaw402Balance <= 0
),
}
: null
const enabledExchanges = const enabledExchanges =
allExchanges?.filter((e) => { allExchanges?.filter((e) => {
if (!e.enabled) return false if (!e.enabled) return false
@@ -501,8 +180,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
} }
const getExchangeUsageInfo = (exchangeId: string) => { const getExchangeUsageInfo = (exchangeId: string) => {
const usingTraders = const usingTraders = traders?.filter((tr) => tr.exchange_id === exchangeId) || []
traders?.filter((tr) => tr.exchange_id === exchangeId) || []
const runningCount = usingTraders.filter((tr) => tr.is_running).length const runningCount = usingTraders.filter((tr) => tr.is_running).length
const totalCount = usingTraders.length const totalCount = usingTraders.length
return { runningCount, totalCount, usingTraders } return { runningCount, totalCount, usingTraders }
@@ -526,19 +204,26 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const handleCreateTrader = async (data: CreateTraderRequest) => { const handleCreateTrader = async (data: CreateTraderRequest) => {
try { try {
const createdTrader = await api.createTrader(data) const model = allModels?.find((m) => m.id === data.ai_model_id)
if (createdTrader.startup_warning) { const exchange = allExchanges?.find((e) => e.id === data.exchange_id)
toast.success(t('aiTradersToast.created', language), {
description: createdTrader.startup_warning, if (!model?.enabled) {
}) toast.error(t('modelNotConfigured', language))
} else { return
toast.success(t('aiTradersToast.created', language))
} }
if (!exchange?.enabled) {
toast.error(t('exchangeNotConfigured', language))
return
}
await api.createTrader(data)
toast.success(t('aiTradersToast.created', language))
setShowCreateModal(false) setShowCreateModal(false)
await mutateTraders() await mutateTraders()
} catch (error) { } catch (error) {
console.error('Failed to create trader:', error) console.error('Failed to create trader:', error)
showActionableError(t('createTraderFailed', language), error) toast.error(t('createTraderFailed', language))
} }
} }
@@ -588,7 +273,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
await mutateTraders() await mutateTraders()
} catch (error) { } catch (error) {
console.error('Failed to update trader:', error) console.error('Failed to update trader:', error)
showActionableError(t('updateTraderFailed', language), error) toast.error(t('updateTraderFailed', language))
} }
} }
@@ -615,18 +300,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
await api.stopTrader(traderId) await api.stopTrader(traderId)
toast.success(t('aiTradersToast.stopped', language)) toast.success(t('aiTradersToast.stopped', language))
} else { } else {
const claw402Issue = getClaw402BalanceIssue(traderId)
if (claw402Issue?.blocking) {
toast.error(claw402Issue.title, {
description: claw402Issue.description,
})
return
}
if (claw402Issue && !claw402Issue.blocking) {
toast.warning(claw402Issue.title, {
description: claw402Issue.description,
})
}
await api.startTrader(traderId) await api.startTrader(traderId)
toast.success(t('aiTradersToast.started', language)) toast.success(t('aiTradersToast.started', language))
} }
@@ -634,27 +307,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
await mutateTraders() await mutateTraders()
} catch (error) { } catch (error) {
console.error('Failed to toggle trader:', error) console.error('Failed to toggle trader:', error)
showActionableError( toast.error(t('operationFailed', language))
running
? t('aiTradersToast.stopFailed', language)
: t('aiTradersToast.startFailed', language),
error
)
} }
} }
const handleToggleCompetition = async ( const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => {
traderId: string,
currentShowInCompetition: boolean
) => {
try { try {
const newValue = !currentShowInCompetition const newValue = !currentShowInCompetition
await api.toggleCompetition(traderId, newValue) await api.toggleCompetition(traderId, newValue)
toast.success( toast.success(newValue ? t('aiTradersToast.showInCompetition', language) : t('aiTradersToast.hideInCompetition', language))
newValue
? t('aiTradersToast.showInCompetition', language)
: t('aiTradersToast.hideInCompetition', language)
)
await mutateTraders() await mutateTraders()
} catch (error) { } catch (error) {
@@ -856,7 +517,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const refreshedExchanges = await api.getExchangeConfigs() const refreshedExchanges = await api.getExchangeConfigs()
setAllExchanges(refreshedExchanges) setAllExchanges(refreshedExchanges)
await mutateExchangeAccountStates()
setShowExchangeModal(false) setShowExchangeModal(false)
setEditingExchange(null) setEditingExchange(null)
@@ -938,7 +598,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const refreshedExchanges = await api.getExchangeConfigs() const refreshedExchanges = await api.getExchangeConfigs()
setAllExchanges(refreshedExchanges) setAllExchanges(refreshedExchanges)
await mutateExchangeAccountStates()
setShowExchangeModal(false) setShowExchangeModal(false)
setEditingExchange(null) setEditingExchange(null)
@@ -958,40 +617,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
setShowExchangeModal(true) setShowExchangeModal(true)
} }
const handleQuickSetupClaw402 = async () => {
if (quickSetupLoading) return
try {
setQuickSetupLoading(true)
const result = await api.prepareBeginnerOnboarding()
setBeginnerWalletAddress(result.address)
const refreshedModels = await api.getModelConfigs()
setAllModels(refreshedModels)
toast.success(
language === 'zh'
? 'Claw402 已默认配置为 DeepSeek'
: 'Claw402 is configured with DeepSeek by default'
)
} catch (error) {
console.error('Failed to quick setup claw402:', error)
toast.error(
language === 'zh'
? '一键配置 Claw402 失败'
: 'Failed to quick setup Claw402'
)
} finally {
setQuickSetupLoading(false)
}
}
const claw402Configured = configuredModels.some(
(model) => model.provider === 'claw402'
)
const hasStrategies = (strategies?.length || 0) > 0
const hasCreatedTrader = (traders?.length || 0) > 0
const canCreateTrader =
configuredModels.length > 0 && configuredExchanges.length > 0
return ( return (
<DeepVoidBackground className="py-8" disableAnimation> <DeepVoidBackground className="py-8" disableAnimation>
<div className="w-full px-4 md:px-8 space-y-8 animate-fade-in"> <div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
@@ -1051,10 +676,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
disabled={ disabled={configuredModels.length === 0 || configuredExchanges.length === 0}
configuredModels.length === 0 ||
configuredExchanges.length === 0
}
className="group relative px-6 py-2 rounded text-xs font-bold font-mono uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap overflow-hidden bg-nofx-gold text-black hover:bg-yellow-400 shadow-[0_0_20px_rgba(240,185,11,0.2)] hover:shadow-[0_0_30px_rgba(240,185,11,0.4)]" className="group relative px-6 py-2 rounded text-xs font-bold font-mono uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap overflow-hidden bg-nofx-gold text-black hover:bg-yellow-400 shadow-[0_0_20px_rgba(240,185,11,0.2)] hover:shadow-[0_0_30px_rgba(240,185,11,0.4)]"
> >
<span className="relative z-10 flex items-center gap-2"> <span className="relative z-10 flex items-center gap-2">
@@ -1066,89 +688,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
</div> </div>
</div> </div>
{isBeginnerMode ? (
<BeginnerGuideCards
language={language}
claw402Ready={claw402Configured}
exchangeReady={configuredExchanges.length > 0}
strategyReady={hasStrategies}
traderReady={hasCreatedTrader}
canCreateTrader={canCreateTrader}
walletAddress={beginnerWalletAddress}
onQuickSetupClaw402={handleQuickSetupClaw402}
onOpenExchange={handleAddExchange}
onOpenStrategy={() => navigateInApp('/strategy')}
onCreateTrader={() => setShowCreateModal(true)}
/>
) : null}
{claw402BalanceAlert ? (
<div
className="mb-6 rounded-xl border px-4 py-4 md:px-5 md:py-4 flex flex-col md:flex-row md:items-start md:justify-between gap-3"
style={{
borderColor: claw402BalanceAlert.blocking
? 'rgba(239, 68, 68, 0.55)'
: 'rgba(245, 158, 11, 0.45)',
background: claw402BalanceAlert.blocking
? 'rgba(127, 29, 29, 0.22)'
: 'rgba(120, 53, 15, 0.18)',
}}
>
<div className="flex items-start gap-3">
<div
className="mt-0.5 rounded-full p-2"
style={{
background: claw402BalanceAlert.blocking
? 'rgba(239, 68, 68, 0.16)'
: 'rgba(245, 158, 11, 0.14)',
color: claw402BalanceAlert.blocking ? '#F87171' : '#FBBF24',
}}
>
<AlertTriangle className="w-4 h-4" />
</div>
<div>
<div
className="text-sm font-semibold"
style={{
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
}}
>
{claw402BalanceAlert.title}
</div>
<div
className="text-sm mt-1 leading-6"
style={{ color: '#D4D4D8' }}
>
{claw402BalanceAlert.description}
</div>
</div>
</div>
<button
type="button"
onClick={() =>
enabledClaw402Model && handleModelClick(enabledClaw402Model.id)
}
className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider border whitespace-nowrap self-start"
style={{
borderColor: claw402BalanceAlert.blocking
? 'rgba(248, 113, 113, 0.45)'
: 'rgba(251, 191, 36, 0.35)',
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
background: 'rgba(0, 0, 0, 0.18)',
}}
>
{language === 'zh' ? '查看 AI 钱包' : 'Open AI wallet'}
</button>
</div>
) : null}
{/* Configuration Status Grid */} {/* Configuration Status Grid */}
<ConfigStatusGrid <ConfigStatusGrid
configuredModels={configuredModels} configuredModels={configuredModels}
configuredExchanges={configuredExchanges} configuredExchanges={configuredExchanges}
exchangeAccountStates={exchangeAccountStateData?.states}
isExchangeAccountStatesLoading={isExchangeAccountStatesLoading}
visibleExchangeAddresses={visibleExchangeAddresses} visibleExchangeAddresses={visibleExchangeAddresses}
copiedId={copiedId} copiedId={copiedId}
language={language} language={language}
@@ -1173,7 +716,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
copiedId={copiedId} copiedId={copiedId}
language={language} language={language}
onTraderSelect={onTraderSelect} onTraderSelect={onTraderSelect}
onNavigate={navigateInApp} onNavigate={(path) => navigate(path)}
onEditTrader={handleEditTrader} onEditTrader={handleEditTrader}
onToggleTrader={handleToggleTrader} onToggleTrader={handleToggleTrader}
onToggleCompetition={handleToggleCompetition} onToggleCompetition={handleToggleCompetition}
+12 -12
View File
@@ -28,7 +28,7 @@ export function CompetitionPage() {
const handleTraderClick = async (traderId: string) => { const handleTraderClick = async (traderId: string) => {
try { try {
const traderConfig = await api.getTraderConfig(traderId) const traderConfig = await api.getPublicTraderConfig(traderId)
setSelectedTrader(traderConfig) setSelectedTrader(traderConfig)
setIsModalOpen(true) setIsModalOpen(true)
} catch (error) { } catch (error) {
@@ -281,14 +281,14 @@ export function CompetitionPage() {
</div> </div>
{/* Stats */} {/* Stats */}
<div className="flex items-center gap-4 md:gap-6"> <div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
{/* Total Equity */} {/* Total Equity */}
<div className="text-right min-w-[60px] md:min-w-[80px]"> <div className="text-right">
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}> <div className="text-xs" style={{ color: '#848E9C' }}>
{t('equity', language)} {t('equity', language)}
</div> </div>
<div <div
className="text-sm md:text-base font-bold mono" className="text-xs md:text-sm font-bold mono"
style={{ color: '#EAECEF' }} style={{ color: '#EAECEF' }}
> >
{trader.total_equity?.toFixed(2) || '0.00'} {trader.total_equity?.toFixed(2) || '0.00'}
@@ -297,11 +297,11 @@ export function CompetitionPage() {
{/* P&L */} {/* P&L */}
<div className="text-right min-w-[70px] md:min-w-[90px]"> <div className="text-right min-w-[70px] md:min-w-[90px]">
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}> <div className="text-xs" style={{ color: '#848E9C' }}>
{t('pnl', language)} {t('pnl', language)}
</div> </div>
<div <div
className="text-sm md:text-base font-bold mono" className="text-base md:text-lg font-bold mono"
style={{ style={{
color: color:
(trader.total_pnl ?? 0) >= 0 (trader.total_pnl ?? 0) >= 0
@@ -313,7 +313,7 @@ export function CompetitionPage() {
{trader.total_pnl_pct?.toFixed(2) || '0.00'}% {trader.total_pnl_pct?.toFixed(2) || '0.00'}%
</div> </div>
<div <div
className="text-[10px] mono" className="text-xs mono"
style={{ color: '#848E9C' }} style={{ color: '#848E9C' }}
> >
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''} {(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
@@ -322,17 +322,17 @@ export function CompetitionPage() {
</div> </div>
{/* Positions */} {/* Positions */}
<div className="text-right min-w-[40px] md:min-w-[50px]"> <div className="text-right">
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}> <div className="text-xs" style={{ color: '#848E9C' }}>
{t('pos', language)} {t('pos', language)}
</div> </div>
<div <div
className="text-sm md:text-base font-bold mono" className="text-xs md:text-sm font-bold mono"
style={{ color: '#EAECEF' }} style={{ color: '#EAECEF' }}
> >
{trader.position_count} {trader.position_count}
</div> </div>
<div className="text-[10px]" style={{ color: '#848E9C' }}> <div className="text-xs" style={{ color: '#848E9C' }}>
{trader.margin_used_pct.toFixed(1)}% {trader.margin_used_pct.toFixed(1)}%
</div> </div>
</div> </div>
@@ -539,6 +539,22 @@ export function ExchangeConfigModal({
</div> </div>
)} )}
{editingExchangeId && selectedExchange && (
<div
className="p-3 rounded-xl text-xs"
style={{ background: 'rgba(14, 203, 129, 0.08)', border: '1px solid rgba(14, 203, 129, 0.2)', color: '#9FE8C5' }}
>
{' '}
API Key {selectedExchange.has_api_key ? '已配置' : '未配置'}
{' · '}
Secret {selectedExchange.has_secret_key ? '已配置' : '未配置'}
{(currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'kucoin')
? ` · Passphrase ${selectedExchange.has_passphrase ? '已配置' : '未配置'}`
: ''}
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}> <label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<Key className="w-4 h-4" style={{ color: '#F0B90B' }} /> <Key className="w-4 h-4" style={{ color: '#F0B90B' }} />
@@ -548,7 +564,11 @@ export function ExchangeConfigModal({
type="password" type="password"
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
placeholder={t('enterAPIKey', language)} placeholder={
editingExchangeId && selectedExchange?.has_api_key
? '已保存,如需更换请重新输入'
: t('enterAPIKey', language)
}
className="w-full px-4 py-3 rounded-xl" className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
required required
@@ -564,7 +584,11 @@ export function ExchangeConfigModal({
type="password" type="password"
value={secretKey} value={secretKey}
onChange={(e) => setSecretKey(e.target.value)} onChange={(e) => setSecretKey(e.target.value)}
placeholder={t('enterSecretKey', language)} placeholder={
editingExchangeId && selectedExchange?.has_secret_key
? '已保存,如需更换请重新输入'
: t('enterSecretKey', language)
}
className="w-full px-4 py-3 rounded-xl" className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
required required
@@ -581,7 +605,11 @@ export function ExchangeConfigModal({
type="password" type="password"
value={passphrase} value={passphrase}
onChange={(e) => setPassphrase(e.target.value)} onChange={(e) => setPassphrase(e.target.value)}
placeholder={t('enterPassphrase', language)} placeholder={
editingExchangeId && selectedExchange?.has_passphrase
? '已保存,如需更换请重新输入'
: t('enterPassphrase', language)
}
className="w-full px-4 py-3 rounded-xl" className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
required required
+177 -218
View File
@@ -4,16 +4,15 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react'
import type { AIModel } from '../../types' import type { AIModel } from '../../types'
import type { Language } from '../../i18n/translations' import type { Language } from '../../i18n/translations'
import { t } from '../../i18n/translations' import { t } from '../../i18n/translations'
import { api } from '../../lib/api'
import { getModelIcon } from '../common/ModelIcons' import { getModelIcon } from '../common/ModelIcons'
import { ModelStepIndicator } from './ModelStepIndicator' import { ModelStepIndicator } from './ModelStepIndicator'
import { ModelCard } from './ModelCard' import { ModelCard } from './ModelCard'
import { import {
BLOCKRUN_MODELS,
CLAW402_MODELS, CLAW402_MODELS,
AI_PROVIDER_CONFIG, AI_PROVIDER_CONFIG,
getShortName, getShortName,
} from './model-constants' } from './model-constants'
import { getBeginnerWalletAddress, getUserMode } from '../../lib/onboarding'
interface ModelConfigModalProps { interface ModelConfigModalProps {
allModels: AIModel[] allModels: AIModel[]
@@ -44,22 +43,20 @@ export function ModelConfigModal({
const [apiKey, setApiKey] = useState('') const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('') const [baseUrl, setBaseUrl] = useState('')
const [modelName, setModelName] = useState('') const [modelName, setModelName] = useState('')
const configuredModel =
configuredModels?.find((model) => model.id === selectedModelId) || null
// Always prefer allModels (supportedModels) for provider/id lookup; // Always prefer allModels (supportedModels) for provider/id lookup;
// fall back to configuredModels for edit mode details (apiKey etc.) // fall back to configuredModels for edit mode details (apiKey etc.)
const selectedModel = const selectedModel =
allModels?.find((m) => m.id === selectedModelId) || configuredModel allModels?.find((m) => m.id === selectedModelId) ||
configuredModels?.find((m) => m.id === selectedModelId)
useEffect(() => { useEffect(() => {
const modelDetails = configuredModel || selectedModel if (editingModelId && selectedModel) {
if (editingModelId && modelDetails) { setApiKey(selectedModel.apiKey || '')
setApiKey(modelDetails.apiKey || '') setBaseUrl(selectedModel.customApiUrl || '')
setBaseUrl(modelDetails.customApiUrl || '') setModelName(selectedModel.customModelName || '')
setModelName(modelDetails.customModelName || '')
} }
}, [editingModelId, configuredModel, selectedModel]) }, [editingModelId, selectedModel])
const handleSelectModel = (modelId: string) => { const handleSelectModel = (modelId: string) => {
setSelectedModelId(modelId) setSelectedModelId(modelId)
@@ -77,28 +74,13 @@ export function ModelConfigModal({
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!selectedModelId) return if (!selectedModelId || !apiKey.trim()) return
const key = apiKey.trim() onSave(selectedModelId, apiKey.trim(), baseUrl.trim() || undefined, modelName.trim() || undefined)
// Allow empty key when editing an existing model (backend preserves existing key)
if (!key && !editingModelId) return
onSave(selectedModelId, key, baseUrl.trim() || undefined, modelName.trim() || undefined)
} }
const availableModels = allModels || [] const availableModels = allModels || []
const configuredIds = new Set(configuredModels?.map(m => m.id) || []) const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
const isClaw402Selected = selectedModel?.provider === 'claw402' || selectedModel?.id === 'claw402' const stepLabels = [t('modelConfig.selectModel', language), t('modelConfig.configureApi', language)]
const isBeginnerDefaultModel = isClaw402Selected && getUserMode() === 'beginner'
const stepLabels = [
t('modelConfig.selectModel', language),
t(
!selectedModel
? 'modelConfig.configure'
: isClaw402Selected
? 'modelConfig.configureWallet'
: 'modelConfig.configure',
language
),
]
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
@@ -121,7 +103,7 @@ export function ModelConfigModal({
</h3> </h3>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{editingModelId && !isBeginnerDefaultModel && ( {editingModelId && (
<button <button
type="button" type="button"
onClick={() => onDelete(editingModelId)} onClick={() => onDelete(editingModelId)}
@@ -162,7 +144,6 @@ export function ModelConfigModal({
<Claw402ConfigForm <Claw402ConfigForm
apiKey={apiKey} apiKey={apiKey}
modelName={modelName} modelName={modelName}
configuredModel={configuredModel}
editingModelId={editingModelId} editingModelId={editingModelId}
onApiKeyChange={setApiKey} onApiKeyChange={setApiKey}
onModelNameChange={setModelName} onModelNameChange={setModelName}
@@ -209,10 +190,6 @@ function ModelSelectionStep({
onSelectModel: (modelId: string) => void onSelectModel: (modelId: string) => void
language: Language language: Language
}) { }) {
const [showOtherProviders, setShowOtherProviders] = useState(false)
const claw402Model = availableModels.find((m) => m.provider === 'claw402')
const otherProviders = availableModels.filter((m) => m.provider !== 'claw402')
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}> <div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
@@ -220,11 +197,12 @@ function ModelSelectionStep({
</div> </div>
{/* Claw402 Featured Card */} {/* Claw402 Featured Card */}
{claw402Model && ( {availableModels.some(m => m.provider === 'claw402') && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
onSelectModel(claw402Model.id) const claw = availableModels.find(m => m.provider === 'claw402')
if (claw) onSelectModel(claw.id)
}} }}
className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]" className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]"
style={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)', border: '1.5px solid rgba(37, 99, 235, 0.4)' }} style={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)', border: '1.5px solid rgba(37, 99, 235, 0.4)' }}
@@ -245,7 +223,7 @@ function ModelSelectionStep({
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{configuredIds.has(claw402Model.id) && ( {configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && (
<div className="w-2 h-2 rounded-full" style={{ background: '#00E096' }} /> <div className="w-2 h-2 rounded-full" style={{ background: '#00E096' }} />
)} )}
<div className="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}> <div className="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
@@ -258,41 +236,11 @@ function ModelSelectionStep({
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
</span> </span>
</div> </div>
<div className="mt-4 ml-[52px] text-[11px]" style={{ color: '#A0AEC0' }}>
{t('modelConfig.claw402EntryDesc', language)}
</div>
</button> </button>
)} )}
{otherProviders.length > 0 && (
<div className="rounded-xl border border-white/10 bg-black/20 overflow-hidden">
<button
type="button"
onClick={() => setShowOtherProviders((prev) => !prev)}
className="w-full flex items-center justify-between px-4 py-4 text-left transition-all hover:bg-white/5"
>
<div>
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{t('modelConfig.otherApiEntry', language)}
</div>
<div className="mt-1 text-xs" style={{ color: '#848E9C' }}>
{t('modelConfig.otherApiEntryDesc', language)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full border border-white/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.2em]" style={{ color: '#A0AEC0' }}>
{otherProviders.length} API
</span>
<span className="text-sm" style={{ color: '#60A5FA' }}>
{showOtherProviders ? '' : '+'}
</span>
</div>
</button>
{showOtherProviders && (
<div className="border-t border-white/5 px-4 py-4">
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{otherProviders.map((model) => ( {availableModels.filter(m => !m.provider?.startsWith('blockrun') && m.provider !== 'claw402').map((model) => (
<ModelCard <ModelCard
key={model.id} key={model.id}
model={model} model={model}
@@ -302,21 +250,38 @@ function ModelSelectionStep({
/> />
))} ))}
</div> </div>
<div className="text-xs text-center pt-3" style={{ color: '#848E9C' }}> {availableModels.some(m => m.provider?.startsWith('blockrun')) && (
<>
<div className="flex items-center gap-3 pt-2">
<div className="flex-1 h-px" style={{ background: '#2B3139' }} />
<span className="text-xs font-medium px-2" style={{ color: '#848E9C' }}>
{t('modelConfig.viaBlockrunWallet', language)}
</span>
<div className="flex-1 h-px" style={{ background: '#2B3139' }} />
</div>
<div className="grid grid-cols-2 gap-3">
{availableModels.filter(m => m.provider?.startsWith('blockrun')).map((model) => (
<ModelCard
key={model.id}
model={model}
selected={selectedModelId === model.id}
onClick={() => onSelectModel(model.id)}
configured={configuredIds.has(model.id)}
/>
))}
</div>
</>
)}
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
{t('modelConfig.modelsConfigured', language)} {t('modelConfig.modelsConfigured', language)}
</div> </div>
</div> </div>
)}
</div>
)}
</div>
) )
} }
function Claw402ConfigForm({ function Claw402ConfigForm({
apiKey, apiKey,
modelName, modelName,
configuredModel,
editingModelId, editingModelId,
onApiKeyChange, onApiKeyChange,
onModelNameChange, onModelNameChange,
@@ -326,7 +291,6 @@ function Claw402ConfigForm({
}: { }: {
apiKey: string apiKey: string
modelName: string modelName: string
configuredModel: AIModel | null
editingModelId: string | null editingModelId: string | null
onApiKeyChange: (value: string) => void onApiKeyChange: (value: string) => void
onModelNameChange: (value: string) => void onModelNameChange: (value: string) => void
@@ -337,21 +301,14 @@ function Claw402ConfigForm({
const [walletAddress, setWalletAddress] = useState('') const [walletAddress, setWalletAddress] = useState('')
const [copiedAddr, setCopiedAddr] = useState(false) const [copiedAddr, setCopiedAddr] = useState(false)
const [showDeposit, setShowDeposit] = useState(false) const [showDeposit, setShowDeposit] = useState(false)
const [showNewWalletBackup, setShowNewWalletBackup] = useState(false)
const [newWalletKey, setNewWalletKey] = useState('')
const [usdcBalance, setUsdcBalance] = useState<string | null>(null) const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
const [keyError, setKeyError] = useState('') const [keyError, setKeyError] = useState('')
const [validating, setValidating] = useState(false) const [validating, setValidating] = useState(false)
const [claw402Status, setClaw402Status] = useState<string | null>(null) const [claw402Status, setClaw402Status] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null) const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null)
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [serverWalletAddress, setServerWalletAddress] = useState('')
const [serverWalletBalance, setServerWalletBalance] = useState<string | null>(null)
const localWalletAddress = getBeginnerWalletAddress()?.trim() || ''
const configuredWalletAddress =
configuredModel?.walletAddress?.trim() || localWalletAddress || serverWalletAddress
const resolvedWalletAddress = walletAddress || configuredWalletAddress
const resolvedUsdcBalance =
usdcBalance ?? configuredModel?.balanceUsdc ?? serverWalletBalance ?? null
const hasExistingWallet = Boolean(configuredWalletAddress)
// Client-side validation helper // Client-side validation helper
const getClientError = (key: string): string => { const getClientError = (key: string): string => {
@@ -364,36 +321,8 @@ function Claw402ConfigForm({
const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey) const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey)
useEffect(() => { // Truncate address for display
if (hasExistingWallet) {
setShowDeposit(true)
}
}, [hasExistingWallet])
useEffect(() => {
if (configuredModel?.walletAddress || localWalletAddress || serverWalletAddress) {
return
}
let cancelled = false
void api
.getCurrentBeginnerWallet()
.then((result) => {
setClaw402Status(result.claw402_status || 'unknown')
if (cancelled || !result.found || !result.address) {
return
}
setServerWalletAddress(result.address)
setServerWalletBalance(result.balance_usdc || null)
})
.catch(() => {
// Ignore silently: this is a best-effort fallback for showing the current wallet.
})
return () => {
cancelled = true
}
}, [configuredModel?.walletAddress, localWalletAddress, serverWalletAddress])
// Debounced validation when apiKey changes // Debounced validation when apiKey changes
useEffect(() => { useEffect(() => {
@@ -441,23 +370,6 @@ function Claw402ConfigForm({
setTesting(true) setTesting(true)
setTestResult(null) setTestResult(null)
try { try {
if (!apiKey && hasExistingWallet) {
const result = await api.getCurrentBeginnerWallet()
setClaw402Status(result.claw402_status || 'unknown')
if (result.found && result.address) {
setWalletAddress(result.address)
setUsdcBalance(result.balance_usdc || '0.00')
setShowDeposit(true)
}
setTestResult({
status: result.claw402_status === 'ok' ? 'ok' : 'error',
message: result.claw402_status === 'ok'
? t('modelConfig.claw402Connected', language)
: t('modelConfig.claw402Unreachable', language),
})
return
}
const res = await fetch('/api/wallet/validate', { const res = await fetch('/api/wallet/validate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -485,7 +397,7 @@ function Claw402ConfigForm({
} }
} }
const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0 const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0
return ( return (
<form onSubmit={onSubmit} className="space-y-5"> <form onSubmit={onSubmit} className="space-y-5">
@@ -507,25 +419,6 @@ function Claw402ConfigForm({
</span> </span>
))} ))}
</div> </div>
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
<button
type="button"
onClick={handleTestConnection}
disabled={testing || (!hasExistingWallet && !isKeyValid)}
className="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-semibold transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
>
<span>🔗</span>
{testing ? t('modelConfig.testingConnection', language) : t('modelConfig.testConnection', language)}
</button>
{claw402Status ? (
<div className="text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#F59E0B' }}>
{claw402Status === 'ok'
? t('modelConfig.claw402Connected', language)
: t('modelConfig.claw402Unreachable', language)}
</div>
) : null}
</div>
</div> </div>
{/* Step 1: Select AI Model */} {/* Step 1: Select AI Model */}
@@ -539,7 +432,7 @@ function Claw402ConfigForm({
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{CLAW402_MODELS.map((m) => { {CLAW402_MODELS.map((m) => {
const isSelected = (modelName || 'glm-5') === m.id const isSelected = (modelName || 'deepseek') === m.id
return ( return (
<button <button
key={m.id} key={m.id}
@@ -597,33 +490,6 @@ function Claw402ConfigForm({
</div> </div>
</div> </div>
{hasExistingWallet && (
<div className="p-3 rounded-xl" style={{ background: 'rgba(0, 224, 150, 0.05)', border: '1px solid rgba(0, 224, 150, 0.18)' }}>
<div className="text-xs font-semibold mb-1.5" style={{ color: '#00E096' }}>
{language === 'zh' ? '已自动提取当前钱包' : 'Current wallet loaded automatically'}
</div>
<div className="text-[11px] leading-5" style={{ color: '#A0AEC0' }}>
{language === 'zh'
? '你现在可以直接查看当前钱包地址、余额和充值二维码。只有在想更换钱包时,才需要重新输入新的私钥。'
: 'You can view the current wallet address, balance, and deposit QR code right away. Only enter a new private key if you want to replace this wallet.'}
</div>
{!configuredModel?.walletAddress && localWalletAddress ? (
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前地址来自本地已保存的新手钱包。'
: 'This address comes from the locally saved beginner wallet.'}
</div>
) : null}
{!configuredModel?.walletAddress && !localWalletAddress && serverWalletAddress ? (
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前地址来自后端保存的钱包配置。'
: 'This address comes from the wallet saved on the server.'}
</div>
) : null}
</div>
)}
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}> <div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
{t('modelConfig.walletPrivateKey', language)} {t('modelConfig.walletPrivateKey', language)}
@@ -633,30 +499,72 @@ function Claw402ConfigForm({
type="password" type="password"
value={apiKey} value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)} onChange={(e) => onApiKeyChange(e.target.value)}
placeholder={ placeholder="0x..."
hasExistingWallet
? language === 'zh'
? '如需切换钱包,请手动输入新的私钥'
: 'Enter a new private key only if you want to switch wallets'
: '0x...'
}
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm" className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
style={{ style={{
background: '#0B0E11', background: '#0B0E11',
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139', border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
color: '#EAECEF', color: '#EAECEF',
}} }}
required={!hasExistingWallet} required
/> />
{!apiKey && (
<button
type="button"
onClick={async () => {
try {
const res = await fetch('/api/wallet/generate', { method: 'POST' })
const data = await res.json()
if (data.private_key) {
onApiKeyChange(data.private_key)
setShowNewWalletBackup(true)
setNewWalletKey(data.private_key)
}
} catch { /* ignore */ }
}}
className="shrink-0 px-3 py-3 rounded-xl text-xs font-semibold transition-all hover:scale-[1.02]"
style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff', border: 'none', cursor: 'pointer' }}
>
{language === 'zh' ? '🔑 创建钱包' : '🔑 Create Wallet'}
</button>
)}
</div> </div>
{hasExistingWallet && !apiKey ? ( {/* New wallet backup warning */}
<div className="text-[11px] leading-5" style={{ color: '#848E9C' }}> {showNewWalletBackup && newWalletKey && (
{language === 'zh' <div className="p-3 rounded-xl" style={{ background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。' <div className="text-xs font-bold mb-2" style={{ color: '#EF4444' }}>
: 'This screen keeps using the wallet created and saved the first time. Enter a new private key manually only if you want to switch wallets.'} 🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'}
</div> </div>
) : null} <div className="text-[11px] mb-2" style={{ color: '#F87171' }}>
{language === 'zh'
? '这是你的钱包私钥,丢失后无法恢复,钱包里的资产将永久丢失。请复制并安全保存。'
: 'This is your wallet private key. If lost, it cannot be recovered and all assets will be permanently lost. Copy and save it securely.'}
</div>
<div className="flex items-center gap-2 mb-2">
<code className="text-[10px] font-mono break-all select-all flex-1 p-2 rounded" style={{ background: '#0B0E11', color: '#F87171' }}>
{newWalletKey}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(newWalletKey)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
className="shrink-0 text-[10px] px-2 py-1 rounded"
style={{ background: 'rgba(239,68,68,0.15)', color: '#F87171', border: 'none', cursor: 'pointer' }}
>
{copiedAddr ? '✅ Copied' : '📋 Copy Key'}
</button>
</div>
<div className="text-[10px] space-y-1" style={{ color: '#848E9C' }}>
<div> {language === 'zh' ? '建议保存到密码管理器(1Password / Bitwarden' : 'Save to a password manager (1Password / Bitwarden)'}</div>
<div> {language === 'zh' ? '或抄在纸上放安全的地方' : 'Or write it down and store it safely'}</div>
<div> {language === 'zh' ? '不要截图发给别人' : 'Do NOT screenshot or share with anyone'}</div>
</div>
</div>
)}
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}> <div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
<span className="mt-px">🔒</span> <span className="mt-px">🔒</span>
@@ -667,7 +575,7 @@ function Claw402ConfigForm({
</div> </div>
{/* Wallet Validation Results */} {/* Wallet Validation Results */}
{(apiKey || hasExistingWallet) && ( {apiKey && (
<div className="space-y-2 pl-1"> <div className="space-y-2 pl-1">
{/* Validating spinner */} {/* Validating spinner */}
{validating && ( {validating && (
@@ -686,7 +594,7 @@ function Claw402ConfigForm({
)} )}
{/* Success: address + balance + status */} {/* Success: address + balance + status */}
{resolvedWalletAddress && !validating && !keyError && ( {walletAddress && !validating && !keyError && (
<> <>
<div className="p-2.5 rounded-lg" style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.15)' }}> <div className="p-2.5 rounded-lg" style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.15)' }}>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
@@ -696,7 +604,7 @@ function Claw402ConfigForm({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(resolvedWalletAddress) navigator.clipboard.writeText(walletAddress)
setCopiedAddr(true) setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000) setTimeout(() => setCopiedAddr(false), 2000)
}} }}
@@ -706,16 +614,16 @@ function Claw402ConfigForm({
{copiedAddr ? '✅' : '📋'} {copiedAddr ? '✅' : '📋'}
</button> </button>
</div> </div>
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code> <code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<div className="text-[10px] mt-1.5" style={{ color: '#F59E0B' }}> <div className="text-[10px] mt-1.5" style={{ color: '#F59E0B' }}>
{language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'} {language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
</div> </div>
</div> </div>
{resolvedUsdcBalance !== null && ( {usdcBalance !== null && (
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span>💰</span> <span>💰</span>
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}> <span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
{t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance} {t('modelConfig.usdcBalance', language)}: ${usdcBalance}
</span> </span>
<button <button
type="button" type="button"
@@ -736,17 +644,17 @@ function Claw402ConfigForm({
</div> </div>
<div className="flex gap-3 items-start mb-3"> <div className="flex gap-3 items-start mb-3">
<div className="shrink-0 p-1.5 rounded-lg" style={{ background: '#fff' }}> <div className="shrink-0 p-1.5 rounded-lg" style={{ background: '#fff' }}>
<QRCodeSVG value={resolvedWalletAddress} size={80} level="M" /> <QRCodeSVG value={walletAddress} size={80} level="M" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}> <div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'} {language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
</div> </div>
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code> <code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(resolvedWalletAddress) navigator.clipboard.writeText(walletAddress)
setCopiedAddr(true) setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000) setTimeout(() => setCopiedAddr(false), 2000)
}} }}
@@ -765,13 +673,6 @@ function Claw402ConfigForm({
</div> </div>
</div> </div>
)} )}
{!apiKey && hasExistingWallet && (
<div className="text-[11px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前正在使用这个钱包充值。若要切换钱包,再输入新的私钥并保存即可。'
: 'This wallet is currently used for funding. Enter a new private key only if you want to switch wallets.'}
</div>
)}
{claw402Status && ( {claw402Status && (
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}> <div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span> <span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
@@ -784,11 +685,11 @@ function Claw402ConfigForm({
)} )}
{/* Test Connection button */} {/* Test Connection button */}
{(isKeyValid || hasExistingWallet) && !validating && ( {isKeyValid && !validating && (
<button <button
type="button" type="button"
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={testing || (!hasExistingWallet && !isKeyValid)} disabled={testing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all hover:scale-[1.02] disabled:opacity-50" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all hover:scale-[1.02] disabled:opacity-50"
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }} style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
> >
@@ -836,9 +737,9 @@ function Claw402ConfigForm({
</button> </button>
<button <button
type="submit" type="submit"
disabled={!isKeyValid && !hasExistingWallet} disabled={!isKeyValid}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: (isKeyValid || hasExistingWallet) ? 'linear-gradient(135deg, #2563EB, #7C3AED)' : '#2B3139', color: '#fff' }} style={{ background: isKeyValid ? 'linear-gradient(135deg, #2563EB, #7C3AED)' : '#2B3139', color: '#fff' }}
> >
{'🚀 ' + t('modelConfig.startTrading', language)} {'🚀 ' + t('modelConfig.startTrading', language)}
</button> </button>
@@ -899,7 +800,9 @@ function StandardProviderConfigForm({
> >
<ExternalLink className="w-4 h-4" style={{ color: '#A78BFA' }} /> <ExternalLink className="w-4 h-4" style={{ color: '#A78BFA' }} />
<span className="text-sm font-medium" style={{ color: '#A78BFA' }}> <span className="text-sm font-medium" style={{ color: '#A78BFA' }}>
{t('modelConfig.getApiKey', language)} {selectedModel.provider?.startsWith('blockrun')
? t('modelConfig.getStarted', language)
: t('modelConfig.getApiKey', language)}
</span> </span>
</a> </a>
)} )}
@@ -918,25 +821,45 @@ function StandardProviderConfigForm({
)} )}
{/* API Key / Wallet Private Key */} {/* API Key / Wallet Private Key */}
{editingModelId && selectedModel && 'has_api_key' in selectedModel && (
<div
className="p-3 rounded-xl text-xs"
style={{ background: 'rgba(14, 203, 129, 0.08)', border: '1px solid rgba(14, 203, 129, 0.2)', color: '#9FE8C5' }}
>
{selectedModel.has_api_key ? '已配置 API Key' : '未配置 API Key'}
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}> <label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg> </svg>
{'API Key *'} {selectedModel.provider?.startsWith('blockrun')
? t('modelConfig.walletPrivateKeyLabel', language)
: 'API Key *'}
</label> </label>
<input <input
type="password" type="password"
value={apiKey} value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)} onChange={(e) => onApiKeyChange(e.target.value)}
placeholder={t('enterAPIKey', language)} placeholder={
editingModelId && selectedModel.has_api_key
? '已保存,如需更换请重新输入'
: selectedModel.provider === 'blockrun-base'
? '0x... (EVM private key)'
: selectedModel.provider === 'blockrun-sol'
? 'bs58 encoded key (Solana)'
: t('enterAPIKey', language)
}
className="w-full px-4 py-3 rounded-xl" className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
required required
/> />
</div> </div>
{/* Custom Base URL */} {/* Custom Base URL (hidden for BlockRun) */}
{!selectedModel.provider?.startsWith('blockrun') && (
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}> <label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -956,8 +879,10 @@ function StandardProviderConfigForm({
{t('leaveBlankForDefault', language)} {t('leaveBlankForDefault', language)}
</div> </div>
</div> </div>
)}
{/* Custom Model Name */} {/* Custom Model Name (hidden for BlockRun) */}
{!selectedModel.provider?.startsWith('blockrun') && (
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}> <label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -977,7 +902,41 @@ function StandardProviderConfigForm({
{t('leaveBlankForDefaultModel', language)} {t('leaveBlankForDefaultModel', language)}
</div> </div>
</div> </div>
)}
{/* BlockRun Model Selector */}
{selectedModel.provider?.startsWith('blockrun') && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{t('modelConfig.selectModelLabel', language)}
</label>
<div className="grid grid-cols-2 gap-2">
{BLOCKRUN_MODELS.map((m) => {
const isSelected = (modelName || BLOCKRUN_MODELS[0].id) === m.id
return (
<button
key={m.id}
type="button"
onClick={() => onModelNameChange(m.id)}
className="flex flex-col items-start px-3 py-2 rounded-xl text-left transition-all"
style={{
background: isSelected ? 'rgba(37, 99, 235, 0.2)' : '#0B0E11',
border: isSelected ? '1px solid #2563EB' : '1px solid #2B3139',
}}
>
<span className="text-xs font-semibold" style={{ color: isSelected ? '#60A5FA' : '#EAECEF' }}>
{m.name}
</span>
<span className="text-[10px]" style={{ color: '#848E9C' }}>{m.desc}</span>
</button>
)
})}
</div>
</div>
)}
{/* Info Box */} {/* Info Box */}
<div className="p-4 rounded-xl" style={{ background: 'rgba(139, 92, 246, 0.1)', border: '1px solid rgba(139, 92, 246, 0.2)' }}> <div className="p-4 rounded-xl" style={{ background: 'rgba(139, 92, 246, 0.1)', border: '1px solid rgba(139, 92, 246, 0.2)' }}>
@@ -15,6 +15,12 @@ export interface AIProviderConfig {
apiName: string apiName: string
} }
export interface BlockrunModel {
id: string
name: string
desc: string
}
// Get friendly AI model display name // Get friendly AI model display name
export function getModelDisplayName(modelId: string): string { export function getModelDisplayName(modelId: string): string {
switch (modelId.toLowerCase()) { switch (modelId.toLowerCase()) {
@@ -53,6 +59,29 @@ export const CLAW402_MODELS: Claw402Model[] = [
{ id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI', desc: '$0.50/call', icon: '🧠', price: 0.50 }, { id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI', desc: '$0.50/call', icon: '🧠', price: 0.50 },
] ]
export const BLOCKRUN_MODELS: BlockrunModel[] = [
{
id: 'gpt-5.2',
name: 'GPT-5.2',
desc: 'Base wallet payment',
},
{
id: 'claude-opus-4-6',
name: 'Claude Opus 4.6',
desc: 'Base wallet payment',
},
{
id: 'gemini-3.1-pro',
name: 'Gemini 3.1 Pro',
desc: 'Base wallet payment',
},
{
id: 'qwen3-max',
name: 'Qwen 3 Max',
desc: 'Base wallet payment',
},
]
// AI Provider configuration - default models and API links // AI Provider configuration - default models and API links
export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = { export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = {
deepseek: { deepseek: {
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
LEGACY_AGENT_CHAT_STORAGE_KEY,
chatStorageKey,
clearAgentMessages,
loadAgentMessages,
migrateAgentMessages,
normalizeStorageUserId,
prepareAgentMessagesForPersistence,
} from './agentChatStorage'
function createStorage(): Storage {
const data = new Map<string, string>()
return {
get length() {
return data.size
},
clear() {
data.clear()
},
getItem(key: string) {
return data.has(key) ? data.get(key)! : null
},
key(index: number) {
return Array.from(data.keys())[index] ?? null
},
removeItem(key: string) {
data.delete(key)
},
setItem(key: string, value: string) {
data.set(key, value)
},
}
}
describe('agentChatStorage', () => {
it('normalizes string and numeric user ids', () => {
expect(normalizeStorageUserId(' user-1 ')).toBe('user-1')
expect(normalizeStorageUserId(42)).toBe('42')
expect(normalizeStorageUserId('')).toBeUndefined()
})
it('falls back to guest history for a logged-in user when user history is empty', () => {
const storage = createStorage()
const guestMessages = [{ id: '1', text: 'hello' }]
storage.setItem(chatStorageKey('guest'), JSON.stringify(guestMessages))
expect(loadAgentMessages(storage, 'user-1')).toEqual({
messages: guestMessages,
sourceKey: chatStorageKey('guest'),
})
})
it('migrates guest history into the user-specific key after login', () => {
const storage = createStorage()
const guestMessages = [{ id: '1', text: 'hello' }]
storage.setItem(chatStorageKey('guest'), JSON.stringify(guestMessages))
migrateAgentMessages(storage, 'user-1')
expect(storage.getItem(chatStorageKey('user-1'))).toBe(JSON.stringify(guestMessages))
})
it('clears primary and fallback chat storage keys', () => {
const storage = createStorage()
storage.setItem(chatStorageKey('user-1'), JSON.stringify([{ id: '1' }]))
storage.setItem(chatStorageKey('guest'), JSON.stringify([{ id: '2' }]))
storage.setItem(LEGACY_AGENT_CHAT_STORAGE_KEY, JSON.stringify([{ id: '3' }]))
clearAgentMessages(storage, 'user-1')
expect(storage.getItem(chatStorageKey('user-1'))).toBeNull()
expect(storage.getItem(chatStorageKey('guest'))).toBeNull()
expect(storage.getItem(LEGACY_AGENT_CHAT_STORAGE_KEY)).toBeNull()
})
it('persists streaming messages as non-streaming snapshots', () => {
const messages = [
{ id: '1', text: 'hello', streaming: true, steps: [{ id: 's1' }] },
{ id: '2', text: 'done', streaming: false },
]
expect(prepareAgentMessagesForPersistence(messages)).toEqual([
{ id: '1', text: 'hello', streaming: false, steps: [{ id: 's1' }], time: '' },
{ id: '2', text: 'done', streaming: false },
])
})
})
+104
View File
@@ -0,0 +1,104 @@
export const LEGACY_AGENT_CHAT_STORAGE_KEY = 'nofxi-agent-chat'
export function normalizeStorageUserId(value: unknown): string | undefined {
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed || undefined
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return undefined
}
export function chatStorageKey(userId?: string) {
return `nofxi-agent-chat:${userId || 'guest'}`
}
export function getStoredAuthUserId(storage: Storage = window.localStorage) {
try {
const raw = storage.getItem('auth_user')
if (!raw) return undefined
const parsed = JSON.parse(raw)
return normalizeStorageUserId(parsed?.id)
} catch {
return undefined
}
}
function loadMessagesFromKey<T>(storage: Storage, key: string): T[] {
try {
const raw = storage.getItem(key)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function candidateStorageKeys(userId?: string): string[] {
const keys = [chatStorageKey(userId)]
if (userId) {
keys.push(chatStorageKey('guest'))
}
keys.push(LEGACY_AGENT_CHAT_STORAGE_KEY)
return [...new Set(keys)]
}
export function loadAgentMessages<T>(storage: Storage, userId?: string) {
const keys = candidateStorageKeys(userId)
for (const key of keys) {
const messages = loadMessagesFromKey<T>(storage, key)
if (messages.length > 0) {
return { messages, sourceKey: key }
}
}
return { messages: [] as T[], sourceKey: chatStorageKey(userId) }
}
export function persistAgentMessages<T>(
storage: Storage,
userId: string | undefined,
messages: T[]
) {
storage.setItem(chatStorageKey(userId), JSON.stringify(messages))
}
export function prepareAgentMessagesForPersistence<
T extends { streaming?: boolean; text?: string; steps?: unknown[]; time?: string }
>(messages: T[]): T[] {
return messages.map((message) => {
if (!message.streaming) {
return message
}
return {
...message,
// Persist the latest visible snapshot, but don't restore it as an
// actively streaming message after the user leaves and comes back.
streaming: false,
time: message.time || '',
}
})
}
export function migrateAgentMessages(storage: Storage, userId?: string) {
if (!userId) return
const targetKey = chatStorageKey(userId)
const targetMessages = loadMessagesFromKey(storage, targetKey)
if (targetMessages.length > 0) return
for (const sourceKey of [chatStorageKey('guest'), LEGACY_AGENT_CHAT_STORAGE_KEY]) {
const sourceMessages = loadMessagesFromKey(storage, sourceKey)
if (sourceMessages.length === 0) continue
storage.setItem(targetKey, JSON.stringify(sourceMessages))
return
}
}
export function clearAgentMessages(storage: Storage, userId?: string) {
for (const key of candidateStorageKeys(userId)) {
storage.removeItem(key)
}
}
+13 -4
View File
@@ -28,7 +28,10 @@ export const dataApi = {
return result.data! return result.data!
}, },
async getPositions(traderId?: string, silent?: boolean): Promise<Position[]> { async getPositions(
traderId?: string,
silent?: boolean
): Promise<Position[]> {
const url = traderId const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}` ? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions` : `${API_BASE}/positions`
@@ -65,7 +68,10 @@ export const dataApi = {
return result.data! return result.data!
}, },
async getStatistics(traderId?: string, silent?: boolean): Promise<Statistics> { async getStatistics(
traderId?: string,
silent?: boolean
): Promise<Statistics> {
const url = traderId const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}` ? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics` : `${API_BASE}/statistics`
@@ -74,7 +80,10 @@ export const dataApi = {
return result.data! return result.data!
}, },
async getEquityHistory(traderId?: string, silent?: boolean): Promise<any[]> { async getEquityHistory(
traderId?: string,
silent?: boolean
): Promise<any[]> {
const url = traderId const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}` ? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history` : `${API_BASE}/equity-history`
@@ -100,7 +109,7 @@ export const dataApi = {
async getPublicTraderConfig(traderId: string): Promise<any> { async getPublicTraderConfig(traderId: string): Promise<any> {
const result = await httpClient.get<any>( const result = await httpClient.get<any>(
`${API_BASE}/trader/${traderId}/config` `${API_BASE}/traders/${traderId}/public-config`
) )
if (!result.success) throw new Error('Failed to fetch public trader config') if (!result.success) throw new Error('Failed to fetch public trader config')
return result.data! return result.data!
+759
View File
@@ -0,0 +1,759 @@
import { useState, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
PanelRightClose,
PanelRightOpen,
TrendingUp,
Wallet,
Bot,
Bookmark,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext'
import { useAuth } from '../contexts/AuthContext'
import { MarketTicker } from '../components/agent/MarketTicker'
import { PositionsPanel } from '../components/agent/PositionsPanel'
import { TraderStatusPanel } from '../components/agent/TraderStatusPanel'
import { WelcomeScreen } from '../components/agent/WelcomeScreen'
import { ChatMessages } from '../components/agent/ChatMessages'
import { ChatInput, type ChatInputHandle } from '../components/agent/ChatInput'
import { UserPreferencesPanel } from '../components/agent/UserPreferencesPanel'
import {
useAgentChatStore,
type AgentMessage as Message,
type AgentStep,
} from '../stores/agentChatStore'
import {
chatStorageKey,
clearAgentMessages,
getStoredAuthUserId,
loadAgentMessages,
migrateAgentMessages,
prepareAgentMessagesForPersistence,
persistAgentMessages,
} from '../lib/agentChatStorage'
let msgIdCounter = 0
function nextId() {
return `msg-${Date.now()}-${++msgIdCounter}`
}
function appendStep(
existing: AgentStep[] | undefined,
step: AgentStep
): AgentStep[] {
const prev = existing ?? []
const index = prev.findIndex((item) => item.id === step.id)
if (index === -1) return [...prev, step]
return prev.map((item, i) => (i === index ? { ...item, ...step } : item))
}
function parsePlanSteps(data: string): AgentStep[] {
const text = data.replace(/^🗺️\s*(Plan|计划):\s*/i, '').trim()
if (!text) return []
return text.split(/\s*->\s*/).map((part, index) => {
const cleaned = part.replace(/^\d+\./, '').trim()
return {
id: `action-${index + 1}`,
label: cleaned || `Step ${index + 1}`,
status: 'pending',
}
})
}
function parseStepEvent(data: string, fallbackIndex: number): AgentStep {
const match = data.match(/Step\s+(\d+)\/(\d+):\s+(.+)$/i) || data.match(/步骤\s+(\d+)\/(\d+):\s+(.+)$/)
if (match) {
const id = `action-${match[1]}`
return {
id,
label: match[3].trim(),
status: 'running',
detail: data,
}
}
return {
id: `step-${fallbackIndex}`,
label: data,
status: 'running',
detail: data,
}
}
function markLatestRunningCompleted(existing: AgentStep[] | undefined, detail: string): AgentStep[] {
const prev = existing ?? []
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].status === 'running') {
return prev.map((step, index) =>
index === i ? { ...step, status: 'completed', detail } : step
)
}
}
return prev
}
export function AgentChatPage() {
const { language } = useLanguage()
const { token, user } = useAuth()
const [storageUserId, setStorageUserId] = useState<string | undefined>(() => getStoredAuthUserId())
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
const storageKey = chatStorageKey(user?.id || storageUserId)
const messages = useAgentChatStore((state) => state.messages)
const loading = useAgentChatStore((state) => state.loading)
const historyHydrated = useAgentChatStore((state) => state.hydrated)
const activeUserId = useAgentChatStore((state) => state.activeUserId)
const setMessages = useAgentChatStore((state) => state.setMessages)
const updateMessages = useAgentChatStore((state) => state.updateMessages)
const setLoading = useAgentChatStore((state) => state.setLoading)
const resetForUser = useAgentChatStore((state) => state.resetForUser)
const messagesEndRef = useRef<HTMLDivElement>(null)
const chatInputRef = useRef<ChatInputHandle>(null)
const abortRef = useRef<AbortController | null>(null)
// Sidebar section collapse state
const [sections, setSections] = useState({
market: true,
positions: true,
traders: false,
preferences: true,
})
const toggleSection = (key: keyof typeof sections) => {
setSections((prev) => ({ ...prev, [key]: !prev[key] }))
}
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
useEffect(() => {
setStorageUserId(user?.id || getStoredAuthUserId())
}, [user?.id])
useEffect(() => {
if (!user?.id) return
migrateAgentMessages(window.localStorage, user.id)
}, [user?.id])
// Restore chat history for the current user when opening the agent page.
useEffect(() => {
const nextUserId = user?.id || storageUserId
if (activeUserId === nextUserId && historyHydrated) return
resetForUser(
nextUserId,
loadAgentMessages<Message>(window.localStorage, nextUserId).messages
)
}, [activeUserId, historyHydrated, resetForUser, storageKey, storageUserId, user?.id])
// Persist chat history locally so page navigation does not wipe the conversation.
useEffect(() => {
if (!historyHydrated) return
try {
const persistable = prepareAgentMessagesForPersistence(messages).slice(-100)
persistAgentMessages(window.localStorage, user?.id || storageUserId, persistable)
} catch {
// Ignore storage failures and keep the chat usable.
}
}, [historyHydrated, messages, storageKey, storageUserId, user?.id])
const persistMessagesSnapshot = (nextMessages: Message[]) => {
const persistable = prepareAgentMessagesForPersistence(nextMessages).slice(-100)
persistAgentMessages(window.localStorage, user?.id || storageUserId, persistable)
}
const replaceMessages = (nextMessages: Message[]) => {
setMessages(nextMessages)
if (historyHydrated) {
persistMessagesSnapshot(nextMessages)
}
}
const patchMessages = (updater: (prev: Message[]) => Message[]) => {
const nextMessages = updater(useAgentChatStore.getState().messages)
updateMessages(() => nextMessages)
if (useAgentChatStore.getState().hydrated) {
persistMessagesSnapshot(nextMessages)
}
}
// Responsive sidebar
useEffect(() => {
const handleResize = () => {
if (window.innerWidth <= 768) setSidebarOpen(false)
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// Escape to close sidebar on mobile
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && window.innerWidth <= 768) {
setSidebarOpen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
const send = async (text: string) => {
if (!text || loading) return
const time = new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
const userMsg: Message = { id: nextId(), role: 'user', text, time }
const botId = nextId()
const nextConversation: Message[] = [
userMsg,
{
id: botId,
role: 'bot',
text: '',
time: '',
streaming: true,
},
]
replaceMessages(
text.trim() === '/clear'
? nextConversation
: [...useAgentChatStore.getState().messages, ...nextConversation]
)
setLoading(true)
if (text.trim() === '/clear') {
try {
clearAgentMessages(window.localStorage, user?.id || storageUserId)
} catch {
// Ignore storage cleanup failure.
}
}
try {
// Abort any in-flight request
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const res = await fetch('/api/agent/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ message: text, lang: language, user_key: user?.id }),
signal: controller.signal,
})
if (!res.ok) {
const errData = await res.json().catch(() => ({}))
throw new Error(errData.error || `Server error (${res.status})`)
}
// Real SSE streaming
const reader = res.body?.getReader()
const decoder = new TextDecoder()
if (!reader) throw new Error('No response body')
let buffer = ''
let finalText = ''
let stepCounter = 0
const now = () =>
new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || '' // Keep incomplete line in buffer
let eventType = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim()
} else if (line.startsWith('data: ') && eventType) {
const rawData = line.slice(6)
let data: string
try {
data = JSON.parse(rawData)
} catch {
// Ignore malformed SSE data lines
eventType = ''
continue
}
if (eventType === 'delta') {
// data is the accumulated text so far
finalText = data
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? { ...m, text: data, time: now() }
: m
)
)
} else if (eventType === 'plan') {
const parsedSteps = parsePlanSteps(data)
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: parsedSteps.length > 0 ? parsedSteps : m.steps,
time: now(),
}
: m
)
)
} else if (eventType === 'step_start') {
stepCounter += 1
const nextStep = parseStepEvent(data, stepCounter)
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, nextStep),
time: now(),
}
: m
)
)
} else if (eventType === 'step_complete') {
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: markLatestRunningCompleted(m.steps, data),
time: now(),
}
: m
)
)
} else if (eventType === 'replan') {
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, {
id: `replan-${Date.now()}`,
label: data,
status: 'replanned',
detail: data,
}),
time: now(),
}
: m
)
)
} else if (
eventType === 'tool'
) {
// Show tool being called as a status indicator
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, {
id: `tool-${Date.now()}`,
label: `Tool: ${data}`,
status: 'running',
detail: data,
}),
time: now(),
}
: m
)
)
} else if (eventType === 'done') {
finalText = data
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? { ...m, text: data, time: now(), streaming: false }
: m
)
)
} else if (eventType === 'error') {
throw new Error(data)
}
eventType = ''
}
}
}
// If stream ended without a "done" event, mark as done
patchMessages((prev) =>
prev.map((m) =>
m.id === botId && m.streaming
? {
...m,
text: finalText || m.text || 'No response',
streaming: false,
time: now(),
}
: m
)
)
window.dispatchEvent(new CustomEvent('agent-preferences-refresh'))
window.dispatchEvent(new CustomEvent('agent-config-refresh'))
} catch (e: any) {
if (e.name === 'AbortError') {
// Request was cancelled (e.g. user sent a new message), clean up silently
patchMessages((prev) => prev.filter((m) => m.id !== botId))
} else {
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
text: '⚠️ Error: ' + e.message,
time: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
streaming: false,
}
: m
)
)
}
}
setLoading(false)
chatInputRef.current?.focus()
}
const quickActions = language === 'zh'
? [
{ label: '💼 持仓', cmd: '/positions' },
{ label: '💰 余额', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
{ label: '🧹 清除记忆', cmd: '/clear' },
{ label: '❓ 帮助', cmd: '/help' },
]
: [
{ label: '💼 Positions', cmd: '/positions' },
{ label: '💰 Balance', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
{ label: '🧹 Clear', cmd: '/clear' },
{ label: '❓ Help', cmd: '/help' },
]
const sidebarSections = [
{
key: 'market' as const,
icon: <TrendingUp size={14} />,
title: language === 'zh' ? '市场行情' : 'Market',
component: <MarketTicker />,
},
{
key: 'positions' as const,
icon: <Wallet size={14} />,
title: language === 'zh' ? '持仓' : 'Positions',
component: <PositionsPanel />,
},
{
key: 'traders' as const,
icon: <Bot size={14} />,
title: 'Traders',
component: <TraderStatusPanel />,
},
{
key: 'preferences' as const,
icon: <Bookmark size={14} />,
title: language === 'zh' ? '用户偏好' : 'Preferences',
component: <UserPreferencesPanel token={token} language={language} />,
},
]
const isWelcomeState = messages.length === 0
return (
<div
style={{
display: 'flex',
height: 'calc(100dvh - 64px)',
background: '#09090b',
overflow: 'hidden',
}}
>
{/* ==================== MAIN CHAT AREA ==================== */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
minWidth: 0,
position: 'relative',
}}
>
{/* Top bar with quick actions */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 16px',
borderBottom: '1px solid rgba(255,255,255,0.04)',
overflowX: 'auto',
flexShrink: 0,
backdropFilter: 'blur(12px)',
background: 'rgba(9,9,11,0.8)',
}}
className="hide-scrollbar"
>
{quickActions.map((a, i) => (
<button
key={i}
onClick={() => void send(a.cmd)}
className="quick-action-btn"
style={{
padding: '5px 12px',
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 20,
color: '#6c6c82',
fontSize: 12,
cursor: 'pointer',
whiteSpace: 'nowrap',
fontFamily: 'inherit',
transition: 'all 0.2s ease',
}}
>
{a.label}
</button>
))}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
style={{
marginLeft: 'auto',
padding: 6,
background: 'transparent',
border: 'none',
color: '#4c4c62',
cursor: 'pointer',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
flexShrink: 0,
transition: 'color 0.2s',
}}
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
onMouseEnter={(e) => { e.currentTarget.style.color = '#8a8aa0' }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#4c4c62' }}
>
{sidebarOpen ? (
<PanelRightClose size={18} />
) : (
<PanelRightOpen size={18} />
)}
</button>
</div>
{/* Messages area or Welcome state */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '20px 0',
}}
className="custom-scrollbar"
>
{isWelcomeState ? (
<WelcomeScreen language={language} onSend={send} />
) : (
<ChatMessages messages={messages} ref={messagesEndRef} />
)}
</div>
{/* Input area */}
<ChatInput
ref={chatInputRef}
language={language}
loading={loading}
onSend={send}
/>
</div>
{/* ==================== RIGHT SIDEBAR ==================== */}
<AnimatePresence>
{sidebarOpen && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 280, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
style={{
borderLeft: '1px solid rgba(255,255,255,0.04)',
background: 'rgba(11,11,19,0.6)',
backdropFilter: 'blur(12px)',
overflowY: 'auto',
overflowX: 'hidden',
flexShrink: 0,
}}
className="custom-scrollbar"
>
<div style={{ padding: '12px 10px 20px', width: 280 }}>
{/* Sidebar header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
padding: '4px 6px',
}}
>
<span
style={{
fontSize: 10,
fontWeight: 700,
color: '#4c4c62',
textTransform: 'uppercase',
letterSpacing: 1.5,
}}
>
{language === 'zh' ? '交易面板' : 'Trading Panel'}
</span>
</div>
{/* Sidebar sections */}
{sidebarSections.map((section) => (
<div key={section.key} style={{ marginBottom: 8 }}>
<button
onClick={() => toggleSection(section.key)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
width: '100%',
padding: '7px 8px',
background: 'transparent',
border: 'none',
color: '#7a7a90',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
borderRadius: 8,
transition: 'all 0.15s ease',
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.03)'
e.currentTarget.style.color = '#a0a0b0'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = '#7a7a90'
}}
>
{section.icon}
<span>{section.title}</span>
<span style={{ marginLeft: 'auto', transition: 'transform 0.2s' }}>
{sections[section.key] ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</span>
</button>
<AnimatePresence>
{sections[section.key] && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
style={{ overflow: 'hidden', padding: '0 4px' }}
>
<div style={{ paddingTop: 4 }}>
{section.component}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Animations */}
<style>{`
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.3; }
30% { transform: translateY(-4px); opacity: 0.8; }
}
.typing-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #F0B90B;
display: inline-block;
animation: typingBounce 1.2s infinite;
}
.suggestion-card:hover {
background: rgba(240,185,11,0.04) !important;
border-color: rgba(240,185,11,0.15) !important;
transform: translateY(-1px);
}
.quick-action-btn:hover {
border-color: rgba(240,185,11,0.2) !important;
color: #F0B90B !important;
background: rgba(240,185,11,0.04) !important;
}
.chat-input-wrapper:focus-within {
border-color: rgba(240,185,11,0.25) !important;
box-shadow: 0 0 0 1px rgba(240,185,11,0.08);
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.06);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.1);
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
@media (max-width: 640px) {
.suggestion-card {
padding: 12px !important;
}
}
`}</style>
</div>
)
}
+92 -237
View File
@@ -1,26 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, Pencil } from 'lucide-react'
User,
Cpu,
Building2,
MessageCircle,
Eye,
EyeOff,
ChevronRight,
Plus,
Pencil,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext' import { useLanguage } from '../contexts/LanguageContext'
import { api } from '../lib/api' import { api } from '../lib/api'
import {
getPostAuthPath,
getUserMode,
setUserMode,
type UserMode,
} from '../lib/onboarding'
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal' import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal' import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
import { ModelConfigModal } from '../components/trader/ModelConfigModal' import { ModelConfigModal } from '../components/trader/ModelConfigModal'
@@ -28,14 +11,24 @@ import type { Exchange, AIModel } from '../types'
type Tab = 'account' | 'models' | 'exchanges' | 'telegram' type Tab = 'account' | 'models' | 'exchanges' | 'telegram'
function configBadge(label: string, active: boolean) {
return (
<span
className={`text-[11px] px-2 py-0.5 rounded-full ${
active
? 'bg-emerald-500/10 text-emerald-300'
: 'bg-zinc-800 text-zinc-500'
}`}
>
{label}
</span>
)
}
export function SettingsPage() { export function SettingsPage() {
const { user } = useAuth() const { user } = useAuth()
const { language } = useLanguage() const { language } = useLanguage()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<Tab>('account') const [activeTab, setActiveTab] = useState<Tab>('account')
const [userMode, setUserModeState] = useState<UserMode>(
() => getUserMode() ?? 'advanced'
)
// Account state // Account state
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
@@ -56,24 +49,41 @@ export function SettingsPage() {
// Telegram state // Telegram state
const [showTelegramModal, setShowTelegramModal] = useState(false) const [showTelegramModal, setShowTelegramModal] = useState(false)
const refreshModelConfigs = async () => {
const [configs, supported] = await Promise.all([
api.getModelConfigs(),
api.getSupportedModels(),
])
setConfiguredModels(configs)
setSupportedModels(supported)
}
const refreshExchangeConfigs = async () => {
const refreshed = await api.getExchangeConfigs()
setExchanges(refreshed)
}
// Fetch data when tabs are visited // Fetch data when tabs are visited
useEffect(() => { useEffect(() => {
if (activeTab === 'models') { if (activeTab === 'models') {
Promise.all([api.getModelConfigs(), api.getSupportedModels()]) refreshModelConfigs()
.then(([configs, supported]) => {
setConfiguredModels(configs)
setSupportedModels(supported)
})
.catch(() => toast.error('Failed to load AI models')) .catch(() => toast.error('Failed to load AI models'))
} }
if (activeTab === 'exchanges') { if (activeTab === 'exchanges') {
api refreshExchangeConfigs()
.getExchangeConfigs()
.then(setExchanges)
.catch(() => toast.error('Failed to load exchanges')) .catch(() => toast.error('Failed to load exchanges'))
} }
}, [activeTab]) }, [activeTab])
useEffect(() => {
const handleRefresh = () => {
refreshModelConfigs().catch(() => {})
refreshExchangeConfigs().catch(() => {})
}
window.addEventListener('agent-config-refresh', handleRefresh)
return () => window.removeEventListener('agent-config-refresh', handleRefresh)
}, [])
const handleChangePassword = async (e: React.FormEvent) => { const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (newPassword.length < 8) { if (newPassword.length < 8) {
@@ -86,7 +96,7 @@ export function SettingsPage() {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token') || ''}`, Authorization: `Bearer ${localStorage.getItem('auth_token') || ''}`,
}, },
body: JSON.stringify({ new_password: newPassword }), body: JSON.stringify({ new_password: newPassword }),
}) })
@@ -97,33 +107,12 @@ export function SettingsPage() {
toast.success('Password updated successfully') toast.success('Password updated successfully')
setNewPassword('') setNewPassword('')
} catch (err) { } catch (err) {
toast.error( toast.error(err instanceof Error ? err.message : 'Failed to update password')
err instanceof Error ? err.message : 'Failed to update password'
)
} finally { } finally {
setChangingPassword(false) setChangingPassword(false)
} }
} }
const handleSwitchMode = (nextMode: UserMode) => {
if (nextMode === userMode) {
return
}
setUserMode(nextMode)
setUserModeState(nextMode)
toast.success(
language === 'zh'
? `已切换到${nextMode === 'beginner' ? '新手模式' : '老手模式'}`
: nextMode === 'beginner'
? 'Switched to beginner mode'
: 'Switched to advanced mode'
)
const nextPath = getPostAuthPath(nextMode)
navigate(nextPath)
}
const handleSaveModel = async ( const handleSaveModel = async (
modelId: string, modelId: string,
apiKey: string, apiKey: string,
@@ -134,54 +123,38 @@ export function SettingsPage() {
const existingModel = configuredModels.find((m) => m.id === modelId) const existingModel = configuredModels.find((m) => m.id === modelId)
const modelTemplate = supportedModels.find((m) => m.id === modelId) const modelTemplate = supportedModels.find((m) => m.id === modelId)
const modelToUpdate = existingModel || modelTemplate const modelToUpdate = existingModel || modelTemplate
if (!modelToUpdate) { if (!modelToUpdate) { toast.error('Model not found'); return }
toast.error('Model not found')
return
}
let updatedModels: AIModel[] let updatedModels: AIModel[]
if (existingModel) { if (existingModel) {
updatedModels = configuredModels.map((m) => updatedModels = configuredModels.map((m) =>
m.id === modelId m.id === modelId
? { ? { ...m, apiKey, customApiUrl: customApiUrl || '', customModelName: customModelName || '', enabled: true }
...m,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}
: m : m
) )
} else { } else {
updatedModels = [ updatedModels = [...configuredModels, {
...configuredModels,
{
...modelToUpdate, ...modelToUpdate,
apiKey, apiKey,
customApiUrl: customApiUrl || '', customApiUrl: customApiUrl || '',
customModelName: customModelName || '', customModelName: customModelName || '',
enabled: true, enabled: true,
}, }]
]
} }
const request = { const request = {
models: Object.fromEntries( models: Object.fromEntries(
updatedModels.map((m) => [ updatedModels.map((m) => [m.provider, {
m.provider,
{
enabled: m.enabled, enabled: m.enabled,
api_key: m.apiKey || '', api_key: m.apiKey || '',
custom_api_url: m.customApiUrl || '', custom_api_url: m.customApiUrl || '',
custom_model_name: m.customModelName || '', custom_model_name: m.customModelName || '',
}, }])
])
), ),
} }
await api.updateModelConfigs(request) await api.updateModelConfigs(request)
toast.success('Model config saved') toast.success('Model config saved')
const refreshed = await api.getModelConfigs() await refreshModelConfigs()
setConfiguredModels(refreshed)
setShowModelModal(false) setShowModelModal(false)
setEditingModel(null) setEditingModel(null)
} catch { } catch {
@@ -192,32 +165,20 @@ export function SettingsPage() {
const handleDeleteModel = async (modelId: string) => { const handleDeleteModel = async (modelId: string) => {
try { try {
const updatedModels = configuredModels.map((m) => const updatedModels = configuredModels.map((m) =>
m.id === modelId m.id === modelId ? { ...m, apiKey: '', customApiUrl: '', customModelName: '', enabled: false } : m
? {
...m,
apiKey: '',
customApiUrl: '',
customModelName: '',
enabled: false,
}
: m
) )
const request = { const request = {
models: Object.fromEntries( models: Object.fromEntries(
updatedModels.map((m) => [ updatedModels.map((m) => [m.provider, {
m.provider,
{
enabled: m.enabled, enabled: m.enabled,
api_key: m.apiKey || '', api_key: m.apiKey || '',
custom_api_url: m.customApiUrl || '', custom_api_url: m.customApiUrl || '',
custom_model_name: m.customModelName || '', custom_model_name: m.customModelName || '',
}, }])
])
), ),
} }
await api.updateModelConfigs(request) await api.updateModelConfigs(request)
const refreshed = await api.getModelConfigs() await refreshModelConfigs()
setConfiguredModels(refreshed)
setShowModelModal(false) setShowModelModal(false)
setEditingModel(null) setEditingModel(null)
toast.success('Model config removed') toast.success('Model config removed')
@@ -287,8 +248,7 @@ export function SettingsPage() {
await api.createExchangeEncrypted(createRequest) await api.createExchangeEncrypted(createRequest)
toast.success('Exchange account created') toast.success('Exchange account created')
} }
const refreshed = await api.getExchangeConfigs() await refreshExchangeConfigs()
setExchanges(refreshed)
setShowExchangeModal(false) setShowExchangeModal(false)
setEditingExchange(null) setEditingExchange(null)
} catch { } catch {
@@ -300,8 +260,7 @@ export function SettingsPage() {
try { try {
await api.deleteExchange(exchangeId) await api.deleteExchange(exchangeId)
toast.success('Exchange account deleted') toast.success('Exchange account deleted')
const refreshed = await api.getExchangeConfigs() await refreshExchangeConfigs()
setExchanges(refreshed)
setShowExchangeModal(false) setShowExchangeModal(false)
setEditingExchange(null) setEditingExchange(null)
} catch { } catch {
@@ -317,10 +276,7 @@ export function SettingsPage() {
] ]
return ( return (
<div <div className="min-h-screen pt-20 pb-12 px-4" style={{ background: '#0B0E11' }}>
className="min-h-screen pt-20 pb-12 px-4"
style={{ background: '#0B0E11' }}
>
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<h1 className="text-xl font-bold text-white mb-6">Settings</h1> <h1 className="text-xl font-bold text-white mb-6">Settings</h1>
@@ -331,8 +287,7 @@ export function SettingsPage() {
key={tab.key} key={tab.key}
onClick={() => setActiveTab(tab.key)} onClick={() => setActiveTab(tab.key)}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all
${ ${activeTab === tab.key
activeTab === tab.key
? 'bg-nofx-gold text-black' ? 'bg-nofx-gold text-black'
: 'text-zinc-400 hover:text-white' : 'text-zinc-400 hover:text-white'
}`} }`}
@@ -345,6 +300,7 @@ export function SettingsPage() {
{/* Tab Content */} {/* Tab Content */}
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-6"> <div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-6">
{/* Account Tab */} {/* Account Tab */}
{activeTab === 'account' && ( {activeTab === 'account' && (
<div className="space-y-6"> <div className="space-y-6">
@@ -354,78 +310,10 @@ export function SettingsPage() {
</div> </div>
<div className="border-t border-zinc-800 pt-6"> <div className="border-t border-zinc-800 pt-6">
<div className="flex items-center justify-between gap-4"> <h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
<div>
<h3 className="text-sm font-semibold text-white">
{language === 'zh' ? '使用模式' : 'Usage Mode'}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '新手模式会显示钱包引导和 4 步卡片;老手模式保持原来的专业界面。'
: 'Beginner mode shows wallet onboarding and quickstart cards. Advanced mode keeps the original pro workflow.'}
</p>
</div>
<span className="rounded-full border border-nofx-gold/20 bg-nofx-gold/10 px-3 py-1 text-xs font-semibold text-nofx-gold">
{userMode === 'beginner'
? language === 'zh'
? '当前:新手模式'
: 'Current: Beginner'
: language === 'zh'
? '当前:老手模式'
: 'Current: Advanced'}
</span>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => handleSwitchMode('beginner')}
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
userMode === 'beginner'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
}`}
>
<div className="text-sm font-semibold text-white">
{language === 'zh' ? '新手模式' : 'Beginner Mode'}
</div>
<div className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '更简单,优先显示钱包、充值和快速上手引导。'
: 'Simpler flow with wallet, funding, and quickstart guidance first.'}
</div>
</button>
<button
type="button"
onClick={() => handleSwitchMode('advanced')}
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
userMode === 'advanced'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
}`}
>
<div className="text-sm font-semibold text-white">
{language === 'zh' ? '老手模式' : 'Advanced Mode'}
</div>
<div className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '保持原来的配置与交易流程,不展示新手引导。'
: 'Keeps the original configuration and trading workflow without beginner hints.'}
</div>
</button>
</div>
</div>
<div className="border-t border-zinc-800 pt-6">
<h3 className="text-sm font-semibold text-white mb-4">
Change Password
</h3>
<form onSubmit={handleChangePassword} className="space-y-4"> <form onSubmit={handleChangePassword} className="space-y-4">
<div> <div>
<label className="block text-xs font-medium text-zinc-400 mb-2"> <label className="block text-xs font-medium text-zinc-400 mb-2">New Password</label>
New Password
</label>
<div className="relative"> <div className="relative">
<input <input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
@@ -440,11 +328,7 @@ export function SettingsPage() {
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors" className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
> >
{showPassword ? ( {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button> </button>
</div> </div>
</div> </div>
@@ -465,14 +349,10 @@ export function SettingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-zinc-400"> <p className="text-sm text-zinc-400">
{configuredModels.length} model {configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured
{configuredModels.length !== 1 ? 's' : ''} configured
</p> </p>
<button <button
onClick={() => { onClick={() => { setEditingModel(null); setShowModelModal(true) }}
setEditingModel(null)
setShowModelModal(true)
}}
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors" className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
> >
<Plus size={14} /> <Plus size={14} />
@@ -489,10 +369,7 @@ export function SettingsPage() {
{configuredModels.map((model) => ( {configuredModels.map((model) => (
<button <button
key={model.id} key={model.id}
onClick={() => { onClick={() => { setEditingModel(model.id); setShowModelModal(true) }}
setEditingModel(model.id)
setShowModelModal(true)
}}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group" className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -500,24 +377,20 @@ export function SettingsPage() {
<Cpu size={14} className="text-zinc-300" /> <Cpu size={14} className="text-zinc-300" />
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="text-sm font-medium text-white"> <p className="text-sm font-medium text-white">{model.name}</p>
{model.name} <div className="flex flex-wrap items-center gap-1.5 mt-1">
</p> <p className="text-xs text-zinc-500">{model.provider}</p>
<p className="text-xs text-zinc-500"> {configBadge('API Key', !!model.has_api_key)}
{model.provider} {model.customModelName ? configBadge('Custom Model', true) : null}
</p> {model.customApiUrl ? configBadge('Base URL', true) : null}
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}>
className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}
>
{model.enabled ? 'Active' : 'Inactive'} {model.enabled ? 'Active' : 'Inactive'}
</span> </span>
<Pencil <Pencil size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
size={14}
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
/>
</div> </div>
</button> </button>
))} ))}
@@ -531,14 +404,10 @@ export function SettingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-zinc-400"> <p className="text-sm text-zinc-400">
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''}{' '} {exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected
connected
</p> </p>
<button <button
onClick={() => { onClick={() => { setEditingExchange(null); setShowExchangeModal(true) }}
setEditingExchange(null)
setShowExchangeModal(true)
}}
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors" className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
> >
<Plus size={14} /> <Plus size={14} />
@@ -555,10 +424,7 @@ export function SettingsPage() {
{exchanges.map((exchange) => ( {exchanges.map((exchange) => (
<button <button
key={exchange.id} key={exchange.id}
onClick={() => { onClick={() => { setEditingExchange(exchange.id); setShowExchangeModal(true) }}
setEditingExchange(exchange.id)
setShowExchangeModal(true)
}}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group" className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -566,18 +432,19 @@ export function SettingsPage() {
<Building2 size={14} className="text-zinc-300" /> <Building2 size={14} className="text-zinc-300" />
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="text-sm font-medium text-white"> <p className="text-sm font-medium text-white">{exchange.account_name || exchange.name}</p>
{exchange.account_name || exchange.name} <div className="flex flex-wrap items-center gap-1.5 mt-1">
</p> <p className="text-xs text-zinc-500 capitalize">{exchange.exchange_type || exchange.type}</p>
<p className="text-xs text-zinc-500 capitalize"> {configBadge('API Key', !!exchange.has_api_key)}
{exchange.exchange_type || exchange.type} {configBadge('Secret', !!exchange.has_secret_key)}
</p> {exchange.has_passphrase ? configBadge('Passphrase', true) : null}
{exchange.hyperliquidWalletAddr ? configBadge('Wallet', true) : null}
{exchange.has_aster_private_key ? configBadge('Aster Key', true) : null}
{exchange.has_lighter_private_key || exchange.has_lighter_api_key_private_key ? configBadge('Lighter Key', true) : null}
</div> </div>
</div> </div>
<ChevronRight </div>
size={14} <ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
/>
</button> </button>
))} ))}
</div> </div>
@@ -589,8 +456,7 @@ export function SettingsPage() {
{activeTab === 'telegram' && ( {activeTab === 'telegram' && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-zinc-400"> <p className="text-sm text-zinc-400">
Connect a Telegram bot to receive trading notifications and Connect a Telegram bot to receive trading notifications and interact with your traders.
interact with your traders.
</p> </p>
<button <button
onClick={() => setShowTelegramModal(true)} onClick={() => setShowTelegramModal(true)}
@@ -600,14 +466,9 @@ export function SettingsPage() {
<div className="w-8 h-8 rounded-lg bg-[#0088cc]/20 flex items-center justify-center"> <div className="w-8 h-8 rounded-lg bg-[#0088cc]/20 flex items-center justify-center">
<MessageCircle size={14} className="text-[#0088cc]" /> <MessageCircle size={14} className="text-[#0088cc]" />
</div> </div>
<span className="text-sm font-medium text-white"> <span className="text-sm font-medium text-white">Configure Telegram Bot</span>
Configure Telegram Bot
</span>
</div> </div>
<ChevronRight <ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
size={14}
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
/>
</button> </button>
</div> </div>
)} )}
@@ -623,10 +484,7 @@ export function SettingsPage() {
editingModelId={editingModel} editingModelId={editingModel}
onSave={handleSaveModel} onSave={handleSaveModel}
onDelete={handleDeleteModel} onDelete={handleDeleteModel}
onClose={() => { onClose={() => { setShowModelModal(false); setEditingModel(null) }}
setShowModelModal(false)
setEditingModel(null)
}}
language={language} language={language}
/> />
</div> </div>
@@ -640,10 +498,7 @@ export function SettingsPage() {
editingExchangeId={editingExchange} editingExchangeId={editingExchange}
onSave={handleSaveExchange} onSave={handleSaveExchange}
onDelete={handleDeleteExchange} onDelete={handleDeleteExchange}
onClose={() => { onClose={() => { setShowExchangeModal(false); setEditingExchange(null) }}
setShowExchangeModal(false)
setEditingExchange(null)
}}
language={language} language={language}
/> />
</div> </div>
+9
View File
@@ -22,6 +22,7 @@ import { FAQPage } from '../pages/FAQPage'
import { LandingPage } from '../pages/LandingPage' import { LandingPage } from '../pages/LandingPage'
import { BeginnerOnboardingPage } from '../pages/BeginnerOnboardingPage' import { BeginnerOnboardingPage } from '../pages/BeginnerOnboardingPage'
import { DataPage } from '../pages/DataPage' import { DataPage } from '../pages/DataPage'
import { AgentChatPage } from '../pages/AgentChatPage'
import { SettingsPage } from '../pages/SettingsPage' import { SettingsPage } from '../pages/SettingsPage'
import { StrategyMarketPage } from '../pages/StrategyMarketPage' import { StrategyMarketPage } from '../pages/StrategyMarketPage'
import { StrategyStudioPage } from '../pages/StrategyStudioPage' import { StrategyStudioPage } from '../pages/StrategyStudioPage'
@@ -456,6 +457,14 @@ export function AppRoutes() {
</AppChrome> </AppChrome>
} }
/> />
<Route
path={ROUTES.agent}
element={
<AppChrome currentPage="agent" showFooter={false}>
<AgentChatPage />
</AppChrome>
}
/>
<Route <Route
path={ROUTES.data} path={ROUTES.data}
element={ element={
+6
View File
@@ -1,4 +1,5 @@
export type Page = export type Page =
| 'agent'
| 'competition' | 'competition'
| 'traders' | 'traders'
| 'trader' | 'trader'
@@ -11,6 +12,7 @@ export type Page =
export const ROUTES = { export const ROUTES = {
home: '/', home: '/',
agent: '/agent',
login: '/login', login: '/login',
register: '/register', register: '/register',
setup: '/setup', setup: '/setup',
@@ -27,6 +29,7 @@ export const ROUTES = {
} as const } as const
export const PAGE_PATHS: Record<Page, string> = { export const PAGE_PATHS: Record<Page, string> = {
agent: ROUTES.agent,
competition: ROUTES.competition, competition: ROUTES.competition,
traders: ROUTES.traders, traders: ROUTES.traders,
trader: ROUTES.dashboard, trader: ROUTES.dashboard,
@@ -39,6 +42,7 @@ export const PAGE_PATHS: Record<Page, string> = {
} }
export const LEGACY_HASH_ROUTES: Record<string, string> = { export const LEGACY_HASH_ROUTES: Record<string, string> = {
agent: ROUTES.agent,
competition: ROUTES.competition, competition: ROUTES.competition,
traders: ROUTES.traders, traders: ROUTES.traders,
trader: ROUTES.dashboard, trader: ROUTES.dashboard,
@@ -50,6 +54,8 @@ export const LEGACY_HASH_ROUTES: Record<string, string> = {
export function getCurrentPageForPath(pathname: string): Page | undefined { export function getCurrentPageForPath(pathname: string): Page | undefined {
switch (pathname) { switch (pathname) {
case ROUTES.agent:
return 'agent'
case ROUTES.welcome: case ROUTES.welcome:
case ROUTES.traders: case ROUTES.traders:
return 'traders' return 'traders'
+52
View File
@@ -0,0 +1,52 @@
import { create } from 'zustand'
export interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
export interface AgentMessage {
id: string
role: 'user' | 'bot'
text: string
time: string
streaming?: boolean
steps?: AgentStep[]
}
interface AgentChatStoreState {
activeUserId?: string
messages: AgentMessage[]
loading: boolean
hydrated: boolean
setActiveUserId: (userId?: string) => void
setMessages: (messages: AgentMessage[]) => void
updateMessages: (
updater: (messages: AgentMessage[]) => AgentMessage[]
) => void
setLoading: (loading: boolean) => void
setHydrated: (hydrated: boolean) => void
resetForUser: (userId?: string, messages?: AgentMessage[]) => void
}
export const useAgentChatStore = create<AgentChatStoreState>((set) => ({
activeUserId: undefined,
messages: [],
loading: false,
hydrated: false,
setActiveUserId: (userId) => set({ activeUserId: userId }),
setMessages: (messages) => set({ messages }),
updateMessages: (updater) =>
set((state) => ({ messages: updater(state.messages) })),
setLoading: (loading) => set({ loading }),
setHydrated: (hydrated) => set({ hydrated }),
resetForUser: (userId, messages = []) =>
set({
activeUserId: userId,
messages,
loading: false,
hydrated: true,
}),
}))
+8
View File
@@ -3,6 +3,7 @@ export interface AIModel {
name: string name: string
provider: string provider: string
enabled: boolean enabled: boolean
has_api_key?: boolean
apiKey?: string apiKey?: string
customApiUrl?: string customApiUrl?: string
customModelName?: string customModelName?: string
@@ -24,18 +25,25 @@ export interface Exchange {
name: string // Display name name: string // Display name
type: 'cex' | 'dex' type: 'cex' | 'dex'
enabled: boolean enabled: boolean
has_api_key?: boolean
has_secret_key?: boolean
has_passphrase?: boolean
apiKey?: string apiKey?: string
secretKey?: string secretKey?: string
passphrase?: string // OKX specific passphrase?: string // OKX specific
testnet?: boolean testnet?: boolean
// Hyperliquid specific // Hyperliquid specific
hyperliquidWalletAddr?: string hyperliquidWalletAddr?: string
has_hyperliquid_secret?: boolean
// Aster specific // Aster specific
asterUser?: string asterUser?: string
asterSigner?: string asterSigner?: string
has_aster_private_key?: boolean
asterPrivateKey?: string asterPrivateKey?: string
// LIGHTER specific // LIGHTER specific
lighterWalletAddr?: string lighterWalletAddr?: string
has_lighter_private_key?: boolean
has_lighter_api_key_private_key?: boolean
lighterPrivateKey?: string lighterPrivateKey?: string
lighterApiKeyPrivateKey?: string lighterApiKeyPrivateKey?: string
lighterApiKeyIndex?: number lighterApiKeyIndex?: number