mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
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:
+806
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "exchange_diagnosis",
|
||||
"kind": "diagnosis",
|
||||
"domain": "exchange",
|
||||
"description": "当用户反馈交易所 API 连接失败、签名错误、timestamp 异常、权限不足、IP 白名单限制、账户不可用等问题时调用。适用于用户在手动配置或运行交易员时遇到的交易所接入故障。不用于创建、修改、删除或查询交易所配置这类管理操作。"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "model_diagnosis",
|
||||
"kind": "diagnosis",
|
||||
"domain": "model",
|
||||
"description": "当用户反馈模型配置失败、API Key 无效、Base URL 非法、模型名不匹配、调用返回错误、模型不可用等问题时调用。适用于用户在接入或测试大模型时遇到的配置与兼容性故障。不用于创建、修改、删除或查询模型配置这类管理操作。"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "strategy_diagnosis",
|
||||
"kind": "diagnosis",
|
||||
"domain": "strategy",
|
||||
"description": "当用户反馈策略未生效、策略输出异常、提示词或配置结果与预期不一致、策略执行表现异常时调用。适用于策略内容和执行效果相关的排障与解释。不用于创建、修改、删除、激活、复制或查询策略模板这类管理操作。"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "trader_diagnosis",
|
||||
"kind": "diagnosis",
|
||||
"domain": "trader",
|
||||
"description": "当用户反馈交易员无法启动、启动后不交易、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。"
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 层:只兜底少数复杂情况
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -30,6 +30,7 @@ type SafeModelConfig struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
|
||||
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
|
||||
WalletAddress string `json:"walletAddress,omitempty"`
|
||||
@@ -60,14 +61,14 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
if len(models) == 0 {
|
||||
logger.Infof("⚠️ No AI models in database, returning defaults")
|
||||
defaultModels := []SafeModelConfig{
|
||||
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false},
|
||||
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false},
|
||||
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false},
|
||||
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false},
|
||||
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false},
|
||||
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false},
|
||||
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false},
|
||||
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false},
|
||||
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false},
|
||||
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
|
||||
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
|
||||
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
|
||||
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
|
||||
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
|
||||
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
|
||||
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
|
||||
}
|
||||
c.JSON(http.StatusOK, defaultModels)
|
||||
return
|
||||
@@ -83,6 +84,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
Name: model.Name,
|
||||
Provider: model.Provider,
|
||||
Enabled: model.Enabled,
|
||||
HasAPIKey: model.APIKey != "",
|
||||
CustomAPIURL: model.CustomAPIURL,
|
||||
CustomModelName: model.CustomModelName,
|
||||
}
|
||||
@@ -171,7 +173,8 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
if modelData.CustomAPIURL != "" {
|
||||
cleanURL := strings.TrimSuffix(modelData.CustomAPIURL, "#")
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -214,11 +217,13 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
||||
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
|
||||
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
|
||||
{"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": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
|
||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
|
||||
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "glm-5"},
|
||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.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)
|
||||
|
||||
@@ -127,6 +127,9 @@ func (s *Server) setupRoutes() {
|
||||
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, "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
|
||||
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 预检查阶段
|
||||
- 如果后续需要,支持一个用户多条并发执行会话
|
||||
@@ -1,13 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"nofx/api"
|
||||
nofxiagent "nofx/agent"
|
||||
"nofx/auth"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/telemetry"
|
||||
"nofx/logger"
|
||||
"nofx/manager"
|
||||
"nofx/telemetry"
|
||||
_ "nofx/mcp/payment"
|
||||
_ "nofx/mcp/provider"
|
||||
"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)
|
||||
go telegram.Start(cfg, st, telegramReloadCh)
|
||||
|
||||
@@ -154,6 +164,14 @@ func main() {
|
||||
<-quit
|
||||
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
|
||||
traderManager.StopAll()
|
||||
logger.Info("✅ System shut down safely")
|
||||
|
||||
@@ -11,6 +11,13 @@ import (
|
||||
"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
|
||||
type CompetitionCache struct {
|
||||
data map[string]interface{}
|
||||
@@ -88,9 +95,9 @@ func (tm *TraderManager) StartAll() {
|
||||
logger.Info("🚀 Starting all traders...")
|
||||
for id, t := range tm.traders {
|
||||
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 {
|
||||
logger.Infof("❌ %s runtime error: %v", at.GetName(), err)
|
||||
logger.Warnf("%s runtime error: %v", traderLogTag(traderID, at.GetName()), err)
|
||||
}
|
||||
}(id, t)
|
||||
}
|
||||
@@ -136,9 +143,9 @@ func (tm *TraderManager) AutoStartRunningTraders(st *store.Store) {
|
||||
for id, t := range tm.traders {
|
||||
if runningTraderIDs[id] {
|
||||
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 {
|
||||
logger.Infof("❌ %s runtime error: %v", at.GetName(), err)
|
||||
logger.Warnf("%s runtime error: %v", traderLogTag(traderID, at.GetName()), err)
|
||||
}
|
||||
}(id, t)
|
||||
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)
|
||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
|
||||
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
|
||||
tm.loadErrors[traderCfg.ID] = err
|
||||
} else {
|
||||
@@ -592,7 +599,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
||||
// Add to TraderManager (ai500APIURL/oiTopAPIURL already obtained from strategy config)
|
||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -727,17 +734,17 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
|
||||
// Auto-start if trader was running before shutdown
|
||||
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) {
|
||||
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
|
||||
if st != nil {
|
||||
_ = st.Trader().UpdateStatus(userID, traderID, false)
|
||||
}
|
||||
}
|
||||
}(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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package mcp
|
||||
|
||||
import "context"
|
||||
|
||||
// Message represents a conversation message.
|
||||
// Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls),
|
||||
// and tool result messages (Role="tool", ToolCallID, Content).
|
||||
@@ -62,6 +64,9 @@ type Request struct {
|
||||
// Advanced features
|
||||
Tools []Tool `json:"tools,omitempty"` // Available tools list
|
||||
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
|
||||
|
||||
+59
@@ -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
@@ -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
@@ -131,7 +131,7 @@ func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
model, err := s.firstEnabled(userID)
|
||||
model, err := s.firstEnabledUsable(userID)
|
||||
if err == nil {
|
||||
return model, nil
|
||||
}
|
||||
@@ -139,14 +139,14 @@ func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
|
||||
return nil, err
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) {
|
||||
func (s *AIModelStore) firstEnabledUsable(userID string) (*AIModel, error) {
|
||||
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").
|
||||
First(&model).Error
|
||||
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
|
||||
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
@@ -24,6 +24,31 @@ import (
|
||||
"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)
|
||||
type AutoTraderConfig struct {
|
||||
// Trader identification
|
||||
@@ -381,8 +406,8 @@ func (at *AutoTrader) Run() error {
|
||||
at.startTime = time.Now()
|
||||
|
||||
logger.Info("🚀 AI-driven automatic trading system started")
|
||||
logger.Infof("💰 Initial balance: %.2f USDT", at.initialBalance)
|
||||
logger.Infof("⚙️ Scan interval: %v", at.config.ScanInterval)
|
||||
at.logInfof("💰 Initial balance: %.2f USDT", at.initialBalance)
|
||||
at.logInfof("⚙️ Scan interval: %v", at.config.ScanInterval)
|
||||
logger.Info("🤖 AI will make full decisions on leverage, position size, stop loss/take profit, etc.")
|
||||
|
||||
// Pre-launch checks for claw402 users
|
||||
@@ -397,7 +422,7 @@ func (at *AutoTrader) Run() error {
|
||||
if at.exchange == "lighter" {
|
||||
if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil {
|
||||
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 hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil {
|
||||
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 bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil {
|
||||
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 okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil {
|
||||
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 bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil {
|
||||
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 asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil {
|
||||
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 binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil {
|
||||
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 gateTrader, ok := at.trader.(*gate.GateTrader); ok && at.store != nil {
|
||||
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 kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil {
|
||||
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
|
||||
isGridStrategy := at.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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -481,11 +506,11 @@ func (at *AutoTrader) Run() error {
|
||||
// Execute immediately on first run
|
||||
if isGridStrategy {
|
||||
if err := at.RunGridCycle(); err != nil {
|
||||
logger.Infof("❌ Grid execution failed: %v", err)
|
||||
at.logErrorf("❌ Grid execution failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
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:
|
||||
if isGridStrategy {
|
||||
if err := at.RunGridCycle(); err != nil {
|
||||
logger.Infof("❌ Grid execution failed: %v", err)
|
||||
at.logErrorf("❌ Grid execution failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
at.logErrorf("❌ Execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -590,6 +615,22 @@ func (at *AutoTrader) GetSystemPromptTemplate() string {
|
||||
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.)
|
||||
func (at *AutoTrader) GetStore() *store.Store {
|
||||
return at.store
|
||||
|
||||
+30
-30
@@ -24,7 +24,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
running := at.isRunning
|
||||
at.isRunningMutex.RUnlock()
|
||||
if !running {
|
||||
logger.Infof("⏹ Trader is stopped, aborting cycle #%d", at.callCount)
|
||||
at.logInfof("⏹ Trader is stopped, aborting cycle #%d", at.callCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
// 1. Check if trading needs to be stopped
|
||||
if time.Now().Before(at.stopUntil) {
|
||||
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.ErrorMessage = fmt.Sprintf("Risk control paused, remaining %.0f minutes", remaining.Minutes())
|
||||
at.saveDecision(record)
|
||||
@@ -59,6 +59,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
// 4. Collect trading context
|
||||
ctx, err := at.buildTradingContext()
|
||||
if err != nil {
|
||||
at.logErrorf("failed to build trading context: %v", err)
|
||||
record.Success = false
|
||||
record.ErrorMessage = fmt.Sprintf("Failed to build trading context: %v", err)
|
||||
at.saveDecision(record)
|
||||
@@ -71,7 +72,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
|
||||
// If no candidate coins available, log but do not error
|
||||
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.ExecutionLog = append(record.ExecutionLog, "No candidate coins available, cycle skipped")
|
||||
record.AccountState = store.AccountSnapshot{
|
||||
@@ -90,16 +91,16 @@ func (at *AutoTrader) runCycle() error {
|
||||
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)
|
||||
|
||||
// 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")
|
||||
|
||||
if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
|
||||
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,
|
||||
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)
|
||||
if aiDecision != nil && at.store != 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 {
|
||||
at.safeMode = true
|
||||
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.name, at.consecutiveAIFailures)
|
||||
logger.Errorf("🛡️ [%s] Reason: %v", at.name, err)
|
||||
logger.Errorf("🛡️ [%s] Action: Will keep trying AI each cycle. Safe mode auto-deactivates when AI recovers.", at.name)
|
||||
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.logErrorf("🛡️ Reason: %v", err)
|
||||
at.logErrorf("🛡️ Action: Will keep trying AI each cycle. Safe mode auto-deactivates when AI recovers.")
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -168,11 +168,11 @@ func (at *AutoTrader) runCycle() error {
|
||||
|
||||
// AI succeeded — reset failure counter and deactivate safe mode
|
||||
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
|
||||
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.safeModeReason = ""
|
||||
}
|
||||
@@ -219,7 +219,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
running = at.isRunning
|
||||
at.isRunningMutex.RUnlock()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -228,14 +228,14 @@ func (at *AutoTrader) runCycle() error {
|
||||
filtered := make([]kernel.Decision, 0)
|
||||
for _, d := range sortedDecisions {
|
||||
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
|
||||
}
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
sortedDecisions = filtered
|
||||
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
|
||||
at.isRunningMutex.RUnlock()
|
||||
if !running {
|
||||
logger.Infof("⏹ Trader stopped during decision execution, aborting remaining decisions")
|
||||
at.logInfof("⏹ Trader stopped during decision execution, aborting remaining decisions")
|
||||
break
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
}
|
||||
|
||||
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()
|
||||
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("❌ %s %s failed: %v", d.Symbol, d.Action, err))
|
||||
} else {
|
||||
@@ -280,7 +280,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
|
||||
// 9. Save decision record
|
||||
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
|
||||
@@ -417,12 +417,12 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||||
// 3. Use strategy engine to get candidate coins (must have strategy engine)
|
||||
var candidateCoins []kernel.CandidateCoin
|
||||
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 {
|
||||
coins, err := at.strategyEngine.GetCandidateCoins()
|
||||
if err != nil {
|
||||
// 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 {
|
||||
candidateCoins = coins
|
||||
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
|
||||
recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10)
|
||||
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 {
|
||||
logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades))
|
||||
for _, trade := range recentTrades {
|
||||
@@ -503,11 +503,11 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||||
// Get trading statistics for AI context
|
||||
stats, err := at.store.Position().GetFullStats(at.id)
|
||||
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 {
|
||||
logger.Infof("⚠️ [%s] GetFullStats returned nil", at.name)
|
||||
at.logWarnf("⚠️ GetFullStats returned nil")
|
||||
} 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 {
|
||||
ctx.TradingStats = &kernel.TradingStats{
|
||||
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)
|
||||
}
|
||||
} 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)
|
||||
@@ -630,15 +630,15 @@ func (at *AutoTrader) checkClaw402Balance() {
|
||||
if at.claw402WalletAddr != "" {
|
||||
balance, err := wallet.QueryUSDCBalance(at.claw402WalletAddr)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -109,6 +109,12 @@ export default function HeaderBar({
|
||||
label: string
|
||||
requiresAuth: boolean
|
||||
}[] = [
|
||||
{
|
||||
page: 'agent',
|
||||
path: ROUTES.agent,
|
||||
label: 'Agent',
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
page: 'data',
|
||||
path: ROUTES.data,
|
||||
@@ -431,6 +437,12 @@ export default function HeaderBar({
|
||||
label: string
|
||||
requiresAuth: boolean
|
||||
}[] = [
|
||||
{
|
||||
page: 'agent',
|
||||
path: ROUTES.agent,
|
||||
label: 'Agent',
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
page: 'data',
|
||||
path: ROUTES.data,
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
CreateTraderRequest,
|
||||
AIModel,
|
||||
Exchange,
|
||||
ExchangeAccountState,
|
||||
} from '../../types'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
@@ -19,17 +18,13 @@ import { TelegramConfigModal } from './TelegramConfigModal'
|
||||
import { ModelConfigModal } from './ModelConfigModal'
|
||||
import { ConfigStatusGrid } from './ConfigStatusGrid'
|
||||
import { TradersList } from './TradersList'
|
||||
import { BeginnerGuideCards } from './BeginnerGuideCards'
|
||||
import { AlertTriangle, Bot, Plus, MessageCircle } from 'lucide-react'
|
||||
import {
|
||||
Bot,
|
||||
Plus,
|
||||
MessageCircle,
|
||||
} from 'lucide-react'
|
||||
import { confirmToast } from '../../lib/notify'
|
||||
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 {
|
||||
onTraderSelect?: (traderId: string) => void
|
||||
@@ -50,288 +45,34 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
const [allModels, setAllModels] = useState<AIModel[]>([])
|
||||
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
|
||||
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
|
||||
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
|
||||
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
|
||||
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'
|
||||
|
||||
switch (errorKey) {
|
||||
case 'trader.create.invalid_request':
|
||||
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)
|
||||
const loadConfigs = async () => {
|
||||
if (!user || !token) {
|
||||
const models = await api.getSupportedModels()
|
||||
setSupportedModels(models)
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(title, {
|
||||
description,
|
||||
})
|
||||
}
|
||||
const parseBalanceUsdc = (balance?: string) => {
|
||||
if (!balance) return null
|
||||
const parsed = Number.parseFloat(balance)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
const getClaw402BalanceMessage = (balance: number, blocking: boolean) => {
|
||||
if (language === 'zh') {
|
||||
return blocking
|
||||
? `当前 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)
|
||||
const [
|
||||
modelConfigs,
|
||||
exchangeConfigs,
|
||||
models,
|
||||
] = await Promise.all([
|
||||
api.getModelConfigs(),
|
||||
api.getExchangeConfigs(),
|
||||
api.getSupportedModels(),
|
||||
])
|
||||
setAllModels(modelConfigs)
|
||||
setAllExchanges(exchangeConfigs)
|
||||
setSupportedModels(models)
|
||||
}
|
||||
|
||||
// Toggle wallet address visibility for a trader
|
||||
const toggleTraderAddressVisibility = (traderId: string) => {
|
||||
setVisibleTraderAddresses((prev) => {
|
||||
setVisibleTraderAddresses(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(traderId)) {
|
||||
next.delete(traderId)
|
||||
@@ -344,7 +85,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
// Toggle wallet address visibility for an exchange
|
||||
const toggleExchangeAddressVisibility = (exchangeId: string) => {
|
||||
setVisibleExchangeAddresses((prev) => {
|
||||
setVisibleExchangeAddresses(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(exchangeId)) {
|
||||
next.delete(exchangeId)
|
||||
@@ -366,64 +107,27 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
data: traders,
|
||||
mutate: mutateTraders,
|
||||
isLoading: isTradersLoading,
|
||||
} = 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 }
|
||||
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
|
||||
user && token ? 'traders' : null,
|
||||
api.getTraders,
|
||||
{ refreshInterval: 5000 }
|
||||
)
|
||||
|
||||
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()
|
||||
.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])
|
||||
|
||||
const configuredModels =
|
||||
@@ -443,31 +147,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}) || []
|
||||
|
||||
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 =
|
||||
allExchanges?.filter((e) => {
|
||||
if (!e.enabled) return false
|
||||
@@ -501,8 +180,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
|
||||
const getExchangeUsageInfo = (exchangeId: string) => {
|
||||
const usingTraders =
|
||||
traders?.filter((tr) => tr.exchange_id === exchangeId) || []
|
||||
const usingTraders = traders?.filter((tr) => tr.exchange_id === exchangeId) || []
|
||||
const runningCount = usingTraders.filter((tr) => tr.is_running).length
|
||||
const totalCount = usingTraders.length
|
||||
return { runningCount, totalCount, usingTraders }
|
||||
@@ -526,19 +204,26 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
const handleCreateTrader = async (data: CreateTraderRequest) => {
|
||||
try {
|
||||
const createdTrader = await api.createTrader(data)
|
||||
if (createdTrader.startup_warning) {
|
||||
toast.success(t('aiTradersToast.created', language), {
|
||||
description: createdTrader.startup_warning,
|
||||
})
|
||||
} else {
|
||||
toast.success(t('aiTradersToast.created', language))
|
||||
const model = allModels?.find((m) => m.id === data.ai_model_id)
|
||||
const exchange = allExchanges?.find((e) => e.id === data.exchange_id)
|
||||
|
||||
if (!model?.enabled) {
|
||||
toast.error(t('modelNotConfigured', language))
|
||||
return
|
||||
}
|
||||
|
||||
if (!exchange?.enabled) {
|
||||
toast.error(t('exchangeNotConfigured', language))
|
||||
return
|
||||
}
|
||||
|
||||
await api.createTrader(data)
|
||||
toast.success(t('aiTradersToast.created', language))
|
||||
setShowCreateModal(false)
|
||||
await mutateTraders()
|
||||
} catch (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()
|
||||
} catch (error) {
|
||||
console.error('Failed to update trader:', error)
|
||||
showActionableError(t('updateTraderFailed', language), error)
|
||||
toast.error(t('updateTraderFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,48 +298,24 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
try {
|
||||
if (running) {
|
||||
await api.stopTrader(traderId)
|
||||
toast.success(t('aiTradersToast.stopped', language))
|
||||
toast.success(t('aiTradersToast.stopped', language))
|
||||
} 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)
|
||||
toast.success(t('aiTradersToast.started', language))
|
||||
toast.success(t('aiTradersToast.started', language))
|
||||
}
|
||||
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle trader:', error)
|
||||
showActionableError(
|
||||
running
|
||||
? t('aiTradersToast.stopFailed', language)
|
||||
: t('aiTradersToast.startFailed', language),
|
||||
error
|
||||
)
|
||||
toast.error(t('operationFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleCompetition = async (
|
||||
traderId: string,
|
||||
currentShowInCompetition: boolean
|
||||
) => {
|
||||
const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => {
|
||||
try {
|
||||
const newValue = !currentShowInCompetition
|
||||
await api.toggleCompetition(traderId, newValue)
|
||||
toast.success(
|
||||
newValue
|
||||
? t('aiTradersToast.showInCompetition', language)
|
||||
: t('aiTradersToast.hideInCompetition', language)
|
||||
)
|
||||
toast.success(newValue ? t('aiTradersToast.showInCompetition', language) : t('aiTradersToast.hideInCompetition', language))
|
||||
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
@@ -791,12 +452,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
allModels?.map((m) =>
|
||||
m.id === modelId
|
||||
? {
|
||||
...m,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
}
|
||||
...m,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
}
|
||||
: m
|
||||
) || []
|
||||
} else {
|
||||
@@ -856,7 +517,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
const refreshedExchanges = await api.getExchangeConfigs()
|
||||
setAllExchanges(refreshedExchanges)
|
||||
await mutateExchangeAccountStates()
|
||||
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
@@ -912,7 +572,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
|
||||
await api.updateExchangeConfigsEncrypted(request)
|
||||
toast.success(t('aiTradersToast.exchangeConfigUpdated', language))
|
||||
toast.success(t('aiTradersToast.exchangeConfigUpdated', language))
|
||||
} else {
|
||||
const createRequest = {
|
||||
exchange_type: exchangeType,
|
||||
@@ -933,12 +593,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
|
||||
await api.createExchangeEncrypted(createRequest)
|
||||
toast.success(t('aiTradersToast.exchangeCreated', language))
|
||||
toast.success(t('aiTradersToast.exchangeCreated', language))
|
||||
}
|
||||
|
||||
const refreshedExchanges = await api.getExchangeConfigs()
|
||||
setAllExchanges(refreshedExchanges)
|
||||
await mutateExchangeAccountStates()
|
||||
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
@@ -958,40 +617,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
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 (
|
||||
<DeepVoidBackground className="py-8" disableAnimation>
|
||||
<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
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={
|
||||
configuredModels.length === 0 ||
|
||||
configuredExchanges.length === 0
|
||||
}
|
||||
disabled={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)]"
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
@@ -1066,89 +688,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
</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 */}
|
||||
<ConfigStatusGrid
|
||||
configuredModels={configuredModels}
|
||||
configuredExchanges={configuredExchanges}
|
||||
exchangeAccountStates={exchangeAccountStateData?.states}
|
||||
isExchangeAccountStatesLoading={isExchangeAccountStatesLoading}
|
||||
visibleExchangeAddresses={visibleExchangeAddresses}
|
||||
copiedId={copiedId}
|
||||
language={language}
|
||||
@@ -1173,7 +716,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
copiedId={copiedId}
|
||||
language={language}
|
||||
onTraderSelect={onTraderSelect}
|
||||
onNavigate={navigateInApp}
|
||||
onNavigate={(path) => navigate(path)}
|
||||
onEditTrader={handleEditTrader}
|
||||
onToggleTrader={handleToggleTrader}
|
||||
onToggleCompetition={handleToggleCompetition}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function CompetitionPage() {
|
||||
|
||||
const handleTraderClick = async (traderId: string) => {
|
||||
try {
|
||||
const traderConfig = await api.getTraderConfig(traderId)
|
||||
const traderConfig = await api.getPublicTraderConfig(traderId)
|
||||
setSelectedTrader(traderConfig)
|
||||
setIsModalOpen(true)
|
||||
} catch (error) {
|
||||
@@ -281,14 +281,14 @@ export function CompetitionPage() {
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="text-right min-w-[60px] md:min-w-[80px]">
|
||||
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('equity', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.total_equity?.toFixed(2) || '0.00'}
|
||||
@@ -297,11 +297,11 @@ export function CompetitionPage() {
|
||||
|
||||
{/* P&L */}
|
||||
<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)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
className="text-base md:text-lg font-bold mono"
|
||||
style={{
|
||||
color:
|
||||
(trader.total_pnl ?? 0) >= 0
|
||||
@@ -313,7 +313,7 @@ export function CompetitionPage() {
|
||||
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] mono"
|
||||
className="text-xs mono"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
@@ -322,17 +322,17 @@ export function CompetitionPage() {
|
||||
</div>
|
||||
|
||||
{/* Positions */}
|
||||
<div className="text-right min-w-[40px] md:min-w-[50px]">
|
||||
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('pos', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.position_count}
|
||||
</div>
|
||||
<div className="text-[10px]" style={{ color: '#848E9C' }}>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{trader.margin_used_pct.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -539,6 +539,22 @@ export function ExchangeConfigModal({
|
||||
</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">
|
||||
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
<Key className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
@@ -548,7 +564,11 @@ export function ExchangeConfigModal({
|
||||
type="password"
|
||||
value={apiKey}
|
||||
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"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
@@ -564,7 +584,11 @@ export function ExchangeConfigModal({
|
||||
type="password"
|
||||
value={secretKey}
|
||||
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"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
@@ -581,7 +605,11 @@ export function ExchangeConfigModal({
|
||||
type="password"
|
||||
value={passphrase}
|
||||
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"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
|
||||
@@ -4,16 +4,15 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react'
|
||||
import type { AIModel } from '../../types'
|
||||
import type { Language } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { api } from '../../lib/api'
|
||||
import { getModelIcon } from '../common/ModelIcons'
|
||||
import { ModelStepIndicator } from './ModelStepIndicator'
|
||||
import { ModelCard } from './ModelCard'
|
||||
import {
|
||||
BLOCKRUN_MODELS,
|
||||
CLAW402_MODELS,
|
||||
AI_PROVIDER_CONFIG,
|
||||
getShortName,
|
||||
} from './model-constants'
|
||||
import { getBeginnerWalletAddress, getUserMode } from '../../lib/onboarding'
|
||||
|
||||
interface ModelConfigModalProps {
|
||||
allModels: AIModel[]
|
||||
@@ -44,22 +43,20 @@ export function ModelConfigModal({
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [modelName, setModelName] = useState('')
|
||||
const configuredModel =
|
||||
configuredModels?.find((model) => model.id === selectedModelId) || null
|
||||
|
||||
// Always prefer allModels (supportedModels) for provider/id lookup;
|
||||
// fall back to configuredModels for edit mode details (apiKey etc.)
|
||||
const selectedModel =
|
||||
allModels?.find((m) => m.id === selectedModelId) || configuredModel
|
||||
allModels?.find((m) => m.id === selectedModelId) ||
|
||||
configuredModels?.find((m) => m.id === selectedModelId)
|
||||
|
||||
useEffect(() => {
|
||||
const modelDetails = configuredModel || selectedModel
|
||||
if (editingModelId && modelDetails) {
|
||||
setApiKey(modelDetails.apiKey || '')
|
||||
setBaseUrl(modelDetails.customApiUrl || '')
|
||||
setModelName(modelDetails.customModelName || '')
|
||||
if (editingModelId && selectedModel) {
|
||||
setApiKey(selectedModel.apiKey || '')
|
||||
setBaseUrl(selectedModel.customApiUrl || '')
|
||||
setModelName(selectedModel.customModelName || '')
|
||||
}
|
||||
}, [editingModelId, configuredModel, selectedModel])
|
||||
}, [editingModelId, selectedModel])
|
||||
|
||||
const handleSelectModel = (modelId: string) => {
|
||||
setSelectedModelId(modelId)
|
||||
@@ -77,28 +74,13 @@ export function ModelConfigModal({
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedModelId) return
|
||||
const key = apiKey.trim()
|
||||
// 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)
|
||||
if (!selectedModelId || !apiKey.trim()) return
|
||||
onSave(selectedModelId, apiKey.trim(), baseUrl.trim() || undefined, modelName.trim() || undefined)
|
||||
}
|
||||
|
||||
const availableModels = allModels || []
|
||||
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
|
||||
const isClaw402Selected = selectedModel?.provider === 'claw402' || selectedModel?.id === 'claw402'
|
||||
const isBeginnerDefaultModel = isClaw402Selected && getUserMode() === 'beginner'
|
||||
const stepLabels = [
|
||||
t('modelConfig.selectModel', language),
|
||||
t(
|
||||
!selectedModel
|
||||
? 'modelConfig.configure'
|
||||
: isClaw402Selected
|
||||
? 'modelConfig.configureWallet'
|
||||
: 'modelConfig.configure',
|
||||
language
|
||||
),
|
||||
]
|
||||
const stepLabels = [t('modelConfig.selectModel', language), t('modelConfig.configureApi', language)]
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{editingModelId && !isBeginnerDefaultModel && (
|
||||
{editingModelId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(editingModelId)}
|
||||
@@ -162,7 +144,6 @@ export function ModelConfigModal({
|
||||
<Claw402ConfigForm
|
||||
apiKey={apiKey}
|
||||
modelName={modelName}
|
||||
configuredModel={configuredModel}
|
||||
editingModelId={editingModelId}
|
||||
onApiKeyChange={setApiKey}
|
||||
onModelNameChange={setModelName}
|
||||
@@ -209,10 +190,6 @@ function ModelSelectionStep({
|
||||
onSelectModel: (modelId: string) => void
|
||||
language: Language
|
||||
}) {
|
||||
const [showOtherProviders, setShowOtherProviders] = useState(false)
|
||||
const claw402Model = availableModels.find((m) => m.provider === 'claw402')
|
||||
const otherProviders = availableModels.filter((m) => m.provider !== 'claw402')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
@@ -220,11 +197,12 @@ function ModelSelectionStep({
|
||||
</div>
|
||||
|
||||
{/* Claw402 Featured Card */}
|
||||
{claw402Model && (
|
||||
{availableModels.some(m => m.provider === 'claw402') && (
|
||||
<button
|
||||
type="button"
|
||||
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]"
|
||||
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 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="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
|
||||
@@ -258,57 +236,45 @@ function ModelSelectionStep({
|
||||
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 ml-[52px] text-[11px]" style={{ color: '#A0AEC0' }}>
|
||||
{t('modelConfig.claw402EntryDesc', language)}
|
||||
</div>
|
||||
</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">
|
||||
{otherProviders.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-3" style={{ color: '#848E9C' }}>
|
||||
{t('modelConfig.modelsConfigured', language)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{availableModels.filter(m => !m.provider?.startsWith('blockrun') && m.provider !== 'claw402').map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
selected={selectedModelId === model.id}
|
||||
onClick={() => onSelectModel(model.id)}
|
||||
configured={configuredIds.has(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{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)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -316,7 +282,6 @@ function ModelSelectionStep({
|
||||
function Claw402ConfigForm({
|
||||
apiKey,
|
||||
modelName,
|
||||
configuredModel,
|
||||
editingModelId,
|
||||
onApiKeyChange,
|
||||
onModelNameChange,
|
||||
@@ -326,7 +291,6 @@ function Claw402ConfigForm({
|
||||
}: {
|
||||
apiKey: string
|
||||
modelName: string
|
||||
configuredModel: AIModel | null
|
||||
editingModelId: string | null
|
||||
onApiKeyChange: (value: string) => void
|
||||
onModelNameChange: (value: string) => void
|
||||
@@ -337,21 +301,14 @@ function Claw402ConfigForm({
|
||||
const [walletAddress, setWalletAddress] = useState('')
|
||||
const [copiedAddr, setCopiedAddr] = useState(false)
|
||||
const [showDeposit, setShowDeposit] = useState(false)
|
||||
const [showNewWalletBackup, setShowNewWalletBackup] = useState(false)
|
||||
const [newWalletKey, setNewWalletKey] = useState('')
|
||||
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
|
||||
const [keyError, setKeyError] = useState('')
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [claw402Status, setClaw402Status] = useState<string | null>(null)
|
||||
const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null)
|
||||
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
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExistingWallet) {
|
||||
setShowDeposit(true)
|
||||
}
|
||||
}, [hasExistingWallet])
|
||||
// Truncate address for display
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -441,23 +370,6 @@ function Claw402ConfigForm({
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -485,7 +397,7 @@ function Claw402ConfigForm({
|
||||
}
|
||||
}
|
||||
|
||||
const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0
|
||||
const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
@@ -507,25 +419,6 @@ function Claw402ConfigForm({
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Step 1: Select AI Model */}
|
||||
@@ -539,7 +432,7 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{CLAW402_MODELS.map((m) => {
|
||||
const isSelected = (modelName || 'glm-5') === m.id
|
||||
const isSelected = (modelName || 'deepseek') === m.id
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
@@ -597,33 +490,6 @@ function Claw402ConfigForm({
|
||||
</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="text-xs font-medium" style={{ color: '#A0AEC0' }}>
|
||||
{t('modelConfig.walletPrivateKey', language)}
|
||||
@@ -633,30 +499,72 @@ function Claw402ConfigForm({
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||
placeholder={
|
||||
hasExistingWallet
|
||||
? language === 'zh'
|
||||
? '如需切换钱包,请手动输入新的私钥'
|
||||
: 'Enter a new private key only if you want to switch wallets'
|
||||
: '0x...'
|
||||
}
|
||||
placeholder="0x..."
|
||||
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
|
||||
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>
|
||||
|
||||
{hasExistingWallet && !apiKey ? (
|
||||
<div className="text-[11px] leading-5" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。'
|
||||
: '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.'}
|
||||
{/* New wallet backup warning */}
|
||||
{showNewWalletBackup && newWalletKey && (
|
||||
<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' }}>
|
||||
🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'}
|
||||
</div>
|
||||
<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>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
|
||||
<span className="mt-px">🔒</span>
|
||||
@@ -667,7 +575,7 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
|
||||
{/* Wallet Validation Results */}
|
||||
{(apiKey || hasExistingWallet) && (
|
||||
{apiKey && (
|
||||
<div className="space-y-2 pl-1">
|
||||
{/* Validating spinner */}
|
||||
{validating && (
|
||||
@@ -686,7 +594,7 @@ function Claw402ConfigForm({
|
||||
)}
|
||||
|
||||
{/* 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="flex items-center justify-between mb-1">
|
||||
@@ -696,7 +604,7 @@ function Claw402ConfigForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(resolvedWalletAddress)
|
||||
navigator.clipboard.writeText(walletAddress)
|
||||
setCopiedAddr(true)
|
||||
setTimeout(() => setCopiedAddr(false), 2000)
|
||||
}}
|
||||
@@ -706,16 +614,16 @@ function Claw402ConfigForm({
|
||||
{copiedAddr ? '✅' : '📋'}
|
||||
</button>
|
||||
</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' }}>
|
||||
⚠️ {language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
|
||||
</div>
|
||||
</div>
|
||||
{resolvedUsdcBalance !== null && (
|
||||
{usdcBalance !== null && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>💰</span>
|
||||
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
|
||||
{t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance}
|
||||
{t('modelConfig.usdcBalance', language)}: ${usdcBalance}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -736,17 +644,17 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
<div className="flex gap-3 items-start mb-3">
|
||||
<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 className="flex-1 min-w-0">
|
||||
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
|
||||
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(resolvedWalletAddress)
|
||||
navigator.clipboard.writeText(walletAddress)
|
||||
setCopiedAddr(true)
|
||||
setTimeout(() => setCopiedAddr(false), 2000)
|
||||
}}
|
||||
@@ -765,13 +673,6 @@ function Claw402ConfigForm({
|
||||
</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 && (
|
||||
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
|
||||
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
|
||||
@@ -784,11 +685,11 @@ function Claw402ConfigForm({
|
||||
)}
|
||||
|
||||
{/* Test Connection button */}
|
||||
{(isKeyValid || hasExistingWallet) && !validating && (
|
||||
{isKeyValid && !validating && (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
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
|
||||
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"
|
||||
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)}
|
||||
</button>
|
||||
@@ -899,7 +800,9 @@ function StandardProviderConfigForm({
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" 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>
|
||||
</a>
|
||||
)}
|
||||
@@ -918,66 +821,122 @@ function StandardProviderConfigForm({
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<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="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>
|
||||
{'API Key *'}
|
||||
{selectedModel.provider?.startsWith('blockrun')
|
||||
? t('modelConfig.walletPrivateKeyLabel', language)
|
||||
: 'API Key *'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
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"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Base URL */}
|
||||
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
{t('customBaseURL', language)}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => onBaseUrlChange(e.target.value)}
|
||||
placeholder={t('customBaseURLPlaceholder', language)}
|
||||
className="w-full px-4 py-3 rounded-xl"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('leaveBlankForDefault', language)}
|
||||
{/* Custom Base URL (hidden for BlockRun) */}
|
||||
{!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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
{t('customBaseURL', language)}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => onBaseUrlChange(e.target.value)}
|
||||
placeholder={t('customBaseURLPlaceholder', language)}
|
||||
className="w-full px-4 py-3 rounded-xl"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('leaveBlankForDefault', language)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Model Name */}
|
||||
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
{t('customModelName', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modelName}
|
||||
onChange={(e) => onModelNameChange(e.target.value)}
|
||||
placeholder={t('customModelNamePlaceholder', language)}
|
||||
className="w-full px-4 py-3 rounded-xl"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('leaveBlankForDefaultModel', language)}
|
||||
{/* Custom Model Name (hidden for BlockRun) */}
|
||||
{!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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
{t('customModelName', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modelName}
|
||||
onChange={(e) => onModelNameChange(e.target.value)}
|
||||
placeholder={t('customModelNamePlaceholder', language)}
|
||||
className="w-full px-4 py-3 rounded-xl"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('leaveBlankForDefaultModel', language)}
|
||||
</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 */}
|
||||
<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
|
||||
}
|
||||
|
||||
export interface BlockrunModel {
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
// Get friendly AI model display name
|
||||
export function getModelDisplayName(modelId: string): string {
|
||||
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 },
|
||||
]
|
||||
|
||||
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
|
||||
export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = {
|
||||
deepseek: {
|
||||
|
||||
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -28,7 +28,10 @@ export const dataApi = {
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getPositions(traderId?: string, silent?: boolean): Promise<Position[]> {
|
||||
async getPositions(
|
||||
traderId?: string,
|
||||
silent?: boolean
|
||||
): Promise<Position[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/positions?trader_id=${traderId}`
|
||||
: `${API_BASE}/positions`
|
||||
@@ -65,7 +68,10 @@ export const dataApi = {
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getStatistics(traderId?: string, silent?: boolean): Promise<Statistics> {
|
||||
async getStatistics(
|
||||
traderId?: string,
|
||||
silent?: boolean
|
||||
): Promise<Statistics> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/statistics?trader_id=${traderId}`
|
||||
: `${API_BASE}/statistics`
|
||||
@@ -74,7 +80,10 @@ export const dataApi = {
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getEquityHistory(traderId?: string, silent?: boolean): Promise<any[]> {
|
||||
async getEquityHistory(
|
||||
traderId?: string,
|
||||
silent?: boolean
|
||||
): Promise<any[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/equity-history?trader_id=${traderId}`
|
||||
: `${API_BASE}/equity-history`
|
||||
@@ -100,7 +109,7 @@ export const dataApi = {
|
||||
|
||||
async getPublicTraderConfig(traderId: string): Promise<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')
|
||||
return result.data!
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+109
-254
@@ -1,26 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
User,
|
||||
Cpu,
|
||||
Building2,
|
||||
MessageCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Pencil,
|
||||
} from 'lucide-react'
|
||||
import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, Pencil } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
import {
|
||||
getPostAuthPath,
|
||||
getUserMode,
|
||||
setUserMode,
|
||||
type UserMode,
|
||||
} from '../lib/onboarding'
|
||||
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
|
||||
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
|
||||
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
|
||||
@@ -28,14 +11,24 @@ import type { Exchange, AIModel } from '../types'
|
||||
|
||||
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() {
|
||||
const { user } = useAuth()
|
||||
const { language } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('account')
|
||||
const [userMode, setUserModeState] = useState<UserMode>(
|
||||
() => getUserMode() ?? 'advanced'
|
||||
)
|
||||
|
||||
// Account state
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
@@ -56,24 +49,41 @@ export function SettingsPage() {
|
||||
// Telegram state
|
||||
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
|
||||
useEffect(() => {
|
||||
if (activeTab === 'models') {
|
||||
Promise.all([api.getModelConfigs(), api.getSupportedModels()])
|
||||
.then(([configs, supported]) => {
|
||||
setConfiguredModels(configs)
|
||||
setSupportedModels(supported)
|
||||
})
|
||||
refreshModelConfigs()
|
||||
.catch(() => toast.error('Failed to load AI models'))
|
||||
}
|
||||
if (activeTab === 'exchanges') {
|
||||
api
|
||||
.getExchangeConfigs()
|
||||
.then(setExchanges)
|
||||
refreshExchangeConfigs()
|
||||
.catch(() => toast.error('Failed to load exchanges'))
|
||||
}
|
||||
}, [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) => {
|
||||
e.preventDefault()
|
||||
if (newPassword.length < 8) {
|
||||
@@ -86,7 +96,7 @@ export function SettingsPage() {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token') || ''}`,
|
||||
},
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
})
|
||||
@@ -97,33 +107,12 @@ export function SettingsPage() {
|
||||
toast.success('Password updated successfully')
|
||||
setNewPassword('')
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Failed to update password'
|
||||
)
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update password')
|
||||
} finally {
|
||||
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 (
|
||||
modelId: string,
|
||||
apiKey: string,
|
||||
@@ -134,54 +123,38 @@ export function SettingsPage() {
|
||||
const existingModel = configuredModels.find((m) => m.id === modelId)
|
||||
const modelTemplate = supportedModels.find((m) => m.id === modelId)
|
||||
const modelToUpdate = existingModel || modelTemplate
|
||||
if (!modelToUpdate) {
|
||||
toast.error('Model not found')
|
||||
return
|
||||
}
|
||||
if (!modelToUpdate) { toast.error('Model not found'); return }
|
||||
|
||||
let updatedModels: AIModel[]
|
||||
if (existingModel) {
|
||||
updatedModels = configuredModels.map((m) =>
|
||||
m.id === modelId
|
||||
? {
|
||||
...m,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
}
|
||||
? { ...m, apiKey, customApiUrl: customApiUrl || '', customModelName: customModelName || '', enabled: true }
|
||||
: m
|
||||
)
|
||||
} else {
|
||||
updatedModels = [
|
||||
...configuredModels,
|
||||
{
|
||||
...modelToUpdate,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
updatedModels = [...configuredModels, {
|
||||
...modelToUpdate,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
}]
|
||||
}
|
||||
|
||||
const request = {
|
||||
models: Object.fromEntries(
|
||||
updatedModels.map((m) => [
|
||||
m.provider,
|
||||
{
|
||||
enabled: m.enabled,
|
||||
api_key: m.apiKey || '',
|
||||
custom_api_url: m.customApiUrl || '',
|
||||
custom_model_name: m.customModelName || '',
|
||||
},
|
||||
])
|
||||
updatedModels.map((m) => [m.provider, {
|
||||
enabled: m.enabled,
|
||||
api_key: m.apiKey || '',
|
||||
custom_api_url: m.customApiUrl || '',
|
||||
custom_model_name: m.customModelName || '',
|
||||
}])
|
||||
),
|
||||
}
|
||||
await api.updateModelConfigs(request)
|
||||
toast.success('Model config saved')
|
||||
const refreshed = await api.getModelConfigs()
|
||||
setConfiguredModels(refreshed)
|
||||
await refreshModelConfigs()
|
||||
setShowModelModal(false)
|
||||
setEditingModel(null)
|
||||
} catch {
|
||||
@@ -192,32 +165,20 @@ export function SettingsPage() {
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
try {
|
||||
const updatedModels = configuredModels.map((m) =>
|
||||
m.id === modelId
|
||||
? {
|
||||
...m,
|
||||
apiKey: '',
|
||||
customApiUrl: '',
|
||||
customModelName: '',
|
||||
enabled: false,
|
||||
}
|
||||
: m
|
||||
m.id === modelId ? { ...m, apiKey: '', customApiUrl: '', customModelName: '', enabled: false } : m
|
||||
)
|
||||
const request = {
|
||||
models: Object.fromEntries(
|
||||
updatedModels.map((m) => [
|
||||
m.provider,
|
||||
{
|
||||
enabled: m.enabled,
|
||||
api_key: m.apiKey || '',
|
||||
custom_api_url: m.customApiUrl || '',
|
||||
custom_model_name: m.customModelName || '',
|
||||
},
|
||||
])
|
||||
updatedModels.map((m) => [m.provider, {
|
||||
enabled: m.enabled,
|
||||
api_key: m.apiKey || '',
|
||||
custom_api_url: m.customApiUrl || '',
|
||||
custom_model_name: m.customModelName || '',
|
||||
}])
|
||||
),
|
||||
}
|
||||
await api.updateModelConfigs(request)
|
||||
const refreshed = await api.getModelConfigs()
|
||||
setConfiguredModels(refreshed)
|
||||
await refreshModelConfigs()
|
||||
setShowModelModal(false)
|
||||
setEditingModel(null)
|
||||
toast.success('Model config removed')
|
||||
@@ -265,7 +226,7 @@ export function SettingsPage() {
|
||||
},
|
||||
}
|
||||
await api.updateExchangeConfigsEncrypted(request)
|
||||
toast.success('Exchange config updated')
|
||||
toast.success('Exchange config updated')
|
||||
} else {
|
||||
const createRequest = {
|
||||
exchange_type: exchangeType,
|
||||
@@ -285,10 +246,9 @@ export function SettingsPage() {
|
||||
lighter_api_key_index: lighterApiKeyIndex || 0,
|
||||
}
|
||||
await api.createExchangeEncrypted(createRequest)
|
||||
toast.success('Exchange account created')
|
||||
toast.success('Exchange account created')
|
||||
}
|
||||
const refreshed = await api.getExchangeConfigs()
|
||||
setExchanges(refreshed)
|
||||
await refreshExchangeConfigs()
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
} catch {
|
||||
@@ -300,8 +260,7 @@ export function SettingsPage() {
|
||||
try {
|
||||
await api.deleteExchange(exchangeId)
|
||||
toast.success('Exchange account deleted')
|
||||
const refreshed = await api.getExchangeConfigs()
|
||||
setExchanges(refreshed)
|
||||
await refreshExchangeConfigs()
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
} catch {
|
||||
@@ -317,10 +276,7 @@ export function SettingsPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen pt-20 pb-12 px-4"
|
||||
style={{ background: '#0B0E11' }}
|
||||
>
|
||||
<div className="min-h-screen pt-20 pb-12 px-4" style={{ background: '#0B0E11' }}>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-xl font-bold text-white mb-6">Settings</h1>
|
||||
|
||||
@@ -331,10 +287,9 @@ export function SettingsPage() {
|
||||
key={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
|
||||
${
|
||||
activeTab === tab.key
|
||||
? 'bg-nofx-gold text-black'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
${activeTab === tab.key
|
||||
? 'bg-nofx-gold text-black'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
@@ -345,6 +300,7 @@ export function SettingsPage() {
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-6">
|
||||
|
||||
{/* Account Tab */}
|
||||
{activeTab === 'account' && (
|
||||
<div className="space-y-6">
|
||||
@@ -354,78 +310,10 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-800 pt-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<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>
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -440,11 +328,7 @@ export function SettingsPage() {
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff size={16} />
|
||||
) : (
|
||||
<Eye size={16} />
|
||||
)}
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -465,14 +349,10 @@ export function SettingsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{configuredModels.length} model
|
||||
{configuredModels.length !== 1 ? 's' : ''} configured
|
||||
{configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingModel(null)
|
||||
setShowModelModal(true)
|
||||
}}
|
||||
onClick={() => { 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"
|
||||
>
|
||||
<Plus size={14} />
|
||||
@@ -489,10 +369,7 @@ export function SettingsPage() {
|
||||
{configuredModels.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => {
|
||||
setEditingModel(model.id)
|
||||
setShowModelModal(true)
|
||||
}}
|
||||
onClick={() => { 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"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -500,24 +377,20 @@ export function SettingsPage() {
|
||||
<Cpu size={14} className="text-zinc-300" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">
|
||||
{model.name}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{model.provider}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-white">{model.name}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1">
|
||||
<p className="text-xs text-zinc-500">{model.provider}</p>
|
||||
{configBadge('API Key', !!model.has_api_key)}
|
||||
{model.customModelName ? configBadge('Custom Model', true) : null}
|
||||
{model.customApiUrl ? configBadge('Base URL', true) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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'}`}
|
||||
>
|
||||
<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'}`}>
|
||||
{model.enabled ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<Pencil
|
||||
size={14}
|
||||
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||
/>
|
||||
<Pencil size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -531,14 +404,10 @@ export function SettingsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''}{' '}
|
||||
connected
|
||||
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingExchange(null)
|
||||
setShowExchangeModal(true)
|
||||
}}
|
||||
onClick={() => { 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"
|
||||
>
|
||||
<Plus size={14} />
|
||||
@@ -555,10 +424,7 @@ export function SettingsPage() {
|
||||
{exchanges.map((exchange) => (
|
||||
<button
|
||||
key={exchange.id}
|
||||
onClick={() => {
|
||||
setEditingExchange(exchange.id)
|
||||
setShowExchangeModal(true)
|
||||
}}
|
||||
onClick={() => { 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"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -566,18 +432,19 @@ export function SettingsPage() {
|
||||
<Building2 size={14} className="text-zinc-300" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">
|
||||
{exchange.account_name || exchange.name}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 capitalize">
|
||||
{exchange.exchange_type || exchange.type}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-white">{exchange.account_name || exchange.name}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1">
|
||||
<p className="text-xs text-zinc-500 capitalize">{exchange.exchange_type || exchange.type}</p>
|
||||
{configBadge('API Key', !!exchange.has_api_key)}
|
||||
{configBadge('Secret', !!exchange.has_secret_key)}
|
||||
{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>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||
/>
|
||||
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -589,8 +456,7 @@ export function SettingsPage() {
|
||||
{activeTab === 'telegram' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Connect a Telegram bot to receive trading notifications and
|
||||
interact with your traders.
|
||||
Connect a Telegram bot to receive trading notifications and interact with your traders.
|
||||
</p>
|
||||
<button
|
||||
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">
|
||||
<MessageCircle size={14} className="text-[#0088cc]" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white">
|
||||
Configure Telegram Bot
|
||||
</span>
|
||||
<span className="text-sm font-medium text-white">Configure Telegram Bot</span>
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||
/>
|
||||
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -623,10 +484,7 @@ export function SettingsPage() {
|
||||
editingModelId={editingModel}
|
||||
onSave={handleSaveModel}
|
||||
onDelete={handleDeleteModel}
|
||||
onClose={() => {
|
||||
setShowModelModal(false)
|
||||
setEditingModel(null)
|
||||
}}
|
||||
onClose={() => { setShowModelModal(false); setEditingModel(null) }}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
@@ -640,10 +498,7 @@ export function SettingsPage() {
|
||||
editingExchangeId={editingExchange}
|
||||
onSave={handleSaveExchange}
|
||||
onDelete={handleDeleteExchange}
|
||||
onClose={() => {
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
}}
|
||||
onClose={() => { setShowExchangeModal(false); setEditingExchange(null) }}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { FAQPage } from '../pages/FAQPage'
|
||||
import { LandingPage } from '../pages/LandingPage'
|
||||
import { BeginnerOnboardingPage } from '../pages/BeginnerOnboardingPage'
|
||||
import { DataPage } from '../pages/DataPage'
|
||||
import { AgentChatPage } from '../pages/AgentChatPage'
|
||||
import { SettingsPage } from '../pages/SettingsPage'
|
||||
import { StrategyMarketPage } from '../pages/StrategyMarketPage'
|
||||
import { StrategyStudioPage } from '../pages/StrategyStudioPage'
|
||||
@@ -456,6 +457,14 @@ export function AppRoutes() {
|
||||
</AppChrome>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.agent}
|
||||
element={
|
||||
<AppChrome currentPage="agent" showFooter={false}>
|
||||
<AgentChatPage />
|
||||
</AppChrome>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.data}
|
||||
element={
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type Page =
|
||||
| 'agent'
|
||||
| 'competition'
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
@@ -11,6 +12,7 @@ export type Page =
|
||||
|
||||
export const ROUTES = {
|
||||
home: '/',
|
||||
agent: '/agent',
|
||||
login: '/login',
|
||||
register: '/register',
|
||||
setup: '/setup',
|
||||
@@ -27,6 +29,7 @@ export const ROUTES = {
|
||||
} as const
|
||||
|
||||
export const PAGE_PATHS: Record<Page, string> = {
|
||||
agent: ROUTES.agent,
|
||||
competition: ROUTES.competition,
|
||||
traders: ROUTES.traders,
|
||||
trader: ROUTES.dashboard,
|
||||
@@ -39,6 +42,7 @@ export const PAGE_PATHS: Record<Page, string> = {
|
||||
}
|
||||
|
||||
export const LEGACY_HASH_ROUTES: Record<string, string> = {
|
||||
agent: ROUTES.agent,
|
||||
competition: ROUTES.competition,
|
||||
traders: ROUTES.traders,
|
||||
trader: ROUTES.dashboard,
|
||||
@@ -50,6 +54,8 @@ export const LEGACY_HASH_ROUTES: Record<string, string> = {
|
||||
|
||||
export function getCurrentPageForPath(pathname: string): Page | undefined {
|
||||
switch (pathname) {
|
||||
case ROUTES.agent:
|
||||
return 'agent'
|
||||
case ROUTES.welcome:
|
||||
case ROUTES.traders:
|
||||
return 'traders'
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}))
|
||||
@@ -3,6 +3,7 @@ export interface AIModel {
|
||||
name: string
|
||||
provider: string
|
||||
enabled: boolean
|
||||
has_api_key?: boolean
|
||||
apiKey?: string
|
||||
customApiUrl?: string
|
||||
customModelName?: string
|
||||
@@ -24,18 +25,25 @@ export interface Exchange {
|
||||
name: string // Display name
|
||||
type: 'cex' | 'dex'
|
||||
enabled: boolean
|
||||
has_api_key?: boolean
|
||||
has_secret_key?: boolean
|
||||
has_passphrase?: boolean
|
||||
apiKey?: string
|
||||
secretKey?: string
|
||||
passphrase?: string // OKX specific
|
||||
testnet?: boolean
|
||||
// Hyperliquid specific
|
||||
hyperliquidWalletAddr?: string
|
||||
has_hyperliquid_secret?: boolean
|
||||
// Aster specific
|
||||
asterUser?: string
|
||||
asterSigner?: string
|
||||
has_aster_private_key?: boolean
|
||||
asterPrivateKey?: string
|
||||
// LIGHTER specific
|
||||
lighterWalletAddr?: string
|
||||
has_lighter_private_key?: boolean
|
||||
has_lighter_api_key_private_key?: boolean
|
||||
lighterPrivateKey?: string
|
||||
lighterApiKeyPrivateKey?: string
|
||||
lighterApiKeyIndex?: number
|
||||
|
||||
Reference in New Issue
Block a user