diff --git a/.github/workflows/pr-docker-compose-healthcheck.yml b/.github/workflows/pr-docker-compose-healthcheck.yml new file mode 100644 index 0000000000..139477e569 --- /dev/null +++ b/.github/workflows/pr-docker-compose-healthcheck.yml @@ -0,0 +1,152 @@ +name: PR Docker Compose Healthcheck + +# 驗證 docker-compose.yml 的 healthcheck 配置在 Alpine 容器中正常工作 +on: + pull_request: + branches: + - main + - dev + paths: + - 'docker-compose.yml' + - 'docker/Dockerfile.backend' + - 'docker/Dockerfile.frontend' + - '.github/workflows/pr-docker-compose-healthcheck.yml' + +jobs: + healthcheck-test: + name: Test Docker Compose Healthcheck + runs-on: ubuntu-22.04 + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Create minimal .env for testing + run: | + cat > .env < config.json <>>>>>> beta - # 决策日志 decision_logs/ coin_pool_cache/ @@ -59,9 +44,6 @@ web/node_modules/ node_modules/ web/dist/ web/.vite/ -<<<<<<< HEAD -web/yarn.lock -======= # ESLint 临时报告文件(调试时生成,不纳入版本控制) eslint-*.json @@ -140,4 +122,4 @@ dmypy.json # Pyre type checker .pyre/ ->>>>>>> beta +PR_DESCRIPTION.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31fc7c0278..553a0399ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -469,7 +469,6 @@ Reviewers will check: - [Project Roadmap](docs/roadmap/README.md) - [Architecture Documentation](docs/architecture/README.md) -- [API Documentation](docs/api/README.md) - [Deployment Guide](docs/getting-started/docker-deploy.en.md) --- diff --git a/README.md b/README.md index ad86dfa6e2..a444dcc747 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,6 @@ **Languages:** [English](README.md) | [中文](docs/i18n/zh-CN/README.md) | [Українська](docs/i18n/uk/README.md) | [Русский](docs/i18n/ru/README.md) | [日本語](docs/i18n/ja/README.md) -**Official Twitter:** [@nofx_ai](https://x.com/nofx_ai) - **📚 Documentation:** [Docs Home](docs/README.md) | [Getting Started](docs/getting-started/README.md) | [Prompt Writing Guide](docs/prompt-guide.md) ([中文](docs/prompt-guide.zh-CN.md)) | [Changelog](CHANGELOG.md) | [Contributing](CONTRIBUTING.md) | [Security](SECURITY.md) --- @@ -31,7 +29,6 @@ - [🧠 AI Self-Learning](#-ai-self-learning-example) - [📊 Web Interface Features](#-web-interface-features) - [🎛️ API Endpoints](#️-api-endpoints) -- [🔐 Admin Mode (Single-User)](#-admin-mode-single-user) - [⚠️ Important Risk Warnings](#️-important-risk-warnings) - [🛠️ Common Issues](#️-common-issues) - [📈 Performance Tips](#-performance-optimization-tips) @@ -56,15 +53,12 @@ ### 👥 Core Team - **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle) -- **Zack** - [@0x_ZackH](https://x.com/0x_ZackH) ### 💼 Seed Funding Round Open -We are currently raising our **seed round**. - -**For investment inquiries**, please DM **Tinkle** or **Zack** via Twitter. +We are currently raising our **seed round**. -**For partnerships and collaborations**, please DM our official Twitter [@nofx_ai](https://x.com/nofx_ai). +**For investment inquiries**, please DM **Tinkle** via Twitter. --- @@ -245,48 +239,6 @@ NOFX is built with a modern, modular architecture: --- -## 🔐 Admin Mode (Single-User) - -For self-hosted or single-tenant setups, NOFX supports a strict admin-only mode that disables public features and requires an admin password for all access. - -### How it works -- All API endpoints require a valid JWT when `admin_mode=true`, except: - - `GET /api/health` - - `GET /api/config` - - `POST /api/admin-login` -- Logout invalidates the current token via an in-memory blacklist (sufficient for single instance; use Redis for multi-instance – see Notes). - -### Quick setup -1) Set flags in `config.json`: -```jsonc -{ - // ... other config - "admin_mode": true, - "jwt_secret": "YOUR_JWT_SCR" -} -``` - -2) Provide required environment variables: -- `NOFX_ADMIN_PASSWORD` — plaintext admin password (only used at startup to derive a bcrypt hash) - -Docker Compose example (already wired): -```yaml -services: - nofx: - environment: - - NOFX_ADMIN_PASSWORD=${NOFX_ADMIN_PASSWORD} -``` - -1) Login flow (admin mode): -- Open the web UI → you’ll be redirected to the login page -- Enter admin password → the server returns a JWT -- The UI stores the token and authenticates subsequent API calls - -### Notes -- Token lifetime: 24h. On logout, tokens are blacklisted in-memory until expiry. For multi-instance deployments, use a shared store (e.g., Redis) to sync the blacklist. - ---- - ## 💰 Register Binance Account (Save on Fees!) Before using this system, you need a Binance Futures account. **Use our referral link to save on trading fees:** diff --git a/api/server.go b/api/server.go index b3f67f116b..daf3665eac 100644 --- a/api/server.go +++ b/api/server.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "math" "net" "net/http" "nofx/auth" @@ -132,7 +133,6 @@ func (s *Server) setupRoutes() { protected.POST("/traders/:id/start", s.handleStartTrader) protected.POST("/traders/:id/stop", s.handleStopTrader) protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt) - protected.POST("/traders/:id/sync-balance", s.handleSyncBalance) // AI模型配置 protected.GET("/models", s.handleGetModelConfigs) @@ -197,11 +197,18 @@ func (s *Server) handleGetSystemConfig(c *gin.Context) { betaModeStr, _ := s.database.GetSystemConfig("beta_mode") betaMode := betaModeStr == "true" + regEnabledStr, err := s.database.GetSystemConfig("registration_enabled") + registrationEnabled := true + if err == nil { + registrationEnabled = strings.ToLower(regEnabledStr) != "false" + } + c.JSON(http.StatusOK, gin.H{ - "beta_mode": betaMode, - "default_coins": defaultCoins, - "btc_eth_leverage": btcEthLeverage, - "altcoin_leverage": altcoinLeverage, + "beta_mode": betaMode, + "default_coins": defaultCoins, + "btc_eth_leverage": btcEthLeverage, + "altcoin_leverage": altcoinLeverage, + "registration_enabled": registrationEnabled, }) } @@ -485,8 +492,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) { } } - // 生成交易员ID - traderID := fmt.Sprintf("%s_%s_%d", req.ExchangeID, req.AIModelID, time.Now().Unix()) + // 生成交易员ID (使用 UUID 确保唯一性,解决 Issue #893) + // 保留前缀以便调试和日志追踪 + traderID := fmt.Sprintf("%s_%s_%s", req.ExchangeID, req.AIModelID, uuid.New().String()) // 设置默认值 isCrossMargin := true // 默认为全仓模式 @@ -582,16 +590,36 @@ func (s *Server) handleCreateTrader(c *gin.Context) { if balanceErr != nil { log.Printf("⚠️ 查询交易所余额失败,使用用户输入的初始资金: %v", balanceErr) } else { - // 提取可用余额 - if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) - } else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 { - // 有些交易所可能只返回 balance 字段 - actualBalance = totalBalance - log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) + // 🔧 计算Total Equity = Wallet Balance + Unrealized Profit + // 这是账户的真实净值,用作Initial Balance的基准 + var totalWalletBalance float64 + var totalUnrealizedProfit float64 + + // 提取钱包余额 + if wb, ok := balanceInfo["totalWalletBalance"].(float64); ok { + totalWalletBalance = wb + } else if wb, ok := balanceInfo["wallet_balance"].(float64); ok { + totalWalletBalance = wb + } else if wb, ok := balanceInfo["balance"].(float64); ok { + totalWalletBalance = wb + } + + // 提取未实现盈亏 + if up, ok := balanceInfo["totalUnrealizedProfit"].(float64); ok { + totalUnrealizedProfit = up + } else if up, ok := balanceInfo["unrealized_profit"].(float64); ok { + totalUnrealizedProfit = up + } + + // 计算总净值 + totalEquity := totalWalletBalance + totalUnrealizedProfit + + if totalEquity > 0 { + actualBalance = totalEquity + log.Printf("✅ 查询到交易所实际净值: %.2f USDT (钱包: %.2f + 未实现: %.2f, 用户输入: %.2f)", + actualBalance, totalWalletBalance, totalUnrealizedProfit, req.InitialBalance) } else { - log.Printf("⚠️ 无法从余额信息中提取可用余额,使用用户输入的初始资金") + log.Printf("⚠️ 无法从余额信息中计算净值,使用用户输入的初始资金") } } } @@ -745,6 +773,21 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { return } + // 如果请求中包含initial_balance且与现有值不同,单独更新它 + // UpdateTrader不会更新initial_balance,需要使用专门的方法 + if req.InitialBalance > 0 && math.Abs(req.InitialBalance-existingTrader.InitialBalance) > 0.1 { + err = s.database.UpdateTraderInitialBalance(userID, traderID, req.InitialBalance) + if err != nil { + log.Printf("⚠️ 更新初始余额失败: %v", err) + // 不返回错误,因为主要配置已更新成功 + } else { + log.Printf("✓ 初始余额已更新: %.2f -> %.2f", existingTrader.InitialBalance, req.InitialBalance) + } + } + + // 🔄 从内存中移除旧的trader实例,以便重新加载最新配置 + s.traderManager.RemoveTrader(traderID) + // 重新加载交易员到内存 err = s.traderManager.LoadTraderByID(s.database, userID, traderID) if err != nil { @@ -906,113 +949,6 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "自定义prompt已更新"}) } -// handleSyncBalance 同步交易所余额到initial_balance(选项B:手动同步 + 选项C:智能检测) -func (s *Server) handleSyncBalance(c *gin.Context) { - userID := c.GetString("user_id") - traderID := c.Param("id") - - log.Printf("🔄 用户 %s 请求同步交易员 %s 的余额", userID, traderID) - - // 从数据库获取交易员配置(包含交易所信息) - traderConfig, _, exchangeCfg, err := s.database.GetTraderConfig(userID, traderID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"}) - return - } - - if exchangeCfg == nil || !exchangeCfg.Enabled { - c.JSON(http.StatusBadRequest, gin.H{"error": "交易所未配置或未启用"}) - return - } - - // 创建临时 trader 查询余额 - var tempTrader trader.Trader - var createErr error - - switch traderConfig.ExchangeID { - case "binance": - tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID) - case "hyperliquid": - tempTrader, createErr = trader.NewHyperliquidTrader( - exchangeCfg.APIKey, - exchangeCfg.HyperliquidWalletAddr, - exchangeCfg.Testnet, - ) - case "aster": - tempTrader, createErr = trader.NewAsterTrader( - exchangeCfg.AsterUser, - exchangeCfg.AsterSigner, - exchangeCfg.AsterPrivateKey, - ) - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的交易所类型"}) - return - } - - if createErr != nil { - log.Printf("⚠️ 创建临时 trader 失败: %v", createErr) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("连接交易所失败: %v", createErr)}) - return - } - - // 查询实际余额 - balanceInfo, balanceErr := tempTrader.GetBalance() - if balanceErr != nil { - log.Printf("⚠️ 查询交易所余额失败: %v", balanceErr) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("查询余额失败: %v", balanceErr)}) - return - } - - // 提取可用余额 - var actualBalance float64 - if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - } else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - } else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 { - actualBalance = totalBalance - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "无法获取可用余额"}) - return - } - - oldBalance := traderConfig.InitialBalance - - // ✅ 选项C:智能检测余额变化 - changePercent := ((actualBalance - oldBalance) / oldBalance) * 100 - changeType := "增加" - if changePercent < 0 { - changeType = "减少" - } - - log.Printf("✓ 查询到交易所实际余额: %.2f USDT (当前配置: %.2f USDT, 变化: %.2f%%)", - actualBalance, oldBalance, changePercent) - - // 更新数据库中的 initial_balance - err = s.database.UpdateTraderInitialBalance(userID, traderID, actualBalance) - if err != nil { - log.Printf("❌ 更新initial_balance失败: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "更新余额失败"}) - return - } - - // 重新加载交易员到内存 - err = s.traderManager.LoadTraderByID(s.database, userID, traderID) - if err != nil { - log.Printf("⚠️ 重新加载交易员到内存失败: %v", err) - } - - log.Printf("✅ 已同步余额: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent) - - c.JSON(http.StatusOK, gin.H{ - "message": "余额同步成功", - "old_balance": oldBalance, - "new_balance": actualBalance, - "change_percent": changePercent, - "change_type": changeType, - }) -} - // handleGetModelConfigs 获取AI模型配置 func (s *Server) handleGetModelConfigs(c *gin.Context) { userID := c.GetString("user_id") @@ -1270,12 +1206,13 @@ func (s *Server) handleTraderList(c *gin.Context) { // 返回完整的 AIModelID(如 "admin_deepseek"),不要截断 // 前端需要完整 ID 来验证模型是否存在(与 handleGetTraderConfig 保持一致) result = append(result, map[string]interface{}{ - "trader_id": trader.ID, - "trader_name": trader.Name, - "ai_model": trader.AIModelID, // 使用完整 ID - "exchange_id": trader.ExchangeID, - "is_running": isRunning, - "initial_balance": trader.InitialBalance, + "trader_id": trader.ID, + "trader_name": trader.Name, + "ai_model": trader.AIModelID, // 使用完整 ID + "exchange_id": trader.ExchangeID, + "is_running": isRunning, + "initial_balance": trader.InitialBalance, + "system_prompt_template": trader.SystemPromptTemplate, }) } @@ -1555,22 +1492,16 @@ func (s *Server) handleEquityHistory(c *gin.Context) { CycleNumber int `json:"cycle_number"` } - // 从AutoTrader获取初始余额(用于计算盈亏百分比) - initialBalance := 0.0 + // 从AutoTrader获取当前初始余额(用作旧数据的fallback) + base := 0.0 if status := trader.GetStatus(); status != nil { if ib, ok := status["initial_balance"].(float64); ok && ib > 0 { - initialBalance = ib + base = ib } } - // 如果无法从status获取,且有历史记录,则从第一条记录获取 - if initialBalance == 0 && len(records) > 0 { - // 第一条记录的equity作为初始余额 - initialBalance = records[0].AccountState.TotalBalance - } - // 如果还是无法获取,返回错误 - if initialBalance == 0 { + if base == 0 { c.JSON(http.StatusInternalServerError, gin.H{ "error": "无法获取初始余额", }) @@ -1580,14 +1511,24 @@ func (s *Server) handleEquityHistory(c *gin.Context) { var history []EquityPoint for _, record := range records { // TotalBalance字段实际存储的是TotalEquity - totalEquity := record.AccountState.TotalBalance + // totalEquity := record.AccountState.TotalBalance // TotalUnrealizedProfit字段实际存储的是TotalPnL(相对初始余额) - totalPnL := record.AccountState.TotalUnrealizedProfit + // totalPnL := record.AccountState.TotalUnrealizedProfit + walletBalance := record.AccountState.TotalBalance + unrealizedPnL := record.AccountState.TotalUnrealizedProfit + totalEquity := walletBalance + unrealizedPnL + + // 🔄 使用历史记录中保存的initial_balance(如果有) + // 这样可以保持历史PNL%的准确性,即使用户后来更新了initial_balance + if record.AccountState.InitialBalance > 0 { + base = record.AccountState.InitialBalance + } + totalPnL := totalEquity - base // 计算盈亏百分比 totalPnLPct := 0.0 - if initialBalance > 0 { - totalPnLPct = (totalPnL / initialBalance) * 100 + if base > 0 { + totalPnLPct = (totalPnL / base) * 100 } history = append(history, EquityPoint{ @@ -1704,6 +1645,14 @@ func (s *Server) handleLogout(c *gin.Context) { // handleRegister 处理用户注册请求 func (s *Server) handleRegister(c *gin.Context) { + regEnabled := true + if regStr, err := s.database.GetSystemConfig("registration_enabled"); err == nil { + regEnabled = strings.ToLower(regStr) != "false" + } + if !regEnabled { + c.JSON(http.StatusForbidden, gin.H{"error": "注册已关闭"}) + return + } var req struct { Email string `json:"email" binding:"required,email"` @@ -2155,16 +2104,17 @@ func (s *Server) handlePublicTraderList(c *gin.Context) { result := make([]map[string]interface{}, 0, len(traders)) for _, trader := range traders { result = append(result, map[string]interface{}{ - "trader_id": trader["trader_id"], - "trader_name": trader["trader_name"], - "ai_model": trader["ai_model"], - "exchange": trader["exchange"], - "is_running": trader["is_running"], - "total_equity": trader["total_equity"], - "total_pnl": trader["total_pnl"], - "total_pnl_pct": trader["total_pnl_pct"], - "position_count": trader["position_count"], - "margin_used_pct": trader["margin_used_pct"], + "trader_id": trader["trader_id"], + "trader_name": trader["trader_name"], + "ai_model": trader["ai_model"], + "exchange": trader["exchange"], + "is_running": trader["is_running"], + "total_equity": trader["total_equity"], + "total_pnl": trader["total_pnl"], + "total_pnl_pct": trader["total_pnl_pct"], + "position_count": trader["position_count"], + "margin_used_pct": trader["margin_used_pct"], + "system_prompt_template": trader["system_prompt_template"], }) } diff --git a/api/server_test.go b/api/server_test.go index 6b6b83c100..f59817bd32 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -225,3 +225,81 @@ func TestUpdateTraderRequest_CompleteFields(t *testing.T) { t.Errorf("SystemPromptTemplate mismatch: expected %q, got %q", "nof1", req.SystemPromptTemplate) } } + +// TestTraderListResponse_SystemPromptTemplate 测试 handleTraderList API 返回的 trader 对象是否包含 system_prompt_template 字段 +func TestTraderListResponse_SystemPromptTemplate(t *testing.T) { + // 模拟 handleTraderList 中的 trader 对象构造 + trader := &config.TraderRecord{ + ID: "trader-001", + UserID: "user-1", + Name: "My Trader", + AIModelID: "gpt-4", + ExchangeID: "binance", + InitialBalance: 5000, + SystemPromptTemplate: "nof1", + IsRunning: true, + } + + // 构造 API 响应对象(与 api/server.go 中的逻辑一致) + response := map[string]interface{}{ + "trader_id": trader.ID, + "trader_name": trader.Name, + "ai_model": trader.AIModelID, + "exchange_id": trader.ExchangeID, + "is_running": trader.IsRunning, + "initial_balance": trader.InitialBalance, + "system_prompt_template": trader.SystemPromptTemplate, + } + + // ✅ 验证 system_prompt_template 字段存在 + if _, exists := response["system_prompt_template"]; !exists { + t.Errorf("Trader list response is missing 'system_prompt_template' field") + } + + // ✅ 验证 system_prompt_template 值正确 + if response["system_prompt_template"] != "nof1" { + t.Errorf("Expected system_prompt_template='nof1', got %v", response["system_prompt_template"]) + } +} + +// TestPublicTraderListResponse_SystemPromptTemplate 测试 handlePublicTraderList API 返回的 trader 对象是否包含 system_prompt_template 字段 +func TestPublicTraderListResponse_SystemPromptTemplate(t *testing.T) { + // 模拟 getConcurrentTraderData 返回的 trader 数据 + traderData := map[string]interface{}{ + "trader_id": "trader-002", + "trader_name": "Public Trader", + "ai_model": "claude", + "exchange": "binance", + "total_equity": 10000.0, + "total_pnl": 500.0, + "total_pnl_pct": 5.0, + "position_count": 3, + "margin_used_pct": 25.0, + "is_running": true, + "system_prompt_template": "default", + } + + // 构造 API 响应对象(与 api/server.go handlePublicTraderList 中的逻辑一致) + response := map[string]interface{}{ + "trader_id": traderData["trader_id"], + "trader_name": traderData["trader_name"], + "ai_model": traderData["ai_model"], + "exchange": traderData["exchange"], + "total_equity": traderData["total_equity"], + "total_pnl": traderData["total_pnl"], + "total_pnl_pct": traderData["total_pnl_pct"], + "position_count": traderData["position_count"], + "margin_used_pct": traderData["margin_used_pct"], + "system_prompt_template": traderData["system_prompt_template"], + } + + // ✅ 验证 system_prompt_template 字段存在 + if _, exists := response["system_prompt_template"]; !exists { + t.Errorf("Public trader list response is missing 'system_prompt_template' field") + } + + // ✅ 验证 system_prompt_template 值正确 + if response["system_prompt_template"] != "default" { + t.Errorf("Expected system_prompt_template='default', got %v", response["system_prompt_template"]) + } +} diff --git a/api/traderid_test.go b/api/traderid_test.go new file mode 100644 index 0000000000..52b581c8a8 --- /dev/null +++ b/api/traderid_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "fmt" + "strings" + "testing" + + "github.com/google/uuid" +) + +// TestTraderIDUniqueness 测试 traderID 的唯一性(修复 Issue #893) +// 验证即使在相同的 exchange 和 AI model 下,也能生成唯一的 traderID +func TestTraderIDUniqueness(t *testing.T) { + exchangeID := "binance" + aiModelID := "gpt-4" + + // 模拟同时创建 100 个 trader(相同参数) + traderIDs := make(map[string]bool) + const numTraders = 100 + + for i := 0; i < numTraders; i++ { + // 模拟 api/server.go:497 的 traderID 生成逻辑 + traderID := generateTraderID(exchangeID, aiModelID) + + // ✅ 检查是否重复 + if traderIDs[traderID] { + t.Errorf("Duplicate traderID detected: %s", traderID) + } + traderIDs[traderID] = true + + // ✅ 验证格式:应该是 "exchange_model_uuid" + if !isValidTraderIDFormat(traderID, exchangeID, aiModelID) { + t.Errorf("Invalid traderID format: %s", traderID) + } + } + + // ✅ 验证生成了预期数量的唯一 ID + if len(traderIDs) != numTraders { + t.Errorf("Expected %d unique traderIDs, got %d", numTraders, len(traderIDs)) + } +} + +// generateTraderID 辅助函数,模拟 api/server.go 中的 traderID 生成逻辑 +func generateTraderID(exchangeID, aiModelID string) string { + return fmt.Sprintf("%s_%s_%s", exchangeID, aiModelID, uuid.New().String()) +} + +// isValidTraderIDFormat 验证 traderID 格式是否符合预期 +func isValidTraderIDFormat(traderID, expectedExchange, expectedModel string) bool { + // 格式:exchange_model_uuid + // 例如:binance_gpt-4_a1b2c3d4-e5f6-7890-abcd-ef1234567890 + parts := strings.Split(traderID, "_") + if len(parts) < 3 { + return false + } + + // 验证前缀 + if parts[0] != expectedExchange { + return false + } + + // AI model 可能包含连字符(如 gpt-4),所以需要重组 + // 最后一部分应该是 UUID + uuidPart := parts[len(parts)-1] + + // 验证 UUID 格式(36 个字符,包含 4 个连字符) + _, err := uuid.Parse(uuidPart) + return err == nil +} + +// TestTraderIDFormat 测试 traderID 格式的正确性 +func TestTraderIDFormat(t *testing.T) { + tests := []struct { + name string + exchangeID string + aiModelID string + }{ + {"Binance + GPT-4", "binance", "gpt-4"}, + {"Hyperliquid + Claude", "hyperliquid", "claude-3"}, + {"OKX + Qwen", "okx", "qwen-2.5"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + traderID := generateTraderID(tt.exchangeID, tt.aiModelID) + + // ✅ 验证包含正确的前缀 + if !strings.HasPrefix(traderID, tt.exchangeID+"_"+tt.aiModelID+"_") { + t.Errorf("traderID does not have correct prefix. Got: %s", traderID) + } + + // ✅ 验证格式有效 + if !isValidTraderIDFormat(traderID, tt.exchangeID, tt.aiModelID) { + t.Errorf("Invalid traderID format: %s", traderID) + } + + // ✅ 验证长度合理(至少应该有 exchange + model + "_" + UUID(36) 的长度) + minLength := len(tt.exchangeID) + len(tt.aiModelID) + 2 + 36 // 2个下划线 + 36字符UUID + if len(traderID) < minLength { + t.Errorf("traderID too short: expected at least %d chars, got %d", minLength, len(traderID)) + } + }) + } +} + +// TestTraderIDNoCollision 测试在高并发场景下不会产生碰撞 +func TestTraderIDNoCollision(t *testing.T) { + const iterations = 1000 + uniqueIDs := make(map[string]bool, iterations) + + // 模拟高并发场景 + for i := 0; i < iterations; i++ { + id := generateTraderID("binance", "gpt-4") + if uniqueIDs[id] { + t.Fatalf("Collision detected after %d iterations: %s", i+1, id) + } + uniqueIDs[id] = true + } + + if len(uniqueIDs) != iterations { + t.Errorf("Expected %d unique IDs, got %d", iterations, len(uniqueIDs)) + } +} diff --git a/config.json.example b/config.json.example index 1c7406d00d..331fbc5471 100644 --- a/config.json.example +++ b/config.json.example @@ -1,5 +1,6 @@ { "beta_mode": false, + "registration_enabled": true, "leverage": { "btc_eth_leverage": 5, "altcoin_leverage": 5 diff --git a/config/database.go b/config/database.go index 5977ae3cdd..b275720c90 100644 --- a/config/database.go +++ b/config/database.go @@ -324,6 +324,7 @@ func (d *Database) initDefaultData() error { "btc_eth_leverage": "5", // BTC/ETH杠杆倍数 "altcoin_leverage": "5", // 山寨币杠杆倍数 "jwt_secret": "", // JWT密钥,默认为空,由config.json或系统生成 + "registration_enabled": "true", // 默认允许注册 } for key, value := range systemConfigs { @@ -954,12 +955,12 @@ func (d *Database) UpdateTraderStatus(userID, id string, isRunning bool) error { func (d *Database) UpdateTrader(trader *TraderRecord) error { _, err := d.db.Exec(` UPDATE traders SET - name = ?, ai_model_id = ?, exchange_id = ?, initial_balance = ?, + name = ?, ai_model_id = ?, exchange_id = ?, scan_interval_minutes = ?, btc_eth_leverage = ?, altcoin_leverage = ?, trading_symbols = ?, custom_prompt = ?, override_base_prompt = ?, system_prompt_template = ?, is_cross_margin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? - `, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, + `, trader.Name, trader.AIModelID, trader.ExchangeID, trader.ScanIntervalMinutes, trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate, trader.IsCrossMargin, trader.ID, trader.UserID) @@ -972,7 +973,8 @@ func (d *Database) UpdateTraderCustomPrompt(userID, id string, customPrompt stri return err } -// UpdateTraderInitialBalance 更新交易员初始余额(用于自动同步交易所实际余额) +// UpdateTraderInitialBalance 更新交易员初始余额(仅支持手动更新) +// ⚠️ 注意:系统不会自动调用此方法,仅供用户在充值/提现后手动同步使用 func (d *Database) UpdateTraderInitialBalance(userID, id string, newBalance float64) error { _, err := d.db.Exec(`UPDATE traders SET initial_balance = ? WHERE id = ? AND user_id = ?`, newBalance, id, userID) return err diff --git a/decision/engine.go b/decision/engine.go index bef863dfe9..ea0f4fbcb1 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -48,6 +48,7 @@ type PositionInfo struct { type AccountInfo struct { TotalEquity float64 `json:"total_equity"` // 账户净值 AvailableBalance float64 `json:"available_balance"` // 可用余额 + UnrealizedPnL float64 `json:"unrealized_pnl"` // 未实现盈亏 TotalPnL float64 `json:"total_pnl"` // 总盈亏 TotalPnLPct float64 `json:"total_pnl_pct"` // 总盈亏百分比 MarginUsed float64 `json:"margin_used"` // 已用保证金 @@ -120,12 +121,12 @@ type FullDecision struct { } // GetFullDecision 获取AI的完整交易决策(批量分析所有币种和持仓) -func GetFullDecision(ctx *Context, mcpClient *mcp.Client) (*FullDecision, error) { +func GetFullDecision(ctx *Context, mcpClient mcp.AIClient) (*FullDecision, error) { return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false, "") } // GetFullDecisionWithCustomPrompt 获取AI的完整交易决策(支持自定义prompt和模板选择) -func GetFullDecisionWithCustomPrompt(ctx *Context, mcpClient *mcp.Client, customPrompt string, overrideBase bool, templateName string) (*FullDecision, error) { +func GetFullDecisionWithCustomPrompt(ctx *Context, mcpClient mcp.AIClient, customPrompt string, overrideBase bool, templateName string) (*FullDecision, error) { // 1. 为所有币种获取市场数据 if err := fetchMarketDataForContext(ctx); err != nil { return nil, fmt.Errorf("获取市场数据失败: %w", err) @@ -345,13 +346,17 @@ func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage in sb.WriteString("\n") sb.WriteString("```json\n[\n") sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300, \"reasoning\": \"下跌趋势+MACD死叉\"},\n", btcEthLeverage, accountEquity*5)) + sb.WriteString(" {\"symbol\": \"SOLUSDT\", \"action\": \"update_stop_loss\", \"new_stop_loss\": 155, \"reasoning\": \"移动止损至保本位\"},\n") sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\", \"reasoning\": \"止盈离场\"}\n") sb.WriteString("]\n```\n") sb.WriteString("\n\n") sb.WriteString("## 字段说明\n\n") - sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n") + sb.WriteString("- `action`: open_long | open_short | close_long | close_short | update_stop_loss | update_take_profit | partial_close | hold | wait\n") sb.WriteString("- `confidence`: 0-100(开仓建议≥75)\n") - sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd, reasoning\n\n") + sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd, reasoning\n") + sb.WriteString("- update_stop_loss 时必填: new_stop_loss (注意是 new_stop_loss,不是 stop_loss)\n") + sb.WriteString("- update_take_profit 时必填: new_take_profit (注意是 new_take_profit,不是 take_profit)\n") + sb.WriteString("- partial_close 时必填: close_percentage (0-100)\n\n") return sb.String() } diff --git a/decision/prompt_test.go b/decision/prompt_test.go new file mode 100644 index 0000000000..f8fc1b3b34 --- /dev/null +++ b/decision/prompt_test.go @@ -0,0 +1,50 @@ +package decision + +import ( + "strings" + "testing" +) + +// TestBuildSystemPrompt_ContainsAllValidActions 测试 prompt 是否包含所有有效的 action +func TestBuildSystemPrompt_ContainsAllValidActions(t *testing.T) { + // 这是系统中定义的所有有效 action(来自 validateDecision) + validActions := []string{ + "open_long", + "open_short", + "close_long", + "close_short", + "update_stop_loss", + "update_take_profit", + "partial_close", + "hold", + "wait", + } + + // 构建 prompt + prompt := buildSystemPrompt(1000.0, 10, 5, "default") + + // 验证每个有效 action 都在 prompt 中出现 + for _, action := range validActions { + if !strings.Contains(prompt, action) { + t.Errorf("Prompt 缺少有效的 action: %s", action) + } + } +} + +// TestBuildSystemPrompt_ActionListCompleteness 测试 action 列表的完整性 +func TestBuildSystemPrompt_ActionListCompleteness(t *testing.T) { + prompt := buildSystemPrompt(1000.0, 10, 5, "default") + + // 检查是否包含关键的缺失 action + missingActions := []string{ + "update_stop_loss", + "update_take_profit", + "partial_close", + } + + for _, action := range missingActions { + if !strings.Contains(prompt, action) { + t.Errorf("Prompt 缺少关键 action: %s(这会导致 AI 返回无效决策)", action) + } + } +} diff --git a/decision/validate_test.go b/decision/validate_test.go index faac4fe5af..d7e89229db 100644 --- a/decision/validate_test.go +++ b/decision/validate_test.go @@ -98,3 +98,198 @@ func TestLeverageFallback(t *testing.T) { }) } } + +// TestUpdateStopLossValidation 测试 update_stop_loss 动作的字段验证 +func TestUpdateStopLossValidation(t *testing.T) { + tests := []struct { + name string + decision Decision + wantError bool + errorMsg string + }{ + { + name: "正确使用new_stop_loss字段", + decision: Decision{ + Symbol: "SOLUSDT", + Action: "update_stop_loss", + NewStopLoss: 155.5, + Reasoning: "移动止损至保本位", + }, + wantError: false, + }, + { + name: "new_stop_loss为0应该报错", + decision: Decision{ + Symbol: "SOLUSDT", + Action: "update_stop_loss", + NewStopLoss: 0, + Reasoning: "测试错误情况", + }, + wantError: true, + errorMsg: "新止损价格必须大于0", + }, + { + name: "new_stop_loss为负数应该报错", + decision: Decision{ + Symbol: "SOLUSDT", + Action: "update_stop_loss", + NewStopLoss: -100, + Reasoning: "测试错误情况", + }, + wantError: true, + errorMsg: "新止损价格必须大于0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDecision(&tt.decision, 1000.0, 10, 5) + + if (err != nil) != tt.wantError { + t.Errorf("validateDecision() error = %v, wantError %v", err, tt.wantError) + return + } + + if tt.wantError && err != nil { + if tt.errorMsg != "" && !contains(err.Error(), tt.errorMsg) { + t.Errorf("错误信息不匹配: got %q, want to contain %q", err.Error(), tt.errorMsg) + } + } + }) + } +} + +// TestUpdateTakeProfitValidation 测试 update_take_profit 动作的字段验证 +func TestUpdateTakeProfitValidation(t *testing.T) { + tests := []struct { + name string + decision Decision + wantError bool + errorMsg string + }{ + { + name: "正确使用new_take_profit字段", + decision: Decision{ + Symbol: "BTCUSDT", + Action: "update_take_profit", + NewTakeProfit: 98000, + Reasoning: "调整止盈至关键阻力位", + }, + wantError: false, + }, + { + name: "new_take_profit为0应该报错", + decision: Decision{ + Symbol: "BTCUSDT", + Action: "update_take_profit", + NewTakeProfit: 0, + Reasoning: "测试错误情况", + }, + wantError: true, + errorMsg: "新止盈价格必须大于0", + }, + { + name: "new_take_profit为负数应该报错", + decision: Decision{ + Symbol: "BTCUSDT", + Action: "update_take_profit", + NewTakeProfit: -1000, + Reasoning: "测试错误情况", + }, + wantError: true, + errorMsg: "新止盈价格必须大于0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDecision(&tt.decision, 1000.0, 10, 5) + + if (err != nil) != tt.wantError { + t.Errorf("validateDecision() error = %v, wantError %v", err, tt.wantError) + return + } + + if tt.wantError && err != nil { + if tt.errorMsg != "" && !contains(err.Error(), tt.errorMsg) { + t.Errorf("错误信息不匹配: got %q, want to contain %q", err.Error(), tt.errorMsg) + } + } + }) + } +} + +// TestPartialCloseValidation 测试 partial_close 动作的字段验证 +func TestPartialCloseValidation(t *testing.T) { + tests := []struct { + name string + decision Decision + wantError bool + errorMsg string + }{ + { + name: "正确使用close_percentage字段", + decision: Decision{ + Symbol: "ETHUSDT", + Action: "partial_close", + ClosePercentage: 50.0, + Reasoning: "锁定一半利润", + }, + wantError: false, + }, + { + name: "close_percentage为0应该报错", + decision: Decision{ + Symbol: "ETHUSDT", + Action: "partial_close", + ClosePercentage: 0, + Reasoning: "测试错误情况", + }, + wantError: true, + errorMsg: "平仓百分比必须在0-100之间", + }, + { + name: "close_percentage超过100应该报错", + decision: Decision{ + Symbol: "ETHUSDT", + Action: "partial_close", + ClosePercentage: 150, + Reasoning: "测试错误情况", + }, + wantError: true, + errorMsg: "平仓百分比必须在0-100之间", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDecision(&tt.decision, 1000.0, 10, 5) + + if (err != nil) != tt.wantError { + t.Errorf("validateDecision() error = %v, wantError %v", err, tt.wantError) + return + } + + if tt.wantError && err != nil { + if tt.errorMsg != "" && !contains(err.Error(), tt.errorMsg) { + t.Errorf("错误信息不匹配: got %q, want to contain %q", err.Error(), tt.errorMsg) + } + } + }) + } +} + +// contains 检查字符串是否包含子串(辅助函数) +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && stringContains(s, substr))) +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/docker-compose.yml b/docker-compose.yml index 38278f125c..0fe5099874 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: networks: - nofx-network healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"] interval: 30s timeout: 10s retries: 3 @@ -45,7 +45,7 @@ services: depends_on: - nofx healthcheck: - test: ["CMD", "curl", "-f", "http://127.0.0.1/health"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1/health"] interval: 30s timeout: 10s retries: 3 diff --git "a/docs/Git\345\267\245\344\275\234\346\265\201\350\247\204\350\214\203.md" "b/docs/Git\345\267\245\344\275\234\346\265\201\350\247\204\350\214\203.md" new file mode 100644 index 0000000000..583f2bfaa2 --- /dev/null +++ "b/docs/Git\345\267\245\344\275\234\346\265\201\350\247\204\350\214\203.md" @@ -0,0 +1,368 @@ +# Git 工作流规范 + +## 1. 总体目标 + +建立清晰、统一的 Git 工作流规范,解决团队协作中的冲突和代码丢失问题,同时有效管理开源和闭源两个版本的代码。 + +## 2. 分支管理策略 + +### 2.1 开源版本(GitHub Flow) + +目前高频更新时期,针对开源版本的 GitHub Flow 工作流: + +``` +dev 分支 + ↓ Developer 新建功能分支 +feat/hotfix 分支 + ↓ 开发、测试 + ↓ Pull Request + ↓ Review +dev (合并回dev分支) + ↓ 不定期完整测试 +main (合并回main分支) + ↓ 自动发布 +Release Tag +``` + +**分支规范:** +- `main`:唯一稳定分支 +- `dev`:高频更新分支 +- 功能开发分支:开发者自建分支,`feat/功能描述` 格式(从 `dev` 检出) +- 热修复分支:开发者自建分支,`hotfix/问题描述` 格式(从 `dev` 检出) + +**操作流程:** +1. 从 `dev` 分支创建功能/修复分支 +2. 在功能分支上进行开发 +3. 开发完成后提交 Pull Request +4. 代码审查通过后合并到 `dev` +5. 不定期完整测试`dev`稳定后 +6. 合并到`main`,自动触发 CI/CD 流程,发布 Release 并打 Tag + +**完整测试频率建议:** +- 至少每周一次 +- 重要功能发布后 +- 紧急修复后 + +### 2.2 闭源版本(简化版 Git Flow) + +针对闭源版本的 Git Flow 工作流: + +``` +test 分支(测试环境) + ↓ Developer 新建功能分支 +feat/hotfix 分支 + ↓ 开发、测试 + ↓ Pull Request + ↓ Review + ↓ 测试:测试人员拉取当前开发者的feature/hotfix分支,对feature/hotfix进行测试 +test-cp (合并进test-cp分支测试) + ↓ 完整测试 --> 失败回滚 +test (合并进test分支) + ↓ 合并test分支 + ↓ 更新test环境 +main (合并回main分支) + ↓ 自动发布 +Release Tag +``` + + +**分支规范:** +- `main`:生产环境稳定分支 +- `test`:测试环境分支(从 `main` 检出)- +- `test-cp`:测试者的临时测试环境分支(从 `test` 检出) +- 功能开发分支:开发者自建 `feat/功能描述` 格式(从 `test` 检出) +- 热修复分支:开发者自建 `hotfix/问题描述` 格式(从 `test` 检出) + +**操作流程:** + +**开发场景:** +``` +1. test → 创建 feat/f-support-sql-driver +2. feat/f-support-sql-driver → 开发完成后PR +3. 测试人员拉取当前开发者的feature/hotfix分支合并到 test-cp,对test-cp进行测试 +4. PR合并到 test +5. test测试验证通过 → 提交 PR 合并到 main +6. main → 完成自动触发 release 打 tag +``` + +## 3. 开源与闭源版本管理 + +### 3.1 仓库分离策略 + +``` +上游 (开源版本) 下游 (闭源版本) +[公有仓库] [私有仓库] + | | + | | + 开源核心代码 ←←←←←←←←←←← 商业版本完整代码 + | | + | | + 社区贡献 闭源功能 +``` + +**仓库结构:** +- 公有仓库:存放开源版本代码,所有人可访问 +- 私有仓库:存放商业版本完整代码(开源核心 + 闭源功能) + +### 3.2 代码流向规范 + +**单向流动原则:** +- 开源核心的所有改进(新功能、Bug修复)定期从公有仓库合并到私有仓库 +- 私有仓库中的闭源代码不流入公有仓库 + +### 3.3 实现方式 + +开源main分支发布后, 团队讨论后决定是否合并到闭源 + +## 4. 操作步骤详解 + +### 4.1 开源版本操作步骤 + +**新建功能开发:** +```bash +# 1. 切换到 dev 分支并更新 +git checkout dev +git pull origin dev + +# 2. 创建功能分支 +git checkout -b feat/功能描述 + +# 3. 开发并提交代码 +git add . +git commit -m "功能描述" + +# 4. 推送分支到远程仓库 +git push origin feat/功能描述 + +# 5. 在 GitHub 上创建 Pull Request +# 6. 代码审查通过后合并到dev +# 7. dev不定期完整测试后,并到main +``` + +### 4.2 闭源版本操作步骤 + +**新建功能开发:** +```bash +# 1. 切换到 test 分支并更新 +git checkout test +git pull origin test + +# 2. 创建功能分支 +git checkout -b feat/功能描述 + +# 3. 开发并提交代码 +git add . +git commit -m "功能描述" + +# 4. 推送分支到远程仓库 +git push origin feat/功能描述 + +# 5. 在 GitHub 上创建 Pull Request +# 6. 测试人员拉取当前开发者的feature/hotfix分支并到 test-cp,对test-cp进行测试 +# 7. 测试人员test-cp分支完整测试验证通过 +# 8. 测试通过后PR合并到 test分支,更新测试环境 +# 9. 提交 PR 合并到 main +# 10. main → 完成自动触发 release 打 tag +``` + +## 5. 定期同步开源版本到闭源版本 + +为了确保闭源版本能够及时获得开源版本的改进,团队需要定期讨论是否将公有仓库的更新同步到私有仓库 + +**同步频率建议:** +- 每周一次定期同步 +- 重要功能发布后立即同步 +- 紧急修复后立即同步 + +## 6. GitHub里程碑自动Release案例 + +可以使用 GitHub Actions 来实现基于里程碑的自动发布,创建 `.github/workflows/auto-release.yml` 文件: + +```yaml +name: Auto Release on Milestone Completion + +on: + milestone: + types: [closed] + workflow_dispatch: + inputs: + milestone_title: + description: 'Milestone title to release' + required: true + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Get milestone info + id: milestone + run: | + if [ "${{ github.event_name }}" = "milestone" ]; then + MILESTONE_TITLE="${{ github.event.milestone.title }}" + MILESTONE_DESC="${{ github.event.milestone.description }}" + else + MILESTONE_TITLE="${{ github.event.inputs.milestone_title }}" + MILESTONE_DESC="" + fi + + echo "milestone_title=$MILESTONE_TITLE" >> $GITHUB_OUTPUT + echo "milestone_description=$MILESTONE_DESC" >> $GITHUB_OUTPUT + + - name: Create release tag + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git tag -a "v${{ steps.milestone.outputs.milestone_title }}" -m "${{ steps.milestone.outputs.milestone_description }}" + git push origin "v${{ steps.milestone.outputs.milestone_title }}" + + - name: Create GitHub Release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: "v${{ steps.milestone.outputs.milestone_title }}" + release_name: "Release ${{ steps.milestone.outputs.milestone_title }}" + body: | + ## Release Notes + ${{ steps.milestone.outputs.milestone_description }} + + ### Features + - Feature 1 + - Feature 2 + + ### Bug Fixes + - Bug fix 1 + - Bug fix 2 + + draft: false + prerelease: false +``` + +**使用说明:** +1. 当里程碑完成并关闭时,自动触发发布流程 +2. 根据里程碑标题创建版本标签(如 v1.0.0) +3. 自动生成 GitHub Release 页面 +4. 支持手动触发特定里程碑的发布 + +## 7. 冲突解决策略? + +在将功能分支合并到 「test」/ 「dev」 分支时,可能会遇到代码冲突。为了解决这个问题并保持功能分支的独立性,推荐使用临时分支处理方式: + +```bash +# 1. 创建临时分支用于解决冲突 +git checkout -b test-tmp origin/test + +# 2. 将功能分支合并到临时分支 +git merge feat/功能分支名称 + +# 3. 解决冲突并提交 +# 在编辑器中解决冲突文件 +git add . +git commit -m "解决合并冲突" + +# 4. 推送临时分支到远程仓库 +git push origin test-tmp + +# 5. 创建从 test-tmp 到 test 的 Pull Request +# 6. 代码审查通过后合并到 test 分支 + +# 7. 清理临时分支 +git checkout test +git pull origin test +git branch -d test-tmp +git push origin --delete test-tmp +``` + +这种方式的优点: +- 保持功能分支的独立性,不会被 「test」 分支污染 「test」 分支可能包含其他正在测试的功能特性,避免相互影响 +- 冲突解决过程在临时分支中进行,更加安全 + +## 8. 当前策略可能存在的问题 +* 需要建立完整测试流程与feature/hotfix测试流程 +* 需要专业测试人员,或者培训现有开发做测试 +* 开源版本:dev完整测试的频率是否合适 +* 同步开源版本到闭源版本的频率是否合适 + +## 9. 分支模型合并流转图 + +角色说明 +- [开] 开发者(Developer) +- [评] 评审者(Reviewer) +- [维] 维护者(Maintainer): 暂由Tester兼任 +- [测] 测试人员或测试责任(可由 Maintainer/CI/QA 共同承担) +- [CI] CI/CD 系统(自动化构建/测试/发布) + +### 9.1 开源版本分支模型 +```text +Feature/Hotfix 分支 ───● 从Dev检出创建分支[开]──────────● 开发&自测[开]──────────● 提交PR→dev[开] + + +Dev 分支 ● 接收PR/Review[评]───● 合并到 dev[评]───● dev 不定期完整测试[测/CI][通过?]───● 发起 PR:dev→main[维] + + +Main 分支 ───● Review[评]───────────● 合并到 main[维]───────────● Release + Tag[CI]────────▶ +``` + +### 9.2 闭源版本分支模型 +``` +Feature/Hotfix 分支 ───● 从Test检出创建分支[开]───────────● 开发&自测[开]──────────● 提交PR→test[开] ● 修复并更新PR[开]────▶ + ╱ + ╱ 测试失败回路:test 未通过 → 返回修复并更新 PR) +Test 分支 ● 接收PR/Review[评]───● 合并到test-cp[维]───● 构建环境[CI]───● 回归测试[测][通过?]───● 合并进test,发起PR:test→main[维] + + +Main 分支 ● Review[评]──────────● 合并到 main[维]──────────● Release + Tag[CI]────────▶ + +``` +说明 +- 失败回路:若 test-cp 分支的完整测试未通过,返回到 Feature/Hotfix 的“修复并更新PR”,开发者继续提交修复,PR 自动更新,再次进入 Review→合并→测试流程。 + + +### 9.3 跨仓库代码流转图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 跨仓库代码流转模型 │ +└─────────────────────────────────────────────────────────────┘ + +[公有仓库 - 开源版本] [私有仓库 - 闭源版本] + │ │ + ▼ ▼ + 新功能开发 商业功能开发 + │ │ + ▼ ▼ + 代码审查 闭源功能集成 + │ │ + ▼ │ + 合并到main │ + │ │ + ▼ ▼ + 发布Release 定期同步开源更新 + │ │ + └─────────────────────────────────┘ + │ + ▼ + 版本兼容性测试 + +``` + +## 10. 最佳实践 + +1. **提交信息规范:** + - 使用清晰、简洁的提交信息 + - 遵循约定式提交格式:`(): ` + +2. **分支命名规范:** + - 功能分支:`feat/功能简述` + - 热修复分支:`hotfix/问题简述` + - 使用英文小写字母,单词间用连字符分隔 + +3. **代码审查:** + - 所有合并到主分支的代码必须经过代码审查 + - 审查人员应关注代码质量、功能正确性和安全性 + +4. **定期同步:** + - 定期将开源版本的改进同步到闭源版本 \ No newline at end of file diff --git a/docs/community/README.md b/docs/community/README.md index 8241c721bd..43de34af61 100644 --- a/docs/community/README.md +++ b/docs/community/README.md @@ -145,7 +145,7 @@ NOFX offers bounties for valuable contributions: ### Core Team - **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle) -- **Zack** - [@0x_ZackH](https://x.com/0x_ZackH) +- **Tintin** - [@Tintinx2021](https://x.com/Tintinx2021) **Contact:** - Technical questions → Telegram or GitHub Issues diff --git a/docs/i18n/ja/README.md b/docs/i18n/ja/README.md index 48455f1133..eca32114fb 100644 --- a/docs/i18n/ja/README.md +++ b/docs/i18n/ja/README.md @@ -8,8 +8,6 @@ **言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](../uk/README.md) | [Русский](../ru/README.md) | [日本語](README.md) -**公式Twitter:** [@nofx_ai](https://x.com/nofx_ai) - --- ## 📑 目次 @@ -55,15 +53,12 @@ ### 👥 コアチーム - **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle) -- **Zack** - [@0x_ZackH](https://x.com/0x_ZackH) ### 💼 シードラウンド募集中 現在、**シードラウンド**の資金調達を行っています。 -**投資に関するお問い合わせ**は、TwitterでTinkleまたはZackにDMをお送りください。 - -**パートナーシップおよび協業**については、公式Twitter [@nofx_ai](https://x.com/nofx_ai)にDMをお送りください。 +**投資に関するお問い合わせ**は、TwitterでTinkleにDMをお送りください。 --- @@ -350,9 +345,9 @@ docker compose up -d --build ``` **📖 詳細なDockerデプロイガイド、トラブルシューティング、高度な設定について:** -- **English**: See [DOCKER_DEPLOY.en.md](DOCKER_DEPLOY.en.md) -- **中文**: 查看 [DOCKER_DEPLOY.md](DOCKER_DEPLOY.md) -- **日本語**: [DOCKER_DEPLOY.ja.md](DOCKER_DEPLOY.ja.md)を参照 +- **English**: See [docker-deploy.md](../../getting-started/docker-deploy.en.md) +- **中文**: 查看 [docker-deploy.zh-CN.md](../../getting-started/docker-deploy.zh-CN.md) +- **日本語**: [docker-deploy.md](docker-deploy.md)を参照 --- diff --git a/DOCKER_DEPLOY.ja.md b/docs/i18n/ja/docker-deploy.md similarity index 100% rename from DOCKER_DEPLOY.ja.md rename to docs/i18n/ja/docker-deploy.md diff --git a/docs/i18n/ru/README.md b/docs/i18n/ru/README.md index f18c615537..33d0ce1116 100644 --- a/docs/i18n/ru/README.md +++ b/docs/i18n/ru/README.md @@ -8,8 +8,6 @@ **Языки / Languages:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](../uk/README.md) | [Русский](../ru/README.md) | [日本語](../ja/README.md) -**Официальный Twitter:** [@nofx_ai](https://x.com/nofx_ai) - **📚 Документация:** [Главная](../../README.md) | [Начало работы](../../getting-started/README.md) | [Журнал изменений](../../../CHANGELOG.zh-CN.md) | [Сообщество](../../community/README.md) --- @@ -49,15 +47,12 @@ ### 👥 Основная Команда - **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle) -- **Zack** - [@0x_ZackH](https://x.com/0x_ZackH) ### 💼 Открыт Посевной Раунд Финансирования Мы в настоящее время привлекаем **посевной раунд**. -**По вопросам инвестиций**, пишите в DM **Tinkle** или **Zack** в Twitter. - -**По вопросам партнерства и сотрудничества**, пишите в DM нашего официального Twitter [@nofx_ai](https://x.com/nofx_ai). +**По вопросам инвестиций**, пишите в DM **Tinkle** в Twitter. --- @@ -145,6 +140,79 @@ NOFX теперь поддерживает **три основные биржи* --- +## ✨ Текущая реализация - Рынки криптовалют + +NOFX в настоящее время **полностью работает на криптовалютных рынках** со следующими проверенными возможностями: + +### 🏆 Структура конкуренции Multi-Agent +- **Реальная битва AI-агентов**: Торговое соревнование моделей Qwen vs DeepSeek в реальном времени +- **Независимое управление счетами**: Каждый агент ведет отдельные журналы решений и метрики производительности +- **Сравнение производительности в реальном времени**: Отслеживание ROI в реальном времени, статистика винрейта, прямой анализ +- **Цикл самоэволюции**: Агенты учатся на исторической производительности, постоянно совершенствуясь + +### 🧠 AI Самообучение и Оптимизация +- **Система исторической обратной связи**: Анализ последних 20 торговых циклов перед каждым решением +- **Интеллектуальный анализ производительности**: + - Определяет лучшие/худшие активы по производительности + - Рассчитывает винрейт, коэффициент прибыли/убытка, среднюю прибыль в реальных USDT + - Избегает повторяющихся ошибок (паттерны последовательных убытков) + - Усиливает успешные стратегии (высокие паттерны винрейта) +- **Динамическая корректировка стратегии**: AI автономно регулирует торговый стиль на основе результатов бэктеста + +### 📊 Универсальный слой рыночных данных (Криптореализация) +- **Многотаймфреймовый анализ**: 3-минутные реальные данные + 4-часовые трендовые данные +- **Технические индикаторы**: EMA20/50, MACD, RSI(7/14), ATR +- **Отслеживание открытого интереса**: Анализ настроений рынка, денежных потоков +- **Фильтрация ликвидности**: Автоматическая фильтрация активов с низкой ликвидностью (<15M USD) +- **Поддержка кросс-биржевой торговли**: Binance, Hyperliquid, Aster DEX, единый интерфейс данных + +### 🎯 Единая система контроля рисков +- **Лимиты позиций**: Лимиты на актив (Альткоины≤1.5x капитал, BTC/ETH≤10x капитал) +- **Настраиваемое кредитное плечо**: Динамическая настройка от 1x до 50x на основе класса активов и типа счета +- **Управление маржой**: Общее использование≤90%, AI контролирует распределение +- **Принудительное соотношение риск/вознаграждение**: Обязательное соотношение стоп-лосс/тейк-профит ≥1:2 +- **Защита от наслоения**: Предотвращает дублирование позиций по одному активу/направлению + +### ⚡ Движок исполнения с низкой задержкой +- **Интеграция API множества бирж**: Binance Futures, Hyperliquid DEX, Aster DEX +- **Автоматическая обработка точности**: Интеллектуальное форматирование размера и цены ордера для каждой биржи +- **Приоритетное исполнение**: Сначала закрытие существующих позиций, затем открытие новых +- **Контроль проскальзывания**: Проверка перед исполнением, проверка точности в реальном времени + +### 🎨 Профессиональный интерфейс мониторинга +- **Dashboard в стиле Binance**: Профессиональная темная тема с обновлениями в реальном времени +- **Кривые капитала**: Историческое отслеживание стоимости счета (переключение USD/процент) +- **Графики производительности**: Сравнение ROI множества AI-агентов, обновления в реальном времени +- **Полные журналы решений**: Полное рассуждение цепочки мыслей (CoT) для каждой сделки +- **5-секундное обновление данных**: Обновления счета, позиций и P&L в реальном времени + +--- + +## 🔮 Дорожная карта - Расширение универсального рынка + +Миссия NOFX - стать **универсальной AI-торговой ОС для всех финансовых рынков**. + +**Видение:** Одна архитектура. Одна агентная структура. Все рынки. + +**Расширяемые рынки:** +- 📈 **Фондовые рынки**: Акции США, акции Китая, Гонконгские акции +- 📊 **Фьючерсные рынки**: Товарные фьючерсы, индексные фьючерсы +- 🎯 **Опционная торговля**: Опционы на акции, криптоопционы +- 💱 **Рынок Forex**: Основные валютные пары, кросс-пары + +**Предстоящие функции:** +- Расширенные возможности AI (GPT-4, Claude 3, Gemini Pro, гибкие шаблоны промптов) +- Интеграция новых бирж (OKX, Bybit, Lighter, EdgeX + CEX/Perp-DEX) +- Рефакторинг структуры проекта (высокая связность, низкая связанность, принципы SOLID) +- Улучшения безопасности (AES-256 шифрование API-ключей, RBAC, улучшенная 2FA) +- Улучшения UX (отзывчивость мобильных устройств, графики TradingView, система оповещений) + +📖 **Для детальной дорожной карты и графиков см.:** +- **English:** [Roadmap Documentation](../../roadmap/README.md) +- **中文:** [路线图文档](../../roadmap/README.zh-CN.md) + +--- + ## ✨ Основные возможности ### 🏆 Режим конкуренции нескольких AI diff --git a/docs/i18n/uk/README.md b/docs/i18n/uk/README.md index f8dc8c4d68..f4c4645440 100644 --- a/docs/i18n/uk/README.md +++ b/docs/i18n/uk/README.md @@ -8,8 +8,6 @@ **Мови / Languages:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](../uk/README.md) | [Русский](../ru/README.md) | [日本語](../ja/README.md) -**Офіційний Twitter:** [@nofx_ai](https://x.com/nofx_ai) - **📚 Документація:** [Головна](../../README.md) | [Початок роботи](../../getting-started/README.md) | [Спільнота](../../community/README.md) | [Журнал Змін](../../../CHANGELOG.md) --- @@ -50,15 +48,12 @@ ### 👥 Основна Команда - **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle) -- **Zack** - [@0x_ZackH](https://x.com/0x_ZackH) ### 💼 Відкритий Посівний Раунд Фінансування Ми зараз залучаємо **посівний раунд**. -**З питань інвестицій**, пишіть в DM **Tinkle** або **Zack** в Twitter. - -**З питань партнерства та співпраці**, пишіть в DM нашого офіційного Twitter [@nofx_ai](https://x.com/nofx_ai). +**З питань інвестицій**, пишіть в DM **Tinkle** в Twitter. --- @@ -146,6 +141,79 @@ NOFX тепер підтримує **три основні біржі**: Binance --- +## ✨ Поточна реалізація - Ринки криптовалют + +NOFX наразі **повністю працює на криптовалютних ринках** з наступними перевіреними можливостями: + +### 🏆 Структура конкуренції Multi-Agent +- **Реальна битва AI-агентів**: Торгове змагання моделей Qwen vs DeepSeek у реальному часі +- **Незалежне управління рахунками**: Кожен агент веде окремі журнали рішень та метрики продуктивності +- **Порівняння продуктивності в реальному часі**: Відстеження ROI в реальному часі, статистика вінрейту, прямий аналіз +- **Цикл самоеволюції**: Агенти вчаться на історичній продуктивності, постійно вдосконалюючись + +### 🧠 AI Самонавчання та Оптимізація +- **Система історичного зворотного зв'язку**: Аналіз останніх 20 торгових циклів перед кожним рішенням +- **Інтелектуальний аналіз продуктивності**: + - Визначає кращі/гірші активи за продуктивністю + - Розраховує вінрейт, коефіцієнт прибутку/збитку, середній прибуток у реальних USDT + - Уникає повторюваних помилок (патерни послідовних збитків) + - Посилює успішні стратегії (високі патерни вінрейту) +- **Динамічне коригування стратегії**: AI автономно регулює торговий стиль на основі результатів бектесту + +### 📊 Універсальний шар ринкових даних (Криптореалізація) +- **Багатотаймфреймовий аналіз**: 3-хвилинні реальні дані + 4-годинні трендові дані +- **Технічні індикатори**: EMA20/50, MACD, RSI(7/14), ATR +- **Відстеження відкритого інтересу**: Аналіз настроїв ринку, грошових потоків +- **Фільтрація ліквідності**: Автоматична фільтрація активів з низькою ліквідністю (<15M USD) +- **Підтримка крос-біржової торгівлі**: Binance, Hyperliquid, Aster DEX, єдиний інтерфейс даних + +### 🎯 Єдина система контролю ризиків +- **Ліміти позицій**: Ліміти на актив (Альткоїни≤1.5x капітал, BTC/ETH≤10x капітал) +- **Налаштоване кредитне плече**: Динамічне налаштування від 1x до 50x на основі класу активів та типу рахунку +- **Управління маржею**: Загальне використання≤90%, AI контролює розподіл +- **Примусове співвідношення ризик/винагорода**: Обов'язкове співвідношення стоп-лосс/тейк-профіт ≥1:2 +- **Захист від нашарування**: Запобігає дублюванню позицій по одному активу/напрямку + +### ⚡ Рушій виконання з низькою затримкою +- **Інтеграція API множини бірж**: Binance Futures, Hyperliquid DEX, Aster DEX +- **Автоматична обробка точності**: Інтелектуальне форматування розміру та ціни ордера для кожної біржі +- **Пріоритетне виконання**: Спочатку закриття існуючих позицій, потім відкриття нових +- **Контроль прослизання**: Перевірка перед виконанням, перевірка точності в реальному часі + +### 🎨 Професійний інтерфейс моніторингу +- **Dashboard у стилі Binance**: Професійна темна тема з оновленнями в реальному часі +- **Криві капіталу**: Історичне відстеження вартості рахунку (перемикання USD/відсоток) +- **Графіки продуктивності**: Порівняння ROI множини AI-агентів, оновлення в реальному часі +- **Повні журнали рішень**: Повне міркування ланцюга думок (CoT) для кожної угоди +- **5-секундне оновлення даних**: Оновлення рахунку, позицій та P&L у реальному часі + +--- + +## 🔮 Дорожня карта - Розширення універсального ринку + +Місія NOFX - стати **універсальною AI-торговою ОС для всіх фінансових ринків**. + +**Бачення:** Одна архітектура. Одна агентна структура. Всі ринки. + +**Розширювані ринки:** +- 📈 **Фондові ринки**: Акції США, акції Китаю, Гонконгські акції +- 📊 **Ф'ючерсні ринки**: Товарні ф'ючерси, індексні ф'ючерси +- 🎯 **Опціонна торгівля**: Опціони на акції, крипто-опціони +- 💱 **Ринок Forex**: Основні валютні пари, крос-пари + +**Майбутні функції:** +- Розширені можливості AI (GPT-4, Claude 3, Gemini Pro, гнучкі шаблони промптів) +- Інтеграція нових бірж (OKX, Bybit, Lighter, EdgeX + CEX/Perp-DEX) +- Рефакторинг структури проекту (висока зв'язність, низька зв'язаність, принципи SOLID) +- Поліпшення безпеки (AES-256 шифрування API-ключів, RBAC, покращена 2FA) +- Поліпшення UX (відгук мобільних пристроїв, графіки TradingView, система сповіщень) + +📖 **Для детальної дорожньої карти та графіків див.:** +- **English:** [Roadmap Documentation](../../roadmap/README.md) +- **中文:** [路线图文档](../../roadmap/README.zh-CN.md) + +--- + ## ✨ Основні можливості ### 🏆 Режим змагання кількох AI diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index f6915861e6..0d11bd288d 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -8,8 +8,6 @@ **语言 / Languages:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](../uk/README.md) | [Русский](../ru/README.md) | [日本語](../ja/README.md) -**官方推特:** [@nofx_ai](https://x.com/nofx_ai) - **📚 文档中心:** [文档首页](../../README.md) | [快速开始](../../getting-started/README.zh-CN.md) | [更新日志](../../../CHANGELOG.zh-CN.md) | [社区指南](../../community/README.md) --- @@ -57,15 +55,12 @@ ### 👥 核心团队 - **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle) -- **Zack** - [@0x_ZackH](https://x.com/0x_ZackH) ### 💼 种子轮融资进行中 我们正在进行**种子轮融资**。 -**投资咨询**,请通过 Twitter 私信联系 **Tinkle** 或 **Zack**。 - -**商务合作**,请私信官方推特 [@nofx_ai](https://x.com/nofx_ai)。 +**投资咨询**,请通过 Twitter 私信联系 **Tinkle**。 --- diff --git a/docs/pnl.md b/docs/pnl.md new file mode 100644 index 0000000000..8f1c57ca92 --- /dev/null +++ b/docs/pnl.md @@ -0,0 +1,299 @@ +# PNL计算重构方案 - 最终设计 + +## 📋 核心问题与答案 + +### 1. **Initial Balance(初始余额)** + +**定义:** 创建trader时的账户净值(Total Equity),作为所有PNL计算的基准 + +**设置时机:** +- ✅ **创建trader时自动获取** - 从交易所API获取当前的Total Equity +- ✅ **允许用户手动更新** - 充值/提现后可通过前端主动同步 + +**存储位置:** +- 数据库:`traders.initial_balance` 字段 + +**计算公式:** +``` +Initial Balance = Total Wallet Balance + Total Unrealized Profit + = 当前账户净值(创建时快照) +``` + +--- + +### 2. **Equity(账户净值)** + +**定义:** 账户的实时总价值 + +**计算公式:** +``` +Total Equity = Total Wallet Balance + Total Unrealized Profit +``` + +**数据来源:** 实时从交易所API获取 + +**说明:** +- `Total Wallet Balance`: 账户中的实际USDT余额(包括已实现盈亏) +- `Total Unrealized Profit`: 所有持仓的未实现盈亏总和 +- Equity会随着市场价格波动和持仓变化实时变化 + +--- + +### 3. **PNL(盈亏)** + +#### 3.1 Total PNL(总盈亏) + +**计算公式:** +``` +Total PNL = Current Equity - Initial Balance +Total PNL % = (Total PNL / Initial Balance) × 100% +``` + +**示例:** +``` +Initial Balance: 10,000 USDT (创建时) +Current Equity: 11,500 USDT (实时) +----------------------------------- +Total PNL: +1,500 USDT +Total PNL %: +15% +``` + +#### 3.2 Unrealized PNL(未实现盈亏) + +**定义:** 当前所有持仓的未实现盈亏总和 + +**来源:** 直接从交易所API获取 `totalUnrealizedProfit` + +#### 3.3 单个持仓的PNL% + +**计算公式:** +``` +Position PNL % = (Unrealized PnL / Margin Used) × 100% +``` + +其中:`Margin Used = Position Value / Leverage` + +--- + +## 🎯 最终实现方案 + +### 核心原则 + +| 原则 | 说明 | +|-----|------| +| ❌ **禁用自动同步** | 系统**不会**自动修改Initial Balance | +| ✅ **创建时自动获取** | 创建trader时从交易所获取真实equity | +| ✅ **允许手动更新** | 用户可通过前端主动同步(充值/提现后) | +| 🔒 **常规更新保护** | UpdateTrader方法**不允许**修改Initial Balance | + +--- + +## 🔧 实现细节 + +### 1. 创建Trader时自动获取Initial Balance + +**文件:** `api/server.go:handleCreateTrader()` + +**逻辑:** +```go +// 查询交易所余额 +balanceInfo, _ := tempTrader.GetBalance() + +// 提取钱包余额和未实现盈亏 +totalWalletBalance := balanceInfo["totalWalletBalance"].(float64) +totalUnrealizedProfit := balanceInfo["totalUnrealizedProfit"].(float64) + +// 计算Total Equity作为Initial Balance +initialEquity := totalWalletBalance + totalUnrealizedProfit + +// 存入数据库 +trader := &config.TraderRecord{ + InitialBalance: initialEquity, // 自动设置 + // ... 其他字段 +} +``` + +--- + +### 2. 禁用自动同步机制 + +**修改:** `trader/auto_trader.go:autoSyncBalanceIfNeeded()` + +**操作:** +- 函数重命名为 `autoSyncBalanceIfNeeded_DEPRECATED()` +- 在 `runCycle()` 中注释掉调用 + +**效果:** 系统运行过程中**不会**自动修改Initial Balance + +--- + +### 3. 保护UpdateTrader方法 + +**文件:** `config/database.go:UpdateTrader()` + +**修改:** 从SQL UPDATE语句中移除 `initial_balance` 字段 + +**效果:** 常规的配置更新操作**无法**修改Initial Balance + +--- + +### 4. 提供手动更新API + +**端点:** `POST /traders/:id` + +**实现:** `api/server.go:handleUpdateTrader()` + +**用途:** update trader, 包括Initial Balance基准值 + +**请求体:** +```json +{ + "initial_balance": 10000.0 +} +``` + +**流程:** +``` +1. 用户输入新的initial_balance值 +2. 更新数据库的initial_balance字段 +3. 重新加载trader到内存 +4. 返回更新前后的对比信息 +``` + +**特点:** +- ✅ 用户可以输入**任意值**,不限于交易所当前余额 +- ✅ 适用于充值/提现后重置基准 +- ✅ 也可用于手动校正或调整统计基准 + +--- + +## 📊 数据流设计 + +``` +┌─────────────────────────────────────────┐ +│ 1. 创建Trader │ +│ - 用户配置AI模型、交易所 │ +│ - 系统自动获取当前equity │ +│ → initial_balance = Total Equity │ +└──────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ 2. 运行期间 │ +│ - 系统不会自动修改initial_balance │ +│ - 实时计算: │ +│ current_equity = API获取 │ +│ total_pnl = current - initial │ +└──────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ 3. 充值/提现后 │ +│ - 用户点击"更新初始余额"按钮 │ +│ - 更新initial_balance │ +│ - PNL计算重新基于新的基准 │ +└─────────────────────────────────────────┘ +``` + +--- + +## 📝 字段定义总结 + +| 字段 | 定义 | 计算方式 | 存储位置 | 更新频率 | +|-----|------|---------|---------|---------| +| **Initial Balance** | 基准余额 | 创建/手动同步时获取equity | DB: traders.initial_balance | 创建时+手动 | +| **Current Equity** | 当前净值 | wallet + unrealized | 不存储(实时计算) | 实时 | +| **Total PNL** | 总盈亏 | current_equity - initial_balance | 不存储(实时计算) | 实时 | +| **Total PNL %** | 盈亏百分比 | (total_pnl / initial_balance) × 100 | 不存储(实时计算) | 实时 | + +--- + +## 🎮 用户操作场景 + +### 场景1:创建新的Trader +``` +用户操作:填写基本配置(不需要输入余额) +系统行为:自动从交易所获取当前equity,设置为initial_balance +结果:initial_balance = 当前账户净值 +``` + +### 场景2:正常交易运行 +``` +用户操作:无 +系统行为:实时计算PNL,不修改initial_balance +结果:PNL = 当前equity - initial_balance +``` + +### 场景3:充值后重新校准 +``` +用户操作:充值 → 输入新的Initial Balance(如:10000 + 5000 = 15000) +系统行为:更新initial_balance为15000 +结果:PNL统计基于新的基准15000计算 +``` + +### 场景4:提现后重新校准 +``` +用户操作:提现 → 输入新的Initial Balance(如:10000 - 2000 = 8000) +系统行为:更新initial_balance为8000 +结果:PNL统计基于新的基准8000计算 +``` + +### 场景5:手动调整统计基准 +``` +用户操作:想重新开始统计PNL → 输入当前账户净值作为新基准 +系统行为:更新initial_balance为用户输入的值 +结果:PNL统计重置,从新基准开始计算 +``` + +--- + +## ✅ 优势分析 + +1. **稳定性**:PNL基准不会自动变化,统计更可靠 +2. **灵活性**:用户可以在需要时主动校准 +3. **准确性**:Initial Balance基于真实equity,不是手动输入 +4. **可控性**:充值/提现后,用户可以重置PNL统计 + +--- + +## 🚀 前端需要做的改动 + +### 1. 创建Trader页面 +- ✅ 移除"初始资金"输入框 +- ✅ 添加说明:系统将自动获取您的账户净值 + +### 2. Trader详情页面 +- ✅ 添加"更新初始余额"按钮/表单 +- ✅ 弹窗/输入框:让用户输入新的Initial Balance值 +- ✅ 提示文案: + ``` + 当前初始余额: 10,000 USDT + 请输入新的初始余额(用于重新校准PNL统计) + ``` + + +### 4. 用户体验建议 +- 💡 可以在输入框旁边显示当前账户净值作为参考 +- 💡 充值/提现后,提示用户是否需要更新Initial Balance +- 💡 显示更新前后的对比信息,让用户确认 + +--- + +## 📖 关键代码位置 + +| 功能 | 文件 | 行号/函数 | +|-----|------|----------| +| 创建时自动获取equity | api/server.go | handleCreateTrader:540-625 | +| 禁用自动同步 | trader/auto_trader.go | autoSyncBalanceIfNeeded_DEPRECATED:291 | +| 保护UpdateTrader | config/database.go | UpdateTrader:954-969 | +| 手动同步API | api/server.go | handleSyncBalance:937-1050 | +| 手动同步数据库方法 | config/database.go | UpdateTraderInitialBalance:977-982 | + +--- + +## 🎯 总结 + +这个设计平衡了**稳定性**和**灵活性**: +- Initial Balance不会被系统自动修改,确保PNL统计的一致性 +- 用户拥有主动权,可以在充值/提现后重新校准 +- 创建时自动获取真实equity,避免手动输入错误 diff --git a/logger/decision_logger.go b/logger/decision_logger.go index 81ae52ef09..1886f51cea 100644 --- a/logger/decision_logger.go +++ b/logger/decision_logger.go @@ -36,6 +36,7 @@ type AccountSnapshot struct { TotalUnrealizedProfit float64 `json:"total_unrealized_profit"` PositionCount int `json:"position_count"` MarginUsedPct float64 `json:"margin_used_pct"` + InitialBalance float64 `json:"initial_balance"` // 记录当时的初始余额基准 } // PositionSnapshot 持仓快照 @@ -63,6 +64,22 @@ type DecisionAction struct { Error string `json:"error"` // 错误信息 } +// IDecisionLogger 决策日志记录器接口 +type IDecisionLogger interface { + // LogDecision 记录决策 + LogDecision(record *DecisionRecord) error + // GetLatestRecords 获取最近N条记录(按时间正序:从旧到新) + GetLatestRecords(n int) ([]*DecisionRecord, error) + // GetRecordByDate 获取指定日期的所有记录 + GetRecordByDate(date time.Time) ([]*DecisionRecord, error) + // CleanOldRecords 清理N天前的旧记录 + CleanOldRecords(days int) error + // GetStatistics 获取统计信息 + GetStatistics() (*Statistics, error) + // AnalyzePerformance 分析最近N个周期的交易表现 + AnalyzePerformance(lookbackCycles int) (*PerformanceAnalysis, error) +} + // DecisionLogger 决策日志记录器 type DecisionLogger struct { logDir string @@ -70,7 +87,7 @@ type DecisionLogger struct { } // NewDecisionLogger 创建决策日志记录器 -func NewDecisionLogger(logDir string) *DecisionLogger { +func NewDecisionLogger(logDir string) IDecisionLogger { if logDir == "" { logDir = "decision_logs" } diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 9e1e9fb705..f331f3e42a 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -590,48 +590,51 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [ case account := <-accountChan: // 成功获取账户信息 traderData = map[string]interface{}{ - "trader_id": trader.GetID(), - "trader_name": trader.GetName(), - "ai_model": trader.GetAIModel(), - "exchange": trader.GetExchange(), - "total_equity": account["total_equity"], - "total_pnl": account["total_pnl"], - "total_pnl_pct": account["total_pnl_pct"], - "position_count": account["position_count"], - "margin_used_pct": account["margin_used_pct"], - "is_running": status["is_running"], + "trader_id": trader.GetID(), + "trader_name": trader.GetName(), + "ai_model": trader.GetAIModel(), + "exchange": trader.GetExchange(), + "total_equity": account["total_equity"], + "total_pnl": account["total_pnl"], + "total_pnl_pct": account["total_pnl_pct"], + "position_count": account["position_count"], + "margin_used_pct": account["margin_used_pct"], + "is_running": status["is_running"], + "system_prompt_template": trader.GetSystemPromptTemplate(), } case err := <-errorChan: // 获取账户信息失败 log.Printf("⚠️ 获取交易员 %s 账户信息失败: %v", trader.GetID(), err) traderData = map[string]interface{}{ - "trader_id": trader.GetID(), - "trader_name": trader.GetName(), - "ai_model": trader.GetAIModel(), - "exchange": trader.GetExchange(), - "total_equity": 0.0, - "total_pnl": 0.0, - "total_pnl_pct": 0.0, - "position_count": 0, - "margin_used_pct": 0.0, - "is_running": status["is_running"], - "error": "账户数据获取失败", + "trader_id": trader.GetID(), + "trader_name": trader.GetName(), + "ai_model": trader.GetAIModel(), + "exchange": trader.GetExchange(), + "total_equity": 0.0, + "total_pnl": 0.0, + "total_pnl_pct": 0.0, + "position_count": 0, + "margin_used_pct": 0.0, + "is_running": status["is_running"], + "system_prompt_template": trader.GetSystemPromptTemplate(), + "error": "账户数据获取失败", } case <-ctx.Done(): // 超时 log.Printf("⏰ 获取交易员 %s 账户信息超时", trader.GetID()) traderData = map[string]interface{}{ - "trader_id": trader.GetID(), - "trader_name": trader.GetName(), - "ai_model": trader.GetAIModel(), - "exchange": trader.GetExchange(), - "total_equity": 0.0, - "total_pnl": 0.0, - "total_pnl_pct": 0.0, - "position_count": 0, - "margin_used_pct": 0.0, - "is_running": status["is_running"], - "error": "获取超时", + "trader_id": trader.GetID(), + "trader_name": trader.GetName(), + "ai_model": trader.GetAIModel(), + "exchange": trader.GetExchange(), + "total_equity": 0.0, + "total_pnl": 0.0, + "total_pnl_pct": 0.0, + "position_count": 0, + "margin_used_pct": 0.0, + "is_running": status["is_running"], + "system_prompt_template": trader.GetSystemPromptTemplate(), + "error": "获取超时", } } @@ -1086,3 +1089,15 @@ func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiMode log.Printf("✓ Trader '%s' (%s + %s) 已为用户加载到内存", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) return nil } + +// RemoveTrader 从内存中移除指定的trader(不影响数据库) +// 用于更新trader配置时强制重新加载 +func (tm *TraderManager) RemoveTrader(traderID string) { + tm.mu.Lock() + defer tm.mu.Unlock() + + if _, exists := tm.traders[traderID]; exists { + delete(tm.traders, traderID) + log.Printf("✓ Trader %s 已从内存中移除", traderID) + } +} diff --git a/manager/trader_manager_test.go b/manager/trader_manager_test.go new file mode 100644 index 0000000000..c5344d959a --- /dev/null +++ b/manager/trader_manager_test.go @@ -0,0 +1,87 @@ +package manager + +import ( + "testing" +) + +// TestRemoveTrader 测试从内存中移除trader +func TestRemoveTrader(t *testing.T) { + tm := NewTraderManager() + + // 创建一个模拟的 trader 并添加到 map + traderID := "test-trader-123" + tm.traders[traderID] = nil // 使用 nil 作为占位符,实际测试中只需验证删除逻辑 + + // 验证 trader 存在 + if _, exists := tm.traders[traderID]; !exists { + t.Fatal("trader 应该存在于 map 中") + } + + // 调用 RemoveTrader + tm.RemoveTrader(traderID) + + // 验证 trader 已被移除 + if _, exists := tm.traders[traderID]; exists { + t.Error("trader 应该已从 map 中移除") + } +} + +// TestRemoveTrader_NonExistent 测试移除不存在的trader不会报错 +func TestRemoveTrader_NonExistent(t *testing.T) { + tm := NewTraderManager() + + // 尝试移除不存在的 trader,不应该 panic + defer func() { + if r := recover(); r != nil { + t.Errorf("移除不存在的 trader 不应该 panic: %v", r) + } + }() + + tm.RemoveTrader("non-existent-trader") +} + +// TestRemoveTrader_Concurrent 测试并发移除trader的安全性 +func TestRemoveTrader_Concurrent(t *testing.T) { + tm := NewTraderManager() + traderID := "test-trader-concurrent" + + // 添加 trader + tm.traders[traderID] = nil + + // 并发调用 RemoveTrader + done := make(chan bool, 10) + for i := 0; i < 10; i++ { + go func() { + tm.RemoveTrader(traderID) + done <- true + }() + } + + // 等待所有 goroutine 完成 + for i := 0; i < 10; i++ { + <-done + } + + // 验证 trader 已被移除 + if _, exists := tm.traders[traderID]; exists { + t.Error("trader 应该已从 map 中移除") + } +} + +// TestGetTrader_AfterRemove 测试移除后获取trader返回错误 +func TestGetTrader_AfterRemove(t *testing.T) { + tm := NewTraderManager() + traderID := "test-trader-get" + + // 添加 trader + tm.traders[traderID] = nil + + // 移除 trader + tm.RemoveTrader(traderID) + + // 尝试获取已移除的 trader + _, err := tm.GetTrader(traderID) + if err == nil { + t.Error("获取已移除的 trader 应该返回错误") + } +} diff --git a/market/monitor.go b/market/monitor.go index 340613ac89..717c96fed1 100644 --- a/market/monitor.go +++ b/market/monitor.go @@ -232,26 +232,26 @@ func (m *WSMonitor) processKlineUpdate(symbol string, wsData KlineWSData, _time klineDataMap.Store(symbol, klines) } -func (m *WSMonitor) GetCurrentKlines(symbol string, _time string) ([]Kline, error) { +func (m *WSMonitor) GetCurrentKlines(symbol string, duration string) ([]Kline, error) { // 对每一个进来的symbol检测是否存在内类 是否的话就订阅它 - value, exists := m.getKlineDataMap(_time).Load(symbol) + value, exists := m.getKlineDataMap(duration).Load(symbol) if !exists { // 如果Ws数据未初始化完成时,单独使用api获取 - 兼容性代码 (防止在未初始化完成是,已经有交易员运行) apiClient := NewAPIClient() - klines, err := apiClient.GetKlines(symbol, _time, 100) + klines, err := apiClient.GetKlines(symbol, duration, 100) if err != nil { - return nil, fmt.Errorf("获取%v分钟K线失败: %v", _time, err) + return nil, fmt.Errorf("获取%v分钟K线失败: %v", duration, err) } // 动态缓存进缓存 - m.getKlineDataMap(_time).Store(strings.ToUpper(symbol), klines) + m.getKlineDataMap(duration).Store(strings.ToUpper(symbol), klines) // 订阅 WebSocket 流 - subStr := m.subscribeSymbol(symbol, _time) + subStr := m.subscribeSymbol(symbol, duration) subErr := m.combinedClient.subscribeStreams(subStr) log.Printf("动态订阅流: %v", subStr) if subErr != nil { - log.Printf("警告: 动态订阅%v分钟K线失败: %v (使用API数据)", _time, subErr) + log.Printf("警告: 动态订阅%v分钟K线失败: %v (使用API数据)", duration, subErr) } // ✅ FIX: 返回深拷贝而非引用 diff --git a/mcp/client.go b/mcp/client.go index 0f78553439..ab34f4020c 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -5,108 +5,115 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" - "os" - "strconv" "strings" "time" ) -// Provider AI提供商类型 -type Provider string - const ( - ProviderDeepSeek Provider = "deepseek" - ProviderQwen Provider = "qwen" - ProviderCustom Provider = "custom" + ProviderCustom = "custom" + + MCPClientTemperature = 0.5 +) + +var ( + DefaultTimeout = 120 * time.Second + + MaxRetryTimes = 3 + + retryableErrors = []string{ + "EOF", + "timeout", + "connection reset", + "connection refused", + "temporary failure", + "no such host", + "stream error", // HTTP/2 stream 错误 + "INTERNAL_ERROR", // 服务端内部错误 + } ) // Client AI API配置 type Client struct { - Provider Provider + Provider string APIKey string BaseURL string Model string - Timeout time.Duration UseFullURL bool // 是否使用完整URL(不添加/chat/completions) MaxTokens int // AI响应的最大token数 -} -func New() *Client { - // 从环境变量读取 MaxTokens,默认 2000 - maxTokens := 2000 - if envMaxTokens := os.Getenv("AI_MAX_TOKENS"); envMaxTokens != "" { - if parsed, err := strconv.Atoi(envMaxTokens); err == nil && parsed > 0 { - maxTokens = parsed - log.Printf("🔧 [MCP] 使用环境变量 AI_MAX_TOKENS: %d", maxTokens) - } else { - log.Printf("⚠️ [MCP] 环境变量 AI_MAX_TOKENS 无效 (%s),使用默认值: %d", envMaxTokens, maxTokens) - } - } + httpClient *http.Client + logger Logger // 日志器(可替换) + config *Config // 配置对象(保存所有配置) - // 默认配置 - return &Client{ - Provider: ProviderDeepSeek, - BaseURL: "https://api.deepseek.com/v1", - Model: "deepseek-chat", - Timeout: 120 * time.Second, // 增加到120秒,因为AI需要分析大量数据 - MaxTokens: maxTokens, - } + // hooks 用于实现动态分派(多态) + // 当 DeepSeekClient 嵌入 Client 时,hooks 指向 DeepSeekClient + // 这样 call() 中调用的方法会自动分派到子类重写的版本 + hooks clientHooks } -// SetDeepSeekAPIKey 设置DeepSeek API密钥 -// customURL 为空时使用默认URL,customModel 为空时使用默认模型 -func (client *Client) SetDeepSeekAPIKey(apiKey string, customURL string, customModel string) { - client.Provider = ProviderDeepSeek - client.APIKey = apiKey - if customURL != "" { - client.BaseURL = customURL - log.Printf("🔧 [MCP] DeepSeek 使用自定义 BaseURL: %s", customURL) - } else { - client.BaseURL = "https://api.deepseek.com/v1" - log.Printf("🔧 [MCP] DeepSeek 使用默认 BaseURL: %s", client.BaseURL) - } - if customModel != "" { - client.Model = customModel - log.Printf("🔧 [MCP] DeepSeek 使用自定义 Model: %s", customModel) - } else { - client.Model = "deepseek-chat" - log.Printf("🔧 [MCP] DeepSeek 使用默认 Model: %s", client.Model) - } - // 打印 API Key 的前后各4位用于验证 - if len(apiKey) > 8 { - log.Printf("🔧 [MCP] DeepSeek API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:]) - } +// New 创建默认客户端(向前兼容) +// +// Deprecated: 推荐使用 NewClient(...opts) 以获得更好的灵活性 +func New() AIClient { + return NewClient() } -// SetQwenAPIKey 设置阿里云Qwen API密钥 -// customURL 为空时使用默认URL,customModel 为空时使用默认模型 -func (client *Client) SetQwenAPIKey(apiKey string, customURL string, customModel string) { - client.Provider = ProviderQwen - client.APIKey = apiKey - if customURL != "" { - client.BaseURL = customURL - log.Printf("🔧 [MCP] Qwen 使用自定义 BaseURL: %s", customURL) - } else { - client.BaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1" - log.Printf("🔧 [MCP] Qwen 使用默认 BaseURL: %s", client.BaseURL) +// NewClient 创建客户端(支持选项模式) +// +// 使用示例: +// // 基础用法(向前兼容) +// client := mcp.NewClient() +// +// // 自定义日志 +// client := mcp.NewClient(mcp.WithLogger(customLogger)) +// +// // 自定义超时 +// client := mcp.NewClient(mcp.WithTimeout(60*time.Second)) +// +// // 组合多个选项 +// client := mcp.NewClient( +// mcp.WithDeepSeekConfig("sk-xxx"), +// mcp.WithLogger(customLogger), +// mcp.WithTimeout(60*time.Second), +// ) +func NewClient(opts ...ClientOption) AIClient { + // 1. 创建默认配置 + cfg := DefaultConfig() + + // 2. 应用用户选项 + for _, opt := range opts { + opt(cfg) } - if customModel != "" { - client.Model = customModel - log.Printf("🔧 [MCP] Qwen 使用自定义 Model: %s", customModel) - } else { - client.Model = "qwen3-max" - log.Printf("🔧 [MCP] Qwen 使用默认 Model: %s", client.Model) + + // 3. 创建客户端实例 + client := &Client{ + Provider: cfg.Provider, + APIKey: cfg.APIKey, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + MaxTokens: cfg.MaxTokens, + UseFullURL: cfg.UseFullURL, + httpClient: cfg.HTTPClient, + logger: cfg.Logger, + config: cfg, } - // 打印 API Key 的前后各4位用于验证 - if len(apiKey) > 8 { - log.Printf("🔧 [MCP] Qwen API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:]) + + // 4. 设置默认 Provider(如果未设置) + if client.Provider == "" { + client.Provider = ProviderDeepSeek + client.BaseURL = DefaultDeepSeekBaseURL + client.Model = DefaultDeepSeekModel } + + // 5. 设置 hooks 指向自己 + client.hooks = client + + return client } // SetCustomAPI 设置自定义OpenAI兼容API -func (client *Client) SetCustomAPI(apiURL, apiKey, modelName string) { +func (client *Client) SetAPIKey(apiKey, apiURL, customModel string) { client.Provider = ProviderCustom client.APIKey = apiKey @@ -119,51 +126,47 @@ func (client *Client) SetCustomAPI(apiURL, apiKey, modelName string) { client.UseFullURL = false } - client.Model = modelName - client.Timeout = 120 * time.Second + client.Model = customModel } -// SetClient 设置完整的AI配置(高级用户) -func (client *Client) SetClient(Client Client) { - if Client.Timeout == 0 { - Client.Timeout = 30 * time.Second - } - client = &Client +func (client *Client) SetTimeout(timeout time.Duration) { + client.httpClient.Timeout = timeout } -// CallWithMessages 使用 system + user prompt 调用AI API(推荐) +// CallWithMessages 模板方法 - 固定的重试流程(不可重写) func (client *Client) CallWithMessages(systemPrompt, userPrompt string) (string, error) { if client.APIKey == "" { - return "", fmt.Errorf("AI API密钥未设置,请先调用 SetDeepSeekAPIKey() 或 SetQwenAPIKey()") + return "", fmt.Errorf("AI API密钥未设置,请先调用 SetAPIKey") } - // 重试配置 - maxRetries := 3 + // 固定的重试流程 var lastErr error + maxRetries := client.config.MaxRetries for attempt := 1; attempt <= maxRetries; attempt++ { if attempt > 1 { - fmt.Printf("⚠️ AI API调用失败,正在重试 (%d/%d)...\n", attempt, maxRetries) + client.logger.Warnf("⚠️ AI API调用失败,正在重试 (%d/%d)...", attempt, maxRetries) } - result, err := client.callOnce(systemPrompt, userPrompt) + // 调用固定的单次调用流程 + result, err := client.hooks.call(systemPrompt, userPrompt) if err == nil { if attempt > 1 { - fmt.Printf("✓ AI API重试成功\n") + client.logger.Infof("✓ AI API重试成功") } return result, nil } lastErr = err - // 如果不是网络错误,不重试 - if !isRetryableError(err) { + // 通过 hooks 判断是否可重试(支持子类自定义重试策略) + if !client.hooks.isRetryableError(err) { return "", err } // 重试前等待 if attempt < maxRetries { - waitTime := time.Duration(attempt) * 2 * time.Second - fmt.Printf("⏳ 等待%v后重试...\n", waitTime) + waitTime := client.config.RetryWaitBase * time.Duration(attempt) + client.logger.Infof("⏳ 等待%v后重试...", waitTime) time.Sleep(waitTime) } } @@ -171,18 +174,11 @@ func (client *Client) CallWithMessages(systemPrompt, userPrompt string) (string, return "", fmt.Errorf("重试%d次后仍然失败: %w", maxRetries, lastErr) } -// callOnce 单次调用AI API(内部使用) -func (client *Client) callOnce(systemPrompt, userPrompt string) (string, error) { - // 打印当前 AI 配置 - log.Printf("📡 [MCP] AI 请求配置:") - log.Printf(" Provider: %s", client.Provider) - log.Printf(" BaseURL: %s", client.BaseURL) - log.Printf(" Model: %s", client.Model) - log.Printf(" UseFullURL: %v", client.UseFullURL) - if len(client.APIKey) > 8 { - log.Printf(" API Key: %s...%s", client.APIKey[:4], client.APIKey[len(client.APIKey)-4:]) - } +func (client *Client) setAuthHeader(reqHeader http.Header) { + reqHeader.Set("Authorization", fmt.Sprintf("Bearer %s", client.APIKey)) +} +func (client *Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any { // 构建 messages 数组 messages := []map[string]string{} @@ -193,7 +189,6 @@ func (client *Client) callOnce(systemPrompt, userPrompt string) (string, error) "content": systemPrompt, }) } - // 添加 user message messages = append(messages, map[string]string{ "role": "user", @@ -204,104 +199,310 @@ func (client *Client) callOnce(systemPrompt, userPrompt string) (string, error) requestBody := map[string]interface{}{ "model": client.Model, "messages": messages, - "temperature": 0.5, // 降低temperature以提高JSON格式稳定性 + "temperature": client.config.Temperature, // 使用配置的 temperature "max_tokens": client.MaxTokens, } + return requestBody +} - // 注意:response_format 参数仅 OpenAI 支持,DeepSeek/Qwen 不支持 - // 我们通过强化 prompt 和后处理来确保 JSON 格式正确 - +// can be used to marshal the request body and can be overridden +func (client *Client) marshalRequestBody(requestBody map[string]any) ([]byte, error) { jsonData, err := json.Marshal(requestBody) if err != nil { - return "", fmt.Errorf("序列化请求失败: %w", err) + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + return jsonData, nil +} + +func (client *Client) parseMCPResponse(body []byte) (string, error) { + var result struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` } - // 创建HTTP请求 - var url string + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("解析响应失败: %w", err) + } + + if len(result.Choices) == 0 { + return "", fmt.Errorf("API返回空响应") + } + + return result.Choices[0].Message.Content, nil +} + +func (client *Client) buildUrl() string { if client.UseFullURL { - // 使用完整URL,不添加/chat/completions - url = client.BaseURL - } else { - // 默认行为:添加/chat/completions - url = fmt.Sprintf("%s/chat/completions", client.BaseURL) + return client.BaseURL } - log.Printf("📡 [MCP] 请求 URL: %s", url) + return fmt.Sprintf("%s/chat/completions", client.BaseURL) +} +func (client *Client) buildRequest(url string, jsonData []byte) (*http.Request, error) { + // Create HTTP request req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { - return "", fmt.Errorf("创建请求失败: %w", err) + return nil, fmt.Errorf("fail to build request: %w", err) } req.Header.Set("Content-Type", "application/json") - // 根据不同的Provider设置认证方式 - switch client.Provider { - case ProviderDeepSeek: - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.APIKey)) - case ProviderQwen: - // 阿里云Qwen使用API-Key认证 - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.APIKey)) - // 注意:如果使用的不是兼容模式,可能需要不同的认证方式 - default: - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.APIKey)) - } - - // 发送请求 - httpClient := &http.Client{Timeout: client.Timeout} - resp, err := httpClient.Do(req) + // 通过 hooks 设置认证头(支持子类重写) + client.hooks.setAuthHeader(req.Header) + + return req, nil +} + +// call 单次调用AI API(固定流程,不可重写) +func (client *Client) call(systemPrompt, userPrompt string) (string, error) { + // 打印当前 AI 配置 + client.logger.Infof("📡 [%s] Request AI Server: BaseURL: %s", client.String(), client.BaseURL) + client.logger.Debugf("[%s] UseFullURL: %v", client.String(), client.UseFullURL) + if len(client.APIKey) > 8 { + client.logger.Debugf("[%s] API Key: %s...%s", client.String(), client.APIKey[:4], client.APIKey[len(client.APIKey)-4:]) + } + + // Step 1: 构建请求体(通过 hooks 实现动态分派) + requestBody := client.hooks.buildMCPRequestBody(systemPrompt, userPrompt) + + // Step 2: 序列化请求体(通过 hooks 实现动态分派) + jsonData, err := client.hooks.marshalRequestBody(requestBody) + if err != nil { + return "", err + } + + // Step 3: 构建 URL(通过 hooks 实现动态分派) + url := client.hooks.buildUrl() + client.logger.Infof("📡 [MCP %s] 请求 URL: %s", client.String(), url) + + // Step 4: 创建 HTTP 请求(固定逻辑) + req, err := client.hooks.buildRequest(url, jsonData) + if err != nil { + return "", fmt.Errorf("创建请求失败: %w", err) + } + + // Step 5: 发送 HTTP 请求(固定逻辑) + resp, err := client.httpClient.Do(req) if err != nil { return "", fmt.Errorf("发送请求失败: %w", err) } defer resp.Body.Close() - // 读取响应 + // Step 6: 读取响应体(固定逻辑) body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("读取响应失败: %w", err) } + // Step 7: 检查 HTTP 状态码(固定逻辑) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("API返回错误 (status %d): %s", resp.StatusCode, string(body)) } - // 解析响应 - var result struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` - } - - if err := json.Unmarshal(body, &result); err != nil { - return "", fmt.Errorf("解析响应失败: %w", err) + // Step 8: 解析响应(通过 hooks 实现动态分派) + result, err := client.hooks.parseMCPResponse(body) + if err != nil { + return "", fmt.Errorf("fail to parse AI server response: %w", err) } - if len(result.Choices) == 0 { - return "", fmt.Errorf("API返回空响应") - } + return result, nil +} - return result.Choices[0].Message.Content, nil +func (client *Client) String() string { + return fmt.Sprintf("[Provider: %s, Model: %s]", + client.Provider, client.Model) } -// isRetryableError 判断错误是否可重试 -func isRetryableError(err error) bool { +// isRetryableError 判断错误是否可重试(网络错误、超时等) +func (client *Client) isRetryableError(err error) bool { errStr := err.Error() // 网络错误、超时、EOF等可以重试 - retryableErrors := []string{ - "EOF", - "timeout", - "connection reset", - "connection refused", - "temporary failure", - "no such host", - "stream error", // HTTP/2 stream 错误 - "INTERNAL_ERROR", // 服务端内部错误 - } - for _, retryable := range retryableErrors { + for _, retryable := range client.config.RetryableErrors { if strings.Contains(errStr, retryable) { return true } } return false } + +// ============================================================ +// 构建器模式 API(高级功能) +// ============================================================ + +// CallWithRequest 使用 Request 对象调用 AI API(支持高级功能) +// +// 此方法支持: +// - 多轮对话历史 +// - 精细参数控制(temperature、top_p、penalties 等) +// - Function Calling / Tools +// - 流式响应(未来支持) +// +// 使用示例: +// request := NewRequestBuilder(). +// WithSystemPrompt("You are helpful"). +// WithUserPrompt("Hello"). +// WithTemperature(0.8). +// Build() +// result, err := client.CallWithRequest(request) +func (client *Client) CallWithRequest(req *Request) (string, error) { + if client.APIKey == "" { + return "", fmt.Errorf("AI API密钥未设置,请先调用 SetAPIKey") + } + + // 如果 Request 中没有设置 Model,使用 Client 的 Model + if req.Model == "" { + req.Model = client.Model + } + + // 固定的重试流程 + var lastErr error + maxRetries := client.config.MaxRetries + + for attempt := 1; attempt <= maxRetries; attempt++ { + if attempt > 1 { + client.logger.Warnf("⚠️ AI API调用失败,正在重试 (%d/%d)...", attempt, maxRetries) + } + + // 调用单次请求 + result, err := client.callWithRequest(req) + if err == nil { + if attempt > 1 { + client.logger.Infof("✓ AI API重试成功") + } + return result, nil + } + + lastErr = err + // 判断是否可重试 + if !client.hooks.isRetryableError(err) { + return "", err + } + + // 重试前等待 + if attempt < maxRetries { + waitTime := client.config.RetryWaitBase * time.Duration(attempt) + client.logger.Infof("⏳ 等待%v后重试...", waitTime) + time.Sleep(waitTime) + } + } + + return "", fmt.Errorf("重试%d次后仍然失败: %w", maxRetries, lastErr) +} + +// callWithRequest 单次调用 AI API(使用 Request 对象) +func (client *Client) callWithRequest(req *Request) (string, error) { + // 打印当前 AI 配置 + client.logger.Infof("📡 [%s] Request AI Server with Builder: BaseURL: %s", client.String(), client.BaseURL) + client.logger.Debugf("[%s] Messages count: %d", client.String(), len(req.Messages)) + + // 构建请求体(从 Request 对象) + requestBody := client.buildRequestBodyFromRequest(req) + + // 序列化请求体 + jsonData, err := client.hooks.marshalRequestBody(requestBody) + if err != nil { + return "", err + } + + // 构建 URL + url := client.hooks.buildUrl() + client.logger.Infof("📡 [MCP %s] 请求 URL: %s", client.String(), url) + + // 创建 HTTP 请求 + httpReq, err := client.hooks.buildRequest(url, jsonData) + if err != nil { + return "", fmt.Errorf("创建请求失败: %w", err) + } + + // 发送 HTTP 请求 + resp, err := client.httpClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + // 读取响应体 + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应失败: %w", err) + } + + // 检查 HTTP 状态码 + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("API返回错误 (status %d): %s", resp.StatusCode, string(body)) + } + + // 解析响应 + result, err := client.hooks.parseMCPResponse(body) + if err != nil { + return "", fmt.Errorf("fail to parse AI server response: %w", err) + } + + return result, nil +} + +// buildRequestBodyFromRequest 从 Request 对象构建请求体 +func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any { + // 转换 Message 为 API 格式 + messages := make([]map[string]string, 0, len(req.Messages)) + for _, msg := range req.Messages { + messages = append(messages, map[string]string{ + "role": msg.Role, + "content": msg.Content, + }) + } + + // 构建基础请求体 + requestBody := map[string]interface{}{ + "model": req.Model, + "messages": messages, + } + + // 添加可选参数(只添加非 nil 的参数) + if req.Temperature != nil { + requestBody["temperature"] = *req.Temperature + } else { + // 如果 Request 中没有设置,使用 Client 的配置 + requestBody["temperature"] = client.config.Temperature + } + + if req.MaxTokens != nil { + requestBody["max_tokens"] = *req.MaxTokens + } else { + // 如果 Request 中没有设置,使用 Client 的 MaxTokens + requestBody["max_tokens"] = client.MaxTokens + } + + if req.TopP != nil { + requestBody["top_p"] = *req.TopP + } + + if req.FrequencyPenalty != nil { + requestBody["frequency_penalty"] = *req.FrequencyPenalty + } + + if req.PresencePenalty != nil { + requestBody["presence_penalty"] = *req.PresencePenalty + } + + if len(req.Stop) > 0 { + requestBody["stop"] = req.Stop + } + + if len(req.Tools) > 0 { + requestBody["tools"] = req.Tools + } + + if req.ToolChoice != "" { + requestBody["tool_choice"] = req.ToolChoice + } + + if req.Stream { + requestBody["stream"] = true + } + + return requestBody +} diff --git a/mcp/client_test.go b/mcp/client_test.go new file mode 100644 index 0000000000..4a0e9e4612 --- /dev/null +++ b/mcp/client_test.go @@ -0,0 +1,419 @@ +package mcp + +import ( + "errors" + "net/http" + "testing" + "time" +) + +// ============================================================ +// 测试 Client 创建和配置 +// ============================================================ + +func TestNewClient_Default(t *testing.T) { + client := NewClient() + + if client == nil { + t.Fatal("client should not be nil") + } + + c := client.(*Client) + if c.Provider == "" { + t.Error("Provider should have default value") + } + + if c.MaxTokens <= 0 { + t.Error("MaxTokens should be positive") + } + + if c.logger == nil { + t.Error("logger should not be nil") + } + + if c.httpClient == nil { + t.Error("httpClient should not be nil") + } + + if c.hooks == nil { + t.Error("hooks should not be nil") + } +} + +func TestNewClient_WithOptions(t *testing.T) { + mockLogger := NewMockLogger() + mockHTTP := &http.Client{Timeout: 30 * time.Second} + + client := NewClient( + WithLogger(mockLogger), + WithHTTPClient(mockHTTP), + WithMaxTokens(4000), + WithTimeout(60*time.Second), + WithAPIKey("test-key"), + ) + + c := client.(*Client) + + if c.logger != mockLogger { + t.Error("logger should be set from option") + } + + if c.httpClient != mockHTTP { + t.Error("httpClient should be set from option") + } + + if c.MaxTokens != 4000 { + t.Error("MaxTokens should be 4000") + } + + if c.APIKey != "test-key" { + t.Error("APIKey should be test-key") + } +} + +// ============================================================ +// 测试 CallWithMessages +// ============================================================ + +func TestClient_CallWithMessages_Success(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockHTTP.SetSuccessResponse("AI response content") + mockLogger := NewMockLogger() + + client := NewClient( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("test-key"), + WithBaseURL("https://api.test.com"), + ) + + result, err := client.CallWithMessages("system prompt", "user prompt") + + if err != nil { + t.Fatalf("should not error: %v", err) + } + + if result != "AI response content" { + t.Errorf("expected 'AI response content', got '%s'", result) + } + + // 验证请求 + requests := mockHTTP.GetRequests() + if len(requests) != 1 { + t.Errorf("expected 1 request, got %d", len(requests)) + } + + if len(requests) > 0 { + req := requests[0] + if req.Header.Get("Authorization") == "" { + t.Error("Authorization header should be set") + } + if req.Header.Get("Content-Type") != "application/json" { + t.Error("Content-Type should be application/json") + } + } +} + +func TestClient_CallWithMessages_NoAPIKey(t *testing.T) { + client := NewClient() + + _, err := client.CallWithMessages("system", "user") + + if err == nil { + t.Error("should error when API key is not set") + } + + if err.Error() != "AI API密钥未设置,请先调用 SetAPIKey" { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestClient_CallWithMessages_HTTPError(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockHTTP.SetErrorResponse(500, "Internal Server Error") + mockLogger := NewMockLogger() + + client := NewClient( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("test-key"), + ) + + _, err := client.CallWithMessages("system", "user") + + if err == nil { + t.Error("should error on HTTP error") + } +} + +// ============================================================ +// 测试重试逻辑 +// ============================================================ + +func TestClient_Retry_Success(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockLogger := NewMockLogger() + + // 模拟:第一次失败,第二次成功 + callCount := 0 + mockHTTP.ResponseFunc = func(req *http.Request) (*http.Response, error) { + callCount++ + if callCount == 1 { + return nil, errors.New("connection reset") + } + return &http.Response{ + StatusCode: 200, + Body: http.NoBody, + }, nil + } + + client := NewClient( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("test-key"), + WithMaxRetries(3), + ) + + // 由于我们的 client 使用 hooks.call,需要特殊处理 + // 这里我们测试的是 CallWithMessages 会调用 retry 逻辑 + c := client.(*Client) + + // 临时修改重试等待时间为 0 以加速测试 + oldRetries := MaxRetryTimes + MaxRetryTimes = 3 + defer func() { MaxRetryTimes = oldRetries }() + + _, err := c.CallWithMessages("system", "user") + + // 第一次失败(connection reset),第二次成功,但是响应格式不对,会失败 + // 但至少验证了重试逻辑被触发 + if callCount < 2 { + t.Errorf("should retry, got %d calls", callCount) + } + + // 检查日志中是否有重试信息 + logs := mockLogger.GetLogsByLevel("WARN") + hasRetryLog := false + for _, log := range logs { + if log.Message == "⚠️ AI API调用失败,正在重试 (2/3)..." { + hasRetryLog = true + break + } + } + + if !hasRetryLog && callCount >= 2 { + // 如果确实重试了,应该有警告日志 + // 但由于我们的测试设置,可能不会触发,所以这里只是检查 + t.Log("Retry was attempted") + } + + _ = err // 忽略错误,我们主要测试重试逻辑被触发 +} + +func TestClient_Retry_NonRetryableError(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockHTTP.SetErrorResponse(400, "Bad Request") + mockLogger := NewMockLogger() + + client := NewClient( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("test-key"), + ) + + _, err := client.CallWithMessages("system", "user") + + if err == nil { + t.Error("should error") + } + + // 验证没有重试(因为 400 不是可重试错误) + requests := mockHTTP.GetRequests() + if len(requests) != 1 { + t.Errorf("should not retry for 400 error, got %d requests", len(requests)) + } +} + +// ============================================================ +// 测试钩子方法 +// ============================================================ + +func TestClient_BuildMCPRequestBody(t *testing.T) { + client := NewClient() + c := client.(*Client) + + body := c.buildMCPRequestBody("system prompt", "user prompt") + + if body == nil { + t.Fatal("body should not be nil") + } + + if body["model"] == nil { + t.Error("body should have model field") + } + + messages, ok := body["messages"].([]map[string]string) + if !ok { + t.Fatal("messages should be []map[string]string") + } + + if len(messages) != 2 { + t.Errorf("expected 2 messages, got %d", len(messages)) + } + + if messages[0]["role"] != "system" { + t.Error("first message should be system") + } + + if messages[1]["role"] != "user" { + t.Error("second message should be user") + } +} + +func TestClient_BuildUrl(t *testing.T) { + tests := []struct { + name string + baseURL string + useFullURL bool + expected string + }{ + { + name: "normal URL", + baseURL: "https://api.test.com/v1", + useFullURL: false, + expected: "https://api.test.com/v1/chat/completions", + }, + { + name: "full URL", + baseURL: "https://api.test.com/custom/endpoint", + useFullURL: true, + expected: "https://api.test.com/custom/endpoint", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient( + WithProvider("test-provider"), // Prevent default DeepSeek settings + WithBaseURL(tt.baseURL), + WithUseFullURL(tt.useFullURL), + ) + c := client.(*Client) + + url := c.buildUrl() + if url != tt.expected { + t.Errorf("expected '%s', got '%s'", tt.expected, url) + } + }) + } +} + +func TestClient_SetAuthHeader(t *testing.T) { + client := NewClient(WithAPIKey("test-api-key")) + c := client.(*Client) + + headers := make(http.Header) + c.setAuthHeader(headers) + + authHeader := headers.Get("Authorization") + if authHeader != "Bearer test-api-key" { + t.Errorf("expected 'Bearer test-api-key', got '%s'", authHeader) + } +} + +func TestClient_IsRetryableError(t *testing.T) { + client := NewClient() + c := client.(*Client) + + tests := []struct { + name string + err error + expected bool + }{ + { + name: "EOF error", + err: errors.New("unexpected EOF"), + expected: true, + }, + { + name: "timeout error", + err: errors.New("timeout exceeded"), + expected: true, + }, + { + name: "connection reset", + err: errors.New("connection reset by peer"), + expected: true, + }, + { + name: "normal error", + err: errors.New("bad request"), + expected: false, + }, + { + name: "validation error", + err: errors.New("invalid input"), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := c.isRetryableError(tt.err) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +// ============================================================ +// 测试 SetTimeout +// ============================================================ + +func TestClient_SetTimeout(t *testing.T) { + client := NewClient() + + newTimeout := 90 * time.Second + client.SetTimeout(newTimeout) + + c := client.(*Client) + if c.httpClient.Timeout != newTimeout { + t.Errorf("expected timeout %v, got %v", newTimeout, c.httpClient.Timeout) + } +} + +// ============================================================ +// 测试 String 方法 +// ============================================================ + +func TestClient_String(t *testing.T) { + client := NewClient( + WithProvider("test-provider"), + WithModel("test-model"), + ) + + c := client.(*Client) + str := c.String() + + expectedContains := []string{"test-provider", "test-model"} + for _, exp := range expectedContains { + if !contains(str, exp) { + t.Errorf("String() should contain '%s', got '%s'", exp, str) + } + } +} + +// 辅助函数 +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && findSubstring(s, substr)) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/mcp/config.go b/mcp/config.go new file mode 100644 index 0000000000..a32686a5ac --- /dev/null +++ b/mcp/config.go @@ -0,0 +1,69 @@ +package mcp + +import ( + "net/http" + "os" + "strconv" + "time" +) + +// Config 客户端配置(集中管理所有配置) +type Config struct { + // Provider 配置 + Provider string + APIKey string + BaseURL string + Model string + + // 行为配置 + MaxTokens int + Temperature float64 + UseFullURL bool + + // 重试配置 + MaxRetries int + RetryWaitBase time.Duration + RetryableErrors []string + + // 超时配置 + Timeout time.Duration + + // 依赖注入 + Logger Logger + HTTPClient *http.Client +} + +// DefaultConfig 返回默认配置 +func DefaultConfig() *Config { + return &Config{ + // 默认值 + MaxTokens: getEnvInt("AI_MAX_TOKENS", 2000), + Temperature: MCPClientTemperature, + MaxRetries: MaxRetryTimes, + RetryWaitBase: 2 * time.Second, + Timeout: DefaultTimeout, + RetryableErrors: retryableErrors, + + // 默认依赖 + Logger: &defaultLogger{}, + HTTPClient: &http.Client{Timeout: DefaultTimeout}, + } +} + +// getEnvInt 从环境变量读取整数,失败则返回默认值 +func getEnvInt(key string, defaultValue int) int { + if val := os.Getenv(key); val != "" { + if parsed, err := strconv.Atoi(val); err == nil && parsed > 0 { + return parsed + } + } + return defaultValue +} + +// getEnvString 从环境变量读取字符串,为空则返回默认值 +func getEnvString(key string, defaultValue string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultValue +} diff --git a/mcp/config_usage_test.go b/mcp/config_usage_test.go new file mode 100644 index 0000000000..0972cb200c --- /dev/null +++ b/mcp/config_usage_test.go @@ -0,0 +1,262 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "testing" + "time" +) + +// ============================================================ +// 测试 Config 字段真正被使用(验证问题2修复) +// ============================================================ + +func TestConfig_MaxRetries_IsUsed(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockLogger := NewMockLogger() + + // 设置 HTTP 客户端返回错误 + callCount := 0 + mockHTTP.ResponseFunc = func(req *http.Request) (*http.Response, error) { + callCount++ + return nil, errors.New("connection reset") + } + + // 创建客户端并设置自定义重试次数为 5 + client := NewClient( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("sk-test-key"), + WithMaxRetries(5), // ✅ 设置重试5次 + ) + + // 调用 API(应该失败) + _, err := client.CallWithMessages("system", "user") + + if err == nil { + t.Error("should error") + } + + // 验证确实重试了5次(而不是默认的3次) + if callCount != 5 { + t.Errorf("expected 5 retry attempts (from WithMaxRetries(5)), got %d", callCount) + } + + // 验证日志中显示正确的重试次数 + logs := mockLogger.GetLogsByLevel("WARN") + expectedWarningCount := 4 // 第2、3、4、5次重试时会打印警告 + actualWarningCount := 0 + for _, log := range logs { + if log.Message == "⚠️ AI API调用失败,正在重试 (2/5)..." || + log.Message == "⚠️ AI API调用失败,正在重试 (3/5)..." || + log.Message == "⚠️ AI API调用失败,正在重试 (4/5)..." || + log.Message == "⚠️ AI API调用失败,正在重试 (5/5)..." { + actualWarningCount++ + } + } + + if actualWarningCount != expectedWarningCount { + t.Errorf("expected %d warning logs, got %d", expectedWarningCount, actualWarningCount) + for _, log := range logs { + t.Logf(" WARN: %s", log.Message) + } + } +} + +func TestConfig_Temperature_IsUsed(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockHTTP.SetSuccessResponse("AI response") + mockLogger := NewMockLogger() + + customTemperature := 0.8 + + // 创建客户端并设置自定义 temperature + client := NewClient( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("sk-test-key"), + WithTemperature(customTemperature), // ✅ 设置自定义 temperature + ) + + c := client.(*Client) + + // 构建请求体 + requestBody := c.buildMCPRequestBody("system", "user") + + // 验证 temperature 字段 + temp, ok := requestBody["temperature"].(float64) + if !ok { + t.Fatal("temperature should be float64") + } + + if temp != customTemperature { + t.Errorf("expected temperature %f (from WithTemperature), got %f", customTemperature, temp) + } + + // 也可以通过实际 HTTP 请求验证 + _, err := client.CallWithMessages("system", "user") + if err != nil { + t.Fatalf("should not error: %v", err) + } + + // 检查发送的请求体 + requests := mockHTTP.GetRequests() + if len(requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(requests)) + } + + // 解析请求体 + var body map[string]interface{} + decoder := json.NewDecoder(requests[0].Body) + if err := decoder.Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + + // 验证 temperature + if body["temperature"] != customTemperature { + t.Errorf("expected temperature %f in HTTP request, got %v", customTemperature, body["temperature"]) + } +} + +func TestConfig_RetryWaitBase_IsUsed(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockLogger := NewMockLogger() + + // 设置成功响应(在 ResponseFunc 之前) + mockHTTP.SetSuccessResponse("AI response") + + // 设置 HTTP 客户端前2次返回错误,第3次成功 + callCount := 0 + successResponse := mockHTTP.Response // 保存成功响应字符串 + mockHTTP.ResponseFunc = func(req *http.Request) (*http.Response, error) { + callCount++ + if callCount <= 2 { + return nil, errors.New("timeout exceeded") + } + // 第3次返回成功响应 + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(successResponse)), + Header: make(http.Header), + }, nil + } + + // 设置自定义重试等待基数为 1 秒(而不是默认的 2 秒) + customWaitBase := 1 * time.Second + + client := NewClient( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("sk-test-key"), + WithRetryWaitBase(customWaitBase), // ✅ 设置自定义等待时间 + WithMaxRetries(3), + ) + + // 记录开始时间 + start := time.Now() + + // 调用 API + _, err := client.CallWithMessages("system", "user") + + // 记录结束时间 + elapsed := time.Since(start) + + // 第3次成功,但前面失败了2次 + if err != nil { + t.Fatalf("should succeed on 3rd attempt, got error: %v", err) + } + + if callCount != 3 { + t.Errorf("expected 3 attempts, got %d", callCount) + } + + // 验证等待时间 + // 第1次失败后等待 1s (customWaitBase * 1) + // 第2次失败后等待 2s (customWaitBase * 2) + // 总等待时间应该约为 3s (允许一些误差) + expectedWait := 3 * time.Second + tolerance := 200 * time.Millisecond + + if elapsed < expectedWait-tolerance || elapsed > expectedWait+tolerance { + t.Errorf("expected total time ~%v (with RetryWaitBase=%v), got %v", expectedWait, customWaitBase, elapsed) + } +} + +func TestConfig_RetryableErrors_IsUsed(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockLogger := NewMockLogger() + + // 自定义可重试错误列表(只包含 "custom error") + customRetryableErrors := []string{"custom error"} + + client := NewClient( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("sk-test-key"), + ) + + c := client.(*Client) + + // 修改 config 的 RetryableErrors(暂时没有 WithRetryableErrors 选项) + c.config.RetryableErrors = customRetryableErrors + + tests := []struct { + name string + err error + retryable bool + }{ + { + name: "custom error should be retryable", + err: errors.New("custom error occurred"), + retryable: true, + }, + { + name: "EOF should NOT be retryable (not in custom list)", + err: errors.New("unexpected EOF"), + retryable: false, + }, + { + name: "timeout should NOT be retryable (not in custom list)", + err: errors.New("timeout exceeded"), + retryable: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := c.isRetryableError(tt.err) + if result != tt.retryable { + t.Errorf("expected isRetryableError(%v) = %v, got %v", tt.err, tt.retryable, result) + } + }) + } +} + +// ============================================================ +// 测试默认值 +// ============================================================ + +func TestConfig_DefaultValues(t *testing.T) { + client := NewClient() + c := client.(*Client) + + // 验证默认值 + if c.config.MaxRetries != 3 { + t.Errorf("default MaxRetries should be 3, got %d", c.config.MaxRetries) + } + + if c.config.Temperature != 0.5 { + t.Errorf("default Temperature should be 0.5, got %f", c.config.Temperature) + } + + if c.config.RetryWaitBase != 2*time.Second { + t.Errorf("default RetryWaitBase should be 2s, got %v", c.config.RetryWaitBase) + } + + if len(c.config.RetryableErrors) == 0 { + t.Error("default RetryableErrors should not be empty") + } +} diff --git a/mcp/deepseek_client.go b/mcp/deepseek_client.go new file mode 100644 index 0000000000..62e2849015 --- /dev/null +++ b/mcp/deepseek_client.go @@ -0,0 +1,83 @@ +package mcp + +import ( + "net/http" +) + +const ( + ProviderDeepSeek = "deepseek" + DefaultDeepSeekBaseURL = "https://api.deepseek.com/v1" + DefaultDeepSeekModel = "deepseek-chat" +) + +type DeepSeekClient struct { + *Client +} + +// NewDeepSeekClient 创建 DeepSeek 客户端(向前兼容) +// +// Deprecated: 推荐使用 NewDeepSeekClientWithOptions 以获得更好的灵活性 +func NewDeepSeekClient() AIClient { + return NewDeepSeekClientWithOptions() +} + +// NewDeepSeekClientWithOptions 创建 DeepSeek 客户端(支持选项模式) +// +// 使用示例: +// // 基础用法 +// client := mcp.NewDeepSeekClientWithOptions() +// +// // 自定义配置 +// client := mcp.NewDeepSeekClientWithOptions( +// mcp.WithAPIKey("sk-xxx"), +// mcp.WithLogger(customLogger), +// mcp.WithTimeout(60*time.Second), +// ) +func NewDeepSeekClientWithOptions(opts ...ClientOption) AIClient { + // 1. 创建 DeepSeek 预设选项 + deepseekOpts := []ClientOption{ + WithProvider(ProviderDeepSeek), + WithModel(DefaultDeepSeekModel), + WithBaseURL(DefaultDeepSeekBaseURL), + } + + // 2. 合并用户选项(用户选项优先级更高) + allOpts := append(deepseekOpts, opts...) + + // 3. 创建基础客户端 + baseClient := NewClient(allOpts...).(*Client) + + // 4. 创建 DeepSeek 客户端 + dsClient := &DeepSeekClient{ + Client: baseClient, + } + + // 5. 设置 hooks 指向 DeepSeekClient(实现动态分派) + baseClient.hooks = dsClient + + return dsClient +} + +func (dsClient *DeepSeekClient) SetAPIKey(apiKey string, customURL string, customModel string) { + dsClient.APIKey = apiKey + + if len(apiKey) > 8 { + dsClient.logger.Infof("🔧 [MCP] DeepSeek API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:]) + } + if customURL != "" { + dsClient.BaseURL = customURL + dsClient.logger.Infof("🔧 [MCP] DeepSeek 使用自定义 BaseURL: %s", customURL) + } else { + dsClient.logger.Infof("🔧 [MCP] DeepSeek 使用默认 BaseURL: %s", dsClient.BaseURL) + } + if customModel != "" { + dsClient.Model = customModel + dsClient.logger.Infof("🔧 [MCP] DeepSeek 使用自定义 Model: %s", customModel) + } else { + dsClient.logger.Infof("🔧 [MCP] DeepSeek 使用默认 Model: %s", dsClient.Model) + } +} + +func (dsClient *DeepSeekClient) setAuthHeader(reqHeaders http.Header) { + dsClient.Client.setAuthHeader(reqHeaders) +} diff --git a/mcp/deepseek_client_test.go b/mcp/deepseek_client_test.go new file mode 100644 index 0000000000..8be91d522a --- /dev/null +++ b/mcp/deepseek_client_test.go @@ -0,0 +1,272 @@ +package mcp + +import ( + "testing" + "time" +) + +// ============================================================ +// 测试 DeepSeekClient 创建和配置 +// ============================================================ + +func TestNewDeepSeekClient_Default(t *testing.T) { + client := NewDeepSeekClient() + + if client == nil { + t.Fatal("client should not be nil") + } + + // 类型断言检查 + dsClient, ok := client.(*DeepSeekClient) + if !ok { + t.Fatal("client should be *DeepSeekClient") + } + + // 验证默认值 + if dsClient.Provider != ProviderDeepSeek { + t.Errorf("Provider should be '%s', got '%s'", ProviderDeepSeek, dsClient.Provider) + } + + if dsClient.BaseURL != DefaultDeepSeekBaseURL { + t.Errorf("BaseURL should be '%s', got '%s'", DefaultDeepSeekBaseURL, dsClient.BaseURL) + } + + if dsClient.Model != DefaultDeepSeekModel { + t.Errorf("Model should be '%s', got '%s'", DefaultDeepSeekModel, dsClient.Model) + } + + if dsClient.logger == nil { + t.Error("logger should not be nil") + } + + if dsClient.httpClient == nil { + t.Error("httpClient should not be nil") + } +} + +func TestNewDeepSeekClientWithOptions(t *testing.T) { + mockLogger := NewMockLogger() + customModel := "deepseek-v2" + customAPIKey := "sk-custom-key" + + client := NewDeepSeekClientWithOptions( + WithLogger(mockLogger), + WithModel(customModel), + WithAPIKey(customAPIKey), + WithMaxTokens(4000), + ) + + dsClient := client.(*DeepSeekClient) + + // 验证自定义选项被应用 + if dsClient.logger != mockLogger { + t.Error("logger should be set from option") + } + + if dsClient.Model != customModel { + t.Error("Model should be set from option") + } + + if dsClient.APIKey != customAPIKey { + t.Error("APIKey should be set from option") + } + + if dsClient.MaxTokens != 4000 { + t.Error("MaxTokens should be 4000") + } + + // 验证 DeepSeek 默认值仍然保留 + if dsClient.Provider != ProviderDeepSeek { + t.Errorf("Provider should still be '%s'", ProviderDeepSeek) + } + + if dsClient.BaseURL != DefaultDeepSeekBaseURL { + t.Errorf("BaseURL should still be '%s'", DefaultDeepSeekBaseURL) + } +} + +// ============================================================ +// 测试 SetAPIKey +// ============================================================ + +func TestDeepSeekClient_SetAPIKey(t *testing.T) { + mockLogger := NewMockLogger() + client := NewDeepSeekClientWithOptions( + WithLogger(mockLogger), + ) + + dsClient := client.(*DeepSeekClient) + + // 测试设置 API Key(默认 URL 和 Model) + dsClient.SetAPIKey("sk-test-key-12345678", "", "") + + if dsClient.APIKey != "sk-test-key-12345678" { + t.Errorf("APIKey should be 'sk-test-key-12345678', got '%s'", dsClient.APIKey) + } + + // 验证日志记录 + logs := mockLogger.GetLogsByLevel("INFO") + if len(logs) == 0 { + t.Error("should have logged API key setting") + } + + // 验证 BaseURL 和 Model 保持默认 + if dsClient.BaseURL != DefaultDeepSeekBaseURL { + t.Error("BaseURL should remain default") + } + + if dsClient.Model != DefaultDeepSeekModel { + t.Error("Model should remain default") + } +} + +func TestDeepSeekClient_SetAPIKey_WithCustomURL(t *testing.T) { + mockLogger := NewMockLogger() + client := NewDeepSeekClientWithOptions( + WithLogger(mockLogger), + ) + + dsClient := client.(*DeepSeekClient) + + customURL := "https://custom.api.com/v1" + dsClient.SetAPIKey("sk-test-key-12345678", customURL, "") + + if dsClient.BaseURL != customURL { + t.Errorf("BaseURL should be '%s', got '%s'", customURL, dsClient.BaseURL) + } + + // 验证日志记录 + logs := mockLogger.GetLogsByLevel("INFO") + hasCustomURLLog := false + for _, log := range logs { + if log.Format == "🔧 [MCP] DeepSeek 使用自定义 BaseURL: %s" { + hasCustomURLLog = true + break + } + } + + if !hasCustomURLLog { + t.Error("should have logged custom BaseURL") + } +} + +func TestDeepSeekClient_SetAPIKey_WithCustomModel(t *testing.T) { + mockLogger := NewMockLogger() + client := NewDeepSeekClientWithOptions( + WithLogger(mockLogger), + ) + + dsClient := client.(*DeepSeekClient) + + customModel := "deepseek-v3" + dsClient.SetAPIKey("sk-test-key-12345678", "", customModel) + + if dsClient.Model != customModel { + t.Errorf("Model should be '%s', got '%s'", customModel, dsClient.Model) + } + + // 验证日志记录 + logs := mockLogger.GetLogsByLevel("INFO") + hasCustomModelLog := false + for _, log := range logs { + if log.Format == "🔧 [MCP] DeepSeek 使用自定义 Model: %s" { + hasCustomModelLog = true + break + } + } + + if !hasCustomModelLog { + t.Error("should have logged custom Model") + } +} + +// ============================================================ +// 测试集成功能 +// ============================================================ + +func TestDeepSeekClient_CallWithMessages_Success(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockHTTP.SetSuccessResponse("DeepSeek AI response") + mockLogger := NewMockLogger() + + client := NewDeepSeekClientWithOptions( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("sk-test-key"), + ) + + result, err := client.CallWithMessages("system prompt", "user prompt") + + if err != nil { + t.Fatalf("should not error: %v", err) + } + + if result != "DeepSeek AI response" { + t.Errorf("expected 'DeepSeek AI response', got '%s'", result) + } + + // 验证请求 + requests := mockHTTP.GetRequests() + if len(requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(requests)) + } + + req := requests[0] + + // 验证 URL + expectedURL := DefaultDeepSeekBaseURL + "/chat/completions" + if req.URL.String() != expectedURL { + t.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL.String()) + } + + // 验证 Authorization header + authHeader := req.Header.Get("Authorization") + if authHeader != "Bearer sk-test-key" { + t.Errorf("expected 'Bearer sk-test-key', got '%s'", authHeader) + } + + // 验证 Content-Type + if req.Header.Get("Content-Type") != "application/json" { + t.Error("Content-Type should be application/json") + } +} + +func TestDeepSeekClient_Timeout(t *testing.T) { + client := NewDeepSeekClientWithOptions( + WithTimeout(30 * time.Second), + ) + + dsClient := client.(*DeepSeekClient) + + if dsClient.httpClient.Timeout != 30*time.Second { + t.Errorf("expected timeout 30s, got %v", dsClient.httpClient.Timeout) + } + + // 测试 SetTimeout + client.SetTimeout(60 * time.Second) + + if dsClient.httpClient.Timeout != 60*time.Second { + t.Errorf("expected timeout 60s after SetTimeout, got %v", dsClient.httpClient.Timeout) + } +} + +// ============================================================ +// 测试 hooks 机制 +// ============================================================ + +func TestDeepSeekClient_HooksIntegration(t *testing.T) { + client := NewDeepSeekClientWithOptions() + dsClient := client.(*DeepSeekClient) + + // 验证 hooks 指向 dsClient 自己(实现多态) + if dsClient.hooks != dsClient { + t.Error("hooks should point to dsClient for polymorphism") + } + + // 验证 buildUrl 使用 DeepSeek 配置 + url := dsClient.buildUrl() + expectedURL := DefaultDeepSeekBaseURL + "/chat/completions" + if url != expectedURL { + t.Errorf("expected URL '%s', got '%s'", expectedURL, url) + } +} diff --git a/mcp/examples_test.go b/mcp/examples_test.go new file mode 100644 index 0000000000..2aa20829dd --- /dev/null +++ b/mcp/examples_test.go @@ -0,0 +1,296 @@ +package mcp_test + +import ( + "fmt" + "net/http" + "time" + + "nofx/mcp" +) + +// ============================================================ +// 示例 1: 基础用法(向前兼容) +// ============================================================ + +func Example_backward_compatible() { + // ✅ 旧代码继续工作,无需修改 + client := mcp.New() + client.SetAPIKey("sk-xxx", "https://api.custom.com", "gpt-4") + + // 使用 + result, _ := client.CallWithMessages("system prompt", "user prompt") + fmt.Println(result) +} + +func Example_deepseek_backward_compatible() { + // ✅ DeepSeek 旧代码继续工作 + client := mcp.NewDeepSeekClient() + client.SetAPIKey("sk-xxx", "", "") + + result, _ := client.CallWithMessages("system", "user") + fmt.Println(result) +} + +// ============================================================ +// 示例 2: 新的推荐用法(选项模式) +// ============================================================ + +func Example_new_client_basic() { + // 使用默认配置 + client := mcp.NewClient() + + // 使用 DeepSeek + client = mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), + ) + + // 使用 Qwen + client = mcp.NewClient( + mcp.WithQwenConfig("sk-xxx"), + ) + + _ = client +} + +func Example_new_client_with_options() { + // 组合多个选项 + client := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), + mcp.WithTimeout(60*time.Second), + mcp.WithMaxRetries(5), + mcp.WithMaxTokens(4000), + mcp.WithTemperature(0.7), + ) + + result, _ := client.CallWithMessages("system", "user") + fmt.Println(result) +} + +// ============================================================ +// 示例 3: 自定义日志器 +// ============================================================ + +// CustomLogger 自定义日志器示例 +type CustomLogger struct{} + +func (l *CustomLogger) Debugf(format string, args ...any) { + fmt.Printf("[DEBUG] "+format+"\n", args...) +} + +func (l *CustomLogger) Infof(format string, args ...any) { + fmt.Printf("[INFO] "+format+"\n", args...) +} + +func (l *CustomLogger) Warnf(format string, args ...any) { + fmt.Printf("[WARN] "+format+"\n", args...) +} + +func (l *CustomLogger) Errorf(format string, args ...any) { + fmt.Printf("[ERROR] "+format+"\n", args...) +} + +func Example_custom_logger() { + // 使用自定义日志器 + customLogger := &CustomLogger{} + + client := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), + mcp.WithLogger(customLogger), + ) + + result, _ := client.CallWithMessages("system", "user") + fmt.Println(result) +} + +func Example_no_logger_for_testing() { + // 测试时禁用日志 + client := mcp.NewClient( + mcp.WithLogger(mcp.NewNoopLogger()), + ) + + result, _ := client.CallWithMessages("system", "user") + fmt.Println(result) +} + +// ============================================================ +// 示例 4: 自定义 HTTP 客户端 +// ============================================================ + +func Example_custom_http_client() { + // 自定义 HTTP 客户端(添加代理、TLS等) + customHTTP := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + // 自定义 TLS、连接池等 + }, + } + + client := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), + mcp.WithHTTPClient(customHTTP), + ) + + result, _ := client.CallWithMessages("system", "user") + fmt.Println(result) +} + +// ============================================================ +// 示例 5: DeepSeek 客户端(新 API) +// ============================================================ + +func Example_deepseek_new_api() { + // 基础用法 + client := mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey("sk-xxx"), + ) + + // 高级用法 + client = mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey("sk-xxx"), + mcp.WithLogger(&CustomLogger{}), + mcp.WithTimeout(90*time.Second), + mcp.WithMaxTokens(8000), + ) + + result, _ := client.CallWithMessages("system", "user") + fmt.Println(result) +} + +// ============================================================ +// 示例 6: Qwen 客户端(新 API) +// ============================================================ + +func Example_qwen_new_api() { + // 基础用法 + client := mcp.NewQwenClientWithOptions( + mcp.WithAPIKey("sk-xxx"), + ) + + // 高级用法 + client = mcp.NewQwenClientWithOptions( + mcp.WithAPIKey("sk-xxx"), + mcp.WithLogger(&CustomLogger{}), + mcp.WithTimeout(90*time.Second), + ) + + result, _ := client.CallWithMessages("system", "user") + fmt.Println(result) +} + +// ============================================================ +// 示例 7: 在 trader/auto_trader.go 中的迁移示例 +// ============================================================ + +func Example_trader_migration() { + // === 旧代码(继续工作)=== + oldStyleClient := func(apiKey, customURL, customModel string) mcp.AIClient { + client := mcp.NewDeepSeekClient() + client.SetAPIKey(apiKey, customURL, customModel) + return client + } + + // === 新代码(推荐)=== + newStyleClient := func(apiKey, customURL, customModel string) mcp.AIClient { + opts := []mcp.ClientOption{ + mcp.WithAPIKey(apiKey), + } + + if customURL != "" { + opts = append(opts, mcp.WithBaseURL(customURL)) + } + + if customModel != "" { + opts = append(opts, mcp.WithModel(customModel)) + } + + return mcp.NewDeepSeekClientWithOptions(opts...) + } + + // 两种方式都能工作 + _ = oldStyleClient("sk-xxx", "", "") + _ = newStyleClient("sk-xxx", "", "") +} + +// ============================================================ +// 示例 8: 测试场景 +// ============================================================ + +// MockHTTPClient Mock HTTP 客户端 +type MockHTTPClient struct { + Response string +} + +func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) { + // 返回预设的响应 + return &http.Response{ + StatusCode: 200, + Body: nil, // 实际测试中需要实现 + }, nil +} + +func Example_testing_with_mock() { + // 测试时使用 Mock + // mockHTTP := &MockHTTPClient{ + // Response: `{"choices":[{"message":{"content":"test response"}}]}`, + // } + + client := mcp.NewClient( + // mcp.WithHTTPClient(mockHTTP), // 实际测试中使用 mockHTTP + mcp.WithLogger(mcp.NewNoopLogger()), // 禁用日志 + ) + + result, _ := client.CallWithMessages("system", "user") + fmt.Println(result) +} + +// ============================================================ +// 示例 9: 环境特定配置 +// ============================================================ + +func Example_environment_specific() { + // 开发环境:详细日志 + devClient := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), + mcp.WithLogger(&CustomLogger{}), // 详细日志 + ) + + // 生产环境:结构化日志 + 超时保护 + prodClient := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), + // mcp.WithLogger(&ZapLogger{}), // 生产级日志 + mcp.WithTimeout(30*time.Second), + mcp.WithMaxRetries(3), + ) + + _, _ = devClient.CallWithMessages("system", "user") + _, _ = prodClient.CallWithMessages("system", "user") +} + +// ============================================================ +// 示例 10: 完整实战示例 +// ============================================================ + +func Example_real_world_usage() { + // 创建带有完整配置的客户端 + client := mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey("sk-xxxxxxxxxx"), + mcp.WithTimeout(60*time.Second), + mcp.WithMaxRetries(5), + mcp.WithMaxTokens(4000), + mcp.WithTemperature(0.5), + mcp.WithLogger(&CustomLogger{}), + ) + + // 使用客户端 + systemPrompt := "你是一个专业的量化交易顾问" + userPrompt := "分析 BTC 当前走势" + + result, err := client.CallWithMessages(systemPrompt, userPrompt) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("AI 响应: %s\n", result) +} diff --git a/mcp/interface.go b/mcp/interface.go new file mode 100644 index 0000000000..e155ac0199 --- /dev/null +++ b/mcp/interface.go @@ -0,0 +1,30 @@ +package mcp + +import ( + "net/http" + "time" +) + +// AIClient AI客户端公开接口(给外部使用) +type AIClient interface { + SetAPIKey(apiKey string, customURL string, customModel string) + SetTimeout(timeout time.Duration) + CallWithMessages(systemPrompt, userPrompt string) (string, error) + CallWithRequest(req *Request) (string, error) // 构建器模式 API(支持高级功能) +} + +// clientHooks 内部钩子接口(用于子类重写特定步骤) +// 这些方法只在包内部使用,实现动态分派 +type clientHooks interface { + // 可被子类重写的钩子方法 + + call(systemPrompt, userPrompt string) (string, error) + + buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any + buildUrl() string + buildRequest(url string, jsonData []byte) (*http.Request, error) + setAuthHeader(reqHeaders http.Header) + marshalRequestBody(requestBody map[string]any) ([]byte, error) + parseMCPResponse(body []byte) (string, error) + isRetryableError(err error) bool +} diff --git a/mcp/intro/BUILDER_EXAMPLES.md b/mcp/intro/BUILDER_EXAMPLES.md new file mode 100644 index 0000000000..8ec8af9e9d --- /dev/null +++ b/mcp/intro/BUILDER_EXAMPLES.md @@ -0,0 +1,572 @@ +# RequestBuilder 使用示例 + +## 📋 目录 +1. [基础用法](#基础用法) +2. [多轮对话](#多轮对话) +3. [参数精细控制](#参数精细控制) +4. [Function Calling](#function-calling) +5. [预设场景](#预设场景) +6. [完整示例](#完整示例) + +--- + +## 基础用法 + +### 简单对话 + +```go +package main + +import ( + "fmt" + "nofx/mcp" +) + +func main() { + // 创建客户端 + client := mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey("sk-xxx"), + ) + + // 使用构建器创建请求 + request := mcp.NewRequestBuilder(). + WithSystemPrompt("You are a helpful assistant"). + WithUserPrompt("What is Go programming language?"). + Build() + + // 调用 API + result, err := client.CallWithRequest(request) + if err != nil { + panic(err) + } + + fmt.Println(result) +} +``` + +### 与传统方式对比 + +```go +// 传统方式(仍然可用) +result, err := client.CallWithMessages( + "You are a helpful assistant", + "What is Go?", +) + +// 构建器方式(新API,功能更强大) +request := mcp.NewRequestBuilder(). + WithSystemPrompt("You are a helpful assistant"). + WithUserPrompt("What is Go?"). + Build() +result, err := client.CallWithRequest(request) +``` + +--- + +## 多轮对话 + +### 带上下文的对话 + +```go +// 构建包含历史的多轮对话 +request := mcp.NewRequestBuilder(). + AddSystemMessage("You are a trading advisor"). + AddUserMessage("Analyze BTC price"). + AddAssistantMessage("BTC is currently in an upward trend..."). + AddUserMessage("What's the best entry point?"). // 继续对话 + WithTemperature(0.3). // 低温度,更精确 + Build() + +result, err := client.CallWithRequest(request) +``` + +### 从历史记录构建 + +```go +// 假设你有保存的对话历史 +history := []mcp.Message{ + mcp.NewUserMessage("Hello"), + mcp.NewAssistantMessage("Hi! How can I help?"), + mcp.NewUserMessage("What's the weather?"), + mcp.NewAssistantMessage("It's sunny today"), +} + +// 继续对话 +request := mcp.NewRequestBuilder(). + AddSystemMessage("You are helpful"). + AddConversationHistory(history). // 添加历史 + AddUserMessage("What about tomorrow?"). // 新问题 + Build() + +result, err := client.CallWithRequest(request) +``` + +--- + +## 参数精细控制 + +### 代码生成(低温度、精确) + +```go +request := mcp.NewRequestBuilder(). + WithSystemPrompt("You are a Go expert"). + WithUserPrompt("Generate a HTTP server"). + WithTemperature(0.2). // 低温度 = 更确定 + WithTopP(0.1). // 低 top_p = 更聚焦 + WithMaxTokens(2000). + AddStopSequence("```"). // 遇到代码块结束符停止 + Build() + +code, err := client.CallWithRequest(request) +``` + +### 创意写作(高温度、随机) + +```go +request := mcp.NewRequestBuilder(). + WithSystemPrompt("You are a creative writer"). + WithUserPrompt("Write a sci-fi story about AI"). + WithTemperature(1.2). // 高温度 = 更创意 + WithTopP(0.95). // 高 top_p = 更多样 + WithPresencePenalty(0.6). // 避免重复主题 + WithFrequencyPenalty(0.5). // 避免重复词汇 + WithMaxTokens(4000). + Build() + +story, err := client.CallWithRequest(request) +``` + +### 精确分析(平衡参数) + +```go +request := mcp.NewRequestBuilder(). + WithSystemPrompt("You are a quantitative analyst"). + WithUserPrompt("Analyze BTC/USDT chart pattern"). + WithTemperature(0.5). // 中等温度 + WithMaxTokens(1500). + WithStopSequences([]string{"---", "END"}). // 多个停止序列 + Build() + +analysis, err := client.CallWithRequest(request) +``` + +--- + +## Function Calling + +### 天气查询工具 + +```go +// 定义工具参数 schema(JSON Schema 格式) +weatherParams := map[string]any{ + "type": "object", + "properties": map[string]any{ + "location": map[string]any{ + "type": "string", + "description": "City name, e.g., Beijing, Shanghai", + }, + "unit": map[string]any{ + "type": "string", + "enum": []string{"celsius", "fahrenheit"}, + }, + }, + "required": []string{"location"}, +} + +// 构建请求 +request := mcp.NewRequestBuilder(). + WithUserPrompt("北京今天天气怎么样?"). + AddFunction( + "get_weather", // 函数名 + "Get current weather", // 函数描述 + weatherParams, // 参数定义 + ). + WithToolChoice("auto"). // 让 AI 自动决定是否调用 + Build() + +response, err := client.CallWithRequest(request) + +// AI 可能返回 tool_calls,你需要执行函数并返回结果 +// (具体实现取决于 AI provider 的响应格式) +``` + +### 多个工具 + +```go +// 定义多个工具 +request := mcp.NewRequestBuilder(). + WithUserPrompt("帮我查询北京天气,并计算100的平方根"). + AddFunction("get_weather", "Get weather", weatherParams). + AddFunction("calculate", "Calculate math", calcParams). + AddFunction("search_web", "Search web", searchParams). + WithToolChoice("auto"). + Build() + +response, err := client.CallWithRequest(request) +// AI 会选择调用相应的工具 +``` + +### 强制使用特定工具 + +```go +request := mcp.NewRequestBuilder(). + WithUserPrompt("北京"). + AddFunction("get_weather", "Get weather", weatherParams). + WithToolChoice(`{"type": "function", "function": {"name": "get_weather"}}`). + Build() + +// AI 必须调用 get_weather 函数 +``` + +--- + +## 预设场景 + +### ForChat - 聊天场景 + +```go +// 预设参数:temperature=0.7, maxTokens=2000 +request := mcp.ForChat(). + WithSystemPrompt("You are a friendly chatbot"). + WithUserPrompt("Hello!"). + Build() + +// 等价于 +request := mcp.NewRequestBuilder(). + WithSystemPrompt("You are a friendly chatbot"). + WithUserPrompt("Hello!"). + WithTemperature(0.7). + WithMaxTokens(2000). + Build() +``` + +### ForCodeGeneration - 代码生成场景 + +```go +// 预设参数:temperature=0.2, topP=0.1, maxTokens=2000 +request := mcp.ForCodeGeneration(). + WithUserPrompt("Generate a REST API in Go"). + Build() + +// 自动使用低温度和低 top_p,确保代码准确性 +``` + +### ForCreativeWriting - 创意写作场景 + +```go +// 预设参数: +// temperature=1.2, topP=0.95, maxTokens=4000 +// presencePenalty=0.6, frequencyPenalty=0.5 +request := mcp.ForCreativeWriting(). + WithSystemPrompt("You are a novelist"). + WithUserPrompt("Write a fantasy story"). + Build() + +// 自动使用高温度和惩罚参数,增加创意和多样性 +``` + +--- + +## 完整示例 + +### 量化交易 AI 顾问 + +```go +package main + +import ( + "fmt" + "log" + "nofx/mcp" + "os" +) + +func main() { + // 创建客户端 + client := mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey(os.Getenv("DEEPSEEK_API_KEY")), + mcp.WithMaxRetries(5), + mcp.WithTimeout(60 * time.Second), + ) + + // 场景1: 市场分析(需要精确) + analysisRequest := mcp.NewRequestBuilder(). + WithSystemPrompt("You are a professional quantitative trader"). + WithUserPrompt("Analyze BTC/USDT 1H chart, current price $45,000"). + WithTemperature(0.3). // 低温度,更精确 + WithMaxTokens(1500). + Build() + + analysis, err := client.CallWithRequest(analysisRequest) + if err != nil { + log.Fatal(err) + } + fmt.Println("=== Market Analysis ===") + fmt.Println(analysis) + + // 场景2: 继续对话,询问入场点 + followUpRequest := mcp.NewRequestBuilder(). + AddSystemMessage("You are a professional quantitative trader"). + AddUserMessage("Analyze BTC/USDT 1H chart, current price $45,000"). + AddAssistantMessage(analysis). // 添加之前的回复 + AddUserMessage("Based on your analysis, what's the best entry point?"). + WithTemperature(0.3). + Build() + + entryPoint, err := client.CallWithRequest(followUpRequest) + if err != nil { + log.Fatal(err) + } + fmt.Println("\n=== Entry Point Suggestion ===") + fmt.Println(entryPoint) +} +``` + +### 代码评审助手 + +```go +func reviewCode(client mcp.AIClient, code string) (string, error) { + request := mcp.ForCodeGeneration(). // 使用代码场景预设 + WithSystemPrompt("You are a senior Go developer reviewing code"). + WithUserPrompt(fmt.Sprintf("Review this code:\n\n```go\n%s\n```", code)). + WithMaxTokens(2000). + AddStopSequence("---END---"). + Build() + + return client.CallWithRequest(request) +} + +func main() { + client := mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey(os.Getenv("DEEPSEEK_API_KEY")), + ) + + code := ` +func Add(a, b int) int { + return a + b +} +` + + review, err := reviewCode(client, code) + if err != nil { + log.Fatal(err) + } + fmt.Println(review) +} +``` + +### AI 聊天机器人(带历史记录) + +```go +type ChatBot struct { + client mcp.AIClient + history []mcp.Message +} + +func NewChatBot(client mcp.AIClient, systemPrompt string) *ChatBot { + return &ChatBot{ + client: client, + history: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + }, + } +} + +func (bot *ChatBot) Chat(userMessage string) (string, error) { + // 添加用户消息到历史 + bot.history = append(bot.history, mcp.NewUserMessage(userMessage)) + + // 构建请求(包含完整历史) + request := mcp.ForChat(). + AddMessages(bot.history...). + Build() + + // 调用 API + response, err := bot.client.CallWithRequest(request) + if err != nil { + return "", err + } + + // 添加 AI 回复到历史 + bot.history = append(bot.history, mcp.NewAssistantMessage(response)) + + return response, nil +} + +func main() { + client := mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey(os.Getenv("DEEPSEEK_API_KEY")), + ) + + bot := NewChatBot(client, "You are a friendly and helpful assistant") + + // 对话1 + resp1, _ := bot.Chat("What is Go?") + fmt.Println("User: What is Go?") + fmt.Println("Bot:", resp1) + + // 对话2(带上下文) + resp2, _ := bot.Chat("What are its main features?") + fmt.Println("\nUser: What are its main features?") + fmt.Println("Bot:", resp2) + + // 对话3(继续上下文) + resp3, _ := bot.Chat("Show me an example") + fmt.Println("\nUser: Show me an example") + fmt.Println("Bot:", resp3) +} +``` + +### Function Calling 完整示例 + +```go +package main + +import ( + "encoding/json" + "fmt" + "nofx/mcp" + "os" +) + +// 天气查询函数(模拟) +func getWeather(location string) string { + return fmt.Sprintf("Weather in %s: Sunny, 25°C", location) +} + +func main() { + client := mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey(os.Getenv("DEEPSEEK_API_KEY")), + ) + + // 定义工具 + weatherParams := map[string]any{ + "type": "object", + "properties": map[string]any{ + "location": map[string]any{ + "type": "string", + "description": "City name", + }, + }, + "required": []string{"location"}, + } + + // 第一步:发送带工具的请求 + request := mcp.NewRequestBuilder(). + WithUserPrompt("北京天气怎么样?"). + AddFunction("get_weather", "Get current weather", weatherParams). + WithToolChoice("auto"). + Build() + + response, err := client.CallWithRequest(request) + if err != nil { + panic(err) + } + + fmt.Println("AI Response:", response) + + // 第二步:如果 AI 返回了 tool_call(实际需要解析 JSON 响应) + // 这里是示例,实际需要根据 provider 的响应格式解析 + // toolCall := parseToolCall(response) + // weatherResult := getWeather(toolCall.Arguments.Location) + + // 第三步:将工具结果返回给 AI + // followUp := mcp.NewRequestBuilder(). + // AddConversationHistory(previousMessages). + // AddToolResult(toolCall.ID, weatherResult). + // Build() + // + // finalResponse, _ := client.CallWithRequest(followUp) +} +``` + +--- + +## 最佳实践 + +### 1. 使用 MustBuild() vs Build() + +```go +// Build() - 返回 error,需要处理 +request, err := NewRequestBuilder(). + WithUserPrompt("Hello"). + Build() +if err != nil { + log.Fatal(err) +} + +// MustBuild() - 如果失败会 panic,适用于确定不会错的场景 +request := NewRequestBuilder(). + WithSystemPrompt("You are helpful"). + WithUserPrompt("Hello"). + MustBuild() // 构建失败会 panic +``` + +### 2. 重用构建器 + +```go +// 创建基础构建器 +baseBuilder := mcp.NewRequestBuilder(). + WithSystemPrompt("You are a trading advisor"). + WithTemperature(0.3) + +// 为不同问题添加用户消息 +question1 := baseBuilder. + AddUserMessage("Analyze BTC"). + Build() + +question2 := baseBuilder. + ClearMessages(). // 清空之前的消息 + AddSystemMessage("You are a trading advisor"). + AddUserMessage("Analyze ETH"). + Build() +``` + +### 3. 选择合适的预设 + +```go +// ✅ 代码生成 - 使用 ForCodeGeneration +ForCodeGeneration().WithUserPrompt("Generate code") + +// ✅ 聊天 - 使用 ForChat +ForChat().WithUserPrompt("Hello") + +// ✅ 创意写作 - 使用 ForCreativeWriting +ForCreativeWriting().WithUserPrompt("Write a story") + +// ✅ 自定义 - 使用 NewRequestBuilder +NewRequestBuilder().WithTemperature(0.6).WithUserPrompt("...") +``` + +--- + +## 迁移指南 + +### 从旧 API 迁移 + +```go +// 旧 API(仍然可用) +result, err := client.CallWithMessages("system", "user") + +// 迁移到新 API +request := mcp.NewRequestBuilder(). + WithSystemPrompt("system"). + WithUserPrompt("user"). + Build() +result, err := client.CallWithRequest(request) + +// 如果需要更多控制 +request := mcp.NewRequestBuilder(). + WithSystemPrompt("system"). + WithUserPrompt("user"). + WithTemperature(0.8). // 新功能 + WithMaxTokens(2000). // 新功能 + Build() +result, err := client.CallWithRequest(request) +``` + +--- + +更多信息请参考: +- [构建器模式价值分析](./BUILDER_PATTERN_BENEFITS.md) +- [MCP 使用指南](./README.md) diff --git a/mcp/intro/BUILDER_PATTERN_BENEFITS.md b/mcp/intro/BUILDER_PATTERN_BENEFITS.md new file mode 100644 index 0000000000..04ff2587f7 --- /dev/null +++ b/mcp/intro/BUILDER_PATTERN_BENEFITS.md @@ -0,0 +1,716 @@ +# 构建器模式在 MCP 模块中的应用价值 + +## 📋 目录 +1. [当前实现的局限性](#当前实现的局限性) +2. [构建器模式的好处](#构建器模式的好处) +3. [实际应用场景](#实际应用场景) +4. [对比示例](#对比示例) +5. [是否需要引入](#是否需要引入) + +--- + +## 当前实现的局限性 + +### 现状分析 + +**当前 buildMCPRequestBody 实现**: +```go +func (client *Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any { + messages := []map[string]string{} + + if systemPrompt != "" { + messages = append(messages, map[string]string{ + "role": "system", + "content": systemPrompt, + }) + } + messages = append(messages, map[string]string{ + "role": "user", + "content": userPrompt, + }) + + return map[string]interface{}{ + "model": client.Model, + "messages": messages, + "temperature": client.config.Temperature, + "max_tokens": client.MaxTokens, + } +} +``` + +### 存在的限制 + +1. **只支持简单对话** + - ❌ 无法添加多轮对话历史 + - ❌ 无法添加 assistant 回复 + - ❌ 无法构建复杂的对话上下文 + +2. **参数固定** + - ❌ 无法动态添加可选参数(如 top_p、frequency_penalty) + - ❌ 无法为单次请求自定义 temperature(会影响全局配置) + - ❌ 无法添加 function calling、tools 等高级功能 + +3. **扩展性差** + - ❌ 每次添加新参数都需要修改方法签名 + - ❌ 参数列表会越来越长 + - ❌ 子类重写时需要处理所有参数 + +--- + +## 构建器模式的好处 + +### 1. 🎯 **灵活性和可读性** + +#### 当前方式(参数传递) +```go +// 问题:参数多了会很混乱 +client.CallWithCustomParams( + "system prompt", + "user prompt", + 0.8, // temperature - 这是什么? + 2000, // max_tokens - 这是什么? + 0.9, // top_p - 这是什么? + 0.5, // frequency_penalty + nil, // stop sequences + false, // stream +) +``` + +#### 构建器方式 +```go +// 清晰、自解释 +request := NewRequestBuilder(). + WithSystemPrompt("You are a helpful assistant"). + WithUserPrompt("Tell me about Go"). + WithTemperature(0.8). + WithMaxTokens(2000). + WithTopP(0.9). + Build() + +result, err := client.CallWithRequest(request) +``` + +--- + +### 2. 📚 **支持复杂场景** + +#### 场景1: 多轮对话 + +**当前方式**: 😢 不支持 +```go +// ❌ 无法实现 +client.CallWithMessages("system", "user prompt") +``` + +**构建器方式**: ✅ 支持 +```go +request := NewRequestBuilder(). + AddSystemMessage("You are a helpful assistant"). + AddUserMessage("What is the weather?"). + AddAssistantMessage("It's sunny today"). + AddUserMessage("What about tomorrow?"). // 继续对话 + WithTemperature(0.7). + Build() +``` + +#### 场景2: 函数调用(Function Calling) + +**当前方式**: 😢 不支持 +```go +// ❌ 无法添加 tools/functions +``` + +**构建器方式**: ✅ 支持 +```go +request := NewRequestBuilder(). + WithUserPrompt("What's the weather in Beijing?"). + AddTool(Tool{ + Type: "function", + Function: FunctionDef{ + Name: "get_weather", + Description: "Get current weather", + Parameters: weatherParamsSchema, + }, + }). + WithToolChoice("auto"). + Build() +``` + +#### 场景3: 流式响应 + +**当前方式**: 😢 需要修改整个架构 +```go +// ❌ CallWithMessages 不支持流式 +``` + +**构建器方式**: ✅ 易于扩展 +```go +request := NewRequestBuilder(). + WithUserPrompt("Write a long story"). + WithStream(true). + Build() + +stream, err := client.CallStream(request) +for chunk := range stream { + fmt.Print(chunk) +} +``` + +--- + +### 3. 🔧 **易于扩展和维护** + +#### 添加新参数 + +**当前方式**: 😢 破坏性修改 +```go +// 需要修改方法签名(破坏现有代码) +func (client *Client) buildMCPRequestBody( + systemPrompt, userPrompt string, + // 新增参数会导致所有调用处都要修改 + topP float64, + presencePenalty float64, +) map[string]any +``` + +**构建器方式**: ✅ 向后兼容 +```go +// 只需添加新方法,不影响现有代码 +func (b *RequestBuilder) WithPresencePenalty(p float64) *RequestBuilder { + b.presencePenalty = p + return b +} + +// 旧代码不受影响 +request := builder.WithUserPrompt("Hello").Build() + +// 新代码可以使用新功能 +request := builder. + WithUserPrompt("Hello"). + WithPresencePenalty(0.6). // 新参数 + Build() +``` + +--- + +### 4. 🎨 **可选参数处理** + +**当前方式**: 😢 难以处理可选参数 +```go +// 方案1: 传 nil/0 值(不优雅) +client.CallWithParams(system, user, 0, 0, nil, nil) + +// 方案2: 使用选项模式(但每次调用都要传) +client.CallWithParams(system, user, WithTopP(0.9), WithPenalty(0.5)) + +// 方案3: 配置对象(需要创建临时对象) +config := &RequestConfig{ + SystemPrompt: system, + UserPrompt: user, + TopP: 0.9, +} +``` + +**构建器方式**: ✅ 优雅处理 +```go +// 只设置需要的参数,其他使用默认值 +request := NewRequestBuilder(). + WithUserPrompt("Hello"). + // 不设置 temperature,使用默认值 + // 不设置 topP,使用默认值 + Build() + +// 也可以全部自定义 +request := NewRequestBuilder(). + WithUserPrompt("Hello"). + WithTemperature(0.8). + WithTopP(0.9). + WithMaxTokens(2000). + Build() +``` + +--- + +### 5. ✅ **类型安全和验证** + +**当前方式**: 😢 运行时才发现错误 +```go +// ❌ 编译时无法发现问题 +client.CallWithMessages("", "") // 空 prompt +client.CallWithMessages("system", "user") // temperature 可能不合法 +``` + +**构建器方式**: ✅ 提前验证 +```go +type RequestBuilder struct { + messages []Message + temperature float64 + maxTokens int +} + +func (b *RequestBuilder) WithTemperature(t float64) *RequestBuilder { + if t < 0 || t > 2 { + panic("temperature must be between 0 and 2") // 或返回 error + } + b.temperature = t + return b +} + +func (b *RequestBuilder) Build() (*Request, error) { + if len(b.messages) == 0 { + return nil, errors.New("at least one message is required") + } + if b.maxTokens <= 0 { + return nil, errors.New("maxTokens must be positive") + } + return &Request{...}, nil +} +``` + +--- + +## 实际应用场景 + +### 场景1: 量化交易 AI 顾问(多轮对话) + +```go +// 构建包含市场数据的上下文对话 +request := NewRequestBuilder(). + AddSystemMessage("You are a quantitative trading advisor"). + AddUserMessage("Analyze BTC trend"). + AddAssistantMessage("BTC is in an upward trend based on..."). + AddUserMessage("What about entry points?"). // 继续对话 + WithTemperature(0.3). // 低温度,更精确 + WithMaxTokens(1000). + Build() + +analysis, err := client.CallWithRequest(request) +``` + +### 场景2: 代码生成(需要精确控制) + +```go +request := NewRequestBuilder(). + WithSystemPrompt("You are a Go expert"). + WithUserPrompt("Generate a HTTP server"). + WithTemperature(0.2). // 低温度,更确定性 + WithTopP(0.1). // 低 top_p,更聚焦 + WithMaxTokens(2000). + WithStopSequences([]string{"```"}). // 遇到代码块结束符停止 + Build() +``` + +### 场景3: 创意写作(需要随机性) + +```go +request := NewRequestBuilder(). + WithSystemPrompt("You are a creative writer"). + WithUserPrompt("Write a sci-fi story"). + WithTemperature(1.2). // 高温度,更创意 + WithTopP(0.95). // 高 top_p,更多样性 + WithPresencePenalty(0.6). // 避免重复 + WithFrequencyPenalty(0.5). + WithMaxTokens(4000). + Build() +``` + +### 场景4: 函数调用(工具使用) + +```go +// 定义工具 +weatherTool := Tool{ + Type: "function", + Function: FunctionDef{ + Name: "get_weather", + Description: "Get current weather for a location", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "location": map[string]any{ + "type": "string", + "description": "City name", + }, + }, + "required": []string{"location"}, + }, + }, +} + +request := NewRequestBuilder(). + WithUserPrompt("What's the weather in Beijing?"). + AddTool(weatherTool). + WithToolChoice("auto"). + Build() + +response, err := client.CallWithRequest(request) +// 解析 response.ToolCalls 并执行实际的天气查询 +``` + +--- + +## 对比示例 + +### 示例1: 基础用法 + +#### 当前实现 +```go +result, err := client.CallWithMessages( + "You are a helpful assistant", + "What is Go?", +) +``` + +#### 构建器模式 +```go +request := NewRequestBuilder(). + WithSystemPrompt("You are a helpful assistant"). + WithUserPrompt("What is Go?"). + Build() + +result, err := client.CallWithRequest(request) +``` + +**分析**: 基础用法下,构建器稍显冗长,但更清晰。 + +--- + +### 示例2: 复杂用法 + +#### 当前实现(假设扩展后) +```go +// 😢 参数太多,难以理解 +result, err := client.CallWithMessagesAdvanced( + "system prompt", + "user prompt", + nil, // messages history? + 0.8, // temperature + 2000, // max_tokens + 0.9, // top_p + 0.5, // frequency_penalty + 0.6, // presence_penalty + nil, // stop sequences + false, // stream + nil, // tools + "", // tool_choice +) +``` + +#### 构建器模式 +```go +// ✅ 清晰、自解释 +request := NewRequestBuilder(). + WithSystemPrompt("system prompt"). + WithUserPrompt("user prompt"). + WithTemperature(0.8). + WithMaxTokens(2000). + WithTopP(0.9). + WithFrequencyPenalty(0.5). + WithPresencePenalty(0.6). + Build() + +result, err := client.CallWithRequest(request) +``` + +**分析**: 复杂场景下,构建器模式优势明显。 + +--- + +## 是否需要引入? + +### ✅ 建议引入的情况 + +1. **需要支持多轮对话** + - 聊天机器人 + - 上下文相关的 AI 助手 + +2. **需要精细控制 AI 参数** + - 不同任务需要不同 temperature + - 需要使用 top_p、penalty 等高级参数 + +3. **需要使用 AI 高级功能** + - Function Calling / Tools + - 流式响应 + - Vision API(图片输入) + +4. **API 接口可能频繁变化** + - AI 提供商经常添加新参数 + - 需要向后兼容 + +### ⚠️ 可以暂缓的情况 + +1. **只有简单的单轮对话** + - 当前 `CallWithMessages` 已足够 + +2. **参数固定不变** + - 所有请求使用相同配置 + +3. **团队规模小,代码量少** + - 引入新模式的学习成本 > 收益 + +--- + +## 推荐方案 + +### 方案1: 渐进式引入(推荐) + +**第一阶段**: 保留现有 API,新增构建器 +```go +// 旧 API 继续工作(向后兼容) +result, err := client.CallWithMessages("system", "user") + +// 新 API 提供高级功能 +request := NewRequestBuilder(). + WithUserPrompt("user"). + WithTemperature(0.8). + Build() +result, err := client.CallWithRequest(request) +``` + +**第二阶段**: 逐步迁移 +```go +// 在文档中推荐使用构建器 +// 旧 API 标记为 Deprecated(但不删除) +``` + +### 方案2: 仅用于高级场景 + +只在需要复杂功能时使用构建器: +```go +// 简单场景:使用现有 API +client.CallWithMessages("system", "user") + +// 复杂场景:使用构建器 +client.CallWithRequest( + NewRequestBuilder(). + AddConversationHistory(history). + AddUserMessage("new question"). + WithTools(tools). + Build(), +) +``` + +--- + +## 实现示例 + +### 完整的构建器实现 + +```go +package mcp + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type Tool struct { + Type string `json:"type"` + Function FunctionDef `json:"function"` +} + +type Request struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + TopP float64 `json:"top_p,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` + Stop []string `json:"stop,omitempty"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +type RequestBuilder struct { + model string + messages []Message + temperature *float64 + maxTokens *int + topP *float64 + frequencyPenalty *float64 + presencePenalty *float64 + stop []string + tools []Tool + toolChoice string + stream bool +} + +func NewRequestBuilder() *RequestBuilder { + return &RequestBuilder{ + messages: make([]Message, 0), + } +} + +func (b *RequestBuilder) WithModel(model string) *RequestBuilder { + b.model = model + return b +} + +func (b *RequestBuilder) WithSystemPrompt(prompt string) *RequestBuilder { + if prompt != "" { + b.messages = append(b.messages, Message{ + Role: "system", + Content: prompt, + }) + } + return b +} + +func (b *RequestBuilder) WithUserPrompt(prompt string) *RequestBuilder { + b.messages = append(b.messages, Message{ + Role: "user", + Content: prompt, + }) + return b +} + +func (b *RequestBuilder) AddUserMessage(content string) *RequestBuilder { + return b.WithUserPrompt(content) +} + +func (b *RequestBuilder) AddSystemMessage(content string) *RequestBuilder { + return b.WithSystemPrompt(content) +} + +func (b *RequestBuilder) AddAssistantMessage(content string) *RequestBuilder { + b.messages = append(b.messages, Message{ + Role: "assistant", + Content: content, + }) + return b +} + +func (b *RequestBuilder) AddMessage(role, content string) *RequestBuilder { + b.messages = append(b.messages, Message{ + Role: role, + Content: content, + }) + return b +} + +func (b *RequestBuilder) AddConversationHistory(history []Message) *RequestBuilder { + b.messages = append(b.messages, history...) + return b +} + +func (b *RequestBuilder) WithTemperature(t float64) *RequestBuilder { + if t < 0 || t > 2 { + panic("temperature must be between 0 and 2") + } + b.temperature = &t + return b +} + +func (b *RequestBuilder) WithMaxTokens(tokens int) *RequestBuilder { + b.maxTokens = &tokens + return b +} + +func (b *RequestBuilder) WithTopP(p float64) *RequestBuilder { + b.topP = &p + return b +} + +func (b *RequestBuilder) WithFrequencyPenalty(p float64) *RequestBuilder { + b.frequencyPenalty = &p + return b +} + +func (b *RequestBuilder) WithPresencePenalty(p float64) *RequestBuilder { + b.presencePenalty = &p + return b +} + +func (b *RequestBuilder) WithStopSequences(sequences []string) *RequestBuilder { + b.stop = sequences + return b +} + +func (b *RequestBuilder) AddTool(tool Tool) *RequestBuilder { + b.tools = append(b.tools, tool) + return b +} + +func (b *RequestBuilder) WithToolChoice(choice string) *RequestBuilder { + b.toolChoice = choice + return b +} + +func (b *RequestBuilder) WithStream(stream bool) *RequestBuilder { + b.stream = stream + return b +} + +func (b *RequestBuilder) Build() (*Request, error) { + if len(b.messages) == 0 { + return nil, errors.New("at least one message is required") + } + + req := &Request{ + Model: b.model, + Messages: b.messages, + Stop: b.stop, + Tools: b.tools, + ToolChoice: b.toolChoice, + Stream: b.stream, + } + + // 只设置非 nil 的可选参数 + if b.temperature != nil { + req.Temperature = *b.temperature + } + if b.maxTokens != nil { + req.MaxTokens = *b.maxTokens + } + if b.topP != nil { + req.TopP = *b.topP + } + if b.frequencyPenalty != nil { + req.FrequencyPenalty = *b.frequencyPenalty + } + if b.presencePenalty != nil { + req.PresencePenalty = *b.presencePenalty + } + + return req, nil +} +``` + +### Client 集成 + +```go +// 新增方法(不影响现有代码) +func (client *Client) CallWithRequest(req *Request) (string, error) { + // 使用 req 中的参数发送请求 + // ... +} +``` + +--- + +## 总结 + +### 核心优势 +1. ✅ **灵活性** - 轻松支持复杂场景 +2. ✅ **可读性** - 代码自解释,易于理解 +3. ✅ **可扩展性** - 添加新功能不破坏现有代码 +4. ✅ **类型安全** - 编译时检查,提前发现错误 +5. ✅ **向后兼容** - 可以与现有 API 共存 + +### 建议 +- **当前阶段**: 如果只需要简单对话,现有实现已足够 +- **未来扩展**: 当需要以下功能时再引入 + - 多轮对话 + - Function Calling + - 流式响应 + - 精细参数控制 + +### 最佳实践 +采用**渐进式引入**策略: +1. 保留现有 `CallWithMessages` API +2. 新增 `CallWithRequest` + 构建器 +3. 在文档中推荐新 API,但不强制迁移 +4. 根据实际需求逐步完善构建器功能 + +这样既能保持向后兼容,又能为未来的功能扩展做好准备。 diff --git a/mcp/intro/LOGRUS_INTEGRATION.md b/mcp/intro/LOGRUS_INTEGRATION.md new file mode 100644 index 0000000000..13630e0232 --- /dev/null +++ b/mcp/intro/LOGRUS_INTEGRATION.md @@ -0,0 +1,268 @@ +# Logrus 集成指南 + +本文档展示如何将 MCP 模块与 Logrus 日志库集成。 + +## 📦 安装 Logrus + +```bash +go get github.com/sirupsen/logrus +``` + +## 🔧 集成步骤 + +### 1. 创建 Logrus 适配器 + +创建一个实现 `mcp.Logger` 接口的适配器: + +```go +package main + +import ( + "github.com/sirupsen/logrus" + "nofx/mcp" +) + +// LogrusLogger Logrus 日志适配器 +type LogrusLogger struct { + logger *logrus.Logger +} + +// NewLogrusLogger 创建 Logrus 日志适配器 +func NewLogrusLogger(logger *logrus.Logger) *LogrusLogger { + return &LogrusLogger{logger: logger} +} + +// Debugf 实现 Debug 日志 +func (l *LogrusLogger) Debugf(format string, args ...any) { + l.logger.Debugf(format, args...) +} + +// Infof 实现 Info 日志 +func (l *LogrusLogger) Infof(format string, args ...any) { + l.logger.Infof(format, args...) +} + +// Warnf 实现 Warn 日志 +func (l *LogrusLogger) Warnf(format string, args ...any) { + l.logger.Warnf(format, args...) +} + +// Errorf 实现 Error 日志 +func (l *LogrusLogger) Errorf(format string, args ...any) { + l.logger.Errorf(format, args...) +} +``` + +### 2. 使用 Logrus Logger + +```go +package main + +import ( + "github.com/sirupsen/logrus" + "nofx/mcp" +) + +func main() { + // 1. 创建 Logrus logger + logger := logrus.New() + + // 2. 配置 Logrus + logger.SetLevel(logrus.DebugLevel) + logger.SetFormatter(&logrus.JSONFormatter{}) + + // 3. 创建适配器 + logrusAdapter := NewLogrusLogger(logger) + + // 4. 使用 MCP 客户端 + client := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), + mcp.WithLogger(logrusAdapter), // 注入 Logrus 日志器 + ) + + // 5. 调用 AI + result, err := client.CallWithMessages("system", "user") + if err != nil { + logger.Errorf("AI 调用失败: %v", err) + return + } + + logger.Infof("AI 响应: %s", result) +} +``` + +## 🎨 高级配置 + +### JSON 格式输出 + +```go +logger := logrus.New() +logger.SetFormatter(&logrus.JSONFormatter{ + TimestampFormat: "2006-01-02 15:04:05", + PrettyPrint: true, +}) +``` + +输出示例: +```json +{ + "level": "info", + "msg": "📡 [Provider: deepseek, Model: deepseek-chat] Request AI Server: BaseURL: https://api.deepseek.com/v1", + "time": "2024-01-15 10:30:45" +} +``` + +### 添加固定字段 + +```go +logger := logrus.New() +logger.WithFields(logrus.Fields{ + "service": "trading-bot", + "version": "1.0.0", +}) +``` + +### 不同环境配置 + +```go +func createLogger(env string) *logrus.Logger { + logger := logrus.New() + + switch env { + case "production": + // 生产环境:JSON 格式,只记录 Info 以上 + logger.SetLevel(logrus.InfoLevel) + logger.SetFormatter(&logrus.JSONFormatter{}) + + case "development": + // 开发环境:文本格式,记录所有级别 + logger.SetLevel(logrus.DebugLevel) + logger.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + }) + + case "test": + // 测试环境:静默模式 + logger.SetLevel(logrus.FatalLevel) + } + + return logger +} + +// 使用 +logger := createLogger("production") +mcpClient := mcp.NewClient( + mcp.WithLogger(NewLogrusLogger(logger)), +) +``` + +## 📝 完整示例 + +```go +package main + +import ( + "os" + + "github.com/sirupsen/logrus" + "nofx/mcp" +) + +// LogrusLogger Logrus 适配器 +type LogrusLogger struct { + logger *logrus.Logger +} + +func NewLogrusLogger(logger *logrus.Logger) *LogrusLogger { + return &LogrusLogger{logger: logger} +} + +func (l *LogrusLogger) Debugf(format string, args ...any) { + l.logger.Debugf(format, args...) +} + +func (l *LogrusLogger) Infof(format string, args ...any) { + l.logger.Infof(format, args...) +} + +func (l *LogrusLogger) Warnf(format string, args ...any) { + l.logger.Warnf(format, args...) +} + +func (l *LogrusLogger) Errorf(format string, args ...any) { + l.logger.Errorf(format, args...) +} + +func main() { + // 创建 Logrus logger + logger := logrus.New() + logger.SetLevel(logrus.DebugLevel) + logger.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + ForceColors: true, + }) + logger.SetOutput(os.Stdout) + + // 创建 MCP 客户端 + client := mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey(os.Getenv("DEEPSEEK_API_KEY")), + mcp.WithLogger(NewLogrusLogger(logger)), + mcp.WithMaxRetries(5), + ) + + // 调用 AI + logger.Info("开始调用 AI...") + result, err := client.CallWithMessages( + "你是一个专业的量化交易顾问", + "分析 BTC 当前走势", + ) + + if err != nil { + logger.WithError(err).Error("AI 调用失败") + return + } + + logger.WithField("result", result).Info("AI 调用成功") +} +``` + +## 🔍 输出示例 + +### 开发环境(Text 格式) + +``` +INFO[2024-01-15 10:30:45] 开始调用 AI... +INFO[2024-01-15 10:30:45] 📡 [Provider: deepseek, Model: deepseek-chat] Request AI Server: BaseURL: https://api.deepseek.com/v1 +DEBUG[2024-01-15 10:30:45] [Provider: deepseek, Model: deepseek-chat] UseFullURL: false +DEBUG[2024-01-15 10:30:45] [Provider: deepseek, Model: deepseek-chat] API Key: sk-x...xxx +INFO[2024-01-15 10:30:45] 📡 [MCP Provider: deepseek, Model: deepseek-chat] 请求 URL: https://api.deepseek.com/v1/chat/completions +INFO[2024-01-15 10:30:46] AI 调用成功 result="[AI 响应内容]" +``` + +### 生产环境(JSON 格式) + +```json +{"level":"info","msg":"开始调用 AI...","time":"2024-01-15T10:30:45+08:00"} +{"level":"info","msg":"📡 [Provider: deepseek, Model: deepseek-chat] Request AI Server: BaseURL: https://api.deepseek.com/v1","time":"2024-01-15T10:30:45+08:00"} +{"level":"info","msg":"AI 调用成功","result":"[AI 响应内容]","time":"2024-01-15T10:30:46+08:00"} +``` + +## 🎯 最佳实践 + +1. **生产环境使用 JSON 格式**,便于日志收集和分析 +2. **开发环境使用 Text 格式**,便于阅读 +3. **测试环境关闭日志**,提高测试速度 +4. **添加请求 ID**,方便追踪请求链路 +5. **记录错误堆栈**,便于问题排查 + +## 📊 性能优化 + +Logrus 在高并发场景下可能有性能瓶颈,推荐使用 [Zap](https://github.com/uber-go/zap) 获得更好的性能。 + +MCP 模块也支持 Zap,集成方式类似。 + +## 🔗 相关资源 + +- [Logrus 官方文档](https://github.com/sirupsen/logrus) +- [Zap 集成示例](./ZAP_INTEGRATION.md) +- [MCP README](./README.md) diff --git a/mcp/intro/MIGRATION_GUIDE.md b/mcp/intro/MIGRATION_GUIDE.md new file mode 100644 index 0000000000..fa6655ec6d --- /dev/null +++ b/mcp/intro/MIGRATION_GUIDE.md @@ -0,0 +1,361 @@ +# MCP 模块重构迁移指南 + +## 📋 重构概览 + +本次重构采用**渐进式、向前兼容**的设计,现有代码**无需修改**即可继续使用,同时提供了更强大的新 API。 + +### 重构目标 + +- ✅ **100% 向前兼容** - 所有现有 API 继续工作 +- ✅ **模块独立** - 可作为独立 Go module 发布 +- ✅ **依赖可替换** - 日志、HTTP 客户端都可自定义 +- ✅ **易于测试** - 支持依赖注入和 mock +- ✅ **配置灵活** - 支持选项模式 (Functional Options) + +--- + +## 🔄 向前兼容保证 + +### ✅ 所有现有代码继续工作 + +```go +// ✅ 这些代码无需修改,继续正常工作 +mcpClient := mcp.New() +mcpClient.SetAPIKey(apiKey, url, model) + +// ✅ 这些也继续工作 +dsClient := mcp.NewDeepSeekClient() +qwenClient := mcp.NewQwenClient() +``` + +**重要**:虽然标记为 `Deprecated`,但这些函数会一直保留,不会被删除。 + +--- + +## 🆕 新特性使用指南 + +### 1. 基础用法(推荐) + +```go +// 新的推荐用法 +client := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), + mcp.WithTimeout(60 * time.Second), +) +``` + +### 2. 自定义日志 + +```go +// 使用自定义日志器(如 zap, logrus) +type MyLogger struct { + zapLogger *zap.Logger +} + +func (l *MyLogger) Info(msg string, args ...any) { + l.zapLogger.Sugar().Infof(msg, args...) +} + +// 注入自定义日志器 +client := mcp.NewClient( + mcp.WithLogger(&MyLogger{zapLogger}), +) +``` + +### 3. 自定义 HTTP 客户端 + +```go +// 添加代理、追踪、自定义 TLS 等 +customHTTP := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{/* ... */}, + }, +} + +client := mcp.NewClient( + mcp.WithHTTPClient(customHTTP), +) +``` + +### 4. 测试场景 + +```go +func TestMyCode(t *testing.T) { + // Mock HTTP 客户端 + mockHTTP := &MockHTTPClient{ + // 返回预设的响应 + } + + // 禁用日志 + client := mcp.NewClient( + mcp.WithHTTPClient(mockHTTP), + mcp.WithLogger(mcp.NewNoopLogger()), + ) + + // 测试... +} +``` + +### 5. 组合多个选项 + +```go +client := mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey("sk-xxx"), + mcp.WithLogger(customLogger), + mcp.WithTimeout(60 * time.Second), + mcp.WithMaxRetries(5), + mcp.WithMaxTokens(4000), +) +``` + +--- + +## 📊 API 对比表 + +### 构造函数对比 + +| 旧 API (仍可用) | 新 API (推荐) | 说明 | +|----------------|--------------|------| +| `mcp.New()` | `mcp.NewClient(opts...)` | 支持选项模式 | +| `mcp.NewDeepSeekClient()` | `mcp.NewDeepSeekClientWithOptions(opts...)` | 支持自定义配置 | +| `mcp.NewQwenClient()` | `mcp.NewQwenClientWithOptions(opts...)` | 支持自定义配置 | + +### 配置选项 + +| 选项函数 | 说明 | 使用示例 | +|---------|------|---------| +| `WithLogger(logger)` | 自定义日志器 | `WithLogger(zapLogger)` | +| `WithHTTPClient(client)` | 自定义 HTTP 客户端 | `WithHTTPClient(customHTTP)` | +| `WithTimeout(duration)` | 设置超时 | `WithTimeout(60*time.Second)` | +| `WithMaxRetries(n)` | 设置重试次数 | `WithMaxRetries(5)` | +| `WithMaxTokens(n)` | 设置最大 token | `WithMaxTokens(4000)` | +| `WithTemperature(t)` | 设置温度参数 | `WithTemperature(0.7)` | +| `WithAPIKey(key)` | 设置 API Key | `WithAPIKey("sk-xxx")` | +| `WithDeepSeekConfig(key)` | 快速配置 DeepSeek | `WithDeepSeekConfig("sk-xxx")` | +| `WithQwenConfig(key)` | 快速配置 Qwen | `WithQwenConfig("sk-xxx")` | + +--- + +## 🔧 迁移步骤 + +### Phase 1: 继续使用现有代码(无需改动) + +```go +// trader/auto_trader.go 中的现有代码 +mcpClient := mcp.New() + +if config.AIModel == "qwen" { + mcpClient = mcp.NewQwenClient() + mcpClient.SetAPIKey(config.QwenKey, config.CustomAPIURL, config.CustomModelName) +} else { + mcpClient = mcp.NewDeepSeekClient() + mcpClient.SetAPIKey(config.DeepSeekKey, config.CustomAPIURL, config.CustomModelName) +} + +// ✅ 继续工作,无需修改 +``` + +### Phase 2: 可选升级到新 API(推荐) + +```go +// 升级后的代码(可选) +var mcpClient mcp.AIClient + +if config.AIModel == "qwen" { + mcpClient = mcp.NewQwenClientWithOptions( + mcp.WithAPIKey(config.QwenKey), + mcp.WithBaseURL(config.CustomAPIURL), + mcp.WithModel(config.CustomModelName), + ) +} else { + mcpClient = mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey(config.DeepSeekKey), + mcp.WithBaseURL(config.CustomAPIURL), + mcp.WithModel(config.CustomModelName), + ) +} +``` + +### Phase 3: 添加自定义配置(高级) + +```go +// 添加自定义日志 +customLogger := &MyZapLogger{zap.NewProduction()} + +mcpClient := mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey(config.DeepSeekKey), + mcp.WithLogger(customLogger), // 自定义日志 + mcp.WithTimeout(90 * time.Second), // 自定义超时 + mcp.WithMaxRetries(5), // 自定义重试次数 +) +``` + +--- + +## 🎯 实际使用场景 + +### 场景 1: 开发环境详细日志 + +```go +// 开发环境:使用详细日志 +devClient := mcp.NewClient( + mcp.WithDeepSeekConfig(apiKey), + mcp.WithLogger(&defaultLogger{}), // 详细日志 +) +``` + +### 场景 2: 生产环境结构化日志 + +```go +// 生产环境:使用 zap 结构化日志 +zapLogger, _ := zap.NewProduction() +prodClient := mcp.NewClient( + mcp.WithDeepSeekConfig(apiKey), + mcp.WithLogger(&ZapLogger{zapLogger}), +) +``` + +### 场景 3: 测试环境 Mock + +```go +// 测试环境:Mock HTTP 响应 +mockHTTP := &MockHTTPClient{ + Response: `{"choices":[{"message":{"content":"test"}}]}`, +} + +testClient := mcp.NewClient( + mcp.WithHTTPClient(mockHTTP), + mcp.WithLogger(mcp.NewNoopLogger()), // 禁用日志 +) +``` + +### 场景 4: 需要代理的网络环境 + +```go +// 使用代理 +proxyURL, _ := url.Parse("http://proxy.company.com:8080") +proxyClient := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + }, +} + +client := mcp.NewClient( + mcp.WithDeepSeekConfig(apiKey), + mcp.WithHTTPClient(proxyClient), +) +``` + +--- + +## 📦 作为独立模块发布 + +重构后,mcp 模块可以独立发布: + +### go.mod +```go +module github.com/yourorg/mcp + +go 1.21 + +// 无外部依赖! +``` + +### 使用方 +```go +import "github.com/yourorg/mcp" + +client := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), +) +``` + +--- + +## 🧪 测试支持 + +### Mock 示例 + +```go +package mypackage_test + +import ( + "testing" + "github.com/stretchr/testify/assert" + "nofx/mcp" +) + +type MockHTTPClient struct { + Response string + Error error +} + +func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) { + if m.Error != nil { + return nil, m.Error + } + + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(m.Response)), + }, nil +} + +func TestAIIntegration(t *testing.T) { + // Arrange + mockHTTP := &MockHTTPClient{ + Response: `{"choices":[{"message":{"content":"success"}}]}`, + } + + client := mcp.NewClient( + mcp.WithHTTPClient(mockHTTP), + mcp.WithLogger(mcp.NewNoopLogger()), + ) + + // Act + result, err := client.CallWithMessages("system", "user") + + // Assert + assert.NoError(t, err) + assert.Equal(t, "success", result) +} +``` + +--- + +## ⚠️ 注意事项 + +1. **向前兼容性** + - 所有 `Deprecated` 的 API 会永久保留 + - 现有代码可以继续使用,不会被破坏 + +2. **渐进式迁移** + - 不需要一次性迁移所有代码 + - 可以逐步采用新 API + +3. **配置优先级** + - 用户传入的选项优先级最高 + - 环境变量次之 + - 默认配置最低 + +4. **日志器接口** + - 可以适配任何日志库(zap, logrus, etc.) + - 测试时可以使用 `NewNoopLogger()` 禁用日志 + +--- + +## 📚 进一步阅读 + +- [选项模式详解](https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis) +- [依赖注入最佳实践](https://go.dev/blog/wire) +- [Go 接口设计原则](https://go.dev/blog/laws-of-reflection) + +--- + +## 🤝 贡献 + +欢迎提交 issue 和 PR! + +如有问题,请联系:[your-email@example.com] diff --git a/mcp/intro/README.md b/mcp/intro/README.md new file mode 100644 index 0000000000..509b4c9c29 --- /dev/null +++ b/mcp/intro/README.md @@ -0,0 +1,379 @@ +# MCP - Model Context Protocol Client + +一个灵活、可扩展的 AI 模型客户端库,支持 DeepSeek、Qwen 等多种 AI 提供商。 + +## ✨ 特性 + +- 🔌 **多 Provider 支持** - DeepSeek、Qwen、OpenAI 兼容 API +- 🎯 **模板方法模式** - 固定流程,可扩展步骤 +- 🏗️ **构建器模式** - 支持多轮对话、Function Calling、精细参数控制 +- 📦 **零外部依赖** - 仅使用 Go 标准库 +- 🔧 **高度可配置** - 支持 Functional Options 模式 +- 🧪 **易于测试** - 支持依赖注入和 Mock +- ⚡ **向前兼容** - 现有代码无需修改 +- 📝 **丰富的日志** - 可替换的日志接口 + +## 🚀 快速开始 + +### 基础用法 + +```go +import "nofx/mcp" + +// 创建客户端 +client := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), +) + +// 调用 AI +result, err := client.CallWithMessages("system prompt", "user prompt") +if err != nil { + log.Fatal(err) +} + +fmt.Println(result) +``` + +### DeepSeek 客户端 + +```go +client := mcp.NewDeepSeekClientWithOptions( + mcp.WithAPIKey("sk-xxx"), + mcp.WithTimeout(60 * time.Second), +) +``` + +### Qwen 客户端 + +```go +client := mcp.NewQwenClientWithOptions( + mcp.WithAPIKey("sk-xxx"), + mcp.WithMaxTokens(4000), +) +``` + +### 🏗️ 构建器模式(高级功能) + +构建器模式支持多轮对话、精细参数控制、Function Calling 等高级功能。 + +#### 简单用法 + +```go +// 使用构建器创建请求 +request := mcp.NewRequestBuilder(). + WithSystemPrompt("You are helpful"). + WithUserPrompt("What is Go?"). + WithTemperature(0.8). + Build() + +result, err := client.CallWithRequest(request) +``` + +#### 多轮对话 + +```go +// 构建包含历史的多轮对话 +request := mcp.NewRequestBuilder(). + AddSystemMessage("You are a trading advisor"). + AddUserMessage("Analyze BTC"). + AddAssistantMessage("BTC is bullish..."). + AddUserMessage("What about entry point?"). // 继续对话 + WithTemperature(0.3). + Build() + +result, err := client.CallWithRequest(request) +``` + +#### 预设场景 + +```go +// 代码生成(低温度、精确) +request := mcp.ForCodeGeneration(). + WithUserPrompt("Generate a HTTP server"). + Build() + +// 创意写作(高温度、随机) +request := mcp.ForCreativeWriting(). + WithUserPrompt("Write a story"). + Build() + +// 聊天(平衡参数) +request := mcp.ForChat(). + WithUserPrompt("Hello"). + Build() +``` + +#### Function Calling + +```go +// 定义工具 +weatherParams := map[string]any{ + "type": "object", + "properties": map[string]any{ + "location": map[string]any{"type": "string"}, + }, +} + +request := mcp.NewRequestBuilder(). + WithUserPrompt("北京天气怎么样?"). + AddFunction("get_weather", "Get weather", weatherParams). + WithToolChoice("auto"). + Build() + +result, err := client.CallWithRequest(request) +``` + +## 📖 详细文档 + +- [构建器模式完整示例](./BUILDER_EXAMPLES.md) - 多轮对话、Function Calling、参数控制 +- [构建器模式价值分析](./BUILDER_PATTERN_BENEFITS.md) - 为什么引入构建器模式 +- [迁移指南](./MIGRATION_GUIDE.md) - 从旧 API 迁移到新 API +- [Logrus 集成](./LOGRUS_INTEGRATION.md) - 日志框架集成示例 +- [代码审查报告](./CODE_REVIEW.md) - 问题分析和修复记录 + +## 🎛️ 配置选项 + +### 依赖注入 + +```go +// 自定义日志器 +mcp.WithLogger(customLogger) + +// 自定义 HTTP 客户端 +mcp.WithHTTPClient(customHTTP) +``` + +### 超时和重试 + +```go +mcp.WithTimeout(60 * time.Second) +mcp.WithMaxRetries(5) +mcp.WithRetryWaitBase(3 * time.Second) +``` + +### AI 参数 + +```go +mcp.WithMaxTokens(4000) +mcp.WithTemperature(0.7) +``` + +### Provider 配置 + +```go +// 快速配置 DeepSeek +mcp.WithDeepSeekConfig("sk-xxx") + +// 快速配置 Qwen +mcp.WithQwenConfig("sk-xxx") + +// 自定义配置 +mcp.WithAPIKey("sk-xxx") +mcp.WithBaseURL("https://api.custom.com") +mcp.WithModel("gpt-4") +``` + +## 🧪 测试 + +```go +// 使用 Mock HTTP 客户端 +mockHTTP := &MockHTTPClient{ + Response: `{"choices":[{"message":{"content":"test"}}]}`, +} + +client := mcp.NewClient( + mcp.WithHTTPClient(mockHTTP), + mcp.WithLogger(mcp.NewNoopLogger()), // 禁用日志 +) +``` + +## 🏗️ 架构设计 + +### 模板方法模式 + +``` +CallWithMessages (固定重试流程) + ↓ +call (固定调用流程) + ↓ +hooks (可重写的步骤) + ├─ buildMCPRequestBody + ├─ marshalRequestBody + ├─ buildUrl + ├─ setAuthHeader + ├─ parseMCPResponse + └─ isRetryableError +``` + +### 接口分离 + +```go +// 公开接口(给外部使用) +type AIClient interface { + SetAPIKey(...) + SetTimeout(...) + CallWithMessages(...) (string, error) +} + +// 内部钩子接口(供子类重写) +type clientHooks interface { + buildMCPRequestBody(...) map[string]any + buildUrl() string + setAuthHeader(...) + marshalRequestBody(...) ([]byte, error) + parseMCPResponse(...) (string, error) + isRetryableError(...) bool +} +``` + +## 🔄 向前兼容 + +所有旧 API 继续工作: + +```go +// ✅ 旧代码无需修改 +client := mcp.New() +client.SetAPIKey("sk-xxx", "https://api.custom.com", "gpt-4") + +dsClient := mcp.NewDeepSeekClient() +dsClient.SetAPIKey("sk-xxx", "", "") +``` + +## 📦 作为独立模块使用 + +```go +// go.mod +module github.com/yourorg/yourproject + +require github.com/yourorg/mcp v1.0.0 +``` + +```go +// main.go +import "github.com/yourorg/mcp" + +client := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), +) +``` + +## 🤝 扩展自定义 Provider + +```go +type CustomProvider struct { + *mcp.Client +} + +// 重写特定钩子 +func (c *CustomProvider) buildUrl() string { + return c.BaseURL + "/custom/endpoint" +} + +func (c *CustomProvider) setAuthHeader(headers http.Header) { + headers.Set("X-Custom-Auth", c.APIKey) +} +``` + +## 📝 日志器适配示例 + +### Zap 日志器 + +```go +type ZapLogger struct { + logger *zap.Logger +} + +func (l *ZapLogger) Infof(format string, args ...any) { + l.logger.Sugar().Infof(format, args...) +} + +func (l *ZapLogger) Debugf(format string, args ...any) { + l.logger.Sugar().Debugf(format, args...) +} + +// 使用 +client := mcp.NewClient( + mcp.WithLogger(&ZapLogger{zapLogger}), +) +``` + +### Logrus 日志器 + +```go +type LogrusLogger struct { + logger *logrus.Logger +} + +func (l *LogrusLogger) Infof(format string, args ...any) { + l.logger.Infof(format, args...) +} + +func (l *LogrusLogger) Debugf(format string, args ...any) { + l.logger.Debugf(format, args...) +} +``` + +## 🎯 使用场景 + +### 开发环境 + +```go +devClient := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), + mcp.WithLogger(&customLogger{}), // 详细日志 +) +``` + +### 生产环境 + +```go +prodClient := mcp.NewClient( + mcp.WithDeepSeekConfig("sk-xxx"), + mcp.WithLogger(&zapLogger{}), // 结构化日志 + mcp.WithTimeout(30*time.Second), // 超时保护 + mcp.WithMaxRetries(3), // 重试保护 +) +``` + +### 测试环境 + +```go +testClient := mcp.NewClient( + mcp.WithHTTPClient(mockHTTP), + mcp.WithLogger(mcp.NewNoopLogger()), +) +``` + +## 📊 性能特性 + +- ✅ HTTP 连接复用 +- ✅ 智能重试机制 +- ✅ 可配置超时 +- ✅ 零分配日志(使用 NoopLogger) + +## 🛡️ 安全性 + +- ✅ API Key 部分脱敏日志 +- ✅ HTTPS 默认启用 +- ✅ 支持自定义 TLS 配置 +- ✅ 请求超时保护 + +## 📈 版本兼容性 + +- Go 1.18+ +- 向前兼容保证 +- 语义化版本管理 + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request! + +## 📄 许可证 + +MIT License + +## 🔗 相关链接 + +- [DeepSeek API 文档](https://platform.deepseek.com/docs) +- [Qwen API 文档](https://help.aliyun.com/zh/dashscope/) +- [OpenAI API 文档](https://platform.openai.com/docs) diff --git a/mcp/logger.go b/mcp/logger.go new file mode 100644 index 0000000000..863310dbc5 --- /dev/null +++ b/mcp/logger.go @@ -0,0 +1,68 @@ +package mcp + +import "log" + +// Logger 日志接口(抽象依赖) +// 使用 Printf 风格的方法名,方便集成 logrus、zap 等主流日志库 +type Logger interface { + Debugf(format string, args ...any) + Infof(format string, args ...any) + Warnf(format string, args ...any) + Errorf(format string, args ...any) +} + +// defaultLogger 默认日志实现(包装标准库 log) +type defaultLogger struct{} + +func (l *defaultLogger) Debugf(format string, args ...any) { + log.Printf("[DEBUG] "+format, args...) +} + +func (l *defaultLogger) Infof(format string, args ...any) { + log.Printf("[INFO] "+format, args...) +} + +func (l *defaultLogger) Warnf(format string, args ...any) { + log.Printf("[WARN] "+format, args...) +} + +func (l *defaultLogger) Errorf(format string, args ...any) { + log.Printf("[ERROR] "+format, args...) +} + +// noopLogger 空日志实现(测试时使用) +type noopLogger struct{} + +func (l *noopLogger) Debugf(format string, args ...any) {} +func (l *noopLogger) Infof(format string, args ...any) {} +func (l *noopLogger) Warnf(format string, args ...any) {} +func (l *noopLogger) Errorf(format string, args ...any) {} + +// NewNoopLogger 创建空日志器(测试使用) +func NewNoopLogger() Logger { + return &noopLogger{} +} + +// ============================================================ +// 适配第三方日志库示例 +// ============================================================ + +// Logrus 适配示例: +// type LogrusLogger struct { +// logger *logrus.Logger +// } +// +// func (l *LogrusLogger) Infof(format string, args ...any) { +// l.logger.Infof(format, args...) +// } +// +// Zap 适配示例: +// type ZapLogger struct { +// logger *zap.Logger +// } +// +// func (l *ZapLogger) Infof(format string, args ...any) { +// l.logger.Sugar().Infof(format, args...) +// } +// +// 然后通过 WithLogger(logger) 注入 diff --git a/mcp/mock_test.go b/mcp/mock_test.go new file mode 100644 index 0000000000..6c39b09960 --- /dev/null +++ b/mcp/mock_test.go @@ -0,0 +1,310 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" +) + +// ============================================================ +// Mock Logger +// ============================================================ + +// MockLogger Mock 日志器(用于测试) +type MockLogger struct { + mu sync.Mutex + Logs []LogEntry + Enabled bool // 是否启用日志记录 +} + +// LogEntry 日志条目 +type LogEntry struct { + Level string + Format string + Args []any + Message string // 格式化后的消息 +} + +func NewMockLogger() *MockLogger { + return &MockLogger{ + Logs: make([]LogEntry, 0), + Enabled: true, + } +} + +func (m *MockLogger) Debugf(format string, args ...any) { + m.log("DEBUG", format, args...) +} + +func (m *MockLogger) Infof(format string, args ...any) { + m.log("INFO", format, args...) +} + +func (m *MockLogger) Warnf(format string, args ...any) { + m.log("WARN", format, args...) +} + +func (m *MockLogger) Errorf(format string, args ...any) { + m.log("ERROR", format, args...) +} + +func (m *MockLogger) log(level, format string, args ...any) { + if !m.Enabled { + return + } + + m.mu.Lock() + defer m.mu.Unlock() + + message := fmt.Sprintf(format, args...) + m.Logs = append(m.Logs, LogEntry{ + Level: level, + Format: format, + Args: args, + Message: message, + }) +} + +// GetLogs 获取所有日志 +func (m *MockLogger) GetLogs() []LogEntry { + m.mu.Lock() + defer m.mu.Unlock() + return append([]LogEntry{}, m.Logs...) +} + +// GetLogsByLevel 获取指定级别的日志 +func (m *MockLogger) GetLogsByLevel(level string) []LogEntry { + m.mu.Lock() + defer m.mu.Unlock() + + var result []LogEntry + for _, log := range m.Logs { + if log.Level == level { + result = append(result, log) + } + } + return result +} + +// Clear 清空日志 +func (m *MockLogger) Clear() { + m.mu.Lock() + defer m.mu.Unlock() + m.Logs = make([]LogEntry, 0) +} + +// HasLog 检查是否包含指定消息 +func (m *MockLogger) HasLog(level, message string) bool { + m.mu.Lock() + defer m.mu.Unlock() + + for _, log := range m.Logs { + if log.Level == level && log.Message == message { + return true + } + } + return false +} + +// ============================================================ +// Mock HTTP Client (实现 http.RoundTripper) +// ============================================================ + +// MockHTTPClient Mock HTTP 客户端(实现 http.RoundTripper) +type MockHTTPClient struct { + mu sync.Mutex + + // 配置 + Response string + StatusCode int + Error error + ResponseFunc func(req *http.Request) (*http.Response, error) // 自定义响应函数 + + // 记录 + Requests []*http.Request +} + +func NewMockHTTPClient() *MockHTTPClient { + return &MockHTTPClient{ + StatusCode: http.StatusOK, + Requests: make([]*http.Request, 0), + } +} + +// ToHTTPClient 转换为 http.Client +func (m *MockHTTPClient) ToHTTPClient() *http.Client { + return &http.Client{ + Transport: m, + } +} + +// RoundTrip 实现 http.RoundTripper 接口 +func (m *MockHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // 记录请求 + m.Requests = append(m.Requests, req) + + // 如果有自定义响应函数,使用它 + if m.ResponseFunc != nil { + return m.ResponseFunc(req) + } + + // 如果设置了错误,返回错误 + if m.Error != nil { + return nil, m.Error + } + + // 返回模拟响应 + resp := &http.Response{ + StatusCode: m.StatusCode, + Body: io.NopCloser(bytes.NewBufferString(m.Response)), + Header: make(http.Header), + } + + return resp, nil +} + +// GetRequests 获取所有请求 +func (m *MockHTTPClient) GetRequests() []*http.Request { + m.mu.Lock() + defer m.mu.Unlock() + return append([]*http.Request{}, m.Requests...) +} + +// GetLastRequest 获取最后一次请求 +func (m *MockHTTPClient) GetLastRequest() *http.Request { + m.mu.Lock() + defer m.mu.Unlock() + + if len(m.Requests) == 0 { + return nil + } + return m.Requests[len(m.Requests)-1] +} + +// Reset 重置状态 +func (m *MockHTTPClient) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.Requests = make([]*http.Request, 0) +} + +// SetSuccessResponse 设置成功响应 +func (m *MockHTTPClient) SetSuccessResponse(content string) { + m.mu.Lock() + defer m.mu.Unlock() + + m.StatusCode = http.StatusOK + m.Response = `{"choices":[{"message":{"content":"` + content + `"}}]}` + m.Error = nil +} + +// SetErrorResponse 设置错误响应 +func (m *MockHTTPClient) SetErrorResponse(statusCode int, message string) { + m.mu.Lock() + defer m.mu.Unlock() + + m.StatusCode = statusCode + m.Response = message + m.Error = nil +} + +// SetNetworkError 设置网络错误 +func (m *MockHTTPClient) SetNetworkError(err error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.Error = err +} + +// ============================================================ +// Mock Client Hooks (用于测试钩子机制) +// ============================================================ + +// MockClientHooks Mock 客户端钩子 +type MockClientHooks struct { + BuildRequestBodyCalled int + BuildUrlCalled int + SetAuthHeaderCalled int + MarshalRequestCalled int + ParseResponseCalled int + IsRetryableErrorCalled int + + // 自定义返回值 + BuildUrlFunc func() string + ParseResponseFunc func([]byte) (string, error) + IsRetryableErrorFunc func(error) bool + BuildRequestBodyFunc func(string, string) map[string]any + MarshalRequestBodyFunc func(map[string]any) ([]byte, error) +} + +func NewMockClientHooks() *MockClientHooks { + return &MockClientHooks{} +} + +func (m *MockClientHooks) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any { + m.BuildRequestBodyCalled++ + if m.BuildRequestBodyFunc != nil { + return m.BuildRequestBodyFunc(systemPrompt, userPrompt) + } + return map[string]any{ + "model": "test-model", + "messages": []map[string]string{ + {"role": "system", "content": systemPrompt}, + {"role": "user", "content": userPrompt}, + }, + } +} + +func (m *MockClientHooks) buildUrl() string { + m.BuildUrlCalled++ + if m.BuildUrlFunc != nil { + return m.BuildUrlFunc() + } + return "https://api.test.com/chat/completions" +} + +func (m *MockClientHooks) setAuthHeader(headers http.Header) { + m.SetAuthHeaderCalled++ + headers.Set("Authorization", "Bearer test-key") +} + +func (m *MockClientHooks) marshalRequestBody(body map[string]any) ([]byte, error) { + m.MarshalRequestCalled++ + if m.MarshalRequestBodyFunc != nil { + return m.MarshalRequestBodyFunc(body) + } + return json.Marshal(body) +} + +func (m *MockClientHooks) parseMCPResponse(body []byte) (string, error) { + m.ParseResponseCalled++ + if m.ParseResponseFunc != nil { + return m.ParseResponseFunc(body) + } + return "mocked response", nil +} + +func (m *MockClientHooks) isRetryableError(err error) bool { + m.IsRetryableErrorCalled++ + if m.IsRetryableErrorFunc != nil { + return m.IsRetryableErrorFunc(err) + } + return false +} + +func (m *MockClientHooks) buildRequest(url string, jsonData []byte) (*http.Request, error) { + req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + m.setAuthHeader(req.Header) + return req, nil +} + +func (m *MockClientHooks) call(systemPrompt, userPrompt string) (string, error) { + return "mocked call result", nil +} diff --git a/mcp/options.go b/mcp/options.go new file mode 100644 index 0000000000..c460224fd7 --- /dev/null +++ b/mcp/options.go @@ -0,0 +1,162 @@ +package mcp + +import ( + "net/http" + "time" +) + +// ClientOption 客户端选项函数(Functional Options 模式) +type ClientOption func(*Config) + +// ============================================================ +// 依赖注入选项 +// ============================================================ + +// WithLogger 设置自定义日志器 +// +// 使用示例: +// client := mcp.NewClient(mcp.WithLogger(customLogger)) +func WithLogger(logger Logger) ClientOption { + return func(c *Config) { + c.Logger = logger + } +} + +// WithHTTPClient 设置自定义 HTTP 客户端 +// +// 使用示例: +// httpClient := &http.Client{Timeout: 60 * time.Second} +// client := mcp.NewClient(mcp.WithHTTPClient(httpClient)) +func WithHTTPClient(client *http.Client) ClientOption { + return func(c *Config) { + c.HTTPClient = client + } +} + +// ============================================================ +// 超时和重试选项 +// ============================================================ + +// WithTimeout 设置请求超时时间 +// +// 使用示例: +// client := mcp.NewClient(mcp.WithTimeout(60 * time.Second)) +func WithTimeout(timeout time.Duration) ClientOption { + return func(c *Config) { + c.Timeout = timeout + c.HTTPClient.Timeout = timeout + } +} + +// WithMaxRetries 设置最大重试次数 +// +// 使用示例: +// client := mcp.NewClient(mcp.WithMaxRetries(5)) +func WithMaxRetries(maxRetries int) ClientOption { + return func(c *Config) { + c.MaxRetries = maxRetries + } +} + +// WithRetryWaitBase 设置重试等待基础时长 +// +// 使用示例: +// client := mcp.NewClient(mcp.WithRetryWaitBase(3 * time.Second)) +func WithRetryWaitBase(waitTime time.Duration) ClientOption { + return func(c *Config) { + c.RetryWaitBase = waitTime + } +} + +// ============================================================ +// AI 参数选项 +// ============================================================ + +// WithMaxTokens 设置最大 token 数 +// +// 使用示例: +// client := mcp.NewClient(mcp.WithMaxTokens(4000)) +func WithMaxTokens(maxTokens int) ClientOption { + return func(c *Config) { + c.MaxTokens = maxTokens + } +} + +// WithTemperature 设置温度参数 +// +// 使用示例: +// client := mcp.NewClient(mcp.WithTemperature(0.7)) +func WithTemperature(temperature float64) ClientOption { + return func(c *Config) { + c.Temperature = temperature + } +} + +// ============================================================ +// Provider 配置选项 +// ============================================================ + +// WithAPIKey 设置 API Key +func WithAPIKey(apiKey string) ClientOption { + return func(c *Config) { + c.APIKey = apiKey + } +} + +// WithBaseURL 设置基础 URL +func WithBaseURL(baseURL string) ClientOption { + return func(c *Config) { + c.BaseURL = baseURL + } +} + +// WithModel 设置模型名称 +func WithModel(model string) ClientOption { + return func(c *Config) { + c.Model = model + } +} + +// WithProvider 设置提供商 +func WithProvider(provider string) ClientOption { + return func(c *Config) { + c.Provider = provider + } +} + +// WithUseFullURL 设置是否使用完整 URL +func WithUseFullURL(useFullURL bool) ClientOption { + return func(c *Config) { + c.UseFullURL = useFullURL + } +} + +// ============================================================ +// 组合选项(便捷方法) +// ============================================================ + +// WithDeepSeekConfig 设置 DeepSeek 配置 +// +// 使用示例: +// client := mcp.NewClient(mcp.WithDeepSeekConfig("sk-xxx")) +func WithDeepSeekConfig(apiKey string) ClientOption { + return func(c *Config) { + c.Provider = ProviderDeepSeek + c.APIKey = apiKey + c.BaseURL = DefaultDeepSeekBaseURL + c.Model = DefaultDeepSeekModel + } +} + +// WithQwenConfig 设置 Qwen 配置 +// +// 使用示例: +// client := mcp.NewClient(mcp.WithQwenConfig("sk-xxx")) +func WithQwenConfig(apiKey string) ClientOption { + return func(c *Config) { + c.Provider = ProviderQwen + c.APIKey = apiKey + c.BaseURL = DefaultQwenBaseURL + c.Model = DefaultQwenModel + } +} diff --git a/mcp/options_test.go b/mcp/options_test.go new file mode 100644 index 0000000000..67ad5b9b7f --- /dev/null +++ b/mcp/options_test.go @@ -0,0 +1,365 @@ +package mcp + +import ( + "net/http" + "testing" + "time" +) + +// ============================================================ +// 测试基础选项 +// ============================================================ + +func TestWithProvider(t *testing.T) { + cfg := DefaultConfig() + WithProvider("custom-provider")(cfg) + + if cfg.Provider != "custom-provider" { + t.Errorf("expected 'custom-provider', got '%s'", cfg.Provider) + } +} + +func TestWithAPIKey(t *testing.T) { + cfg := DefaultConfig() + WithAPIKey("sk-test-key")(cfg) + + if cfg.APIKey != "sk-test-key" { + t.Errorf("expected 'sk-test-key', got '%s'", cfg.APIKey) + } +} + +func TestWithBaseURL(t *testing.T) { + cfg := DefaultConfig() + WithBaseURL("https://api.test.com")(cfg) + + if cfg.BaseURL != "https://api.test.com" { + t.Errorf("expected 'https://api.test.com', got '%s'", cfg.BaseURL) + } +} + +func TestWithModel(t *testing.T) { + cfg := DefaultConfig() + WithModel("test-model")(cfg) + + if cfg.Model != "test-model" { + t.Errorf("expected 'test-model', got '%s'", cfg.Model) + } +} + +func TestWithMaxTokens(t *testing.T) { + cfg := DefaultConfig() + WithMaxTokens(4000)(cfg) + + if cfg.MaxTokens != 4000 { + t.Errorf("expected 4000, got %d", cfg.MaxTokens) + } +} + +func TestWithTemperature(t *testing.T) { + cfg := DefaultConfig() + WithTemperature(0.8)(cfg) + + if cfg.Temperature != 0.8 { + t.Errorf("expected 0.8, got %f", cfg.Temperature) + } +} + +func TestWithUseFullURL(t *testing.T) { + cfg := DefaultConfig() + WithUseFullURL(true)(cfg) + + if !cfg.UseFullURL { + t.Error("UseFullURL should be true") + } +} + +func TestWithMaxRetries(t *testing.T) { + cfg := DefaultConfig() + WithMaxRetries(5)(cfg) + + if cfg.MaxRetries != 5 { + t.Errorf("expected 5, got %d", cfg.MaxRetries) + } +} + +func TestWithTimeout(t *testing.T) { + cfg := DefaultConfig() + WithTimeout(60 * time.Second)(cfg) + + if cfg.Timeout != 60*time.Second { + t.Errorf("expected 60s, got %v", cfg.Timeout) + } +} + +func TestWithLogger(t *testing.T) { + cfg := DefaultConfig() + mockLogger := NewMockLogger() + WithLogger(mockLogger)(cfg) + + if cfg.Logger != mockLogger { + t.Error("Logger should be set to mockLogger") + } +} + +func TestWithHTTPClient(t *testing.T) { + cfg := DefaultConfig() + customClient := &http.Client{Timeout: 30 * time.Second} + WithHTTPClient(customClient)(cfg) + + if cfg.HTTPClient != customClient { + t.Error("HTTPClient should be set to customClient") + } + + if cfg.HTTPClient.Timeout != 30*time.Second { + t.Errorf("expected 30s, got %v", cfg.HTTPClient.Timeout) + } +} + +// ============================================================ +// 测试预设配置选项 +// ============================================================ + +func TestWithDeepSeekConfig(t *testing.T) { + cfg := DefaultConfig() + WithDeepSeekConfig("sk-deepseek-key")(cfg) + + if cfg.Provider != ProviderDeepSeek { + t.Errorf("Provider should be '%s', got '%s'", ProviderDeepSeek, cfg.Provider) + } + + if cfg.APIKey != "sk-deepseek-key" { + t.Errorf("APIKey should be 'sk-deepseek-key', got '%s'", cfg.APIKey) + } + + if cfg.BaseURL != DefaultDeepSeekBaseURL { + t.Errorf("BaseURL should be '%s', got '%s'", DefaultDeepSeekBaseURL, cfg.BaseURL) + } + + if cfg.Model != DefaultDeepSeekModel { + t.Errorf("Model should be '%s', got '%s'", DefaultDeepSeekModel, cfg.Model) + } +} + +func TestWithQwenConfig(t *testing.T) { + cfg := DefaultConfig() + WithQwenConfig("sk-qwen-key")(cfg) + + if cfg.Provider != ProviderQwen { + t.Errorf("Provider should be '%s', got '%s'", ProviderQwen, cfg.Provider) + } + + if cfg.APIKey != "sk-qwen-key" { + t.Errorf("APIKey should be 'sk-qwen-key', got '%s'", cfg.APIKey) + } + + if cfg.BaseURL != DefaultQwenBaseURL { + t.Errorf("BaseURL should be '%s', got '%s'", DefaultQwenBaseURL, cfg.BaseURL) + } + + if cfg.Model != DefaultQwenModel { + t.Errorf("Model should be '%s', got '%s'", DefaultQwenModel, cfg.Model) + } +} + +// ============================================================ +// 测试选项组合 +// ============================================================ + +func TestMultipleOptions(t *testing.T) { + mockLogger := NewMockLogger() + + cfg := DefaultConfig() + + // 应用多个选项 + options := []ClientOption{ + WithProvider("test-provider"), + WithAPIKey("sk-test-key"), + WithBaseURL("https://api.test.com"), + WithModel("test-model"), + WithMaxTokens(4000), + WithTemperature(0.8), + WithLogger(mockLogger), + WithTimeout(60 * time.Second), + } + + for _, opt := range options { + opt(cfg) + } + + // 验证所有选项都被应用 + if cfg.Provider != "test-provider" { + t.Error("Provider should be set") + } + + if cfg.APIKey != "sk-test-key" { + t.Error("APIKey should be set") + } + + if cfg.BaseURL != "https://api.test.com" { + t.Error("BaseURL should be set") + } + + if cfg.Model != "test-model" { + t.Error("Model should be set") + } + + if cfg.MaxTokens != 4000 { + t.Error("MaxTokens should be 4000") + } + + if cfg.Temperature != 0.8 { + t.Error("Temperature should be 0.8") + } + + if cfg.Logger != mockLogger { + t.Error("Logger should be mockLogger") + } + + if cfg.Timeout != 60*time.Second { + t.Error("Timeout should be 60s") + } +} + +func TestOptionsOverride(t *testing.T) { + cfg := DefaultConfig() + + // 先应用 DeepSeek 配置 + WithDeepSeekConfig("sk-deepseek-key")(cfg) + + // 然后覆盖某些选项 + WithModel("custom-model")(cfg) + WithMaxTokens(5000)(cfg) + + // 验证覆盖成功 + if cfg.Model != "custom-model" { + t.Errorf("Model should be overridden to 'custom-model', got '%s'", cfg.Model) + } + + if cfg.MaxTokens != 5000 { + t.Errorf("MaxTokens should be overridden to 5000, got %d", cfg.MaxTokens) + } + + // 验证其他 DeepSeek 配置保持不变 + if cfg.Provider != ProviderDeepSeek { + t.Error("Provider should still be DeepSeek") + } + + if cfg.BaseURL != DefaultDeepSeekBaseURL { + t.Error("BaseURL should still be DeepSeek default") + } +} + +// ============================================================ +// 测试与客户端集成 +// ============================================================ + +func TestOptionsWithNewClient(t *testing.T) { + mockLogger := NewMockLogger() + + client := NewClient( + WithProvider("test-provider"), + WithAPIKey("sk-test-key"), + WithModel("test-model"), + WithLogger(mockLogger), + WithMaxTokens(4000), + ) + + c := client.(*Client) + + // 验证选项被正确应用到客户端 + if c.Provider != "test-provider" { + t.Error("Provider should be set from options") + } + + if c.APIKey != "sk-test-key" { + t.Error("APIKey should be set from options") + } + + if c.Model != "test-model" { + t.Error("Model should be set from options") + } + + if c.logger != mockLogger { + t.Error("logger should be set from options") + } + + if c.MaxTokens != 4000 { + t.Error("MaxTokens should be 4000") + } +} + +func TestOptionsWithDeepSeekClient(t *testing.T) { + mockLogger := NewMockLogger() + + client := NewDeepSeekClientWithOptions( + WithAPIKey("sk-deepseek-key"), + WithLogger(mockLogger), + WithMaxTokens(5000), + ) + + dsClient := client.(*DeepSeekClient) + + // 验证 DeepSeek 默认值 + if dsClient.Provider != ProviderDeepSeek { + t.Error("Provider should be DeepSeek") + } + + if dsClient.BaseURL != DefaultDeepSeekBaseURL { + t.Error("BaseURL should be DeepSeek default") + } + + if dsClient.Model != DefaultDeepSeekModel { + t.Error("Model should be DeepSeek default") + } + + // 验证自定义选项 + if dsClient.APIKey != "sk-deepseek-key" { + t.Error("APIKey should be set from options") + } + + if dsClient.logger != mockLogger { + t.Error("logger should be set from options") + } + + if dsClient.MaxTokens != 5000 { + t.Error("MaxTokens should be 5000") + } +} + +func TestOptionsWithQwenClient(t *testing.T) { + mockLogger := NewMockLogger() + + client := NewQwenClientWithOptions( + WithAPIKey("sk-qwen-key"), + WithLogger(mockLogger), + WithMaxTokens(6000), + ) + + qwenClient := client.(*QwenClient) + + // 验证 Qwen 默认值 + if qwenClient.Provider != ProviderQwen { + t.Error("Provider should be Qwen") + } + + if qwenClient.BaseURL != DefaultQwenBaseURL { + t.Error("BaseURL should be Qwen default") + } + + if qwenClient.Model != DefaultQwenModel { + t.Error("Model should be Qwen default") + } + + // 验证自定义选项 + if qwenClient.APIKey != "sk-qwen-key" { + t.Error("APIKey should be set from options") + } + + if qwenClient.logger != mockLogger { + t.Error("logger should be set from options") + } + + if qwenClient.MaxTokens != 6000 { + t.Error("MaxTokens should be 6000") + } +} diff --git a/mcp/qwen_client.go b/mcp/qwen_client.go new file mode 100644 index 0000000000..f790d08d3a --- /dev/null +++ b/mcp/qwen_client.go @@ -0,0 +1,83 @@ +package mcp + +import ( + "net/http" +) + +const ( + ProviderQwen = "qwen" + DefaultQwenBaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1" + DefaultQwenModel = "qwen3-max" +) + +type QwenClient struct { + *Client +} + +// NewQwenClient 创建 Qwen 客户端(向前兼容) +// +// Deprecated: 推荐使用 NewQwenClientWithOptions 以获得更好的灵活性 +func NewQwenClient() AIClient { + return NewQwenClientWithOptions() +} + +// NewQwenClientWithOptions 创建 Qwen 客户端(支持选项模式) +// +// 使用示例: +// // 基础用法 +// client := mcp.NewQwenClientWithOptions() +// +// // 自定义配置 +// client := mcp.NewQwenClientWithOptions( +// mcp.WithAPIKey("sk-xxx"), +// mcp.WithLogger(customLogger), +// mcp.WithTimeout(60*time.Second), +// ) +func NewQwenClientWithOptions(opts ...ClientOption) AIClient { + // 1. 创建 Qwen 预设选项 + qwenOpts := []ClientOption{ + WithProvider(ProviderQwen), + WithModel(DefaultQwenModel), + WithBaseURL(DefaultQwenBaseURL), + } + + // 2. 合并用户选项(用户选项优先级更高) + allOpts := append(qwenOpts, opts...) + + // 3. 创建基础客户端 + baseClient := NewClient(allOpts...).(*Client) + + // 4. 创建 Qwen 客户端 + qwenClient := &QwenClient{ + Client: baseClient, + } + + // 5. 设置 hooks 指向 QwenClient(实现动态分派) + baseClient.hooks = qwenClient + + return qwenClient +} + +func (qwenClient *QwenClient) SetAPIKey(apiKey string, customURL string, customModel string) { + qwenClient.APIKey = apiKey + + if len(apiKey) > 8 { + qwenClient.logger.Infof("🔧 [MCP] Qwen API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:]) + } + if customURL != "" { + qwenClient.BaseURL = customURL + qwenClient.logger.Infof("🔧 [MCP] Qwen 使用自定义 BaseURL: %s", customURL) + } else { + qwenClient.logger.Infof("🔧 [MCP] Qwen 使用默认 BaseURL: %s", qwenClient.BaseURL) + } + if customModel != "" { + qwenClient.Model = customModel + qwenClient.logger.Infof("🔧 [MCP] Qwen 使用自定义 Model: %s", customModel) + } else { + qwenClient.logger.Infof("🔧 [MCP] Qwen 使用默认 Model: %s", qwenClient.Model) + } +} + +func (qwenClient *QwenClient) setAuthHeader(reqHeaders http.Header) { + qwenClient.Client.setAuthHeader(reqHeaders) +} diff --git a/mcp/qwen_client_test.go b/mcp/qwen_client_test.go new file mode 100644 index 0000000000..d8f0c44cef --- /dev/null +++ b/mcp/qwen_client_test.go @@ -0,0 +1,272 @@ +package mcp + +import ( + "testing" + "time" +) + +// ============================================================ +// 测试 QwenClient 创建和配置 +// ============================================================ + +func TestNewQwenClient_Default(t *testing.T) { + client := NewQwenClient() + + if client == nil { + t.Fatal("client should not be nil") + } + + // 类型断言检查 + qwenClient, ok := client.(*QwenClient) + if !ok { + t.Fatal("client should be *QwenClient") + } + + // 验证默认值 + if qwenClient.Provider != ProviderQwen { + t.Errorf("Provider should be '%s', got '%s'", ProviderQwen, qwenClient.Provider) + } + + if qwenClient.BaseURL != DefaultQwenBaseURL { + t.Errorf("BaseURL should be '%s', got '%s'", DefaultQwenBaseURL, qwenClient.BaseURL) + } + + if qwenClient.Model != DefaultQwenModel { + t.Errorf("Model should be '%s', got '%s'", DefaultQwenModel, qwenClient.Model) + } + + if qwenClient.logger == nil { + t.Error("logger should not be nil") + } + + if qwenClient.httpClient == nil { + t.Error("httpClient should not be nil") + } +} + +func TestNewQwenClientWithOptions(t *testing.T) { + mockLogger := NewMockLogger() + customModel := "qwen-plus" + customAPIKey := "sk-custom-qwen-key" + + client := NewQwenClientWithOptions( + WithLogger(mockLogger), + WithModel(customModel), + WithAPIKey(customAPIKey), + WithMaxTokens(4000), + ) + + qwenClient := client.(*QwenClient) + + // 验证自定义选项被应用 + if qwenClient.logger != mockLogger { + t.Error("logger should be set from option") + } + + if qwenClient.Model != customModel { + t.Error("Model should be set from option") + } + + if qwenClient.APIKey != customAPIKey { + t.Error("APIKey should be set from option") + } + + if qwenClient.MaxTokens != 4000 { + t.Error("MaxTokens should be 4000") + } + + // 验证 Qwen 默认值仍然保留 + if qwenClient.Provider != ProviderQwen { + t.Errorf("Provider should still be '%s'", ProviderQwen) + } + + if qwenClient.BaseURL != DefaultQwenBaseURL { + t.Errorf("BaseURL should still be '%s'", DefaultQwenBaseURL) + } +} + +// ============================================================ +// 测试 SetAPIKey +// ============================================================ + +func TestQwenClient_SetAPIKey(t *testing.T) { + mockLogger := NewMockLogger() + client := NewQwenClientWithOptions( + WithLogger(mockLogger), + ) + + qwenClient := client.(*QwenClient) + + // 测试设置 API Key(默认 URL 和 Model) + qwenClient.SetAPIKey("sk-test-key-12345678", "", "") + + if qwenClient.APIKey != "sk-test-key-12345678" { + t.Errorf("APIKey should be 'sk-test-key-12345678', got '%s'", qwenClient.APIKey) + } + + // 验证日志记录 + logs := mockLogger.GetLogsByLevel("INFO") + if len(logs) == 0 { + t.Error("should have logged API key setting") + } + + // 验证 BaseURL 和 Model 保持默认 + if qwenClient.BaseURL != DefaultQwenBaseURL { + t.Error("BaseURL should remain default") + } + + if qwenClient.Model != DefaultQwenModel { + t.Error("Model should remain default") + } +} + +func TestQwenClient_SetAPIKey_WithCustomURL(t *testing.T) { + mockLogger := NewMockLogger() + client := NewQwenClientWithOptions( + WithLogger(mockLogger), + ) + + qwenClient := client.(*QwenClient) + + customURL := "https://custom.qwen.api.com/v1" + qwenClient.SetAPIKey("sk-test-key-12345678", customURL, "") + + if qwenClient.BaseURL != customURL { + t.Errorf("BaseURL should be '%s', got '%s'", customURL, qwenClient.BaseURL) + } + + // 验证日志记录 + logs := mockLogger.GetLogsByLevel("INFO") + hasCustomURLLog := false + for _, log := range logs { + if log.Format == "🔧 [MCP] Qwen 使用自定义 BaseURL: %s" { + hasCustomURLLog = true + break + } + } + + if !hasCustomURLLog { + t.Error("should have logged custom BaseURL") + } +} + +func TestQwenClient_SetAPIKey_WithCustomModel(t *testing.T) { + mockLogger := NewMockLogger() + client := NewQwenClientWithOptions( + WithLogger(mockLogger), + ) + + qwenClient := client.(*QwenClient) + + customModel := "qwen-turbo" + qwenClient.SetAPIKey("sk-test-key-12345678", "", customModel) + + if qwenClient.Model != customModel { + t.Errorf("Model should be '%s', got '%s'", customModel, qwenClient.Model) + } + + // 验证日志记录 + logs := mockLogger.GetLogsByLevel("INFO") + hasCustomModelLog := false + for _, log := range logs { + if log.Format == "🔧 [MCP] Qwen 使用自定义 Model: %s" { + hasCustomModelLog = true + break + } + } + + if !hasCustomModelLog { + t.Error("should have logged custom Model") + } +} + +// ============================================================ +// 测试集成功能 +// ============================================================ + +func TestQwenClient_CallWithMessages_Success(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockHTTP.SetSuccessResponse("Qwen AI response") + mockLogger := NewMockLogger() + + client := NewQwenClientWithOptions( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("sk-test-key"), + ) + + result, err := client.CallWithMessages("system prompt", "user prompt") + + if err != nil { + t.Fatalf("should not error: %v", err) + } + + if result != "Qwen AI response" { + t.Errorf("expected 'Qwen AI response', got '%s'", result) + } + + // 验证请求 + requests := mockHTTP.GetRequests() + if len(requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(requests)) + } + + req := requests[0] + + // 验证 URL + expectedURL := DefaultQwenBaseURL + "/chat/completions" + if req.URL.String() != expectedURL { + t.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL.String()) + } + + // 验证 Authorization header + authHeader := req.Header.Get("Authorization") + if authHeader != "Bearer sk-test-key" { + t.Errorf("expected 'Bearer sk-test-key', got '%s'", authHeader) + } + + // 验证 Content-Type + if req.Header.Get("Content-Type") != "application/json" { + t.Error("Content-Type should be application/json") + } +} + +func TestQwenClient_Timeout(t *testing.T) { + client := NewQwenClientWithOptions( + WithTimeout(30 * time.Second), + ) + + qwenClient := client.(*QwenClient) + + if qwenClient.httpClient.Timeout != 30*time.Second { + t.Errorf("expected timeout 30s, got %v", qwenClient.httpClient.Timeout) + } + + // 测试 SetTimeout + client.SetTimeout(60 * time.Second) + + if qwenClient.httpClient.Timeout != 60*time.Second { + t.Errorf("expected timeout 60s after SetTimeout, got %v", qwenClient.httpClient.Timeout) + } +} + +// ============================================================ +// 测试 hooks 机制 +// ============================================================ + +func TestQwenClient_HooksIntegration(t *testing.T) { + client := NewQwenClientWithOptions() + qwenClient := client.(*QwenClient) + + // 验证 hooks 指向 qwenClient 自己(实现多态) + if qwenClient.hooks != qwenClient { + t.Error("hooks should point to qwenClient for polymorphism") + } + + // 验证 buildUrl 使用 Qwen 配置 + url := qwenClient.buildUrl() + expectedURL := DefaultQwenBaseURL + "/chat/completions" + if url != expectedURL { + t.Errorf("expected URL '%s', got '%s'", expectedURL, url) + } +} diff --git a/mcp/request.go b/mcp/request.go new file mode 100644 index 0000000000..d706dd63ef --- /dev/null +++ b/mcp/request.go @@ -0,0 +1,72 @@ +package mcp + +// Message 表示一条对话消息 +type Message struct { + Role string `json:"role"` // "system", "user", "assistant" + Content string `json:"content"` // 消息内容 +} + +// Tool 表示 AI 可以调用的工具/函数 +type Tool struct { + Type string `json:"type"` // 通常为 "function" + Function FunctionDef `json:"function"` // 函数定义 +} + +// FunctionDef 函数定义 +type FunctionDef struct { + Name string `json:"name"` // 函数名 + Description string `json:"description,omitempty"` // 函数描述 + Parameters map[string]any `json:"parameters,omitempty"` // 参数 schema (JSON Schema) +} + +// Request AI API 请求(支持高级功能) +type Request struct { + // 基础字段 + Model string `json:"model"` // 模型名称 + Messages []Message `json:"messages"` // 对话消息列表 + Stream bool `json:"stream,omitempty"` // 是否流式响应 + + // 可选参数(用于精细控制) + Temperature *float64 `json:"temperature,omitempty"` // 温度 (0-2),控制随机性 + MaxTokens *int `json:"max_tokens,omitempty"` // 最大 token 数 + TopP *float64 `json:"top_p,omitempty"` // 核采样参数 (0-1) + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` // 频率惩罚 (-2 to 2) + PresencePenalty *float64 `json:"presence_penalty,omitempty"` // 存在惩罚 (-2 to 2) + Stop []string `json:"stop,omitempty"` // 停止序列 + + // 高级功能 + Tools []Tool `json:"tools,omitempty"` // 可用工具列表 + ToolChoice string `json:"tool_choice,omitempty"` // 工具选择策略 ("auto", "none", {"type": "function", "function": {"name": "xxx"}}) +} + +// NewMessage 创建一条消息 +func NewMessage(role, content string) Message { + return Message{ + Role: role, + Content: content, + } +} + +// NewSystemMessage 创建系统消息 +func NewSystemMessage(content string) Message { + return Message{ + Role: "system", + Content: content, + } +} + +// NewUserMessage 创建用户消息 +func NewUserMessage(content string) Message { + return Message{ + Role: "user", + Content: content, + } +} + +// NewAssistantMessage 创建助手消息 +func NewAssistantMessage(content string) Message { + return Message{ + Role: "assistant", + Content: content, + } +} diff --git a/mcp/request_builder.go b/mcp/request_builder.go new file mode 100644 index 0000000000..4c61d6a4cf --- /dev/null +++ b/mcp/request_builder.go @@ -0,0 +1,317 @@ +package mcp + +import ( + "errors" +) + +// RequestBuilder 请求构建器 +type RequestBuilder struct { + model string + messages []Message + stream bool + temperature *float64 + maxTokens *int + topP *float64 + frequencyPenalty *float64 + presencePenalty *float64 + stop []string + tools []Tool + toolChoice string +} + +// NewRequestBuilder 创建请求构建器 +// +// 使用示例: +// request := NewRequestBuilder(). +// WithSystemPrompt("You are helpful"). +// WithUserPrompt("Hello"). +// WithTemperature(0.8). +// Build() +func NewRequestBuilder() *RequestBuilder { + return &RequestBuilder{ + messages: make([]Message, 0), + tools: make([]Tool, 0), + } +} + +// ============================================================ +// 模型和流式配置 +// ============================================================ + +// WithModel 设置模型名称 +func (b *RequestBuilder) WithModel(model string) *RequestBuilder { + b.model = model + return b +} + +// WithStream 设置是否使用流式响应 +func (b *RequestBuilder) WithStream(stream bool) *RequestBuilder { + b.stream = stream + return b +} + +// ============================================================ +// 消息构建方法 +// ============================================================ + +// WithSystemPrompt 添加系统提示词(便捷方法) +func (b *RequestBuilder) WithSystemPrompt(prompt string) *RequestBuilder { + if prompt != "" { + b.messages = append(b.messages, NewSystemMessage(prompt)) + } + return b +} + +// WithUserPrompt 添加用户提示词(便捷方法) +func (b *RequestBuilder) WithUserPrompt(prompt string) *RequestBuilder { + if prompt != "" { + b.messages = append(b.messages, NewUserMessage(prompt)) + } + return b +} + +// AddSystemMessage 添加系统消息 +func (b *RequestBuilder) AddSystemMessage(content string) *RequestBuilder { + return b.WithSystemPrompt(content) +} + +// AddUserMessage 添加用户消息 +func (b *RequestBuilder) AddUserMessage(content string) *RequestBuilder { + return b.WithUserPrompt(content) +} + +// AddAssistantMessage 添加助手消息(用于多轮对话上下文) +func (b *RequestBuilder) AddAssistantMessage(content string) *RequestBuilder { + if content != "" { + b.messages = append(b.messages, NewAssistantMessage(content)) + } + return b +} + +// AddMessage 添加自定义角色的消息 +func (b *RequestBuilder) AddMessage(role, content string) *RequestBuilder { + if content != "" { + b.messages = append(b.messages, NewMessage(role, content)) + } + return b +} + +// AddMessages 批量添加消息 +func (b *RequestBuilder) AddMessages(messages ...Message) *RequestBuilder { + b.messages = append(b.messages, messages...) + return b +} + +// AddConversationHistory 添加对话历史 +func (b *RequestBuilder) AddConversationHistory(history []Message) *RequestBuilder { + b.messages = append(b.messages, history...) + return b +} + +// ClearMessages 清空所有消息 +func (b *RequestBuilder) ClearMessages() *RequestBuilder { + b.messages = make([]Message, 0) + return b +} + +// ============================================================ +// 参数控制方法 +// ============================================================ + +// WithTemperature 设置温度参数 (0-2) +// 较高的温度(如 1.2)会使输出更随机,较低的温度(如 0.2)会使输出更确定 +func (b *RequestBuilder) WithTemperature(t float64) *RequestBuilder { + if t < 0 || t > 2 { + // 可以选择 panic 或者静默忽略,这里选择限制范围 + if t < 0 { + t = 0 + } + if t > 2 { + t = 2 + } + } + b.temperature = &t + return b +} + +// WithMaxTokens 设置最大 token 数 +func (b *RequestBuilder) WithMaxTokens(tokens int) *RequestBuilder { + if tokens > 0 { + b.maxTokens = &tokens + } + return b +} + +// WithTopP 设置 top-p 核采样参数 (0-1) +// 控制考虑的 token 范围,较小的值(如 0.1)使输出更聚焦 +func (b *RequestBuilder) WithTopP(p float64) *RequestBuilder { + if p >= 0 && p <= 1 { + b.topP = &p + } + return b +} + +// WithFrequencyPenalty 设置频率惩罚 (-2 to 2) +// 正值会根据 token 在文本中出现的频率惩罚它们,减少重复 +func (b *RequestBuilder) WithFrequencyPenalty(penalty float64) *RequestBuilder { + if penalty >= -2 && penalty <= 2 { + b.frequencyPenalty = &penalty + } + return b +} + +// WithPresencePenalty 设置存在惩罚 (-2 to 2) +// 正值会根据 token 是否出现在文本中惩罚它们,增加话题多样性 +func (b *RequestBuilder) WithPresencePenalty(penalty float64) *RequestBuilder { + if penalty >= -2 && penalty <= 2 { + b.presencePenalty = &penalty + } + return b +} + +// WithStopSequences 设置停止序列 +// 当模型生成这些序列之一时,将停止生成 +func (b *RequestBuilder) WithStopSequences(sequences []string) *RequestBuilder { + b.stop = sequences + return b +} + +// AddStopSequence 添加单个停止序列 +func (b *RequestBuilder) AddStopSequence(sequence string) *RequestBuilder { + if sequence != "" { + b.stop = append(b.stop, sequence) + } + return b +} + +// ============================================================ +// 工具/函数调用相关 +// ============================================================ + +// AddTool 添加工具 +func (b *RequestBuilder) AddTool(tool Tool) *RequestBuilder { + b.tools = append(b.tools, tool) + return b +} + +// AddFunction 添加函数(便捷方法) +func (b *RequestBuilder) AddFunction(name, description string, parameters map[string]any) *RequestBuilder { + tool := Tool{ + Type: "function", + Function: FunctionDef{ + Name: name, + Description: description, + Parameters: parameters, + }, + } + b.tools = append(b.tools, tool) + return b +} + +// WithToolChoice 设置工具选择策略 +// - "auto": 自动选择是否调用工具 +// - "none": 不调用工具 +// - 也可以指定特定工具: `{"type": "function", "function": {"name": "my_function"}}` +func (b *RequestBuilder) WithToolChoice(choice string) *RequestBuilder { + b.toolChoice = choice + return b +} + +// ============================================================ +// 构建方法 +// ============================================================ + +// Build 构建请求对象 +func (b *RequestBuilder) Build() (*Request, error) { + // 验证:至少需要一条消息 + if len(b.messages) == 0 { + return nil, errors.New("至少需要一条消息") + } + + // 创建请求 + req := &Request{ + Model: b.model, + Messages: b.messages, + Stream: b.stream, + Stop: b.stop, + Tools: b.tools, + ToolChoice: b.toolChoice, + } + + // 只设置非 nil 的可选参数(避免发送 0 值覆盖服务端默认值) + if b.temperature != nil { + req.Temperature = b.temperature + } + if b.maxTokens != nil { + req.MaxTokens = b.maxTokens + } + if b.topP != nil { + req.TopP = b.topP + } + if b.frequencyPenalty != nil { + req.FrequencyPenalty = b.frequencyPenalty + } + if b.presencePenalty != nil { + req.PresencePenalty = b.presencePenalty + } + + return req, nil +} + +// MustBuild 构建请求对象,如果失败则 panic +// 适用于构建过程中确定不会出错的场景 +func (b *RequestBuilder) MustBuild() *Request { + req, err := b.Build() + if err != nil { + panic(err) + } + return req +} + +// ============================================================ +// 便捷方法:预设场景 +// ============================================================ + +// ForChat 创建用于聊天的构建器(预设合理的参数) +func ForChat() *RequestBuilder { + temp := 0.7 + tokens := 2000 + return &RequestBuilder{ + messages: make([]Message, 0), + tools: make([]Tool, 0), + temperature: &temp, + maxTokens: &tokens, + } +} + +// ForCodeGeneration 创建用于代码生成的构建器(低温度,更确定) +func ForCodeGeneration() *RequestBuilder { + temp := 0.2 + tokens := 2000 + topP := 0.1 + return &RequestBuilder{ + messages: make([]Message, 0), + tools: make([]Tool, 0), + temperature: &temp, + maxTokens: &tokens, + topP: &topP, + } +} + +// ForCreativeWriting 创建用于创意写作的构建器(高温度,更随机) +func ForCreativeWriting() *RequestBuilder { + temp := 1.2 + tokens := 4000 + topP := 0.95 + presencePenalty := 0.6 + frequencyPenalty := 0.5 + return &RequestBuilder{ + messages: make([]Message, 0), + tools: make([]Tool, 0), + temperature: &temp, + maxTokens: &tokens, + topP: &topP, + presencePenalty: &presencePenalty, + frequencyPenalty: &frequencyPenalty, + } +} diff --git a/mcp/request_builder_test.go b/mcp/request_builder_test.go new file mode 100644 index 0000000000..be78601f94 --- /dev/null +++ b/mcp/request_builder_test.go @@ -0,0 +1,478 @@ +package mcp + +import ( + "encoding/json" + "testing" +) + +// ============================================================ +// 测试 RequestBuilder 基本功能 +// ============================================================ + +func TestRequestBuilder_BasicUsage(t *testing.T) { + request, err := NewRequestBuilder(). + WithSystemPrompt("You are helpful"). + WithUserPrompt("Hello"). + Build() + + if err != nil { + t.Fatalf("Build should not error: %v", err) + } + + if len(request.Messages) != 2 { + t.Errorf("expected 2 messages, got %d", len(request.Messages)) + } + + if request.Messages[0].Role != "system" { + t.Errorf("first message should be system, got %s", request.Messages[0].Role) + } + + if request.Messages[1].Role != "user" { + t.Errorf("second message should be user, got %s", request.Messages[1].Role) + } +} + +func TestRequestBuilder_EmptyMessages(t *testing.T) { + _, err := NewRequestBuilder().Build() + + if err == nil { + t.Error("Build should error when no messages") + } + + if err.Error() != "至少需要一条消息" { + t.Errorf("unexpected error: %v", err) + } +} + +// ============================================================ +// 测试消息构建方法 +// ============================================================ + +func TestRequestBuilder_MultipleMessages(t *testing.T) { + request := NewRequestBuilder(). + AddSystemMessage("You are helpful"). + AddUserMessage("What is Go?"). + AddAssistantMessage("Go is a programming language"). + AddUserMessage("Tell me more"). + MustBuild() + + if len(request.Messages) != 4 { + t.Fatalf("expected 4 messages, got %d", len(request.Messages)) + } + + expectedRoles := []string{"system", "user", "assistant", "user"} + for i, expected := range expectedRoles { + if request.Messages[i].Role != expected { + t.Errorf("message %d: expected role %s, got %s", i, expected, request.Messages[i].Role) + } + } +} + +func TestRequestBuilder_AddConversationHistory(t *testing.T) { + history := []Message{ + NewUserMessage("Previous question"), + NewAssistantMessage("Previous answer"), + } + + request := NewRequestBuilder(). + AddConversationHistory(history). + AddUserMessage("New question"). + MustBuild() + + if len(request.Messages) != 3 { + t.Fatalf("expected 3 messages, got %d", len(request.Messages)) + } +} + +// ============================================================ +// 测试参数控制方法 +// ============================================================ + +func TestRequestBuilder_WithTemperature(t *testing.T) { + request := NewRequestBuilder(). + WithUserPrompt("Hello"). + WithTemperature(0.8). + MustBuild() + + if request.Temperature == nil { + t.Fatal("Temperature should be set") + } + + if *request.Temperature != 0.8 { + t.Errorf("expected temperature 0.8, got %f", *request.Temperature) + } +} + +func TestRequestBuilder_WithMaxTokens(t *testing.T) { + request := NewRequestBuilder(). + WithUserPrompt("Hello"). + WithMaxTokens(2000). + MustBuild() + + if request.MaxTokens == nil { + t.Fatal("MaxTokens should be set") + } + + if *request.MaxTokens != 2000 { + t.Errorf("expected maxTokens 2000, got %d", *request.MaxTokens) + } +} + +func TestRequestBuilder_WithTopP(t *testing.T) { + request := NewRequestBuilder(). + WithUserPrompt("Hello"). + WithTopP(0.9). + MustBuild() + + if request.TopP == nil { + t.Fatal("TopP should be set") + } + + if *request.TopP != 0.9 { + t.Errorf("expected topP 0.9, got %f", *request.TopP) + } +} + +func TestRequestBuilder_WithPenalties(t *testing.T) { + request := NewRequestBuilder(). + WithUserPrompt("Hello"). + WithFrequencyPenalty(0.5). + WithPresencePenalty(0.6). + MustBuild() + + if request.FrequencyPenalty == nil || *request.FrequencyPenalty != 0.5 { + t.Error("FrequencyPenalty should be 0.5") + } + + if request.PresencePenalty == nil || *request.PresencePenalty != 0.6 { + t.Error("PresencePenalty should be 0.6") + } +} + +func TestRequestBuilder_WithStopSequences(t *testing.T) { + request := NewRequestBuilder(). + WithUserPrompt("Hello"). + WithStopSequences([]string{"STOP", "END"}). + MustBuild() + + if len(request.Stop) != 2 { + t.Fatalf("expected 2 stop sequences, got %d", len(request.Stop)) + } + + if request.Stop[0] != "STOP" || request.Stop[1] != "END" { + t.Error("stop sequences not set correctly") + } +} + +// ============================================================ +// 测试工具/函数调用 +// ============================================================ + +func TestRequestBuilder_AddTool(t *testing.T) { + tool := Tool{ + Type: "function", + Function: FunctionDef{ + Name: "get_weather", + Description: "Get weather", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "location": map[string]any{"type": "string"}, + }, + }, + }, + } + + request := NewRequestBuilder(). + WithUserPrompt("What's the weather?"). + AddTool(tool). + WithToolChoice("auto"). + MustBuild() + + if len(request.Tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(request.Tools)) + } + + if request.Tools[0].Function.Name != "get_weather" { + t.Error("tool not added correctly") + } + + if request.ToolChoice != "auto" { + t.Error("tool choice not set correctly") + } +} + +func TestRequestBuilder_AddFunction(t *testing.T) { + params := map[string]any{ + "type": "object", + "properties": map[string]any{ + "city": map[string]any{"type": "string"}, + }, + } + + request := NewRequestBuilder(). + WithUserPrompt("Hello"). + AddFunction("get_weather", "Get current weather", params). + MustBuild() + + if len(request.Tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(request.Tools)) + } + + if request.Tools[0].Type != "function" { + t.Error("tool type should be function") + } + + if request.Tools[0].Function.Name != "get_weather" { + t.Error("function name not set correctly") + } +} + +// ============================================================ +// 测试便捷方法 +// ============================================================ + +func TestRequestBuilder_ForChat(t *testing.T) { + request := ForChat(). + WithUserPrompt("Hello"). + MustBuild() + + if request.Temperature == nil { + t.Fatal("ForChat should set temperature") + } + + if *request.Temperature != 0.7 { + t.Errorf("ForChat should set temperature to 0.7, got %f", *request.Temperature) + } + + if request.MaxTokens == nil { + t.Fatal("ForChat should set maxTokens") + } + + if *request.MaxTokens != 2000 { + t.Errorf("ForChat should set maxTokens to 2000, got %d", *request.MaxTokens) + } +} + +func TestRequestBuilder_ForCodeGeneration(t *testing.T) { + request := ForCodeGeneration(). + WithUserPrompt("Generate code"). + MustBuild() + + if request.Temperature == nil || *request.Temperature != 0.2 { + t.Error("ForCodeGeneration should set low temperature") + } + + if request.TopP == nil || *request.TopP != 0.1 { + t.Error("ForCodeGeneration should set low topP") + } +} + +func TestRequestBuilder_ForCreativeWriting(t *testing.T) { + request := ForCreativeWriting(). + WithUserPrompt("Write a story"). + MustBuild() + + if request.Temperature == nil || *request.Temperature != 1.2 { + t.Error("ForCreativeWriting should set high temperature") + } + + if request.PresencePenalty == nil || *request.PresencePenalty != 0.6 { + t.Error("ForCreativeWriting should set presence penalty") + } + + if request.FrequencyPenalty == nil || *request.FrequencyPenalty != 0.5 { + t.Error("ForCreativeWriting should set frequency penalty") + } +} + +// ============================================================ +// 测试 CallWithRequest 集成 +// ============================================================ + +func TestClient_CallWithRequest_Success(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockHTTP.SetSuccessResponse("Builder response") + mockLogger := NewMockLogger() + + client := NewClient( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("sk-test-key"), + ) + + request := NewRequestBuilder(). + WithSystemPrompt("You are helpful"). + WithUserPrompt("Hello"). + WithTemperature(0.8). + MustBuild() + + result, err := client.CallWithRequest(request) + + if err != nil { + t.Fatalf("should not error: %v", err) + } + + if result != "Builder response" { + t.Errorf("expected 'Builder response', got '%s'", result) + } + + // 验证请求体 + requests := mockHTTP.GetRequests() + if len(requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(requests)) + } + + // 解析请求体验证参数 + var body map[string]interface{} + decoder := json.NewDecoder(requests[0].Body) + if err := decoder.Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + + // 验证 temperature + if body["temperature"] != 0.8 { + t.Errorf("expected temperature 0.8, got %v", body["temperature"]) + } + + // 验证 messages + messages, ok := body["messages"].([]interface{}) + if !ok || len(messages) != 2 { + t.Error("messages not correctly formatted") + } +} + +func TestClient_CallWithRequest_MultiRound(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockHTTP.SetSuccessResponse("Multi-round response") + mockLogger := NewMockLogger() + + client := NewClient( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("sk-test-key"), + ) + + // 构建多轮对话 + request := NewRequestBuilder(). + AddSystemMessage("You are a trading advisor"). + AddUserMessage("Analyze BTC"). + AddAssistantMessage("BTC is bullish"). + AddUserMessage("What about entry point?"). + WithTemperature(0.3). + MustBuild() + + result, err := client.CallWithRequest(request) + + if err != nil { + t.Fatalf("should not error: %v", err) + } + + if result != "Multi-round response" { + t.Errorf("expected 'Multi-round response', got '%s'", result) + } + + // 验证请求体包含所有消息 + requests := mockHTTP.GetRequests() + var body map[string]interface{} + json.NewDecoder(requests[0].Body).Decode(&body) + + messages := body["messages"].([]interface{}) + if len(messages) != 4 { + t.Errorf("expected 4 messages in request, got %d", len(messages)) + } +} + +func TestClient_CallWithRequest_WithTools(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockHTTP.SetSuccessResponse("Tool response") + mockLogger := NewMockLogger() + + client := NewClient( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("sk-test-key"), + ) + + request := NewRequestBuilder(). + WithUserPrompt("What's the weather in Beijing?"). + AddFunction("get_weather", "Get weather", map[string]any{ + "type": "object", + "properties": map[string]any{ + "location": map[string]any{"type": "string"}, + }, + }). + WithToolChoice("auto"). + MustBuild() + + _, err := client.CallWithRequest(request) + + if err != nil { + t.Fatalf("should not error: %v", err) + } + + // 验证请求体包含 tools + requests := mockHTTP.GetRequests() + var body map[string]interface{} + json.NewDecoder(requests[0].Body).Decode(&body) + + tools, ok := body["tools"].([]interface{}) + if !ok || len(tools) == 0 { + t.Error("tools should be present in request") + } + + toolChoice, ok := body["tool_choice"].(string) + if !ok || toolChoice != "auto" { + t.Error("tool_choice should be 'auto'") + } +} + +func TestClient_CallWithRequest_NoAPIKey(t *testing.T) { + client := NewClient() + + request := NewRequestBuilder(). + WithUserPrompt("Hello"). + MustBuild() + + _, err := client.CallWithRequest(request) + + if err == nil { + t.Error("should error when API key not set") + } + + if err.Error() != "AI API密钥未设置,请先调用 SetAPIKey" { + t.Errorf("unexpected error: %v", err) + } +} + +func TestClient_CallWithRequest_UsesClientModel(t *testing.T) { + mockHTTP := NewMockHTTPClient() + mockHTTP.SetSuccessResponse("Response") + mockLogger := NewMockLogger() + + client := NewDeepSeekClientWithOptions( + WithHTTPClient(mockHTTP.ToHTTPClient()), + WithLogger(mockLogger), + WithAPIKey("sk-test-key"), + ) + + // Request 不设置 model,应该使用 Client 的 model + request := NewRequestBuilder(). + WithUserPrompt("Hello"). + MustBuild() + + if request.Model != "" { + t.Error("request.Model should be empty initially") + } + + client.CallWithRequest(request) + + // 验证使用了 DeepSeek 的 model + requests := mockHTTP.GetRequests() + var body map[string]interface{} + json.NewDecoder(requests[0].Body).Decode(&body) + + if body["model"] != DefaultDeepSeekModel { + t.Errorf("expected model %s, got %v", DefaultDeepSeekModel, body["model"]) + } +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index fd78e90667..4e53a9b43f 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -85,8 +85,8 @@ type AutoTrader struct { exchange string // 交易平台名称 config AutoTraderConfig trader Trader // 使用Trader接口(支持多平台) - mcpClient *mcp.Client - decisionLogger *logger.DecisionLogger // 决策日志记录器 + mcpClient mcp.AIClient + decisionLogger logger.IDecisionLogger // 决策日志记录器 initialBalance float64 dailyPnL float64 customPrompt string // 自定义交易策略prompt @@ -131,11 +131,12 @@ func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string) // 初始化AI if config.AIModel == "custom" { // 使用自定义API - mcpClient.SetCustomAPI(config.CustomAPIURL, config.CustomAPIKey, config.CustomModelName) + mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) log.Printf("🤖 [%s] 使用自定义AI API: %s (模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) } else if config.UseQwen || config.AIModel == "qwen" { // 使用Qwen (支持自定义URL和Model) - mcpClient.SetQwenAPIKey(config.QwenKey, config.CustomAPIURL, config.CustomModelName) + mcpClient = mcp.NewQwenClient() + mcpClient.SetAPIKey(config.QwenKey, config.CustomAPIURL, config.CustomModelName) if config.CustomAPIURL != "" || config.CustomModelName != "" { log.Printf("🤖 [%s] 使用阿里云Qwen AI (自定义URL: %s, 模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) } else { @@ -143,7 +144,8 @@ func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string) } } else { // 默认使用DeepSeek (支持自定义URL和Model) - mcpClient.SetDeepSeekAPIKey(config.DeepSeekKey, config.CustomAPIURL, config.CustomModelName) + mcpClient = mcp.NewDeepSeekClient() + mcpClient.SetAPIKey(config.DeepSeekKey, config.CustomAPIURL, config.CustomModelName) if config.CustomAPIURL != "" || config.CustomModelName != "" { log.Printf("🤖 [%s] 使用DeepSeek AI (自定义URL: %s, 模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) } else { @@ -286,101 +288,6 @@ func (at *AutoTrader) Stop() { log.Println("⏹ 自动交易系统停止") } -// autoSyncBalanceIfNeeded 自动同步余额(每10分钟检查一次,变化>5%才更新) -func (at *AutoTrader) autoSyncBalanceIfNeeded() { - // 距离上次同步不足10分钟,跳过 - if time.Since(at.lastBalanceSyncTime) < 10*time.Minute { - return - } - - log.Printf("🔄 [%s] 开始自动检查余额变化...", at.name) - - // 查询实际余额 - balanceInfo, err := at.trader.GetBalance() - if err != nil { - log.Printf("⚠️ [%s] 查询余额失败: %v", at.name, err) - at.lastBalanceSyncTime = time.Now() // 即使失败也更新时间,避免频繁重试 - return - } - - // 提取可用余额 - var actualBalance float64 - if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - } else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - } else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 { - actualBalance = totalBalance - } else { - log.Printf("⚠️ [%s] 无法提取可用余额", at.name) - at.lastBalanceSyncTime = time.Now() - return - } - - oldBalance := at.initialBalance - - // 防止除以零:如果初始余额无效,直接更新为实际余额 - if oldBalance <= 0 { - log.Printf("⚠️ [%s] 初始余额无效 (%.2f),直接更新为实际余额 %.2f USDT", at.name, oldBalance, actualBalance) - at.initialBalance = actualBalance - if at.database != nil { - type DatabaseUpdater interface { - UpdateTraderInitialBalance(userID, id string, newBalance float64) error - } - if db, ok := at.database.(DatabaseUpdater); ok { - if err := db.UpdateTraderInitialBalance(at.userID, at.id, actualBalance); err != nil { - log.Printf("❌ [%s] 更新数据库失败: %v", at.name, err) - } else { - log.Printf("✅ [%s] 已自动同步余额到数据库", at.name) - } - } else { - log.Printf("⚠️ [%s] 数据库类型不支持UpdateTraderInitialBalance接口", at.name) - } - } else { - log.Printf("⚠️ [%s] 数据库引用为空,余额仅在内存中更新", at.name) - } - at.lastBalanceSyncTime = time.Now() - return - } - - changePercent := ((actualBalance - oldBalance) / oldBalance) * 100 - - // 变化超过5%才更新 - if math.Abs(changePercent) > 5.0 { - log.Printf("🔔 [%s] 检测到余额大幅变化: %.2f → %.2f USDT (%.2f%%)", - at.name, oldBalance, actualBalance, changePercent) - - // 更新内存中的 initialBalance - at.initialBalance = actualBalance - - // 更新数据库(需要类型断言) - if at.database != nil { - // 这里需要根据实际的数据库类型进行类型断言 - // 由于使用了 interface{},我们需要在 TraderManager 层面处理更新 - // 或者在这里进行类型检查 - type DatabaseUpdater interface { - UpdateTraderInitialBalance(userID, id string, newBalance float64) error - } - if db, ok := at.database.(DatabaseUpdater); ok { - err := db.UpdateTraderInitialBalance(at.userID, at.id, actualBalance) - if err != nil { - log.Printf("❌ [%s] 更新数据库失败: %v", at.name, err) - } else { - log.Printf("✅ [%s] 已自动同步余额到数据库", at.name) - } - } else { - log.Printf("⚠️ [%s] 数据库类型不支持UpdateTraderInitialBalance接口", at.name) - } - } else { - log.Printf("⚠️ [%s] 数据库引用为空,余额仅在内存中更新", at.name) - } - } else { - log.Printf("✓ [%s] 余额变化不大 (%.2f%%),无需更新", at.name, changePercent) - } - - at.lastBalanceSyncTime = time.Now() -} - // runCycle 运行一个交易周期(使用AI全权决策) func (at *AutoTrader) runCycle() error { at.callCount++ @@ -412,9 +319,6 @@ func (at *AutoTrader) runCycle() error { log.Println("📅 日盈亏已重置") } - // 3. 自动同步余额(每10分钟检查一次,充值/提现后自动更新) - at.autoSyncBalanceIfNeeded() - // 4. 收集交易上下文 ctx, err := at.buildTradingContext() if err != nil { @@ -426,11 +330,12 @@ func (at *AutoTrader) runCycle() error { // 保存账户状态快照 record.AccountState = logger.AccountSnapshot{ - TotalBalance: ctx.Account.TotalEquity, + TotalBalance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL, AvailableBalance: ctx.Account.AvailableBalance, - TotalUnrealizedProfit: ctx.Account.TotalPnL, + TotalUnrealizedProfit: ctx.Account.UnrealizedPnL, PositionCount: ctx.Account.PositionCount, MarginUsedPct: ctx.Account.MarginUsedPct, + InitialBalance: at.initialBalance, // 记录当时的初始余额基准 } // 保存持仓快照 @@ -651,7 +556,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { // 获取该持仓的历史最高收益率 at.peakPnLCacheMutex.RLock() - peakPnlPct := at.peakPnLCache[symbol] + peakPnlPct := at.peakPnLCache[posKey] at.peakPnLCacheMutex.RUnlock() positionInfos = append(positionInfos, decision.PositionInfo{ @@ -714,6 +619,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { Account: decision.AccountInfo{ TotalEquity: totalEquity, AvailableBalance: availableBalance, + UnrealizedPnL: totalUnrealizedProfit, TotalPnL: totalPnL, TotalPnLPct: totalPnLPct, MarginUsed: totalMarginUsed, @@ -1301,7 +1207,7 @@ func (at *AutoTrader) GetSystemPromptTemplate() string { } // GetDecisionLogger 获取决策日志记录器 -func (at *AutoTrader) GetDecisionLogger() *logger.DecisionLogger { +func (at *AutoTrader) GetDecisionLogger() logger.IDecisionLogger { return at.decisionLogger } @@ -1361,7 +1267,7 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { } totalMarginUsed := 0.0 - totalUnrealizedPnL := 0.0 + totalUnrealizedPnLCalculated := 0.0 for _, pos := range positions { markPrice := pos["markPrice"].(float64) quantity := pos["positionAmt"].(float64) @@ -1369,7 +1275,7 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { quantity = -quantity } unrealizedPnl := pos["unRealizedProfit"].(float64) - totalUnrealizedPnL += unrealizedPnl + totalUnrealizedPnLCalculated += unrealizedPnl leverage := 10 if lev, ok := pos["leverage"].(float64); ok { @@ -1379,10 +1285,19 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { totalMarginUsed += marginUsed } + // 验证未实现盈亏的一致性(API值 vs 从持仓计算) + diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated) + if diff > 0.1 { // 允许0.01 USDT的误差 + log.Printf("⚠️ 未实现盈亏不一致: API=%.4f, 计算=%.4f, 差异=%.4f", + totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff) + } + totalPnL := totalEquity - at.initialBalance totalPnLPct := 0.0 if at.initialBalance > 0 { totalPnLPct = (totalPnL / at.initialBalance) * 100 + } else { + log.Printf("⚠️ Initial Balance异常: %.2f,无法计算PNL百分比", at.initialBalance) } marginUsedPct := 0.0 @@ -1394,15 +1309,14 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { // 核心字段 "total_equity": totalEquity, // 账户净值 = wallet + unrealized "wallet_balance": totalWalletBalance, // 钱包余额(不含未实现盈亏) - "unrealized_profit": totalUnrealizedProfit, // 未实现盈亏(从API) + "unrealized_profit": totalUnrealizedProfit, // 未实现盈亏(交易所API官方值) "available_balance": availableBalance, // 可用余额 // 盈亏统计 - "total_pnl": totalPnL, // 总盈亏 = equity - initial - "total_pnl_pct": totalPnLPct, // 总盈亏百分比 - "total_unrealized_pnl": totalUnrealizedPnL, // 未实现盈亏(从持仓计算) - "initial_balance": at.initialBalance, // 初始余额 - "daily_pnl": at.dailyPnL, // 日盈亏 + "total_pnl": totalPnL, // 总盈亏 = equity - initial + "total_pnl_pct": totalPnLPct, // 总盈亏百分比 + "initial_balance": at.initialBalance, // 初始余额 + "daily_pnl": at.dailyPnL, // 日盈亏 // 持仓信息 "position_count": len(positions), // 持仓数量 diff --git a/trader/auto_trader_test.go b/trader/auto_trader_test.go index 09a2c42833..9316981f58 100644 --- a/trader/auto_trader_test.go +++ b/trader/auto_trader_test.go @@ -31,7 +31,7 @@ type AutoTraderTestSuite struct { // Mock 依赖 mockTrader *MockTrader mockDB *MockDatabase - mockLogger *logger.DecisionLogger + mockLogger logger.IDecisionLogger // gomonkey patches patches *gomonkey.Patches diff --git a/web/package-lock.json b/web/package-lock.json index 85f06c3029..7048f64341 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1160,192 +1160,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@inquirer/ansi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz", - "integrity": "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.19", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.19.tgz", - "integrity": "sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@inquirer/core": "^10.3.0", - "@inquirer/type": "^3.0.9" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.0.tgz", - "integrity": "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@inquirer/ansi": "^1.0.1", - "@inquirer/figures": "^1.0.14", - "@inquirer/type": "^3.0.9", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/core/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@inquirer/core/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@inquirer/core/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/core/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/core/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.14.tgz", - "integrity": "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.9.tgz", - "integrity": "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1408,26 +1222,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mswjs/interceptors": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", - "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1463,37 +1257,6 @@ "node": ">= 8" } }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2406,15 +2169,6 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/statuses": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", @@ -3469,126 +3223,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5055,18 +4689,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -5228,18 +4850,6 @@ "dev": true, "license": "MIT" }, - "node_modules/graphql": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", - "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -5333,15 +4943,6 @@ "node": ">= 0.4" } }, - "node_modules/headers-polyfill": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", - "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -5737,15 +5338,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6515,104 +6107,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, - "node_modules/msw": { - "version": "2.11.6", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.6.tgz", - "integrity": "sha512-MCYMykvmiYScyUm7I6y0VCxpNq1rgd5v7kG8ks5dKtvmxRUUPjribX6mUoUNBbM5/3PhUyoelEWiKXGOz84c+w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.40.0", - "@open-draft/deferred-promise": "^2.2.0", - "@types/statuses": "^2.0.4", - "cookie": "^1.0.2", - "graphql": "^16.8.1", - "headers-polyfill": "^4.0.2", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "path-to-regexp": "^6.3.0", - "picocolors": "^1.1.1", - "rettime": "^0.7.0", - "statuses": "^2.0.2", - "strict-event-emitter": "^0.5.1", - "tough-cookie": "^6.0.0", - "type-fest": "^4.26.1", - "until-async": "^3.0.2", - "yargs": "^17.7.2" - }, - "bin": { - "msw": "cli/index.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mswjs" - }, - "peerDependencies": { - "typescript": ">= 4.8.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/msw/node_modules/tldts": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", - "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tldts-core": "^7.0.17" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/msw/node_modules/tldts-core": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", - "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/msw/node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -6842,15 +6336,6 @@ "node": ">= 0.8.0" } }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -6980,15 +6465,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -7645,18 +7121,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -7704,15 +7168,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rettime": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", - "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8123,18 +7578,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -8156,15 +7599,6 @@ "node": ">= 0.4" } }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -8735,21 +8169,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -8860,18 +8279,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/until-async": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", - "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/kettanaito" - } - }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -10510,18 +9917,6 @@ "dev": true, "license": "MIT" }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -10541,92 +9936,6 @@ "node": ">= 14.6" } }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -10640,21 +9949,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index 5e0666612c..3f05c5e18c 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -244,7 +244,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { error: '创建失败', }) setShowCreateModal(false) - mutateTraders() + // Immediately refresh traders list for better UX + await mutateTraders() } catch (error) { console.error('Failed to create trader:', error) toast.error(t('createTraderFailed', language)) @@ -303,7 +304,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { }) setShowEditModal(false) setEditingTrader(null) - mutateTraders() + // Immediately refresh traders list for better UX + await mutateTraders() } catch (error) { console.error('Failed to update trader:', error) toast.error(t('updateTraderFailed', language)) @@ -322,7 +324,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { success: '删除成功', error: '删除失败', }) - mutateTraders() + + // Immediately refresh traders list for better UX + await mutateTraders() } catch (error) { console.error('Failed to delete trader:', error) toast.error(t('deleteTraderFailed', language)) @@ -344,7 +348,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { error: '启动失败', }) } - mutateTraders() + + // Immediately refresh traders list to update running status + await mutateTraders() } catch (error) { console.error('Failed to toggle trader:', error) toast.error(t('operationFailed', language)) diff --git a/web/src/components/ComparisonChart.tsx b/web/src/components/ComparisonChart.tsx index dc81c9cfa1..554c9b5076 100644 --- a/web/src/components/ComparisonChart.tsx +++ b/web/src/components/ComparisonChart.tsx @@ -100,14 +100,9 @@ export function ComparisonChart({ traders }: ComparisonChartProps) { }) } - // 计算盈亏百分比:从total_pnl和balance计算 - // 假设初始余额 = balance - total_pnl - const initialBalance = point.balance - point.total_pnl - const pnlPct = - initialBalance > 0 ? (point.total_pnl / initialBalance) * 100 : 0 - + // 直接使用后端返回的盈亏百分比,不要在前端重新计算 timestampMap.get(ts)!.traders.set(trader.trader_id, { - pnl_pct: pnlPct, + pnl_pct: point.total_pnl_pct || 0, equity: point.total_equity, }) }) diff --git a/web/src/components/HeaderBar.tsx b/web/src/components/HeaderBar.tsx index 3c1870c4ba..7d3bd1c6e0 100644 --- a/web/src/components/HeaderBar.tsx +++ b/web/src/components/HeaderBar.tsx @@ -4,6 +4,7 @@ import { motion } from 'framer-motion' import { Menu, X, ChevronDown } from 'lucide-react' import { t, type Language } from '../i18n/translations' import { Container } from './Container' +import { useSystemConfig } from '../hooks/useSystemConfig' interface HeaderBarProps { onLoginClick?: () => void @@ -33,6 +34,8 @@ export default function HeaderBar({ const [userDropdownOpen, setUserDropdownOpen] = useState(false) const dropdownRef = useRef(null) const userDropdownRef = useRef(null) + const { config: systemConfig } = useSystemConfig() + const registrationEnabled = systemConfig?.registration_enabled !== false // Close dropdown when clicking outside useEffect(() => { @@ -464,16 +467,18 @@ export default function HeaderBar({ > {t('signIn', language)} - - {t('signUp', language)} - + {registrationEnabled && ( + + {t('signUp', language)} + + )} ) )} @@ -901,17 +906,19 @@ export default function HeaderBar({ > {t('signIn', language)} - setMobileMenuOpen(false)} - > - {t('signUp', language)} - + {registrationEnabled && ( + setMobileMenuOpen(false)} + > + {t('signUp', language)} + + )} )} diff --git a/web/src/components/LoginPage.tsx b/web/src/components/LoginPage.tsx index e498d91daf..a7d04d1ad7 100644 --- a/web/src/components/LoginPage.tsx +++ b/web/src/components/LoginPage.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useAuth } from '../contexts/AuthContext' import { useLanguage } from '../contexts/LanguageContext' @@ -6,6 +6,7 @@ import { t } from '../i18n/translations' import { Eye, EyeOff } from 'lucide-react' import { Input } from './ui/input' import { toast } from 'sonner' +import { useSystemConfig } from '../hooks/useSystemConfig' export function LoginPage() { const { language } = useLanguage() @@ -21,6 +22,20 @@ export function LoginPage() { const [loading, setLoading] = useState(false) const [adminPassword, setAdminPassword] = useState('') const adminMode = false + const { config: systemConfig } = useSystemConfig() + const registrationEnabled = systemConfig?.registration_enabled !== false + const [expiredToastId, setExpiredToastId] = useState(null) + + // Show notification if user was redirected here due to 401 + useEffect(() => { + if (sessionStorage.getItem('from401') === 'true') { + const id = toast.warning(t('sessionExpired', language), { + duration: Infinity // Keep showing until user dismisses or logs in + }) + setExpiredToastId(id) + sessionStorage.removeItem('from401') + } + }, [language]) const handleAdminLogin = async (e: React.FormEvent) => { e.preventDefault() @@ -31,6 +46,11 @@ export function LoginPage() { const msg = result.message || t('loginFailed', language) setError(msg) toast.error(msg) + } else { + // Dismiss the "login expired" toast on successful login + if (expiredToastId) { + toast.dismiss(expiredToastId) + } } setLoading(false) } @@ -46,6 +66,11 @@ export function LoginPage() { if (result.requiresOTP && result.userID) { setUserID(result.userID) setStep('otp') + } else { + // Dismiss the "login expired" toast on successful login (no OTP required) + if (expiredToastId) { + toast.dismiss(expiredToastId) + } } } else { const msg = result.message || t('loginFailed', language) @@ -67,6 +92,11 @@ export function LoginPage() { const msg = result.message || t('verificationFailed', language) setError(msg) toast.error(msg) + } else { + // Dismiss the "login expired" toast on successful OTP verification + if (expiredToastId) { + toast.dismiss(expiredToastId) + } } // 成功的话AuthContext会自动处理登录状态 @@ -313,7 +343,7 @@ export function LoginPage() { {/* Register Link */} - {!adminMode && ( + {!adminMode && registrationEnabled && (

还没有账户?{' '} diff --git a/web/src/components/RegisterPage.tsx b/web/src/components/RegisterPage.tsx index 5f31c9d751..4131ad2cb9 100644 --- a/web/src/components/RegisterPage.tsx +++ b/web/src/components/RegisterPage.tsx @@ -9,6 +9,7 @@ import { copyWithToast } from '../lib/clipboard' import { Eye, EyeOff } from 'lucide-react' import { Input } from './ui/input' import PasswordChecklist from 'react-password-checklist' +import { RegistrationDisabled } from './RegistrationDisabled' export function RegisterPage() { const { language } = useLanguage() @@ -22,6 +23,7 @@ export function RegisterPage() { const [confirmPassword, setConfirmPassword] = useState('') const [betaCode, setBetaCode] = useState('') const [betaMode, setBetaMode] = useState(false) + const [registrationEnabled, setRegistrationEnabled] = useState(true) const [otpCode, setOtpCode] = useState('') const [userID, setUserID] = useState('') const [otpSecret, setOtpSecret] = useState('') @@ -33,16 +35,22 @@ export function RegisterPage() { const [showConfirmPassword, setShowConfirmPassword] = useState(false) useEffect(() => { - // 获取系统配置,检查是否开启内测模式 + // 获取系统配置,检查是否开启内测模式和注册功能 getSystemConfig() .then((config) => { setBetaMode(config.beta_mode || false) + setRegistrationEnabled(config.registration_enabled !== false) }) .catch((err) => { console.error('Failed to fetch system config:', err) }) }, []) + // 如果注册功能被禁用,显示注册已关闭页面 + if (!registrationEnabled) { + return + } + const handleRegister = async (e: React.FormEvent) => { e.preventDefault() setError('') diff --git a/web/src/components/RegistrationDisabled.test.tsx b/web/src/components/RegistrationDisabled.test.tsx new file mode 100644 index 0000000000..beec1b8322 --- /dev/null +++ b/web/src/components/RegistrationDisabled.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { RegistrationDisabled } from './RegistrationDisabled' +import { LanguageProvider } from '../contexts/LanguageContext' + +// Mock useLanguage hook +vi.mock('../contexts/LanguageContext', async () => { + const actual = await vi.importActual('../contexts/LanguageContext') + return { + ...actual, + useLanguage: () => ({ language: 'en' }), + } +}) + +/** + * RegistrationDisabled Component Tests + * + * Tests the component that displays when registration is disabled + * This is part of the registration toggle feature + */ +describe('RegistrationDisabled Component', () => { + const renderComponent = () => { + return render( + + + + ) + } + + describe('Rendering', () => { + it('should render the component without errors', () => { + const { container } = renderComponent() + expect(container).toBeTruthy() + }) + + it('should display the NoFx logo', () => { + renderComponent() + const logo = screen.getByAltText('NoFx Logo') + expect(logo).toBeTruthy() + expect(logo.getAttribute('src')).toBe('/icons/nofx.svg') + }) + + it('should display registration closed heading', () => { + renderComponent() + const heading = screen.getByText('Registration Closed') + expect(heading).toBeTruthy() + }) + + it('should display registration closed message', () => { + renderComponent() + const message = screen.getByText(/User registration is currently disabled/i) + expect(message).toBeTruthy() + }) + + it('should display back to login button', () => { + renderComponent() + const button = screen.getByRole('button', { name: /back to login/i }) + expect(button).toBeTruthy() + }) + }) + + describe('Navigation', () => { + it('should navigate to login page when button is clicked', () => { + const pushStateSpy = vi.spyOn(window.history, 'pushState') + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent') + + renderComponent() + const button = screen.getByRole('button', { name: /back to login/i }) + + fireEvent.click(button) + + expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/login') + expect(dispatchEventSpy).toHaveBeenCalled() + + pushStateSpy.mockRestore() + dispatchEventSpy.mockRestore() + }) + }) + + describe('Styling', () => { + it('should have correct background color', () => { + const { container } = renderComponent() + const mainDiv = container.firstChild as HTMLElement + // Browser converts hex to rgb + expect(mainDiv.style.background).toMatch(/rgb\(11,\s*14,\s*17\)|#0B0E11/i) + }) + + it('should have correct text color', () => { + const { container } = renderComponent() + const mainDiv = container.firstChild as HTMLElement + // Browser converts hex to rgb + expect(mainDiv.style.color).toMatch(/rgb\(234,\s*236,\s*239\)|#EAECEF/i) + }) + + it('should have centered layout', () => { + const { container } = renderComponent() + const mainDiv = container.firstChild as HTMLElement + expect(mainDiv.className).toContain('flex') + expect(mainDiv.className).toContain('items-center') + expect(mainDiv.className).toContain('justify-center') + }) + }) +}) diff --git a/web/src/components/RegistrationDisabled.tsx b/web/src/components/RegistrationDisabled.tsx new file mode 100644 index 0000000000..048a9d6282 --- /dev/null +++ b/web/src/components/RegistrationDisabled.tsx @@ -0,0 +1,39 @@ +import { useLanguage } from '../contexts/LanguageContext' +import { t } from '../i18n/translations' + +export function RegistrationDisabled() { + const { language } = useLanguage() + + const handleBackToLogin = () => { + window.history.pushState({}, '', '/login') + window.dispatchEvent(new PopStateEvent('popstate')) + } + + return ( +

+
+ NoFx Logo +

+ {t('registrationClosed', language)} +

+

+ {t('registrationClosedMessage', language)} +

+ +
+
+ ) +} diff --git a/web/src/components/TraderConfigModal.tsx b/web/src/components/TraderConfigModal.tsx index f06552130a..2fa53a7fdb 100644 --- a/web/src/components/TraderConfigModal.tsx +++ b/web/src/components/TraderConfigModal.tsx @@ -4,6 +4,7 @@ import { useLanguage } from '../contexts/LanguageContext' import { t } from '../i18n/translations' import { toast } from 'sonner' import { Pencil, Plus, X as IconX } from 'lucide-react' +import { httpClient } from '../lib/httpClient' // 提取下划线后面的名称部分 function getShortName(fullName: string): string { @@ -25,7 +26,7 @@ interface TraderConfigData { is_cross_margin: boolean use_coin_pool: boolean use_oi_top: boolean - initial_balance: number + initial_balance?: number // 可选:创建时不需要,编辑时使用 scan_interval_minutes: number } @@ -62,7 +63,6 @@ export function TraderConfigModal({ is_cross_margin: true, use_coin_pool: false, use_oi_top: false, - initial_balance: 1000, scan_interval_minutes: 3, }) const [isSaving, setIsSaving] = useState(false) @@ -115,7 +115,7 @@ export function TraderConfigModal({ useEffect(() => { const fetchConfig = async () => { try { - const response = await fetch('/api/config') + const response = await httpClient.get('/api/config') const config = await response.json() if (config.default_coins) { setAvailableCoins(config.default_coins) @@ -141,7 +141,7 @@ export function TraderConfigModal({ useEffect(() => { const fetchPromptTemplates = async () => { try { - const response = await fetch('/api/prompt-templates') + const response = await httpClient.get('/api/prompt-templates') const data = await response.json() if (data.templates) { setPromptTemplates(data.templates) @@ -199,19 +199,13 @@ export function TraderConfigModal({ throw new Error('未登录,请先登录') } - const response = await fetch( + const response = await httpClient.get( `/api/account?trader_id=${traderData.trader_id}`, { - headers: { - Authorization: `Bearer ${token}`, - }, + Authorization: `Bearer ${token}`, } ) - if (!response.ok) { - throw new Error('获取账户余额失败') - } - const data = await response.json() // total_equity = 当前账户净值(包含未实现盈亏) @@ -247,9 +241,14 @@ export function TraderConfigModal({ is_cross_margin: formData.is_cross_margin, use_coin_pool: formData.use_coin_pool, use_oi_top: formData.use_oi_top, - initial_balance: formData.initial_balance, scan_interval_minutes: formData.scan_interval_minutes, } + + // 只在编辑模式时包含initial_balance(用于手动更新) + if (isEditMode && formData.initial_balance !== undefined) { + saveData.initial_balance = formData.initial_balance + } + await toast.promise(onSave(saveData), { loading: '正在保存…', success: '保存成功', @@ -404,15 +403,12 @@ export function TraderConfigModal({
-
-
- - {isEditMode && ( + {isEditMode && ( +
+
+ +
+ + handleInputChange( + 'initial_balance', + Number(e.target.value) + ) + } + onBlur={(e) => { + // Force minimum value on blur + const value = Number(e.target.value) + if (value < 100) { + handleInputChange('initial_balance', 100) + } + }} + className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none" + min="100" + step="0.01" + /> +

+ 用于手动更新初始余额基准(例如充值/提现后) +

+ {balanceFetchError && ( +

+ {balanceFetchError} +

)}
- - handleInputChange( - 'initial_balance', - Number(e.target.value) - ) - } - onBlur={(e) => { - // Force minimum value on blur - const value = Number(e.target.value) - if (value < 100) { - handleInputChange('initial_balance', 100) - } - }} - className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none" - min="100" - step="0.01" - /> - {!isEditMode && ( -

+ )} + {!isEditMode && ( +

+ +
- - - + + + - 请输入您交易所账户的当前实际余额。如果输入不准确,P&L统计将会错误。 -

- )} - {isEditMode && ( -

- 点击"获取当前余额"按钮可自动获取您交易所账户的当前净值 -

- )} - {balanceFetchError && ( -

- {balanceFetchError} -

- )} -
+ + 系统将自动获取您的账户净值作为初始余额 + +
+
+ )}
{/* 第二行:AI 扫描决策间隔 */} diff --git a/web/src/components/landing/LoginModal.tsx b/web/src/components/landing/LoginModal.tsx index 44032c6ecb..3abaf1b81a 100644 --- a/web/src/components/landing/LoginModal.tsx +++ b/web/src/components/landing/LoginModal.tsx @@ -1,6 +1,7 @@ import { motion } from 'framer-motion' import { X } from 'lucide-react' import { t, Language } from '../../i18n/translations' +import { useSystemConfig } from '../../hooks/useSystemConfig' interface LoginModalProps { onClose: () => void @@ -8,6 +9,9 @@ interface LoginModalProps { } export default function LoginModal({ onClose, language }: LoginModalProps) { + const { config: systemConfig } = useSystemConfig() + const registrationEnabled = systemConfig?.registration_enabled !== false + return ( {t('signIn', language)} - { - window.history.pushState({}, '', '/register') - window.dispatchEvent(new PopStateEvent('popstate')) - onClose() - }} - className="block w-full px-6 py-3 rounded-lg font-semibold text-center" - style={{ - background: 'var(--brand-dark-gray)', - color: 'var(--brand-light-gray)', - border: '1px solid rgba(240, 185, 11, 0.2)', - }} - whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }} - whileTap={{ scale: 0.95 }} - > - {t('registerNewAccount', language)} - + {registrationEnabled && ( + { + window.history.pushState({}, '', '/register') + window.dispatchEvent(new PopStateEvent('popstate')) + onClose() + }} + className="block w-full px-6 py-3 rounded-lg font-semibold text-center" + style={{ + background: 'var(--brand-dark-gray)', + color: 'var(--brand-light-gray)', + border: '1px solid rgba(240, 185, 11, 0.2)', + }} + whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }} + whileTap={{ scale: 0.95 }} + > + {t('registerNewAccount', language)} + + )} diff --git a/web/src/components/traders/ExchangeConfigModal.tsx b/web/src/components/traders/ExchangeConfigModal.tsx new file mode 100644 index 0000000000..29dc286c66 --- /dev/null +++ b/web/src/components/traders/ExchangeConfigModal.tsx @@ -0,0 +1,926 @@ +import React, { useState, useEffect } from 'react' +import type { Exchange } from '../../types' +import { t, type Language } from '../../i18n/translations' +import { api } from '../../lib/api' +import { getExchangeIcon } from '../ExchangeIcons' +import { + TwoStageKeyModal, + type TwoStageKeyModalResult, +} from '../TwoStageKeyModal' +import { + WebCryptoEnvironmentCheck, + type WebCryptoCheckStatus, +} from '../WebCryptoEnvironmentCheck' +import { BookOpen, Trash2, HelpCircle } from 'lucide-react' +import { toast } from 'sonner' +import { Tooltip } from './Tooltip' +import { getShortName } from './utils' + +interface ExchangeConfigModalProps { + allExchanges: Exchange[] + editingExchangeId: string | null + onSave: ( + exchangeId: string, + apiKey: string, + secretKey?: string, + testnet?: boolean, + hyperliquidWalletAddr?: string, + asterUser?: string, + asterSigner?: string, + asterPrivateKey?: string + ) => Promise + onDelete: (exchangeId: string) => void + onClose: () => void + language: Language +} + +export function ExchangeConfigModal({ + allExchanges, + editingExchangeId, + onSave, + onDelete, + onClose, + language, +}: ExchangeConfigModalProps) { + const [selectedExchangeId, setSelectedExchangeId] = useState( + editingExchangeId || '' + ) + const [apiKey, setApiKey] = useState('') + const [secretKey, setSecretKey] = useState('') + const [passphrase, setPassphrase] = useState('') + const [testnet, setTestnet] = useState(false) + const [showGuide, setShowGuide] = useState(false) + const [serverIP, setServerIP] = useState<{ + public_ip: string + message: string + } | null>(null) + const [loadingIP, setLoadingIP] = useState(false) + const [copiedIP, setCopiedIP] = useState(false) + const [webCryptoStatus, setWebCryptoStatus] = + useState('idle') + + // 币安配置指南展开状态 + const [showBinanceGuide, setShowBinanceGuide] = useState(false) + + // Aster 特定字段 + const [asterUser, setAsterUser] = useState('') + const [asterSigner, setAsterSigner] = useState('') + const [asterPrivateKey, setAsterPrivateKey] = useState('') + + // Hyperliquid 特定字段 + const [hyperliquidWalletAddr, setHyperliquidWalletAddr] = useState('') + + // 安全输入状态 + const [secureInputTarget, setSecureInputTarget] = useState< + null | 'hyperliquid' | 'aster' + >(null) + + // 获取当前编辑的交易所信息 + const selectedExchange = allExchanges?.find( + (e) => e.id === selectedExchangeId + ) + + // 如果是编辑现有交易所,初始化表单数据 + useEffect(() => { + if (editingExchangeId && selectedExchange) { + setApiKey(selectedExchange.apiKey || '') + setSecretKey(selectedExchange.secretKey || '') + setPassphrase('') // Don't load existing passphrase for security + setTestnet(selectedExchange.testnet || false) + + // Aster 字段 + setAsterUser(selectedExchange.asterUser || '') + setAsterSigner(selectedExchange.asterSigner || '') + setAsterPrivateKey('') // Don't load existing private key for security + + // Hyperliquid 字段 + setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '') + } + }, [editingExchangeId, selectedExchange]) + + // 加载服务器IP(当选择binance时) + useEffect(() => { + if (selectedExchangeId === 'binance' && !serverIP) { + setLoadingIP(true) + api + .getServerIP() + .then((data) => { + setServerIP(data) + }) + .catch((err) => { + console.error('Failed to load server IP:', err) + }) + .finally(() => { + setLoadingIP(false) + }) + } + }, [selectedExchangeId]) + + const handleCopyIP = async (ip: string) => { + try { + // 优先使用现代 Clipboard API + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(ip) + setCopiedIP(true) + setTimeout(() => setCopiedIP(false), 2000) + toast.success(t('ipCopied', language)) + } else { + // 降级方案: 使用传统的 execCommand 方法 + const textArea = document.createElement('textarea') + textArea.value = ip + textArea.style.position = 'fixed' + textArea.style.left = '-999999px' + textArea.style.top = '-999999px' + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + + try { + const successful = document.execCommand('copy') + if (successful) { + setCopiedIP(true) + setTimeout(() => setCopiedIP(false), 2000) + toast.success(t('ipCopied', language)) + } else { + throw new Error('复制命令执行失败') + } + } finally { + document.body.removeChild(textArea) + } + } + } catch (err) { + console.error('复制失败:', err) + // 显示错误提示 + toast.error( + t('copyIPFailed', language) || `复制失败: ${ip}\n请手动复制此IP地址` + ) + } + } + + // 安全输入处理函数 + const secureInputContextLabel = + secureInputTarget === 'aster' + ? t('asterExchangeName', language) + : secureInputTarget === 'hyperliquid' + ? t('hyperliquidExchangeName', language) + : undefined + + const handleSecureInputCancel = () => { + setSecureInputTarget(null) + } + + const handleSecureInputComplete = ({ + value, + obfuscationLog, + }: TwoStageKeyModalResult) => { + const trimmed = value.trim() + if (secureInputTarget === 'hyperliquid') { + setApiKey(trimmed) + } + if (secureInputTarget === 'aster') { + setAsterPrivateKey(trimmed) + } + console.log('Secure input obfuscation log:', obfuscationLog) + setSecureInputTarget(null) + } + + // 掩盖敏感数据显示 + const maskSecret = (secret: string) => { + if (!secret || secret.length === 0) return '' + if (secret.length <= 8) return '*'.repeat(secret.length) + return ( + secret.slice(0, 4) + + '*'.repeat(Math.max(secret.length - 8, 4)) + + secret.slice(-4) + ) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!selectedExchangeId) return + + // 根据交易所类型验证不同字段 + if (selectedExchange?.id === 'binance') { + if (!apiKey.trim() || !secretKey.trim()) return + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) + } else if (selectedExchange?.id === 'hyperliquid') { + if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return // 验证私钥和钱包地址 + await onSave( + selectedExchangeId, + apiKey.trim(), + '', + testnet, + hyperliquidWalletAddr.trim() + ) + } else if (selectedExchange?.id === 'aster') { + if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) + return + await onSave( + selectedExchangeId, + '', + '', + testnet, + undefined, + asterUser.trim(), + asterSigner.trim(), + asterPrivateKey.trim() + ) + } else if (selectedExchange?.id === 'okx') { + if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) + } else { + // 默认情况(其他CEX交易所) + if (!apiKey.trim() || !secretKey.trim()) return + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) + } + } + + // 可选择的交易所列表(所有支持的交易所) + const availableExchanges = allExchanges || [] + + return ( +
+
+
+

+ {editingExchangeId + ? t('editExchange', language) + : t('addExchange', language)} +

+
+ {selectedExchange?.id === 'binance' && ( + + )} + {editingExchangeId && ( + + )} +
+
+ +
+
+ {!editingExchangeId && ( +
+
+
+ {t('environmentSteps.checkTitle', language)} +
+ +
+
+
+ {t('environmentSteps.selectTitle', language)} +
+ +
+
+ )} + + {selectedExchange && ( +
+
+
+ {getExchangeIcon(selectedExchange.id, { + width: 32, + height: 32, + })} +
+
+
+ {getShortName(selectedExchange.name)} +
+
+ {selectedExchange.type.toUpperCase()} •{' '} + {selectedExchange.id} +
+
+
+
+ )} + + {selectedExchange && ( + <> + {/* Binance 和其他 CEX 交易所的字段 */} + {(selectedExchange.id === 'binance' || + selectedExchange.type === 'cex') && + selectedExchange.id !== 'hyperliquid' && + selectedExchange.id !== 'aster' && ( + <> + {/* 币安用户配置提示 (D1 方案) */} + {selectedExchange.id === 'binance' && ( +
setShowBinanceGuide(!showBinanceGuide)} + > +
+
+ ℹ️ + + 币安用户必读: + 使用「现货与合约交易」API,不要用「统一账户 + API」 + +
+ + {showBinanceGuide ? '▲' : '▼'} + +
+ + {/* 展开的详细说明 */} + {showBinanceGuide && ( +
e.stopPropagation()} + > +

+ 原因:统一账户 API + 权限结构不同,会导致订单提交失败 +

+ +

+ 正确配置步骤: +

+
    +
  1. + 登录币安 → 个人中心 →{' '} + API 管理 +
  2. +
  3. + 创建 API → 选择「 + 系统生成的 API 密钥」 +
  4. +
  5. + 勾选「现货与合约交易」( + + 不选统一账户 + + ) +
  6. +
  7. + IP 限制选「无限制 + 」或添加服务器 IP +
  8. +
+ +

+ 💡 多资产模式用户注意: + 如果您开启了多资产模式,将强制使用全仓模式。建议关闭多资产模式以支持逐仓交易。 +

+ + + 📖 查看币安官方教程 ↗ + +
+ )} +
+ )} + +
+ + setApiKey(e.target.value)} + placeholder={t('enterAPIKey', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ +
+ + setSecretKey(e.target.value)} + placeholder={t('enterSecretKey', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ + {selectedExchange.id === 'okx' && ( +
+ + setPassphrase(e.target.value)} + placeholder={t('enterPassphrase', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ )} + + {/* Binance 白名单IP提示 */} + {selectedExchange.id === 'binance' && ( +
+
+ {t('whitelistIP', language)} +
+
+ {t('whitelistIPDesc', language)} +
+ + {loadingIP ? ( +
+ {t('loadingServerIP', language)} +
+ ) : serverIP && serverIP.public_ip ? ( +
+ + {serverIP.public_ip} + + +
+ ) : null} +
+ )} + + )} + + {/* Aster 交易所的字段 */} + {selectedExchange.id === 'aster' && ( + <> +
+ + setAsterUser(e.target.value)} + placeholder={t('enterUser', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ +
+ + setAsterSigner(e.target.value)} + placeholder={t('enterSigner', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ +
+ + setAsterPrivateKey(e.target.value)} + placeholder={t('enterPrivateKey', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ + )} + + {/* Hyperliquid 交易所的字段 */} + {selectedExchange.id === 'hyperliquid' && ( + <> + {/* 安全提示 banner */} +
+
+ + 🔐 + +
+
+ {t('hyperliquidAgentWalletTitle', language)} +
+
+ {t('hyperliquidAgentWalletDesc', language)} +
+
+
+
+ + {/* Agent Private Key 字段 */} +
+ +
+
+ + + {apiKey && ( + + )} +
+ {apiKey && ( +
+ {t('secureInputHint', language)} +
+ )} +
+
+ {t('hyperliquidAgentPrivateKeyDesc', language)} +
+
+ + {/* Main Wallet Address 字段 */} +
+ + + setHyperliquidWalletAddr(e.target.value) + } + placeholder={t( + 'enterHyperliquidMainWalletAddress', + language + )} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ {t('hyperliquidMainWalletAddressDesc', language)} +
+
+ + )} + + )} +
+ +
+ + +
+
+
+ + {/* Binance Setup Guide Modal */} + {showGuide && ( +
setShowGuide(false)} + > +
e.stopPropagation()} + > +
+

+ + {t('binanceSetupGuide', language)} +

+ +
+
+ {t('binanceSetupGuide', +
+
+
+ )} + + {/* Two Stage Key Modal */} + +
+ ) +} diff --git a/web/src/components/traders/ModelConfigModal.tsx b/web/src/components/traders/ModelConfigModal.tsx new file mode 100644 index 0000000000..86297ba5fc --- /dev/null +++ b/web/src/components/traders/ModelConfigModal.tsx @@ -0,0 +1,291 @@ +import { useState, useEffect } from 'react' +import { Trash2 } from 'lucide-react' +import { t, type Language } from '../../i18n/translations' +import type { AIModel } from '../../types' +import { getModelIcon } from '../ModelIcons' +import { getShortName } from './utils' + +interface ModelConfigModalProps { + allModels: AIModel[] + configuredModels: AIModel[] + editingModelId: string | null + onSave: ( + modelId: string, + apiKey: string, + baseUrl?: string, + modelName?: string + ) => void + onDelete: (modelId: string) => void + onClose: () => void + language: Language +} + +export function ModelConfigModal({ + allModels, + configuredModels, + editingModelId, + onSave, + onDelete, + onClose, + language, +}: ModelConfigModalProps) { + const [selectedModelId, setSelectedModelId] = useState(editingModelId || '') + const [apiKey, setApiKey] = useState('') + const [baseUrl, setBaseUrl] = useState('') + const [modelName, setModelName] = useState('') + + // 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从所有支持的模型中查找 + const selectedModel = editingModelId + ? configuredModels?.find((m) => m.id === selectedModelId) + : allModels?.find((m) => m.id === selectedModelId) + + // 如果是编辑现有模型,初始化API Key、Base URL和Model Name + useEffect(() => { + if (editingModelId && selectedModel) { + setApiKey(selectedModel.apiKey || '') + setBaseUrl(selectedModel.customApiUrl || '') + setModelName(selectedModel.customModelName || '') + } + }, [editingModelId, selectedModel]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!selectedModelId || !apiKey.trim()) return + + onSave( + selectedModelId, + apiKey.trim(), + baseUrl.trim() || undefined, + modelName.trim() || undefined + ) + } + + // 可选择的模型列表(所有支持的模型) + const availableModels = allModels || [] + + return ( +
+
+
+

+ {editingModelId + ? t('editAIModel', language) + : t('addAIModel', language)} +

+ {editingModelId && ( + + )} +
+ +
+
+ {!editingModelId && ( +
+ + +
+ )} + + {selectedModel && ( +
+
+
+ {getModelIcon(selectedModel.provider || selectedModel.id, { + width: 32, + height: 32, + }) || ( +
+ {selectedModel.name[0]} +
+ )} +
+
+
+ {getShortName(selectedModel.name)} +
+
+ {selectedModel.provider} • {selectedModel.id} +
+
+
+
+ )} + + {selectedModel && ( + <> +
+ + setApiKey(e.target.value)} + placeholder={t('enterAPIKey', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ +
+ + setBaseUrl(e.target.value)} + placeholder={t('customBaseURLPlaceholder', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> +
+ {t('leaveBlankForDefault', language)} +
+
+ +
+ + setModelName(e.target.value)} + placeholder="例如: deepseek-chat, qwen3-max, gpt-5" + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> +
+ 留空使用默认模型名称 +
+
+ +
+
+ ℹ️ {t('information', language)} +
+
+
{t('modelConfigInfo1', language)}
+
{t('modelConfigInfo2', language)}
+
{t('modelConfigInfo3', language)}
+
+
+ + )} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/web/src/components/traders/SignalSourceModal.tsx b/web/src/components/traders/SignalSourceModal.tsx new file mode 100644 index 0000000000..7c0fe814f3 --- /dev/null +++ b/web/src/components/traders/SignalSourceModal.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react' +import { t, type Language } from '../../i18n/translations' + +interface SignalSourceModalProps { + coinPoolUrl: string + oiTopUrl: string + onSave: (coinPoolUrl: string, oiTopUrl: string) => void + onClose: () => void + language: Language +} + +export function SignalSourceModal({ + coinPoolUrl, + oiTopUrl, + onSave, + onClose, + language, +}: SignalSourceModalProps) { + const [coinPool, setCoinPool] = useState(coinPoolUrl || '') + const [oiTop, setOiTop] = useState(oiTopUrl || '') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSave(coinPool.trim(), oiTop.trim()) + } + + return ( +
+
+

+ {t('signalSourceConfig', language)} +

+ +
+
+
+ + setCoinPool(e.target.value)} + placeholder="https://api.example.com/coinpool" + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> +
+ {t('coinPoolDescription', language)} +
+
+ +
+ + setOiTop(e.target.value)} + placeholder="https://api.example.com/oitop" + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> +
+ {t('oiTopDescription', language)} +
+
+ +
+
+ ℹ️ {t('information', language)} +
+
+
{t('signalSourceInfo1', language)}
+
{t('signalSourceInfo2', language)}
+
{t('signalSourceInfo3', language)}
+
+
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/web/src/components/traders/Tooltip.tsx b/web/src/components/traders/Tooltip.tsx new file mode 100644 index 0000000000..3b31b79926 --- /dev/null +++ b/web/src/components/traders/Tooltip.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react' + +interface TooltipProps { + content: string + children: React.ReactNode +} + +export function Tooltip({ content, children }: TooltipProps) { + const [show, setShow] = useState(false) + + return ( +
+
setShow(true)} + onMouseLeave={() => setShow(false)} + onClick={() => setShow(!show)} + > + {children} +
+ {show && ( +
+ {content} +
+
+ )} +
+ ) +} diff --git a/web/src/components/traders/index.ts b/web/src/components/traders/index.ts new file mode 100644 index 0000000000..4c0528fc60 --- /dev/null +++ b/web/src/components/traders/index.ts @@ -0,0 +1,5 @@ +export { Tooltip } from './Tooltip' +export { SignalSourceModal } from './SignalSourceModal' +export { ModelConfigModal } from './ModelConfigModal' +export { ExchangeConfigModal } from './ExchangeConfigModal' +export { getModelDisplayName, getShortName } from './utils' diff --git a/web/src/components/traders/sections/AIModelsSection.tsx b/web/src/components/traders/sections/AIModelsSection.tsx new file mode 100644 index 0000000000..059c8fa5be --- /dev/null +++ b/web/src/components/traders/sections/AIModelsSection.tsx @@ -0,0 +1,97 @@ +import { Brain } from 'lucide-react' +import { t, Language } from '../../../i18n/translations' +import { getModelIcon } from '../../ModelIcons' +import { getShortName } from '../utils' +import type { AIModel } from '../../../types' + +interface AIModelsSectionProps { + language: Language + configuredModels: AIModel[] + isModelInUse: (modelId: string) => boolean + onModelClick: (modelId: string) => void +} + +export function AIModelsSection({ + language, + configuredModels, + isModelInUse, + onModelClick, +}: AIModelsSectionProps) { + return ( +
+

+ + {t('aiModels', language)} +

+
+ {configuredModels.map((model) => { + const inUse = isModelInUse(model.id) + return ( +
onModelClick(model.id)} + > +
+
+ {getModelIcon(model.provider || model.id, { + width: 28, + height: 28, + }) || ( +
+ {getShortName(model.name)[0]} +
+ )} +
+
+
+ {getShortName(model.name)} +
+
+ {inUse + ? t('inUse', language) + : model.enabled + ? t('enabled', language) + : t('configured', language)} +
+
+
+
+
+ ) + })} + {configuredModels.length === 0 && ( +
+ +
+ {t('noModelsConfigured', language)} +
+
+ )} +
+
+ ) +} diff --git a/web/src/components/traders/sections/ExchangesSection.tsx b/web/src/components/traders/sections/ExchangesSection.tsx new file mode 100644 index 0000000000..5c0235e2ce --- /dev/null +++ b/web/src/components/traders/sections/ExchangesSection.tsx @@ -0,0 +1,87 @@ +import { Landmark } from 'lucide-react' +import { t, type Language } from '../../../i18n/translations' +import { getExchangeIcon } from '../../ExchangeIcons' +import { getShortName } from '../index' +import type { Exchange } from '../../../types' + +interface ExchangesSectionProps { + language: Language + configuredExchanges: Exchange[] + isExchangeInUse: (exchangeId: string) => boolean + onExchangeClick: (exchangeId: string) => void +} + +export function ExchangesSection({ + language, + configuredExchanges, + isExchangeInUse, + onExchangeClick, +}: ExchangesSectionProps) { + return ( +
+

+ + {t('exchanges', language)} +

+
+ {configuredExchanges.map((exchange) => { + const inUse = isExchangeInUse(exchange.id) + return ( +
onExchangeClick(exchange.id)} + > +
+
+ {getExchangeIcon(exchange.id, { width: 28, height: 28 })} +
+
+
+ {getShortName(exchange.name)} +
+
+ {exchange.type.toUpperCase()} •{' '} + {inUse + ? t('inUse', language) + : exchange.enabled + ? t('enabled', language) + : t('configured', language)} +
+
+
+
+
+ ) + })} + {configuredExchanges.length === 0 && ( +
+ +
+ {t('noExchangesConfigured', language)} +
+
+ )} +
+
+ ) +} diff --git a/web/src/components/traders/sections/PageHeader.tsx b/web/src/components/traders/sections/PageHeader.tsx new file mode 100644 index 0000000000..4584766ac6 --- /dev/null +++ b/web/src/components/traders/sections/PageHeader.tsx @@ -0,0 +1,117 @@ +import { Bot, Plus, Radio } from 'lucide-react' +import { t, type Language } from '../../../i18n/translations' + +interface PageHeaderProps { + language: Language + tradersCount: number + configuredModelsCount: number + configuredExchangesCount: number + onAddModel: () => void + onAddExchange: () => void + onConfigureSignalSource: () => void + onCreateTrader: () => void +} + +export function PageHeader({ + language, + tradersCount, + configuredModelsCount, + configuredExchangesCount, + onAddModel, + onAddExchange, + onConfigureSignalSource, + onCreateTrader, +}: PageHeaderProps) { + const canCreateTrader = + configuredModelsCount > 0 && configuredExchangesCount > 0 + + return ( +
+
+
+ +
+
+

+ {t('aiTraders', language)} + + {tradersCount} {t('active', language)} + +

+

+ {t('manageAITraders', language)} +

+
+
+ +
+ + + + + + + +
+
+ ) +} diff --git a/web/src/components/traders/sections/SignalSourceWarning.tsx b/web/src/components/traders/sections/SignalSourceWarning.tsx new file mode 100644 index 0000000000..5c49f34132 --- /dev/null +++ b/web/src/components/traders/sections/SignalSourceWarning.tsx @@ -0,0 +1,54 @@ +import { AlertTriangle } from 'lucide-react' +import { t, type Language } from '../../../i18n/translations' + +interface SignalSourceWarningProps { + language: Language + onConfigure: () => void +} + +export function SignalSourceWarning({ + language, + onConfigure, +}: SignalSourceWarningProps) { + return ( +
+ +
+
+ ⚠️ {t('signalSourceNotConfigured', language)} +
+
+

{t('signalSourceWarningMessage', language)}

+

+ {t('solutions', language)} +

+
    +
  • 点击"{t('signalSource', language)}"按钮配置API地址
  • +
  • 或在交易员配置中禁用"使用币种池"和"使用OI Top"
  • +
  • 或在交易员配置中设置自定义币种列表
  • +
+
+ +
+
+ ) +} diff --git a/web/src/components/traders/sections/TradersGrid.tsx b/web/src/components/traders/sections/TradersGrid.tsx new file mode 100644 index 0000000000..95307aa854 --- /dev/null +++ b/web/src/components/traders/sections/TradersGrid.tsx @@ -0,0 +1,172 @@ +import { Bot, BarChart3, Trash2, Pencil } from 'lucide-react' +import { t, type Language } from '../../../i18n/translations' +import { getModelDisplayName } from '../index' +import type { TraderInfo } from '../../../types' + +interface TradersGridProps { + language: Language + traders: TraderInfo[] | undefined + onTraderSelect: (traderId: string) => void + onEditTrader: (traderId: string) => void + onDeleteTrader: (traderId: string) => void + onToggleTrader: (traderId: string, running: boolean) => void +} + +export function TradersGrid({ + language, + traders, + onTraderSelect, + onEditTrader, + onDeleteTrader, + onToggleTrader, +}: TradersGridProps) { + if (!traders || traders.length === 0) { + return ( +
+ +
+ {t('noTraders', language)} +
+
+ {t('createFirstTrader', language)} +
+
+ ) + } + + return ( +
+ {traders.map((trader) => ( +
+
+
+ +
+
+
+ {trader.trader_name} +
+
+ {getModelDisplayName( + trader.ai_model.split('_').pop() || trader.ai_model + )}{' '} + Model • {trader.exchange_id?.toUpperCase()} +
+
+
+ +
+ {/* Status */} +
+
+ {trader.is_running + ? t('running', language) + : t('stopped', language)} +
+
+ + {/* Actions: 禁止换行,超出横向滚动 */} +
+ + + + + + + +
+
+
+ ))} +
+ ) +} diff --git a/web/src/components/traders/utils.ts b/web/src/components/traders/utils.ts new file mode 100644 index 0000000000..6235e006f2 --- /dev/null +++ b/web/src/components/traders/utils.ts @@ -0,0 +1,19 @@ +// 获取友好的AI模型名称 +export function getModelDisplayName(modelId: string): string { + switch (modelId.toLowerCase()) { + case 'deepseek': + return 'DeepSeek' + case 'qwen': + return 'Qwen' + case 'claude': + return 'Claude' + default: + return modelId.toUpperCase() + } +} + +// 提取下划线后面的名称部分 +export function getShortName(fullName: string): string { + const parts = fullName.split('_') + return parts.length > 1 ? parts[parts.length - 1] : fullName +} diff --git a/web/src/hooks/useTraderActions.ts b/web/src/hooks/useTraderActions.ts new file mode 100644 index 0000000000..47d3c163cf --- /dev/null +++ b/web/src/hooks/useTraderActions.ts @@ -0,0 +1,639 @@ +import { api } from '../lib/api' +import type { + TraderInfo, + CreateTraderRequest, + TraderConfigData, + AIModel, + Exchange, +} from '../types' +import { t } from '../i18n/translations' +import { confirmToast } from '../lib/notify' +import { toast } from 'sonner' +import type { Language } from '../i18n/translations' + +interface UseTraderActionsParams { + traders: TraderInfo[] | undefined + allModels: AIModel[] + allExchanges: Exchange[] + supportedModels: AIModel[] + supportedExchanges: Exchange[] + language: Language + mutateTraders: () => Promise + setAllModels: (models: AIModel[]) => void + setAllExchanges: (exchanges: Exchange[]) => void + setUserSignalSource: (config: { + coinPoolUrl: string + oiTopUrl: string + }) => void + setShowCreateModal: (show: boolean) => void + setShowEditModal: (show: boolean) => void + setShowModelModal: (show: boolean) => void + setShowExchangeModal: (show: boolean) => void + setShowSignalSourceModal: (show: boolean) => void + setEditingModel: (modelId: string | null) => void + setEditingExchange: (exchangeId: string | null) => void + editingTrader: TraderConfigData | null + setEditingTrader: (trader: TraderConfigData | null) => void +} + +export function useTraderActions({ + traders, + allModels, + allExchanges, + supportedModels, + supportedExchanges, + language, + mutateTraders, + setAllModels, + setAllExchanges, + setUserSignalSource, + setShowCreateModal, + setShowEditModal, + setShowModelModal, + setShowExchangeModal, + setShowSignalSourceModal, + setEditingModel, + setEditingExchange, + editingTrader, + setEditingTrader, +}: UseTraderActionsParams) { + // 检查模型是否正在被运行中的交易员使用(用于UI禁用) + const isModelInUse = (modelId: string) => { + return traders?.some((t) => t.ai_model === modelId && t.is_running) || false + } + + // 检查交易所是否正在被运行中的交易员使用(用于UI禁用) + const isExchangeInUse = (exchangeId: string) => { + return ( + traders?.some((t) => t.exchange_id === exchangeId && t.is_running) || + false + ) + } + + // 检查模型是否被任何交易员使用(包括停止状态的) + const isModelUsedByAnyTrader = (modelId: string) => { + return traders?.some((t) => t.ai_model === modelId) || false + } + + // 检查交易所是否被任何交易员使用(包括停止状态的) + const isExchangeUsedByAnyTrader = (exchangeId: string) => { + return traders?.some((t) => t.exchange_id === exchangeId) || false + } + + // 获取使用特定模型的交易员列表 + const getTradersUsingModel = (modelId: string) => { + return traders?.filter((t) => t.ai_model === modelId) || [] + } + + // 获取使用特定交易所的交易员列表 + const getTradersUsingExchange = (exchangeId: string) => { + return traders?.filter((t) => t.exchange_id === exchangeId) || [] + } + + const handleCreateTrader = async (data: CreateTraderRequest) => { + try { + 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 toast.promise(api.createTrader(data), { + loading: '正在创建…', + success: '创建成功', + error: '创建失败', + }) + setShowCreateModal(false) + // Immediately refresh traders list for better UX + await mutateTraders() + } catch (error) { + console.error('Failed to create trader:', error) + toast.error(t('createTraderFailed', language)) + } + } + + const handleEditTrader = async (traderId: string) => { + try { + const traderConfig = await api.getTraderConfig(traderId) + setEditingTrader(traderConfig) + setShowEditModal(true) + } catch (error) { + console.error('Failed to fetch trader config:', error) + toast.error(t('getTraderConfigFailed', language)) + } + } + + const handleSaveEditTrader = async (data: CreateTraderRequest) => { + if (!editingTrader || !editingTrader.trader_id) return + + try { + const enabledModels = allModels?.filter((m) => m.enabled) || [] + const enabledExchanges = + allExchanges?.filter((e) => { + if (!e.enabled) return false + + // Aster 交易所需要特殊字段 + if (e.id === 'aster') { + return ( + e.asterUser && + e.asterUser.trim() !== '' && + e.asterSigner && + e.asterSigner.trim() !== '' + ) + } + + // Hyperliquid 需要钱包地址 + if (e.id === 'hyperliquid') { + return ( + e.hyperliquidWalletAddr && e.hyperliquidWalletAddr.trim() !== '' + ) + } + + return true + }) || [] + + const model = enabledModels?.find((m) => m.id === data.ai_model_id) + const exchange = enabledExchanges?.find((e) => e.id === data.exchange_id) + + if (!model) { + toast.error(t('modelConfigNotExist', language)) + return + } + + if (!exchange) { + toast.error(t('exchangeConfigNotExist', language)) + return + } + + const request = { + name: data.name, + ai_model_id: data.ai_model_id, + exchange_id: data.exchange_id, + initial_balance: data.initial_balance, + scan_interval_minutes: data.scan_interval_minutes, + btc_eth_leverage: data.btc_eth_leverage, + altcoin_leverage: data.altcoin_leverage, + trading_symbols: data.trading_symbols, + custom_prompt: data.custom_prompt, + override_base_prompt: data.override_base_prompt, + system_prompt_template: data.system_prompt_template, + is_cross_margin: data.is_cross_margin, + use_coin_pool: data.use_coin_pool, + use_oi_top: data.use_oi_top, + } + + await toast.promise(api.updateTrader(editingTrader.trader_id, request), { + loading: '正在保存…', + success: '保存成功', + error: '保存失败', + }) + setShowEditModal(false) + setEditingTrader(null) + // Immediately refresh traders list for better UX + await mutateTraders() + } catch (error) { + console.error('Failed to update trader:', error) + toast.error(t('updateTraderFailed', language)) + } + } + + const handleDeleteTrader = async (traderId: string) => { + { + const ok = await confirmToast(t('confirmDeleteTrader', language)) + if (!ok) return + } + + try { + await toast.promise(api.deleteTrader(traderId), { + loading: '正在删除…', + success: '删除成功', + error: '删除失败', + }) + + // Immediately refresh traders list for better UX + await mutateTraders() + } catch (error) { + console.error('Failed to delete trader:', error) + toast.error(t('deleteTraderFailed', language)) + } + } + + const handleToggleTrader = async (traderId: string, running: boolean) => { + try { + if (running) { + await toast.promise(api.stopTrader(traderId), { + loading: '正在停止…', + success: '已停止', + error: '停止失败', + }) + } else { + await toast.promise(api.startTrader(traderId), { + loading: '正在启动…', + success: '已启动', + error: '启动失败', + }) + } + + // Immediately refresh traders list to update running status + await mutateTraders() + } catch (error) { + console.error('Failed to toggle trader:', error) + toast.error(t('operationFailed', language)) + } + } + + const handleModelClick = (modelId: string) => { + if (!isModelInUse(modelId)) { + setEditingModel(modelId) + setShowModelModal(true) + } + } + + const handleExchangeClick = (exchangeId: string) => { + if (!isExchangeInUse(exchangeId)) { + setEditingExchange(exchangeId) + setShowExchangeModal(true) + } + } + + // 通用删除配置处理函数 + const handleDeleteConfig = async (config: { + id: string + type: 'model' | 'exchange' + checkInUse: (id: string) => boolean + getUsingTraders: (id: string) => any[] + cannotDeleteKey: string + confirmDeleteKey: string + allItems: T[] | undefined + clearFields: (item: T) => T + buildRequest: (items: T[]) => any + updateApi: (request: any) => Promise + refreshApi: () => Promise + setItems: (items: T[]) => void + closeModal: () => void + errorKey: string + }) => { + // 检查是否有交易员正在使用 + if (config.checkInUse(config.id)) { + const usingTraders = config.getUsingTraders(config.id) + const traderNames = usingTraders.map((t) => t.trader_name).join(', ') + toast.error( + `${t(config.cannotDeleteKey, language)} · ${t('tradersUsing', language)}: ${traderNames} · ${t('pleaseDeleteTradersFirst', language)}` + ) + return + } + + { + const ok = await confirmToast(t(config.confirmDeleteKey, language)) + if (!ok) return + } + + try { + const updatedItems = + config.allItems?.map((item) => + item.id === config.id ? config.clearFields(item) : item + ) || [] + + const request = config.buildRequest(updatedItems) + await toast.promise(config.updateApi(request), { + loading: '正在更新配置…', + success: '配置已更新', + error: '更新配置失败', + }) + + // 重新获取用户配置以确保数据同步 + const refreshedItems = await config.refreshApi() + config.setItems(refreshedItems) + + config.closeModal() + } catch (error) { + console.error(`Failed to delete ${config.type} config:`, error) + toast.error(t(config.errorKey, language)) + } + } + + const handleDeleteModel = async (modelId: string) => { + await handleDeleteConfig({ + id: modelId, + type: 'model', + checkInUse: isModelUsedByAnyTrader, + getUsingTraders: getTradersUsingModel, + cannotDeleteKey: 'cannotDeleteModelInUse', + confirmDeleteKey: 'confirmDeleteModel', + allItems: allModels, + clearFields: (m) => ({ + ...m, + apiKey: '', + customApiUrl: '', + customModelName: '', + enabled: false, + }), + buildRequest: (models) => ({ + models: Object.fromEntries( + models.map((model) => [ + model.provider, + { + enabled: model.enabled, + api_key: model.apiKey || '', + custom_api_url: model.customApiUrl || '', + custom_model_name: model.customModelName || '', + }, + ]) + ), + }), + updateApi: api.updateModelConfigs, + refreshApi: api.getModelConfigs, + setItems: (items) => { + // 使用函数式更新确保状态正确更新 + setAllModels([...items]) + }, + closeModal: () => { + setShowModelModal(false) + setEditingModel(null) + }, + errorKey: 'deleteConfigFailed', + }) + } + + const handleSaveModel = async ( + modelId: string, + apiKey: string, + customApiUrl?: string, + customModelName?: string + ) => { + try { + // 创建或更新用户的模型配置 + const existingModel = allModels?.find((m) => m.id === modelId) + let updatedModels + + // 找到要配置的模型(优先从已配置列表,其次从支持列表) + const modelToUpdate = + existingModel || supportedModels?.find((m) => m.id === modelId) + if (!modelToUpdate) { + toast.error(t('modelNotExist', language)) + return + } + + if (existingModel) { + // 更新现有配置 + updatedModels = + allModels?.map((m) => + m.id === modelId + ? { + ...m, + apiKey, + customApiUrl: customApiUrl || '', + customModelName: customModelName || '', + enabled: true, + } + : m + ) || [] + } else { + // 添加新配置 + const newModel = { + ...modelToUpdate, + apiKey, + customApiUrl: customApiUrl || '', + customModelName: customModelName || '', + enabled: true, + } + updatedModels = [...(allModels || []), newModel] + } + + const request = { + models: Object.fromEntries( + updatedModels.map((model) => [ + model.provider, // 使用 provider 而不是 id + { + enabled: model.enabled, + api_key: model.apiKey || '', + custom_api_url: model.customApiUrl || '', + custom_model_name: model.customModelName || '', + }, + ]) + ), + } + + await toast.promise(api.updateModelConfigs(request), { + loading: '正在更新模型配置…', + success: '模型配置已更新', + error: '更新模型配置失败', + }) + + // 重新获取用户配置以确保数据同步 + const refreshedModels = await api.getModelConfigs() + setAllModels(refreshedModels) + + setShowModelModal(false) + setEditingModel(null) + } catch (error) { + console.error('Failed to save model config:', error) + toast.error(t('saveConfigFailed', language)) + } + } + + const handleDeleteExchange = async (exchangeId: string) => { + await handleDeleteConfig({ + id: exchangeId, + type: 'exchange', + checkInUse: isExchangeUsedByAnyTrader, + getUsingTraders: getTradersUsingExchange, + cannotDeleteKey: 'cannotDeleteExchangeInUse', + confirmDeleteKey: 'confirmDeleteExchange', + allItems: allExchanges, + clearFields: (e) => ({ + ...e, + apiKey: '', + secretKey: '', + hyperliquidWalletAddr: '', + asterUser: '', + asterSigner: '', + asterPrivateKey: '', + enabled: false, + }), + buildRequest: (exchanges) => ({ + exchanges: Object.fromEntries( + exchanges.map((exchange) => [ + exchange.id, + { + enabled: exchange.enabled, + api_key: exchange.apiKey || '', + secret_key: exchange.secretKey || '', + testnet: exchange.testnet || false, + hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '', + aster_user: exchange.asterUser || '', + aster_signer: exchange.asterSigner || '', + aster_private_key: exchange.asterPrivateKey || '', + }, + ]) + ), + }), + updateApi: api.updateExchangeConfigsEncrypted, + refreshApi: api.getExchangeConfigs, + setItems: (items) => { + // 使用函数式更新确保状态正确更新 + setAllExchanges([...items]) + }, + closeModal: () => { + setShowExchangeModal(false) + setEditingExchange(null) + }, + errorKey: 'deleteExchangeConfigFailed', + }) + } + + const handleSaveExchange = async ( + exchangeId: string, + apiKey: string, + secretKey?: string, + testnet?: boolean, + hyperliquidWalletAddr?: string, + asterUser?: string, + asterSigner?: string, + asterPrivateKey?: string + ) => { + try { + // 找到要配置的交易所(从supportedExchanges中) + const exchangeToUpdate = supportedExchanges?.find( + (e) => e.id === exchangeId + ) + if (!exchangeToUpdate) { + toast.error(t('exchangeNotExist', language)) + return + } + + // 创建或更新用户的交易所配置 + const existingExchange = allExchanges?.find((e) => e.id === exchangeId) + let updatedExchanges + + if (existingExchange) { + // 更新现有配置 + updatedExchanges = + allExchanges?.map((e) => + e.id === exchangeId + ? { + ...e, + apiKey, + secretKey, + testnet, + hyperliquidWalletAddr, + asterUser, + asterSigner, + asterPrivateKey, + enabled: true, + } + : e + ) || [] + } else { + // 添加新配置 + const newExchange = { + ...exchangeToUpdate, + apiKey, + secretKey, + testnet, + hyperliquidWalletAddr, + asterUser, + asterSigner, + asterPrivateKey, + enabled: true, + } + updatedExchanges = [...(allExchanges || []), newExchange] + } + + const request = { + exchanges: Object.fromEntries( + updatedExchanges.map((exchange) => [ + exchange.id, + { + enabled: exchange.enabled, + api_key: exchange.apiKey || '', + secret_key: exchange.secretKey || '', + testnet: exchange.testnet || false, + hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '', + aster_user: exchange.asterUser || '', + aster_signer: exchange.asterSigner || '', + aster_private_key: exchange.asterPrivateKey || '', + }, + ]) + ), + } + + await toast.promise(api.updateExchangeConfigsEncrypted(request), { + loading: '正在更新交易所配置…', + success: '交易所配置已更新', + error: '更新交易所配置失败', + }) + + // 重新获取用户配置以确保数据同步 + const refreshedExchanges = await api.getExchangeConfigs() + setAllExchanges(refreshedExchanges) + + setShowExchangeModal(false) + setEditingExchange(null) + } catch (error) { + console.error('Failed to save exchange config:', error) + toast.error(t('saveConfigFailed', language)) + } + } + + const handleAddModel = () => { + setEditingModel(null) + setShowModelModal(true) + } + + const handleAddExchange = () => { + setEditingExchange(null) + setShowExchangeModal(true) + } + + const handleSaveSignalSource = async ( + coinPoolUrl: string, + oiTopUrl: string + ) => { + try { + await toast.promise(api.saveUserSignalSource(coinPoolUrl, oiTopUrl), { + loading: '正在保存…', + success: '保存成功', + error: '保存失败', + }) + setUserSignalSource({ coinPoolUrl, oiTopUrl }) + setShowSignalSourceModal(false) + } catch (error) { + console.error('Failed to save signal source:', error) + toast.error(t('saveSignalSourceFailed', language)) + } + } + + return { + // 辅助函数 + isModelInUse, + isExchangeInUse, + isModelUsedByAnyTrader, + isExchangeUsedByAnyTrader, + getTradersUsingModel, + getTradersUsingExchange, + + // 事件处理函数 + handleCreateTrader, + handleEditTrader, + handleSaveEditTrader, + handleDeleteTrader, + handleToggleTrader, + handleAddModel, + handleAddExchange, + handleModelClick, + handleExchangeClick, + handleSaveModel, + handleDeleteModel, + handleSaveExchange, + handleDeleteExchange, + handleSaveSignalSource, + } +} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index c59164c9ba..26f7102aeb 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -474,6 +474,7 @@ export const translations = { registrationFailed: 'Registration failed. Please try again.', verificationFailed: 'OTP verification failed. Please check the code and try again.', + sessionExpired: 'Session expired, please login again', invalidCredentials: 'Invalid email or password', weak: 'Weak', medium: 'Medium', @@ -496,6 +497,9 @@ export const translations = { exitLogin: 'Sign Out', signIn: 'Sign In', signUp: 'Sign Up', + registrationClosed: 'Registration Closed', + registrationClosedMessage: + 'User registration is currently disabled. Please contact the administrator for access.', // Hero Section githubStarsInDays: '2.5K+ GitHub Stars in 3 days', @@ -1284,6 +1288,7 @@ export const translations = { loginFailed: '登录失败,请检查您的邮箱和密码。', registrationFailed: '注册失败,请重试。', verificationFailed: 'OTP 验证失败,请检查验证码后重试。', + sessionExpired: '登录已过期,请重新登录', invalidCredentials: '邮箱或密码错误', weak: '弱', medium: '中', @@ -1305,6 +1310,8 @@ export const translations = { exitLogin: '退出登录', signIn: '登录', signUp: '注册', + registrationClosed: '注册已关闭', + registrationClosedMessage: '平台当前不开放新用户注册,如需访问请联系管理员获取账号。', // Hero Section githubStarsInDays: '3 天内 2.5K+ GitHub Stars', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 39ab8e9e67..9b70edd4e4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -5,6 +5,7 @@ import type { DecisionRecord, Statistics, TraderInfo, + TraderConfigData, AIModel, Exchange, CreateTraderRequest, @@ -94,7 +95,7 @@ export const api = { if (!res.ok) throw new Error('更新自定义策略失败') }, - async getTraderConfig(traderId: string): Promise { + async getTraderConfig(traderId: string): Promise { const res = await httpClient.get( `${API_BASE}/traders/${traderId}/config`, getAuthHeaders() diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 6be8f0349f..335aacd093 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -1,5 +1,6 @@ export interface SystemConfig { beta_mode: boolean + registration_enabled?: boolean } let configPromise: Promise | null = null diff --git a/web/src/lib/httpClient.ts b/web/src/lib/httpClient.ts index 15ebc16c96..079e6bdf46 100644 --- a/web/src/lib/httpClient.ts +++ b/web/src/lib/httpClient.ts @@ -6,10 +6,9 @@ * - Automatic 401 token expiration handling * - Auth state cleanup on unauthorized * - Automatic redirect to login page + * - Notification shown on login page after redirect */ -import { toast } from 'sonner' - export class HttpClient { // Singleton flag to prevent duplicate 401 handling private static isHandling401 = false @@ -21,13 +20,6 @@ export class HttpClient { HttpClient.isHandling401 = false } - /** - * Show login required notification to user - */ - private showLoginRequiredNotification(): void { - toast.warning('登录已过期,请先登录', { duration: 1800 }) - } - /** * Response interceptor - handles common HTTP errors * @@ -53,23 +45,24 @@ export class HttpClient { // Notify global listeners (AuthContext will react to this) window.dispatchEvent(new Event('unauthorized')) - // Show user-friendly notification (only once) - this.showLoginRequiredNotification() + // Only redirect if not already on login page + if (!window.location.pathname.includes('/login')) { + // Save current location for post-login redirect + const returnUrl = window.location.pathname + window.location.search + if (returnUrl !== '/login' && returnUrl !== '/') { + sessionStorage.setItem('returnUrl', returnUrl) + } - // Delay redirect to let user see the notification - setTimeout(() => { - // Only redirect if not already on login page - if (!window.location.pathname.includes('/login')) { - // Save current location for post-login redirect - const returnUrl = window.location.pathname + window.location.search - if (returnUrl !== '/login' && returnUrl !== '/') { - sessionStorage.setItem('returnUrl', returnUrl) - } + // Mark that user came from 401 (login page will show notification) + sessionStorage.setItem('from401', 'true') - window.location.href = '/login' - } - // Note: No need to reset flag since we're redirecting - }, 1500) // 1.5秒延迟,让用户看到提示 + // Redirect immediately to login page + window.location.href = '/login' + + // Return pending promise to prevent error from being caught by SWR/React + // The notification will be shown on the login page + return new Promise(() => {}) as Promise + } throw new Error('登录已过期,请重新登录') } diff --git a/web/src/lib/registrationToggle.test.ts b/web/src/lib/registrationToggle.test.ts new file mode 100644 index 0000000000..9382fc7218 --- /dev/null +++ b/web/src/lib/registrationToggle.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from 'vitest' + +/** + * Registration Toggle Feature Tests + * + * Tests the logic for determining whether registration is enabled + * This validates the registration_enabled configuration behavior + */ +describe('Registration Toggle Logic', () => { + describe('registration_enabled configuration', () => { + it('should default to true when registration_enabled is undefined', () => { + const config = {} + const registrationEnabled = (config as any).registration_enabled !== false + + expect(registrationEnabled).toBe(true) + }) + + it('should be true when registration_enabled is explicitly true', () => { + const config = { registration_enabled: true } + const registrationEnabled = config.registration_enabled !== false + + expect(registrationEnabled).toBe(true) + }) + + it('should be false when registration_enabled is explicitly false', () => { + const config = { registration_enabled: false } + const registrationEnabled = config.registration_enabled !== false + + expect(registrationEnabled).toBe(false) + }) + + it('should default to true when registration_enabled is null', () => { + const config = { registration_enabled: null } + const registrationEnabled = (config.registration_enabled as any) !== false + + expect(registrationEnabled).toBe(true) + }) + + it('should handle missing config gracefully', () => { + const config = null + const registrationEnabled = config?.registration_enabled !== false + + expect(registrationEnabled).toBe(true) + }) + }) + + describe('UI component visibility logic', () => { + it('should show signup button when registration is enabled', () => { + const registrationEnabled = true + const shouldShowSignup = registrationEnabled + + expect(shouldShowSignup).toBe(true) + }) + + it('should hide signup button when registration is disabled', () => { + const registrationEnabled = false + const shouldShowSignup = registrationEnabled + + expect(shouldShowSignup).toBe(false) + }) + }) + + describe('conditional rendering patterns', () => { + it('should render signup link with registrationEnabled && pattern', () => { + const registrationEnabled = true + const signupElement = registrationEnabled && 'SignUpButton' + + expect(signupElement).toBe('SignUpButton') + }) + + it('should not render signup link when disabled', () => { + const registrationEnabled = false + const signupElement = registrationEnabled && 'SignUpButton' + + expect(signupElement).toBe(false) + }) + }) + + describe('SystemConfig interface compliance', () => { + interface SystemConfig { + beta_mode: boolean + registration_enabled?: boolean + } + + it('should have optional registration_enabled field', () => { + const config1: SystemConfig = { + beta_mode: false, + } + + const config2: SystemConfig = { + beta_mode: false, + registration_enabled: true, + } + + expect(config1.beta_mode).toBe(false) + expect(config2.registration_enabled).toBe(true) + }) + + it('should handle both beta_mode and registration_enabled', () => { + const config: SystemConfig = { + beta_mode: true, + registration_enabled: false, + } + + expect(config.beta_mode).toBe(true) + expect(config.registration_enabled).toBe(false) + }) + }) + + describe('edge cases', () => { + it('should treat empty string as truthy (not false)', () => { + const config = { registration_enabled: '' as any } + const registrationEnabled = config.registration_enabled !== false + + expect(registrationEnabled).toBe(true) + }) + + it('should treat 0 as truthy (not false)', () => { + const config = { registration_enabled: 0 as any } + const registrationEnabled = config.registration_enabled !== false + + expect(registrationEnabled).toBe(true) + }) + + it('should treat "false" string as truthy (not false)', () => { + const config = { registration_enabled: 'false' as any } + const registrationEnabled = config.registration_enabled !== false + + expect(registrationEnabled).toBe(true) + }) + + it('should only treat boolean false as disabled', () => { + const testCases = [ + { value: false, expected: false }, + { value: true, expected: true }, + { value: null, expected: true }, + { value: undefined, expected: true }, + { value: 0, expected: true }, + { value: '', expected: true }, + { value: 'false', expected: true }, + { value: [], expected: true }, + { value: {}, expected: true }, + ] + + testCases.forEach(({ value, expected }) => { + const config = { registration_enabled: value as any } + const registrationEnabled = config.registration_enabled !== false + expect(registrationEnabled).toBe(expected) + }) + }) + }) + + describe('backend API response handling', () => { + it('should parse backend response with registration_enabled', () => { + const apiResponse = { + beta_mode: false, + default_coins: ['BTCUSDT'], + btc_eth_leverage: 5, + altcoin_leverage: 5, + registration_enabled: true, + } + + expect(apiResponse.registration_enabled).toBe(true) + }) + + it('should handle backend response without registration_enabled', () => { + const apiResponse = { + beta_mode: false, + default_coins: ['BTCUSDT'], + btc_eth_leverage: 5, + altcoin_leverage: 5, + } + + const registrationEnabled = + (apiResponse as any).registration_enabled !== false + + expect(registrationEnabled).toBe(true) + }) + }) + + describe('multi-location consistency', () => { + const systemConfig = { registration_enabled: false } + + it('should have consistent behavior across LoginPage', () => { + const registrationEnabled = systemConfig?.registration_enabled !== false + expect(registrationEnabled).toBe(false) + }) + + it('should have consistent behavior across RegisterPage', () => { + const registrationEnabled = systemConfig?.registration_enabled !== false + expect(registrationEnabled).toBe(false) + }) + + it('should have consistent behavior across HeaderBar', () => { + const registrationEnabled = systemConfig?.registration_enabled !== false + expect(registrationEnabled).toBe(false) + }) + + it('should have consistent behavior across LoginModal', () => { + const registrationEnabled = systemConfig?.registration_enabled !== false + expect(registrationEnabled).toBe(false) + }) + }) +}) diff --git a/web/src/pages/AITradersPage.tsx b/web/src/pages/AITradersPage.tsx new file mode 100644 index 0000000000..b662d6e7c2 --- /dev/null +++ b/web/src/pages/AITradersPage.tsx @@ -0,0 +1,248 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import useSWR from 'swr' +import { api } from '../lib/api' +import { useLanguage } from '../contexts/LanguageContext' +import { useAuth } from '../contexts/AuthContext' +import { useTradersConfigStore, useTradersModalStore } from '../stores' +import { useTraderActions } from '../hooks/useTraderActions' +import { TraderConfigModal } from '../components/TraderConfigModal' +import { + SignalSourceModal, + ModelConfigModal, + ExchangeConfigModal, +} from '../components/traders' +import { PageHeader } from '../components/traders/sections/PageHeader' +import { SignalSourceWarning } from '../components/traders/sections/SignalSourceWarning' +import { AIModelsSection } from '../components/traders/sections/AIModelsSection' +import { ExchangesSection } from '../components/traders/sections/ExchangesSection' +import { TradersGrid } from '../components/traders/sections/TradersGrid' + +interface AITradersPageProps { + onTraderSelect?: (traderId: string) => void +} + +export function AITradersPage({ onTraderSelect }: AITradersPageProps) { + const { language } = useLanguage() + const { user, token } = useAuth() + const navigate = useNavigate() + + // Zustand stores + const { + allModels, + allExchanges, + supportedModels, + supportedExchanges, + configuredModels, + configuredExchanges, + userSignalSource, + loadConfigs, + setAllModels, + setAllExchanges, + setUserSignalSource, + } = useTradersConfigStore() + + const { + showCreateModal, + showEditModal, + showModelModal, + showExchangeModal, + showSignalSourceModal, + editingModel, + editingExchange, + editingTrader, + setShowCreateModal, + setShowEditModal, + setShowModelModal, + setShowExchangeModal, + setShowSignalSourceModal, + setEditingModel, + setEditingExchange, + setEditingTrader, + } = useTradersModalStore() + + // SWR for traders data + const { data: traders, mutate: mutateTraders } = useSWR( + user && token ? 'traders' : null, + api.getTraders, + { refreshInterval: 5000 } + ) + + // Load configurations + useEffect(() => { + loadConfigs(user, token) + }, [user, token, loadConfigs]) + + // Business logic hook + const { + isModelInUse, + isExchangeInUse, + handleCreateTrader, + handleEditTrader, + handleSaveEditTrader, + handleDeleteTrader, + handleToggleTrader, + handleAddModel, + handleAddExchange, + handleModelClick, + handleExchangeClick, + handleSaveModel, + handleDeleteModel, + handleSaveExchange, + handleDeleteExchange, + handleSaveSignalSource, + } = useTraderActions({ + traders, + allModels, + allExchanges, + supportedModels, + supportedExchanges, + language, + mutateTraders, + setAllModels, + setAllExchanges, + setUserSignalSource, + setShowCreateModal, + setShowEditModal, + setShowModelModal, + setShowExchangeModal, + setShowSignalSourceModal, + setEditingModel, + setEditingExchange, + editingTrader, + setEditingTrader, + }) + + // 计算派生状态 + const enabledModels = allModels?.filter((m) => m.enabled) || [] + const enabledExchanges = + allExchanges?.filter((e) => { + if (!e.enabled) return false + if (e.id === 'aster') { + return e.asterUser?.trim() && e.asterSigner?.trim() + } + if (e.id === 'hyperliquid') { + return e.hyperliquidWalletAddr?.trim() + } + return true + }) || [] + + // 检查是否需要显示信号源警告 + const showSignalWarning = + traders?.some((t) => t.use_coin_pool || t.use_oi_top) && + !userSignalSource.coinPoolUrl && + !userSignalSource.oiTopUrl + + // 处理交易员查看 + const handleTraderSelect = (traderId: string) => { + if (onTraderSelect) { + onTraderSelect(traderId) + } else { + navigate(`/dashboard?trader=${traderId}`) + } + } + + return ( +
+ {/* Header */} + setShowSignalSourceModal(true)} + onCreateTrader={() => setShowCreateModal(true)} + /> + + {/* Signal Source Warning */} + {showSignalWarning && ( + setShowSignalSourceModal(true)} + /> + )} + + {/* Configuration Status */} +
+ + + +
+ + {/* Traders Grid */} + + + {/* Modals */} + setShowCreateModal(false)} + isEditMode={false} + availableModels={enabledModels} + availableExchanges={enabledExchanges} + onSave={handleCreateTrader} + /> + + setShowEditModal(false)} + isEditMode={true} + traderData={editingTrader} + availableModels={enabledModels} + availableExchanges={enabledExchanges} + onSave={handleSaveEditTrader} + /> + + {showModelModal && ( + setShowModelModal(false)} + language={language} + /> + )} + + {showExchangeModal && ( + setShowExchangeModal(false)} + language={language} + /> + )} + + {showSignalSourceModal && ( + setShowSignalSourceModal(false)} + language={language} + /> + )} +
+ ) +} diff --git a/web/src/pages/TraderDashboard.tsx b/web/src/pages/TraderDashboard.tsx index 9d165603b8..d66a141bce 100644 --- a/web/src/pages/TraderDashboard.tsx +++ b/web/src/pages/TraderDashboard.tsx @@ -282,6 +282,8 @@ export default function TraderDashboard() { ) } + const highlightColor = '#60a5fa' + return (
{/* Trader Header */} @@ -346,7 +348,7 @@ export default function TraderDashboard() { style={{ color: selectedTrader.ai_model.includes('qwen') ? '#c084fc' - : '#60a5fa', + : highlightColor, }} > {getModelDisplayName( @@ -355,6 +357,10 @@ export default function TraderDashboard() { )} + + + Prompt: {selectedTrader.system_prompt_template || '-'} + {status && ( <> diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index b49164cfa1..d0793e4839 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -7,7 +7,7 @@ import { LoginPage } from '../components/LoginPage' import { RegisterPage } from '../components/RegisterPage' import { ResetPasswordPage } from '../components/ResetPasswordPage' import { CompetitionPage } from '../components/CompetitionPage' -import { AITradersPage } from '../components/AITradersPage' +import { AITradersPage } from '../pages/AITradersPage' import TraderDashboard from '../pages/TraderDashboard' export const router = createBrowserRouter([ diff --git a/web/src/stores/index.ts b/web/src/stores/index.ts new file mode 100644 index 0000000000..e825ae0483 --- /dev/null +++ b/web/src/stores/index.ts @@ -0,0 +1,2 @@ +export { useTradersConfigStore } from './tradersConfigStore' +export { useTradersModalStore } from './tradersModalStore' diff --git a/web/src/stores/tradersConfigStore.ts b/web/src/stores/tradersConfigStore.ts new file mode 100644 index 0000000000..b58f3a2078 --- /dev/null +++ b/web/src/stores/tradersConfigStore.ts @@ -0,0 +1,128 @@ +import { create } from 'zustand' +import type { AIModel, Exchange } from '../types' +import { api } from '../lib/api' + +interface SignalSource { + coinPoolUrl: string + oiTopUrl: string +} + +interface TradersConfigState { + // 数据 + allModels: AIModel[] + allExchanges: Exchange[] + supportedModels: AIModel[] + supportedExchanges: Exchange[] + userSignalSource: SignalSource + + // 计算属性 + configuredModels: AIModel[] + configuredExchanges: Exchange[] + + // Actions + setAllModels: (models: AIModel[]) => void + setAllExchanges: (exchanges: Exchange[]) => void + setSupportedModels: (models: AIModel[]) => void + setSupportedExchanges: (exchanges: Exchange[]) => void + setUserSignalSource: (source: SignalSource) => void + + // 异步加载 + loadConfigs: (user: any, token: string | null) => Promise + + // 重置 + reset: () => void +} + +const initialState = { + allModels: [], + allExchanges: [], + supportedModels: [], + supportedExchanges: [], + userSignalSource: { coinPoolUrl: '', oiTopUrl: '' }, + configuredModels: [], + configuredExchanges: [], +} + +export const useTradersConfigStore = create((set, get) => ({ + ...initialState, + + setAllModels: (models) => { + set({ allModels: models }) + // 更新 configuredModels + const configuredModels = models.filter((m) => { + return m.enabled || (m.customApiUrl && m.customApiUrl.trim() !== '') + }) + set({ configuredModels }) + }, + + setAllExchanges: (exchanges) => { + set({ allExchanges: exchanges }) + // 更新 configuredExchanges + const configuredExchanges = exchanges.filter((e) => { + if (e.id === 'aster') { + return e.asterUser && e.asterUser.trim() !== '' + } + if (e.id === 'hyperliquid') { + return e.hyperliquidWalletAddr && e.hyperliquidWalletAddr.trim() !== '' + } + // 修复: 添加 enabled 判断,与原始逻辑保持一致 + return e.enabled || (e.apiKey && e.apiKey.trim() !== '') + }) + set({ configuredExchanges }) + }, + + setSupportedModels: (models) => set({ supportedModels: models }), + setSupportedExchanges: (exchanges) => set({ supportedExchanges: exchanges }), + setUserSignalSource: (source) => set({ userSignalSource: source }), + + loadConfigs: async (user, token) => { + if (!user || !token) { + // 未登录时只加载公开的支持模型和交易所 + try { + const [supportedModels, supportedExchanges] = await Promise.all([ + api.getSupportedModels(), + api.getSupportedExchanges(), + ]) + get().setSupportedModels(supportedModels) + get().setSupportedExchanges(supportedExchanges) + } catch (err) { + console.error('Failed to load supported configs:', err) + } + return + } + + try { + const [ + modelConfigs, + exchangeConfigs, + supportedModels, + supportedExchanges, + ] = await Promise.all([ + api.getModelConfigs(), + api.getExchangeConfigs(), + api.getSupportedModels(), + api.getSupportedExchanges(), + ]) + + get().setAllModels(modelConfigs) + get().setAllExchanges(exchangeConfigs) + get().setSupportedModels(supportedModels) + get().setSupportedExchanges(supportedExchanges) + + // 加载用户信号源配置 + try { + const signalSource = await api.getUserSignalSource() + get().setUserSignalSource({ + coinPoolUrl: signalSource.coin_pool_url || '', + oiTopUrl: signalSource.oi_top_url || '', + }) + } catch (error) { + console.log('📡 用户信号源配置暂未设置') + } + } catch (error) { + console.error('Failed to load configs:', error) + } + }, + + reset: () => set(initialState), +})) diff --git a/web/src/stores/tradersModalStore.ts b/web/src/stores/tradersModalStore.ts new file mode 100644 index 0000000000..e70a1912a1 --- /dev/null +++ b/web/src/stores/tradersModalStore.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand' +import type { TraderConfigData } from '../types' + +interface TradersModalState { + // Modal 显示状态 + showCreateModal: boolean + showEditModal: boolean + showModelModal: boolean + showExchangeModal: boolean + showSignalSourceModal: boolean + + // 编辑状态 + editingModel: string | null + editingExchange: string | null + editingTrader: TraderConfigData | null + + // Actions + setShowCreateModal: (show: boolean) => void + setShowEditModal: (show: boolean) => void + setShowModelModal: (show: boolean) => void + setShowExchangeModal: (show: boolean) => void + setShowSignalSourceModal: (show: boolean) => void + + setEditingModel: (modelId: string | null) => void + setEditingExchange: (exchangeId: string | null) => void + setEditingTrader: (trader: TraderConfigData | null) => void + + // 便捷方法 + openModelModal: (modelId?: string) => void + closeModelModal: () => void + openExchangeModal: (exchangeId?: string) => void + closeExchangeModal: () => void + + // 重置 + reset: () => void +} + +const initialState = { + showCreateModal: false, + showEditModal: false, + showModelModal: false, + showExchangeModal: false, + showSignalSourceModal: false, + editingModel: null, + editingExchange: null, + editingTrader: null, +} + +export const useTradersModalStore = create((set) => ({ + ...initialState, + + setShowCreateModal: (show) => set({ showCreateModal: show }), + setShowEditModal: (show) => set({ showEditModal: show }), + setShowModelModal: (show) => set({ showModelModal: show }), + setShowExchangeModal: (show) => set({ showExchangeModal: show }), + setShowSignalSourceModal: (show) => set({ showSignalSourceModal: show }), + + setEditingModel: (modelId) => set({ editingModel: modelId }), + setEditingExchange: (exchangeId) => set({ editingExchange: exchangeId }), + setEditingTrader: (trader) => set({ editingTrader: trader }), + + openModelModal: (modelId) => { + set({ editingModel: modelId || null, showModelModal: true }) + }, + + closeModelModal: () => { + set({ showModelModal: false, editingModel: null }) + }, + + openExchangeModal: (exchangeId) => { + set({ editingExchange: exchangeId || null, showExchangeModal: true }) + }, + + closeExchangeModal: () => { + set({ showExchangeModal: false, editingExchange: null }) + }, + + reset: () => set(initialState), +})) diff --git a/web/src/types.ts b/web/src/types.ts index 60ce44ed5b..8bf1e6bf5e 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -16,11 +16,10 @@ export interface SystemStatus { export interface AccountInfo { total_equity: number wallet_balance: number - unrealized_profit: number + unrealized_profit: number // 未实现盈亏(交易所API官方值) available_balance: number total_pnl: number total_pnl_pct: number - total_unrealized_pnl: number initial_balance: number daily_pnl: number position_count: number @@ -94,6 +93,7 @@ export interface TraderInfo { custom_prompt?: string use_coin_pool?: boolean use_oi_top?: boolean + system_prompt_template?: string } export interface AIModel { @@ -126,7 +126,7 @@ export interface CreateTraderRequest { name: string ai_model_id: string exchange_id: string - initial_balance: number + initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新 scan_interval_minutes?: number btc_eth_leverage?: number altcoin_leverage?: number