From 9b14c5c84d5c3f7b968cfde89323a1d5e3bd8c94 Mon Sep 17 00:00:00 2001 From: Hansen1018 <61605071+Hansen1018@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:37:00 +0800 Subject: [PATCH 01/26] feat: update default MiniMax model to M2.7 (#1428) --- api/handler_ai_model.go | 2 +- mcp/provider/minimax.go | 2 +- mcp/providers.go | 2 +- web/src/components/trader/model-constants.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/handler_ai_model.go b/api/handler_ai_model.go index 32badbd00e..48889d6ae4 100644 --- a/api/handler_ai_model.go +++ b/api/handler_ai_model.go @@ -201,7 +201,7 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) { {"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"}, {"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"}, {"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"}, - {"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.5"}, + {"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"}, {"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek"}, } diff --git a/mcp/provider/minimax.go b/mcp/provider/minimax.go index 23e66686b7..e0d86580a7 100644 --- a/mcp/provider/minimax.go +++ b/mcp/provider/minimax.go @@ -8,7 +8,7 @@ import ( const ( DefaultMiniMaxBaseURL = "https://api.minimax.io/v1" - DefaultMiniMaxModel = "MiniMax-M2.5" + DefaultMiniMaxModel = "MiniMax-M2.7" ) func init() { diff --git a/mcp/providers.go b/mcp/providers.go index 75f3d9c7f9..29018b3c6a 100644 --- a/mcp/providers.go +++ b/mcp/providers.go @@ -25,5 +25,5 @@ const ( // Default MiniMax configuration (used by WithMiniMaxConfig convenience option) DefaultMiniMaxBaseURL = "https://api.minimax.io/v1" - DefaultMiniMaxModel = "MiniMax-M2.5" + DefaultMiniMaxModel = "MiniMax-M2.7" ) diff --git a/web/src/components/trader/model-constants.ts b/web/src/components/trader/model-constants.ts index cb741e2742..2b53be37f4 100644 --- a/web/src/components/trader/model-constants.ts +++ b/web/src/components/trader/model-constants.ts @@ -91,7 +91,7 @@ export const AI_PROVIDER_CONFIG: Record = { apiName: 'Moonshot', }, minimax: { - defaultModel: 'MiniMax-M2.5', + defaultModel: 'MiniMax-M2.7', apiUrl: 'https://platform.minimax.io', apiName: 'MiniMax', }, From 2d68b48f52c5c42a3fd5d340a938ce2f910c0c1d Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Wed, 25 Mar 2026 09:58:24 +0800 Subject: [PATCH 02/26] feat: route nofxos data API calls through claw402 x402 payment When CLAW402_WALLET_KEY env var is set, all nofxos.ai data API calls (AI500, OI rankings, NetFlow, price rankings) are automatically routed through claw402.ai with x402 USDC micropayment. - provider/nofxos/claw402.go: x402 GET request client for data APIs - provider/nofxos/client.go: claw402 mode support in doRequest() - kernel/engine.go: auto-detect CLAW402_WALLET_KEY and enable routing - mcp/payment/x402.go: MakeClaw402SignFunc helper Without CLAW402_WALLET_KEY, falls back to direct nofxos.ai (backward compat). --- kernel/engine.go | 16 ++++++ mcp/payment/x402.go | 7 +++ provider/nofxos/claw402.go | 112 +++++++++++++++++++++++++++++++++++++ provider/nofxos/client.go | 17 +++++- 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 provider/nofxos/claw402.go diff --git a/kernel/engine.go b/kernel/engine.go index 1fe9c95823..3fd95106cd 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "os" "nofx/logger" "nofx/market" "nofx/provider/hyperliquid" @@ -194,6 +195,21 @@ func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine { } client := nofxos.NewClient(nofxos.DefaultBaseURL, apiKey) + // If claw402 wallet key is available, route nofxos requests through claw402 + if walletKey := os.Getenv("CLAW402_WALLET_KEY"); walletKey != "" { + claw402URL := os.Getenv("CLAW402_URL") + if claw402URL == "" { + claw402URL = "https://claw402.ai" + } + claw402Client, err := nofxos.NewClaw402DataClient(claw402URL, walletKey, nil) + if err == nil { + client.SetClaw402(claw402Client) + logger.Infof("🔗 NofxOS data routed through claw402 (%s)", claw402URL) + } else { + logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err) + } + } + return &StrategyEngine{ config: config, nofxosClient: client, diff --git a/mcp/payment/x402.go b/mcp/payment/x402.go index 064cc086d5..7ce70fdc3f 100644 --- a/mcp/payment/x402.go +++ b/mcp/payment/x402.go @@ -81,6 +81,13 @@ func X402DecodeHeader(b64 string) ([]byte, error) { return decoded, nil } +// MakeClaw402SignFunc creates an X402SignFunc from a private key for claw402 payments. +func MakeClaw402SignFunc(privateKey *ecdsa.PrivateKey) X402SignFunc { + return func(paymentHeaderB64 string) (string, error) { + return SignBasePaymentHeader(privateKey, paymentHeaderB64, "Claw402") + } +} + // SignBasePaymentHeader decodes a base64 x402 header, parses it, and signs with // EIP-712 (USDC TransferWithAuthorization). func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) { diff --git a/provider/nofxos/claw402.go b/provider/nofxos/claw402.go new file mode 100644 index 0000000000..0e4d610705 --- /dev/null +++ b/provider/nofxos/claw402.go @@ -0,0 +1,112 @@ +package nofxos + +import ( + "context" + "crypto/ecdsa" + "fmt" + "net/http" + "nofx/mcp" + "nofx/mcp/payment" + "os" + "strings" + "time" + + "github.com/ethereum/go-ethereum/crypto" +) + +// Claw402DataClient wraps nofxos API calls through claw402's x402 payment gateway. +// Instead of calling nofxos.ai directly, it calls claw402.ai/api/v1/nofx/... +// and pays with USDC for each request. +type Claw402DataClient struct { + claw402URL string + privateKey *ecdsa.PrivateKey + httpClient *http.Client + logger mcp.Logger +} + +// NewClaw402DataClient creates a client that routes nofxos requests through claw402. +// privateKeyHex is the wallet private key (0x-prefixed hex string). +func NewClaw402DataClient(claw402URL, privateKeyHex string, logger mcp.Logger) (*Claw402DataClient, error) { + if claw402URL == "" { + claw402URL = "https://claw402.ai" + } + claw402URL = strings.TrimRight(claw402URL, "/") + + if privateKeyHex == "" { + privateKeyHex = os.Getenv("CLAW402_WALLET_KEY") + } + if privateKeyHex == "" { + return nil, fmt.Errorf("claw402 wallet private key not set") + } + + hexKey := strings.TrimPrefix(privateKeyHex, "0x") + pk, err := crypto.HexToECDSA(hexKey) + if err != nil { + return nil, fmt.Errorf("invalid claw402 private key: %w", err) + } + + return &Claw402DataClient{ + claw402URL: claw402URL, + privateKey: pk, + httpClient: &http.Client{Timeout: 30 * time.Second}, + logger: logger, + }, nil +} + +// endpoint mapping: nofxos path → claw402 path +var endpointMap = map[string]string{ + "/api/ai500/list": "/api/v1/nofx/ai500/list", + "/api/ai500/stats": "/api/v1/nofx/ai500/stats", +} + +// mapEndpoint converts a nofxos endpoint to a claw402 endpoint. +// For endpoints not in the static map, applies the general pattern: +// /api/xxx → /api/v1/nofx/xxx +func mapEndpoint(nofxosPath string) string { + if mapped, ok := endpointMap[nofxosPath]; ok { + return mapped + } + // General pattern: /api/xxx → /api/v1/nofx/xxx + if strings.HasPrefix(nofxosPath, "/api/") { + return "/api/v1/nofx/" + strings.TrimPrefix(nofxosPath, "/api/") + } + return nofxosPath +} + +// DoRequest makes a GET request through claw402 with x402 payment. +func (c *Claw402DataClient) DoRequest(endpoint string) ([]byte, error) { + claw402Path := mapEndpoint(endpoint) + // Strip auth= query params (claw402 uses x402 payment, not auth keys) + if idx := strings.Index(claw402Path, "?auth="); idx != -1 { + claw402Path = claw402Path[:idx] + } + if idx := strings.Index(claw402Path, "&auth="); idx != -1 { + claw402Path = claw402Path[:idx] + } + + fullURL := c.claw402URL + claw402Path + + buildReq := func() (*http.Request, error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fullURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Client-ID", "nofx") + return req, nil + } + + signFn := payment.MakeClaw402SignFunc(c.privateKey) + + body, err := payment.DoX402Request( + c.httpClient, + buildReq, + signFn, + "claw402-data", + c.logger, + ) + if err != nil { + return nil, fmt.Errorf("claw402 data request failed (%s): %w", claw402Path, err) + } + + return body, nil +} diff --git a/provider/nofxos/client.go b/provider/nofxos/client.go index a3a99a009b..bee2dad5d1 100644 --- a/provider/nofxos/client.go +++ b/provider/nofxos/client.go @@ -25,6 +25,7 @@ type Client struct { AuthKey string Timeout time.Duration mu sync.RWMutex + claw402 *Claw402DataClient // If set, routes requests through claw402 } var ( @@ -59,6 +60,13 @@ func NewClient(baseURL, authKey string) *Client { } } +// SetClaw402 enables routing requests through claw402 payment gateway. +func (c *Client) SetClaw402(claw402Client *Claw402DataClient) { + c.mu.Lock() + defer c.mu.Unlock() + c.claw402 = claw402Client +} + // SetConfig updates client configuration func (c *Client) SetConfig(baseURL, authKey string) { c.mu.Lock() @@ -85,14 +93,21 @@ func (c *Client) GetAuthKey() string { return c.AuthKey } -// doRequest performs an HTTP GET request with authentication +// doRequest performs an HTTP GET request with authentication. +// If claw402 client is configured, routes through claw402 payment gateway instead. func (c *Client) doRequest(endpoint string) ([]byte, error) { c.mu.RLock() + claw402Client := c.claw402 baseURL := c.BaseURL authKey := c.AuthKey timeout := c.Timeout c.mu.RUnlock() + // Route through claw402 if configured + if claw402Client != nil { + return claw402Client.DoRequest(endpoint) + } + url := baseURL + endpoint if !strings.Contains(url, "auth=") { if strings.Contains(url, "?") { From af6f6d5930bfe5068d7defc9d133cfd5a6917330 Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Wed, 25 Mar 2026 10:08:55 +0800 Subject: [PATCH 03/26] =?UTF-8?q?feat:=20auto-reuse=20claw402=20wallet=20f?= =?UTF-8?q?or=20nofxos=20data=20=E2=80=94=20no=20extra=20config=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a trader uses claw402 as AI provider, the same wallet private key is now automatically used to route nofxos data API calls (AI500, OI, NetFlow, etc.) through claw402 payment as well. Users don't need to configure anything extra — if they already set up claw402 for AI, data APIs automatically go through claw402 too. --- kernel/engine.go | 16 ++++++++++++---- trader/auto_trader.go | 8 +++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/kernel/engine.go b/kernel/engine.go index 3fd95106cd..147cdceb0c 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -186,8 +186,9 @@ type StrategyEngine struct { nofxosClient *nofxos.Client } -// NewStrategyEngine creates strategy execution engine -func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine { +// NewStrategyEngine creates strategy execution engine. +// claw402WalletKey is optional — if provided, nofxos data requests are routed through claw402. +func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string) *StrategyEngine { // Create NofxOS client with API key from config apiKey := config.Indicators.NofxOSAPIKey if apiKey == "" { @@ -195,8 +196,15 @@ func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine { } client := nofxos.NewClient(nofxos.DefaultBaseURL, apiKey) - // If claw402 wallet key is available, route nofxos requests through claw402 - if walletKey := os.Getenv("CLAW402_WALLET_KEY"); walletKey != "" { + // If claw402 wallet key is provided (from trader's AI config), route through claw402 + walletKey := "" + if len(claw402WalletKey) > 0 { + walletKey = claw402WalletKey[0] + } + if walletKey == "" { + walletKey = os.Getenv("CLAW402_WALLET_KEY") + } + if walletKey != "" { claw402URL := os.Getenv("CLAW402_URL") if claw402URL == "" { claw402URL = "https://claw402.ai" diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 5874af028d..42d6a13ff2 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -333,7 +333,13 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au if config.StrategyConfig == nil { return nil, fmt.Errorf("[%s] strategy not configured", config.Name) } - strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig) + // Pass claw402 wallet key to strategy engine so nofxos data requests + // are routed through claw402 (reuses the same wallet as AI calls) + var claw402Key string + if config.AIModel == "claw402" && config.CustomAPIKey != "" { + claw402Key = config.CustomAPIKey + } + strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig, claw402Key) logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name) return &AutoTrader{ From f0d3352971023dd9b493871f637dca77546ba561 Mon Sep 17 00:00:00 2001 From: deanokk Date: Fri, 27 Mar 2026 00:26:40 +0800 Subject: [PATCH 04/26] fix: prevent DeepSeek token overflow with product-level limits (#1431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enforce strategy limits to prevent token overflow * fix: tune token limits after real-world testing - Relax kline max 20→30, timeframes 3→4 (tested ~41K tokens, safe under 131K) - Restore ranking limits to original [5,10,15,20] options (only ~1.5K token impact) - Add static coins limit (max 3) with toast notification - Add timeframe limit toast when exceeding 4 - Log SSE token usage (prompt/completion/total) from API response - Fix nil logger crash in claw402 data client (engine.go) * feat: add token estimation functionality for strategy configurations * feat: add discard changes button in Strategy Studio for unsaved modifications * feat: retain selected strategy after saving in Strategy Studio * feat: enhance strategy display in Strategy Studio with improved layout and sorting of token limits * refactor: improve layout and styling of stats display in CompetitionPage * refactor: replace select elements with NofxSelect component for improved consistency in strategy configuration forms * style: update NofxSelect component to use smaller text size for improved readability * feat: implement token overflow handling in strategy updates and UI --------- Co-authored-by: Dean --- api/server.go | 1 + api/strategy.go | 33 ++ kernel/engine.go | 2 +- kernel/engine_analysis.go | 24 ++ mcp/client.go | 11 + store/strategy.go | 327 +++++++++++++++++- store/strategy_token_test.go | 112 ++++++ .../components/strategy/CoinSourceEditor.tsx | 107 +++--- .../components/strategy/GridConfigEditor.tsx | 37 +- .../components/strategy/IndicatorEditor.tsx | 79 +++-- .../components/strategy/RiskControlEditor.tsx | 2 +- .../components/strategy/TokenEstimateBar.tsx | 143 ++++++++ web/src/components/trader/CompetitionPage.tsx | 22 +- web/src/components/trader/PositionHistory.tsx | 55 ++- .../components/trader/TelegramConfigModal.tsx | 39 +-- .../components/trader/TraderConfigModal.tsx | 69 ++-- web/src/components/ui/select.tsx | 99 ++++++ web/src/i18n/translations.ts | 15 + web/src/pages/StrategyStudioPage.tsx | 167 +++++---- web/src/pages/TraderDashboardPage.tsx | 118 ++++--- 20 files changed, 1124 insertions(+), 338 deletions(-) create mode 100644 store/strategy_token_test.go create mode 100644 web/src/components/strategy/TokenEstimateBar.tsx create mode 100644 web/src/components/ui/select.tsx diff --git a/api/server.go b/api/server.go index a6037e774c..f9377934e0 100644 --- a/api/server.go +++ b/api/server.go @@ -110,6 +110,7 @@ func (s *Server) setupRoutes() { // Public strategy market (no authentication required) s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies) + s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens) // Authentication related routes (no authentication required) s.route(api, "POST", "/register", "Register new user", s.handleRegister) diff --git a/api/strategy.go b/api/strategy.go index c58f327885..8939c8d843 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -31,6 +31,20 @@ func validateStrategyConfig(config *store.StrategyConfig) []string { return warnings } +// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation) +func (s *Server) handleEstimateTokens(c *gin.Context) { + var req struct { + Config store.StrategyConfig `json:"config" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + SafeBadRequest(c, "Invalid request parameters") + return + } + + estimate := req.Config.EstimateTokens() + c.JSON(http.StatusOK, estimate) +} + // handlePublicStrategies Get public strategies for strategy market (no auth required) func (s *Server) handlePublicStrategies(c *gin.Context) { strategies, err := s.store.Strategy().ListPublic() @@ -289,6 +303,25 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) { return } + // Token overflow check — block save if all models exceed context limits + if mergedConfig.StrategyType == "" || mergedConfig.StrategyType == "ai_trading" { + estimate := mergedConfig.EstimateTokens() + allExceed := true + for _, ml := range estimate.ModelLimits { + if ml.UsagePct <= 100 { + allExceed = false + break + } + } + if allExceed && len(estimate.ModelLimits) > 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Estimated %d tokens exceeds all known model context limits. Reduce coins, timeframes, or K-line count.", estimate.Total), + "token_estimate": estimate, + }) + return + } + } + // Validate merged configuration and collect warnings warnings := validateStrategyConfig(&mergedConfig) diff --git a/kernel/engine.go b/kernel/engine.go index 147cdceb0c..a5e1f0ceec 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -209,7 +209,7 @@ func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string) if claw402URL == "" { claw402URL = "https://claw402.ai" } - claw402Client, err := nofxos.NewClaw402DataClient(claw402URL, walletKey, nil) + claw402Client, err := nofxos.NewClaw402DataClient(claw402URL, walletKey, &logger.MCPLogger{}) if err == nil { client.SetClaw402(claw402Client) logger.Infof("🔗 NofxOS data routed through claw402 (%s)", claw402URL) diff --git a/kernel/engine_analysis.go b/kernel/engine_analysis.go index 4a1071bd7c..b367b1ac08 100644 --- a/kernel/engine_analysis.go +++ b/kernel/engine_analysis.go @@ -51,6 +51,30 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S engine = NewStrategyEngine(&defaultConfig) } + // Clamp strategy limits to prevent token overflow + engineConfig := engine.GetConfig() + engineConfig.ClampLimits() + + // Token estimation check — warn or block if exceeding all known model limits + estimate := engineConfig.EstimateTokens() + allExceed := true + anyWarning := false + for _, ml := range estimate.ModelLimits { + if ml.UsagePct <= 100 { + allExceed = false + } + if ml.UsagePct >= 80 { + anyWarning = true + } + } + if allExceed && len(estimate.ModelLimits) > 0 { + logger.Errorf("🚫 Token estimate %d exceeds ALL known model context limits — blocking analysis", estimate.Total) + return nil, fmt.Errorf("estimated %d tokens exceeds all known model context limits; reduce coins, timeframes, or K-line count", estimate.Total) + } + if anyWarning { + logger.Infof("⚠️ Token estimate %d — approaching context limits for some models", estimate.Total) + } + // 1. Fetch market data using strategy config if len(ctx.MarketDataMap) == 0 { if err := fetchMarketDataWithStrategy(ctx, engine); err != nil { diff --git a/mcp/client.go b/mcp/client.go index 99b0fec276..047bc0e950 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -760,10 +760,21 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string } `json:"delta"` FinishReason *string `json:"finish_reason"` } `json:"choices"` + Usage *struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage,omitempty"` } if err := json.Unmarshal([]byte(data), &chunk); err != nil { continue // skip malformed chunks } + + if chunk.Usage != nil && chunk.Usage.TotalTokens > 0 { + fmt.Printf("📊 [TokenUsage] prompt=%d, completion=%d, total=%d\n", + chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.TotalTokens) + } + if len(chunk.Choices) == 0 { continue } diff --git a/store/strategy.go b/store/strategy.go index 80eaa1716d..8045926964 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -3,11 +3,63 @@ package store import ( "encoding/json" "fmt" + "sort" + "strings" "time" "gorm.io/gorm" ) +// Hard limits to prevent token explosion in AI requests +const ( + MaxCandidateCoins = 3 + MaxPositions = 3 + MaxTimeframes = 4 + MinKlineCount = 10 + MaxKlineCount = 30 +) + +// ClampLimits enforces product-level limits on strategy config to prevent token overflow. +func (c *StrategyConfig) ClampLimits() { + // Clamp coin source limits + if c.CoinSource.AI500Limit > MaxCandidateCoins { + c.CoinSource.AI500Limit = MaxCandidateCoins + } + if c.CoinSource.OITopLimit > MaxCandidateCoins { + c.CoinSource.OITopLimit = MaxCandidateCoins + } + if c.CoinSource.OILowLimit > MaxCandidateCoins { + c.CoinSource.OILowLimit = MaxCandidateCoins + } + + // Clamp static coins + if len(c.CoinSource.StaticCoins) > MaxCandidateCoins { + c.CoinSource.StaticCoins = c.CoinSource.StaticCoins[:MaxCandidateCoins] + } + + // Clamp kline count + if c.Indicators.Klines.PrimaryCount < MinKlineCount { + c.Indicators.Klines.PrimaryCount = MinKlineCount + } + if c.Indicators.Klines.PrimaryCount > MaxKlineCount { + c.Indicators.Klines.PrimaryCount = MaxKlineCount + } + if c.Indicators.Klines.LongerCount > MaxKlineCount { + c.Indicators.Klines.LongerCount = MaxKlineCount + } + + // Clamp timeframes + if len(c.Indicators.Klines.SelectedTimeframes) > MaxTimeframes { + c.Indicators.Klines.SelectedTimeframes = c.Indicators.Klines.SelectedTimeframes[:MaxTimeframes] + } + + // Clamp max positions + if c.RiskControl.MaxPositions > MaxPositions { + c.RiskControl.MaxPositions = MaxPositions + } + +} + // StrategyStore strategy storage type StrategyStore struct { db *gorm.DB @@ -260,20 +312,20 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { CoinSource: CoinSourceConfig{ SourceType: "ai500", UseAI500: true, - AI500Limit: 10, + AI500Limit: 3, UseOITop: false, - OITopLimit: 10, + OITopLimit: 3, UseOILow: false, - OILowLimit: 10, + OILowLimit: 3, }, Indicators: IndicatorConfig{ Klines: KlineConfig{ PrimaryTimeframe: "5m", - PrimaryCount: 30, + PrimaryCount: 20, LongerTimeframe: "4h", LongerCount: 10, EnableMultiTimeframe: true, - SelectedTimeframes: []string{"5m", "15m", "1h", "4h"}, + SelectedTimeframes: []string{"5m", "15m", "1h"}, }, EnableRawKlines: true, // Required - raw OHLCV data for AI analysis EnableEMA: false, @@ -510,3 +562,268 @@ func (s *Strategy) SetConfig(config *StrategyConfig) error { s.Config = string(data) return nil } + +// ============================================================================ +// Token Estimation +// ============================================================================ + +// TokenEstimate holds the result of token estimation +type TokenEstimate struct { + Total int `json:"total"` + Breakdown TokenBreakdown `json:"breakdown"` + ModelLimits []ModelLimit `json:"model_limits"` + Suggestions []string `json:"suggestions"` +} + +// TokenBreakdown shows estimated tokens per component +type TokenBreakdown struct { + SystemPrompt int `json:"system_prompt"` + MarketData int `json:"market_data"` + RankingData int `json:"ranking_data"` + QuantData int `json:"quant_data"` + FixedOverhead int `json:"fixed_overhead"` +} + +// ModelLimit shows token usage against a specific model's context limit +type ModelLimit struct { + Name string `json:"name"` + ContextLimit int `json:"context_limit"` + UsagePct int `json:"usage_pct"` + Level string `json:"level"` // "ok" | "warning" | "danger" +} + +// ModelContextLimits maps provider names to their context window sizes (in tokens) +var ModelContextLimits = map[string]int{ + "deepseek": 131072, + "openai": 128000, + "claude": 200000, + "qwen": 131072, + "gemini": 1000000, + "grok": 131072, + "kimi": 131072, + "minimax": 1000000, +} + +// GetContextLimit returns the context limit for a given provider +func GetContextLimit(provider string) int { + if limit, ok := ModelContextLimits[provider]; ok { + return limit + } + return 131072 // safe default +} + +// EstimateTokens estimates the total token count for a strategy configuration. +// This is a pure computation based on config fields — no network calls. +func (c *StrategyConfig) EstimateTokens() TokenEstimate { + breakdown := TokenBreakdown{} + + // --- System Prompt --- + // Base system prompt: schema + role + rules + output format + baseChars := 4000 // English default + if c.Language == "zh" { + baseChars = 3000 + } + // Add prompt sections + baseChars += len(c.PromptSections.RoleDefinition) + baseChars += len(c.PromptSections.TradingFrequency) + baseChars += len(c.PromptSections.EntryStandards) + baseChars += len(c.PromptSections.DecisionProcess) + baseChars += len(c.CustomPrompt) + + if c.Language == "zh" { + breakdown.SystemPrompt = baseChars / 2 // CJK: ~2 chars per token + } else { + breakdown.SystemPrompt = baseChars / 4 // English: ~4 chars per token + } + + // --- Fixed Overhead --- + // Time, BTC price, account info, section headers + breakdown.FixedOverhead = 800 / 4 // ~200 tokens + + // --- Market Data --- + numCoins := c.getEffectiveCoinCount() + numTimeframes := c.getEffectiveTimeframeCount() + klineCount := c.Indicators.Klines.PrimaryCount + if klineCount <= 0 { + klineCount = 20 + } + + // Per coin per timeframe: kline OHLCV rows + charsPerCoinTF := klineCount * 80 // each OHLCV line ~80 chars + + // Add enabled indicator overhead per timeframe + indicatorCharsPerLine := 0 + if c.Indicators.EnableEMA { + indicatorCharsPerLine += 20 // EMA values appended + } + if c.Indicators.EnableMACD { + indicatorCharsPerLine += 30 + } + if c.Indicators.EnableRSI { + indicatorCharsPerLine += 15 + } + if c.Indicators.EnableATR { + indicatorCharsPerLine += 15 + } + if c.Indicators.EnableBOLL { + indicatorCharsPerLine += 25 + } + if c.Indicators.EnableVolume { + indicatorCharsPerLine += 10 + } + charsPerCoinTF += klineCount * indicatorCharsPerLine + + totalMarketChars := numCoins * numTimeframes * charsPerCoinTF + + // OI + Funding per coin + if c.Indicators.EnableOI || c.Indicators.EnableFundingRate { + totalMarketChars += numCoins * 100 + } + + breakdown.MarketData = totalMarketChars / 4 // numeric data: ~4 chars per token + + // --- Quant Data --- + if c.Indicators.EnableQuantData { + quantCharsPerCoin := 0 + if c.Indicators.EnableQuantOI { + quantCharsPerCoin += 300 + } + if c.Indicators.EnableQuantNetflow { + quantCharsPerCoin += 300 + } + breakdown.QuantData = (numCoins * quantCharsPerCoin) / 4 + } + + // --- Ranking Data --- + rankingChars := 0 + if c.Indicators.EnableOIRanking { + limit := c.Indicators.OIRankingLimit + if limit <= 0 { + limit = 10 + } + rankingChars += limit * 60 + } + if c.Indicators.EnableNetFlowRanking { + limit := c.Indicators.NetFlowRankingLimit + if limit <= 0 { + limit = 10 + } + rankingChars += limit * 80 + } + if c.Indicators.EnablePriceRanking { + limit := c.Indicators.PriceRankingLimit + if limit <= 0 { + limit = 10 + } + // Count durations (comma-separated) + numDurations := 1 + if c.Indicators.PriceRankingDuration != "" { + numDurations = len(strings.Split(c.Indicators.PriceRankingDuration, ",")) + } + rankingChars += limit * numDurations * 40 + } + breakdown.RankingData = rankingChars / 4 + + // --- Total with 15% safety margin --- + subtotal := breakdown.SystemPrompt + breakdown.MarketData + breakdown.RankingData + breakdown.QuantData + breakdown.FixedOverhead + total := subtotal * 115 / 100 + + // --- Model limits --- + modelLimits := make([]ModelLimit, 0, len(ModelContextLimits)) + for name, limit := range ModelContextLimits { + pct := total * 100 / limit + level := "ok" + if pct >= 100 { + level = "danger" + } else if pct >= 80 { + level = "warning" + } + modelLimits = append(modelLimits, ModelLimit{ + Name: name, + ContextLimit: limit, + UsagePct: pct, + Level: level, + }) + } + + // Sort by usage_pct desc, then name asc for deterministic order + sort.Slice(modelLimits, func(i, j int) bool { + if modelLimits[i].UsagePct != modelLimits[j].UsagePct { + return modelLimits[i].UsagePct > modelLimits[j].UsagePct + } + return modelLimits[i].Name < modelLimits[j].Name + }) + + // --- Suggestions --- + var suggestions []string + // Find the strictest model (smallest context) + minLimit := 0 + for _, limit := range ModelContextLimits { + if minLimit == 0 || limit < minLimit { + minLimit = limit + } + } + if minLimit > 0 && total > minLimit { + if numTimeframes > 1 { + savedPerTF := (numCoins * klineCount * (80 + indicatorCharsPerLine)) / 4 * 115 / 100 + suggestions = append(suggestions, fmt.Sprintf("Reduce 1 timeframe to save ~%d tokens", savedPerTF)) + } + if numCoins > 1 { + savedPerCoin := (numTimeframes * klineCount * (80 + indicatorCharsPerLine)) / 4 * 115 / 100 + suggestions = append(suggestions, fmt.Sprintf("Reduce 1 coin to save ~%d tokens", savedPerCoin)) + } + if klineCount > 15 { + suggestions = append(suggestions, "Reduce K-line count to 15 to save tokens") + } + } + + return TokenEstimate{ + Total: total, + Breakdown: breakdown, + ModelLimits: modelLimits, + Suggestions: suggestions, + } +} + +// getEffectiveCoinCount returns the estimated number of coins that will be analyzed +func (c *StrategyConfig) getEffectiveCoinCount() int { + count := 0 + switch c.CoinSource.SourceType { + case "static": + count = len(c.CoinSource.StaticCoins) + case "ai500": + count = c.CoinSource.AI500Limit + case "oi_top": + count = c.CoinSource.OITopLimit + case "oi_low": + count = c.CoinSource.OILowLimit + case "mixed": + if c.CoinSource.UseAI500 { + count += c.CoinSource.AI500Limit + } + if c.CoinSource.UseOITop { + count += c.CoinSource.OITopLimit + } + if c.CoinSource.UseOILow { + count += c.CoinSource.OILowLimit + } + default: + count = c.CoinSource.AI500Limit + } + if count <= 0 { + count = 3 + } + return count +} + +// getEffectiveTimeframeCount returns the number of timeframes that will be used +func (c *StrategyConfig) getEffectiveTimeframeCount() int { + if len(c.Indicators.Klines.SelectedTimeframes) > 0 { + return len(c.Indicators.Klines.SelectedTimeframes) + } + count := 1 + if c.Indicators.Klines.LongerTimeframe != "" { + count++ + } + return count +} diff --git a/store/strategy_token_test.go b/store/strategy_token_test.go new file mode 100644 index 0000000000..d9d0997a1e --- /dev/null +++ b/store/strategy_token_test.go @@ -0,0 +1,112 @@ +package store + +import "testing" + +func TestEstimateTokens_DefaultConfig(t *testing.T) { + config := GetDefaultStrategyConfig("en") + est := config.EstimateTokens() + + if est.Total <= 0 { + t.Errorf("expected positive token estimate, got %d", est.Total) + } + if est.Total > 200000 { + t.Errorf("token estimate %d seems unreasonably high for default config", est.Total) + } + + // Breakdown should sum approximately to total (before 15% margin) + subtotal := est.Breakdown.SystemPrompt + est.Breakdown.MarketData + + est.Breakdown.RankingData + est.Breakdown.QuantData + est.Breakdown.FixedOverhead + expectedTotal := subtotal * 115 / 100 + if est.Total != expectedTotal { + t.Errorf("total %d != breakdown subtotal %d * 1.15 = %d", est.Total, subtotal, expectedTotal) + } + + // Should have model limits + if len(est.ModelLimits) == 0 { + t.Error("expected model limits to be populated") + } + + // Default config should be ok for all models + for _, ml := range est.ModelLimits { + if ml.Level == "danger" { + t.Errorf("default config should not exceed %s limit, got %d%%", ml.Name, ml.UsagePct) + } + } +} + +func TestEstimateTokens_ZhVsEn(t *testing.T) { + enConfig := GetDefaultStrategyConfig("en") + zhConfig := GetDefaultStrategyConfig("zh") + + enEst := enConfig.EstimateTokens() + zhEst := zhConfig.EstimateTokens() + + // Chinese config should have more tokens for system prompt due to CJK encoding + // but total can vary — just ensure both are reasonable + if enEst.Total <= 0 || zhEst.Total <= 0 { + t.Errorf("both estimates should be positive: en=%d, zh=%d", enEst.Total, zhEst.Total) + } +} + +func TestEstimateTokens_HighConfig(t *testing.T) { + config := GetDefaultStrategyConfig("en") + // Push config to extremes (beyond clamped limits) + config.CoinSource.SourceType = "static" + config.CoinSource.StaticCoins = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "DOGEUSDT", "XRPUSDT"} + config.Indicators.Klines.SelectedTimeframes = []string{"1m", "3m", "5m", "15m", "1h", "4h"} + config.Indicators.Klines.PrimaryCount = 100 + config.Indicators.EnableEMA = true + config.Indicators.EnableMACD = true + config.Indicators.EnableRSI = true + config.Indicators.EnableATR = true + config.Indicators.EnableBOLL = true + + est := config.EstimateTokens() + + // Should produce a higher estimate than default + defaultCfg := GetDefaultStrategyConfig("en") + defaultEst := defaultCfg.EstimateTokens() + if est.Total <= defaultEst.Total { + t.Errorf("high config estimate %d should be greater than default %d", est.Total, defaultEst.Total) + } + + // Should have some models in warning/danger + hasDanger := false + for _, ml := range est.ModelLimits { + if ml.Level == "danger" || ml.Level == "warning" { + hasDanger = true + break + } + } + // With 5 coins * 6 timeframes * 100 klines, this should exceed small models + if !hasDanger { + t.Logf("high config estimate: %d tokens", est.Total) + } +} + +func TestGetContextLimit(t *testing.T) { + if got := GetContextLimit("deepseek"); got != 131072 { + t.Errorf("deepseek limit = %d, want 131072", got) + } + if got := GetContextLimit("unknown_provider"); got != 131072 { + t.Errorf("unknown provider should return default 131072, got %d", got) + } +} + +func TestGetEffectiveCoinCount(t *testing.T) { + config := StrategyConfig{ + CoinSource: CoinSourceConfig{ + SourceType: "static", + StaticCoins: []string{"BTCUSDT", "ETHUSDT"}, + }, + } + if got := config.getEffectiveCoinCount(); got != 2 { + t.Errorf("static coin count = %d, want 2", got) + } + + config.CoinSource.SourceType = "ai500" + config.CoinSource.AI500Limit = 5 + if got := config.getEffectiveCoinCount(); got != 5 { + t.Errorf("ai500 coin count = %d, want 5", got) + } +} diff --git a/web/src/components/strategy/CoinSourceEditor.tsx b/web/src/components/strategy/CoinSourceEditor.tsx index 86751c899c..fd2b7439db 100644 --- a/web/src/components/strategy/CoinSourceEditor.tsx +++ b/web/src/components/strategy/CoinSourceEditor.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react' import type { CoinSourceConfig } from '../../types' import { coinSource, ts } from '../../i18n/strategy-translations' +import { NofxSelect } from '../ui/select' interface CoinSourceEditorProps { config: CoinSourceConfig @@ -24,7 +25,6 @@ export function CoinSourceEditor({ { value: 'ai500', icon: Database, color: '#F0B90B' }, { value: 'oi_top', icon: TrendingUp, color: '#0ECB81' }, { value: 'oi_low', icon: TrendingDown, color: '#F6465D' }, - { value: 'mixed', icon: Shuffle, color: '#60a5fa' }, ] as const // Calculate mixed mode summary @@ -71,8 +71,26 @@ export function CoinSourceEditor({ return xyzDexAssets.has(base) } + const MAX_STATIC_COINS = 3 + + const showToast = (msg: string) => { + const toast = document.createElement('div') + toast.textContent = msg + toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg' + toast.style.cssText = 'background:#F6465D;color:#fff;' + document.body.appendChild(toast) + setTimeout(() => toast.remove(), 2000) + } + const handleAddCoin = () => { if (!newCoin.trim()) return + + const currentCoins = config.static_coins || [] + if (currentCoins.length >= MAX_STATIC_COINS) { + showToast(language === 'zh' ? `最多添加 ${MAX_STATIC_COINS} 个币种` : `Maximum ${MAX_STATIC_COINS} coins allowed`) + return + } + const symbol = newCoin.toUpperCase().trim() // For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT @@ -85,7 +103,6 @@ export function CoinSourceEditor({ formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT` } - const currentCoins = config.static_coins || [] if (!currentCoins.includes(formattedSymbol)) { onChange({ ...config, @@ -148,7 +165,7 @@ export function CoinSourceEditor({ -
+
{sourceTypes.map(({ value, icon: Icon, color }) => (
)} @@ -366,19 +380,16 @@ export function CoinSourceEditor({ {ts(coinSource.oiTopLimit, language)}: - + />
)} @@ -423,19 +434,16 @@ export function CoinSourceEditor({ {ts(coinSource.oiLowLimit, language)}: - + /> )} @@ -483,20 +491,13 @@ export function CoinSourceEditor({ {config.use_ai500 && (
Limit: - + />
)} @@ -530,20 +531,13 @@ export function CoinSourceEditor({ {config.use_oi_top && (
Limit: - + />
)} @@ -577,20 +571,13 @@ export function CoinSourceEditor({ {config.use_oi_low && (
Limit: - + />
)} diff --git a/web/src/components/strategy/GridConfigEditor.tsx b/web/src/components/strategy/GridConfigEditor.tsx index 4566501ea3..0219a0316e 100644 --- a/web/src/components/strategy/GridConfigEditor.tsx +++ b/web/src/components/strategy/GridConfigEditor.tsx @@ -1,6 +1,7 @@ import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react' import type { GridStrategyConfig } from '../../types' import { gridConfig, ts } from '../../i18n/strategy-translations' +import { NofxSelect } from '../ui/select' interface GridConfigEditorProps { config: GridStrategyConfig @@ -74,20 +75,21 @@ export function GridConfigEditor({

{ts(gridConfig.symbolDesc, language)}

- + options={[ + { value: 'BTCUSDT', label: 'BTC/USDT' }, + { value: 'ETHUSDT', label: 'ETH/USDT' }, + { value: 'SOLUSDT', label: 'SOL/USDT' }, + { value: 'BNBUSDT', label: 'BNB/USDT' }, + { value: 'XRPUSDT', label: 'XRP/USDT' }, + { value: 'DOGEUSDT', label: 'DOGE/USDT' }, + ]} + /> {/* Investment */} @@ -170,17 +172,18 @@ export function GridConfigEditor({

{ts(gridConfig.distributionDesc, language)}

- + options={[ + { value: 'uniform', label: ts(gridConfig.uniform, language) }, + { value: 'gaussian', label: ts(gridConfig.gaussian, language) }, + { value: 'pyramid', label: ts(gridConfig.pyramid, language) }, + ]} + /> diff --git a/web/src/components/strategy/IndicatorEditor.tsx b/web/src/components/strategy/IndicatorEditor.tsx index 2a0667c5d1..e54c1b5662 100644 --- a/web/src/components/strategy/IndicatorEditor.tsx +++ b/web/src/components/strategy/IndicatorEditor.tsx @@ -1,6 +1,7 @@ import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react' import type { IndicatorConfig } from '../../types' import { indicator, ts } from '../../i18n/strategy-translations' +import { NofxSelect } from '../ui/select' // Default NofxOS API Key const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c' @@ -60,6 +61,16 @@ export function IndicatorEditor({ }) } } else { + if (current.length >= 4) { + // Show toast notification + const toast = document.createElement('div') + toast.textContent = language === 'zh' ? '最多选择 4 个时间维度' : 'Maximum 4 timeframes allowed' + toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg' + toast.style.cssText = 'background:#F6465D;color:#fff;' + document.body.appendChild(toast) + setTimeout(() => toast.remove(), 2000) + return + } current.push(tf) onChange({ ...config, @@ -299,26 +310,22 @@ export function IndicatorEditor({

{ts(indicator.oiRankingDesc, language)}

{config.enable_oi_ranking && (
e.stopPropagation()}> - - + options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} + />
)} @@ -359,26 +366,22 @@ export function IndicatorEditor({

{ts(indicator.netflowRankingDesc, language)}

{config.enable_netflow_ranking && (
e.stopPropagation()}> - - + options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} + />
)} @@ -419,27 +422,27 @@ export function IndicatorEditor({

{ts(indicator.priceRankingDesc, language)}

{config.enable_price_ranking && (
e.stopPropagation()}> - - + options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} + />
)} @@ -515,7 +518,7 @@ export function IndicatorEditor({ } disabled={disabled} min={10} - max={200} + max={30} className="w-16 px-2 py-1 rounded text-xs text-center" style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} /> diff --git a/web/src/components/strategy/RiskControlEditor.tsx b/web/src/components/strategy/RiskControlEditor.tsx index 8df913a14a..152c977388 100644 --- a/web/src/components/strategy/RiskControlEditor.tsx +++ b/web/src/components/strategy/RiskControlEditor.tsx @@ -54,7 +54,7 @@ export function RiskControlEditor({ } disabled={disabled} min={1} - max={10} + max={3} className="w-32 px-3 py-2 rounded" style={{ background: '#1E2329', diff --git a/web/src/components/strategy/TokenEstimateBar.tsx b/web/src/components/strategy/TokenEstimateBar.tsx new file mode 100644 index 0000000000..9dd33a7e57 --- /dev/null +++ b/web/src/components/strategy/TokenEstimateBar.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect, useRef } from 'react' +import { Loader2, Info } from 'lucide-react' +import type { StrategyConfig } from '../../types' +import { t, type Language } from '../../i18n/translations' + +const API_BASE = import.meta.env.VITE_API_BASE || '' + +interface ModelLimit { + name: string + context_limit: number + usage_pct: number + level: string +} + +interface TokenEstimateResult { + total: number + model_limits: ModelLimit[] + suggestions: string[] +} + +interface TokenEstimateBarProps { + config: StrategyConfig | null + language: Language + onOverflowChange?: (overflow: boolean) => void +} + +export function TokenEstimateBar({ config, language, onOverflowChange }: TokenEstimateBarProps) { + const [estimate, setEstimate] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const debounceRef = useRef | null>(null) + + const tr = (key: string) => t(`strategyStudio.${key}`, language) + + useEffect(() => { + if (!config) { + setEstimate(null) + return + } + + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + + debounceRef.current = setTimeout(async () => { + setIsLoading(true) + try { + const response = await fetch(`${API_BASE}/api/strategies/estimate-tokens`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }) + if (response.ok) { + const data = await response.json() + setEstimate(data) + } + } catch { + // silently ignore — non-critical UI element + } finally { + setIsLoading(false) + } + }, 800) + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + } + }, [config]) + + useEffect(() => { + if (!estimate) { + onOverflowChange?.(false) + return + } + const maxPct = estimate.model_limits.reduce((max, ml) => Math.max(max, ml.usage_pct), 0) + onOverflowChange?.(maxPct >= 100) + }, [estimate, onOverflowChange]) + + if (!config) return null + + if (isLoading && !estimate) { + return ( +
+ + {tr('tokenEstimating')} +
+ ) + } + + if (!estimate) return null + + // Find the strictest model (smallest context limit = highest usage_pct) + const strictest = estimate.model_limits.reduce( + (max, ml) => (ml.usage_pct > max.usage_pct ? ml : max), + estimate.model_limits[0] + ) + if (!strictest) return null + + const pct = strictest.usage_pct + const barWidth = Math.min(pct, 100) + + let barColor = '#0ECB81' // green + let textColor = '#848E9C' + if (pct >= 100) { + barColor = '#F6465D' // red + textColor = '#F6465D' + } else if (pct >= 80) { + barColor = '#F0B90B' // yellow + textColor = '#F0B90B' + } + + const exceedWarning = pct >= 100 ? tr('tokenExceedWarning') : null + + return ( +
+
+
+
+
+ + {isLoading ? : `${pct}%`} + +
+ +
+ {tr('tokenTooltip')} ({strictest.name} {(strictest.context_limit / 1000).toFixed(0)}K) +
+
+
+ {exceedWarning && ( +

+ {exceedWarning} +

+ )} +
+ ) +} diff --git a/web/src/components/trader/CompetitionPage.tsx b/web/src/components/trader/CompetitionPage.tsx index 71395eb01a..83b6d6c6a5 100644 --- a/web/src/components/trader/CompetitionPage.tsx +++ b/web/src/components/trader/CompetitionPage.tsx @@ -281,14 +281,14 @@ export function CompetitionPage() {
{/* Stats */} -
+
{/* Total Equity */} -
-
+
+
{t('equity', language)}
{trader.total_equity?.toFixed(2) || '0.00'} @@ -297,11 +297,11 @@ export function CompetitionPage() { {/* P&L */}
-
+
{t('pnl', language)}
= 0 @@ -313,7 +313,7 @@ export function CompetitionPage() { {trader.total_pnl_pct?.toFixed(2) || '0.00'}%
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''} @@ -322,17 +322,17 @@ export function CompetitionPage() {
{/* Positions */} -
-
+
+
{t('pos', language)}
{trader.position_count}
-
+
{trader.margin_used_pct.toFixed(1)}%
diff --git a/web/src/components/trader/PositionHistory.tsx b/web/src/components/trader/PositionHistory.tsx index 03b17596ac..42303527b2 100644 --- a/web/src/components/trader/PositionHistory.tsx +++ b/web/src/components/trader/PositionHistory.tsx @@ -4,6 +4,7 @@ import { useLanguage } from '../../contexts/LanguageContext' import { t, type Language } from '../../i18n/translations' import { MetricTooltip } from '../common/MetricTooltip' import { formatPrice, formatQuantity } from '../../utils/format' +import { NofxSelect } from '../ui/select' import type { HistoricalPosition, TraderStats, @@ -664,23 +665,20 @@ export function PositionHistory({ traderId }: PositionHistoryProps) { {t('positionHistory.symbol', language)}: - + />
@@ -708,28 +706,26 @@ export function PositionHistory({ traderId }: PositionHistoryProps) { {t('positionHistory.sort', language)}: - + />
@@ -841,20 +837,21 @@ export function PositionHistory({ traderId }: PositionHistoryProps) { {language === 'zh' ? '每页' : 'Per page'}: - + />
{/* Page navigation */} diff --git a/web/src/components/trader/TelegramConfigModal.tsx b/web/src/components/trader/TelegramConfigModal.tsx index e06184ea6b..97a3fa056a 100644 --- a/web/src/components/trader/TelegramConfigModal.tsx +++ b/web/src/components/trader/TelegramConfigModal.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { api } from '../../lib/api' import type { TelegramConfig, AIModel } from '../../types' import { t, type Language } from '../../i18n/translations' +import { NofxSelect } from '../ui/select' // Step indicator (reused pattern from ExchangeConfigModal) function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) { @@ -133,23 +134,20 @@ export function TelegramConfigModal({ onClose, language }: TelegramConfigModalPr {t('telegram.noEnabledModels', language)}
) : ( - + /> )}
{t('telegram.autoUseEnabled', language)} @@ -489,23 +487,20 @@ function BoundModelSelector({ {t('telegram.aiModelLabel', language)}
- + />
- + className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]" + options={availableExchanges.map((exchange) => ({ + value: exchange.id, + label: getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase() + + (exchange.account_name ? ` - ${exchange.account_name}` : ''), + }))} + /> {/* Exchange Registration Link */} {formData.exchange_id && (() => { // Find the selected exchange to get its type @@ -323,22 +320,20 @@ export function TraderConfigModal({ - + className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]" + options={[ + { value: '', label: t('noStrategyManual', language) }, + ...strategies.map((strategy) => ({ + value: strategy.id, + label: strategy.name + (strategy.is_active ? t('strategyActive', language) : '') + (strategy.is_default ? t('strategyDefault', language) : ''), + })), + ]} + /> {strategies.length === 0 && (

{t('noStrategyHint', language)} diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx new file mode 100644 index 0000000000..ae1e13dc08 --- /dev/null +++ b/web/src/components/ui/select.tsx @@ -0,0 +1,99 @@ +import { useRef, useState, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { ChevronDown } from 'lucide-react' +import { cn } from '../../lib/cn' + +export interface SelectOption { + value: string | number + label: string +} + +interface NofxSelectProps { + value: string | number + onChange: (value: string) => void + options: SelectOption[] + disabled?: boolean + className?: string + style?: React.CSSProperties +} + +export function NofxSelect({ value, onChange, options, disabled, className, style }: NofxSelectProps) { + const [open, setOpen] = useState(false) + const triggerRef = useRef(null) + const dropdownRef = useRef(null) + const [pos, setPos] = useState({ top: 0, left: 0, width: 0 }) + const selected = options.find(o => String(o.value) === String(value)) + + const updatePos = useCallback(() => { + if (!triggerRef.current) return + const rect = triggerRef.current.getBoundingClientRect() + setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }) + }, []) + + useEffect(() => { + if (!open) return + updatePos() + const handleClose = (e: MouseEvent) => { + const target = e.target as Node + if (triggerRef.current?.contains(target)) return + if (dropdownRef.current?.contains(target)) return + setOpen(false) + } + const handleScroll = () => setOpen(false) + document.addEventListener('mousedown', handleClose) + window.addEventListener('scroll', handleScroll, true) + return () => { + document.removeEventListener('mousedown', handleClose) + window.removeEventListener('scroll', handleScroll, true) + } + }, [open, updatePos]) + + return ( +

+
{ + e.stopPropagation() + if (!disabled) setOpen(!open) + }} + > + {selected?.label ?? String(value)} + +
+ {open && createPortal( +
+ {options.map((opt) => ( +
{ + e.stopPropagation() + onChange(String(opt.value)) + setOpen(false) + }} + > + {opt.label} +
+ ))} +
, + document.body, + )} +
+ ) +} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index d524fad645..7540c25dd6 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -1080,11 +1080,16 @@ export const translations = { public: 'Public', addDescription: 'Add strategy description...', unsaved: 'Unsaved', + discardChanges: 'Discard', selectOrCreate: 'Select or create a strategy', customPromptDesc: 'Extra prompt appended to System Prompt for personalized trading style', customPromptPlaceholder: 'Enter custom prompt...', generatePromptPreview: 'Click to generate prompt preview', runAiTestHint: 'Click to run AI test', + tokenEstimate: 'Token Estimate', + tokenExceedWarning: 'Exceeds context limit. Reduce coins or timeframes.', + tokenEstimating: 'Estimating...', + tokenTooltip: 'Based on strictest model', }, // Metric Tooltip @@ -2371,11 +2376,16 @@ export const translations = { public: '公开', addDescription: '添加策略简介...', unsaved: '未保存', + discardChanges: '撤销', selectOrCreate: '选择或创建策略', customPromptDesc: '附加在 System Prompt 末尾的额外提示,用于补充个性化交易风格', customPromptPlaceholder: '输入自定义提示词...', generatePromptPreview: '点击生成 Prompt 预览', runAiTestHint: '点击运行 AI 测试', + tokenEstimate: 'Token 预估', + tokenExceedWarning: '超出上下文限制,建议减少币种或时间框架', + tokenEstimating: '预估中...', + tokenTooltip: '基于最严格模型计算', }, // Metric Tooltip @@ -3464,11 +3474,16 @@ export const translations = { public: 'Publik', addDescription: 'Tambah deskripsi strategi...', unsaved: 'Belum Disimpan', + discardChanges: 'Buang', selectOrCreate: 'Pilih atau buat strategi', customPromptDesc: 'Prompt tambahan di akhir System Prompt untuk gaya trading personal', customPromptPlaceholder: 'Masukkan prompt kustom...', generatePromptPreview: 'Klik untuk generate pratinjau prompt', runAiTestHint: 'Klik untuk menjalankan uji AI', + tokenEstimate: 'Estimasi Token', + tokenExceedWarning: 'Melebihi batas konteks. Kurangi koin atau timeframe.', + tokenEstimating: 'Mengestimasi...', + tokenTooltip: 'Berdasarkan model paling ketat', }, // Metric Tooltip diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index fbc90f36c5..8bf10a7163 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -29,6 +29,7 @@ import { Download, Upload, Globe, + X, } from 'lucide-react' import type { Strategy, StrategyConfig, AIModel } from '../types' import { confirmToast, notify } from '../lib/notify' @@ -38,8 +39,10 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor' import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor' import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor' import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor' +import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar' import { DeepVoidBackground } from '../components/common/DeepVoidBackground' import { t } from '../i18n/translations' +import { NofxSelect } from '../components/ui/select' const API_BASE = import.meta.env.VITE_API_BASE || '' @@ -52,6 +55,7 @@ export function StrategyStudioPage() { const [editingConfig, setEditingConfig] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) + const [tokenOverflow, setTokenOverflow] = useState(false) const [error, setError] = useState(null) const [hasChanges, setHasChanges] = useState(false) @@ -378,6 +382,10 @@ export function StrategyStudioPage() { // Save strategy const handleSaveStrategy = async () => { if (!token || !selectedStrategy || !editingConfig) return + if (tokenOverflow && currentStrategyType === 'ai_trading') { + notify.error(tr('tokenExceedWarning')) + return + } setIsSaving(true) try { // Always sync the config language with the current interface language @@ -405,7 +413,17 @@ export function StrategyStudioPage() { if (!response.ok) throw new Error('Failed to save strategy') setHasChanges(false) notify.success(tr('strategySaved')) + const savedId = selectedStrategy.id await fetchStrategies() + // Stay on the strategy we just saved instead of jumping to active + setStrategies(prev => { + const saved = prev.find(s => s.id === savedId) + if (saved) { + setSelectedStrategy(saved) + setEditingConfig(saved.config) + } + return prev + }) } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { @@ -641,7 +659,7 @@ export function StrategyStudioPage() {
-

{tr('strategyStudio')}

+

{tr('title')}

{tr('subtitle')}

@@ -756,34 +774,24 @@ export function StrategyStudioPage() { {selectedStrategy && editingConfig ? (
{/* Strategy Name & Actions */} -
-
- { - setSelectedStrategy({ ...selectedStrategy, name: e.target.value }) - setHasChanges(true) - }} - disabled={selectedStrategy.is_default} - className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted" - /> - { - setSelectedStrategy({ ...selectedStrategy, description: e.target.value }) - setHasChanges(true) - }} - disabled={selectedStrategy.is_default} - placeholder={tr('addDescription')} - className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1" - /> - {hasChanges && ( - ● {tr('unsaved')} - )} -
-
+
+
+
+ { + setSelectedStrategy({ ...selectedStrategy, name: e.target.value }) + setHasChanges(true) + }} + disabled={selectedStrategy.is_default} + className="text-lg font-bold bg-transparent border-none outline-none flex-1 min-w-0 text-nofx-text placeholder-nofx-text-muted" + /> + {hasChanges && ( + ● {tr('unsaved')} + )} +
+
{!selectedStrategy.is_active && ( )} + {!selectedStrategy.is_default && hasChanges && ( + + )} {!selectedStrategy.is_default && ( )}
+
+ { + setSelectedStrategy({ ...selectedStrategy, description: e.target.value }) + setHasChanges(true) + }} + disabled={selectedStrategy.is_default} + placeholder={tr('addDescription')} + className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1" + />
+ {/* Token Estimate Bar */} + {currentStrategyType === 'ai_trading' && ( +
+ +
+ )} + {/* Strategy Type Selector */} {editingConfig && (
@@ -818,9 +857,12 @@ export function StrategyStudioPage() {
{aiModels.length > 0 ? ( - + /> ) : (
{tr('noModel')} @@ -1025,15 +1067,16 @@ export function StrategyStudioPage() { )}
- + />
{/* Debug Info */} - {account && ( -
- SYSTEM_STATUS::ONLINE +
+ SYSTEM_STATUS::ONLINE + {account ? (
LAST_UPDATE::{lastUpdate} - EQ::{account?.total_equity?.toFixed(2)} - PNL::{account?.total_pnl?.toFixed(2)} + EQ::{account.total_equity?.toFixed(2)} + PNL::{account.total_pnl?.toFixed(2)}
-
- )} + ) : ( +
+ + + +
+ )} +
{/* Account Overview */}
@@ -504,6 +506,7 @@ export function TraderDashboardPage({ change={account?.total_pnl_pct || 0} positive={(account?.total_pnl ?? 0) > 0} icon="💰" + loading={!account} /> = 0} icon="📈" + loading={!account} />
@@ -671,15 +677,12 @@ export function TraderDashboardPage({
{t('traderDashboard.perPage', language)}: - + onChange={(val) => setPositionsPageSize(Number(val))} + options={[{ value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]} + className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main transition-colors" + />
{totalPositionPages > 1 && (
@@ -752,17 +755,12 @@ export function TraderDashboardPage({ )}
{/* Limit Selector */} - + onChange={(val) => onDecisionsLimitChange(Number(val))} + options={[{ value: 5, label: '5' }, { value: 10, label: '10' }, { value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]} + className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent" + />
{/* Decisions List - Scrollable */} @@ -818,6 +816,7 @@ function StatCard({ positive, subtitle, icon, + loading, }: { title: string value: string @@ -826,6 +825,7 @@ function StatCard({ positive?: boolean subtitle?: string icon?: string + loading?: boolean }) { return (
@@ -835,27 +835,35 @@ function StatCard({
{title}
-
-
- {value} + {loading ? ( +
+
+
- {unit && {unit}} -
- - {change !== undefined && ( -
-
- {positive ? '▲' : '▼'} - {positive ? '+' : ''}{change.toFixed(2)}% + ) : ( + <> +
+
+ {value} +
+ {unit && {unit}}
-
- )} - {subtitle && ( -
- {subtitle} -
+ {change !== undefined && ( +
+
+ {positive ? '▲' : '▼'} + {positive ? '+' : ''}{change.toFixed(2)}% +
+
+ )} + {subtitle && ( +
+ {subtitle} +
+ )} + )}
) From 4ab402462867c3e1e1a800504b7408eb1f5c89f0 Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Fri, 27 Mar 2026 13:41:49 +0800 Subject: [PATCH 05/26] fix: fallback to Binance kline when coinank returns empty data for non-Binance exchanges CoinAnk recently stopped providing free kline data for OKX/Bitget/Gate exchanges (returns success but empty array). This caused '3-minute k-line data is empty' errors for all users on those exchanges. Fix: detect empty kline response and automatically fallback to Binance kline data, which is always available. --- market/data_klines.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/market/data_klines.go b/market/data_klines.go index e4cb869fc9..94c320f0ad 100644 --- a/market/data_klines.go +++ b/market/data_klines.go @@ -78,15 +78,19 @@ func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline ts := time.Now().UnixMilli() // Use "To" side to search backward from current time (get historical klines) coinankKlines, err := coinank_api.Kline(ctx, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval) - if err != nil { - // If exchange-specific data fails, fallback to Binance + if err != nil || len(coinankKlines) == 0 { + // If exchange-specific data fails or returns empty, fallback to Binance if coinankExchange != coinank_enum.Binance { - logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err) + if err != nil { + logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err) + } else { + logger.Warnf("⚠️ CoinAnk %s %s data empty for %s, falling back to Binance", exchange, interval, symbol) + } coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval) if err != nil { return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err) } - } else { + } else if err != nil { return nil, fmt.Errorf("CoinAnk API error: %w", err) } } From b331733e234533ee298740197e2eb54888707160 Mon Sep 17 00:00:00 2001 From: Zavier Date: Sat, 28 Mar 2026 00:17:37 +0800 Subject: [PATCH 06/26] feat: improve user onboarding and setup UX (#1436) * feat: add beginner onboarding and mode switching flow * chore: ignore local gh auth config * fix: restore kline fallback and align onboarding language --------- Co-authored-by: zavier-bin --- .gitignore | 2 + api/handler_ai_model.go | 18 +- api/handler_onboarding.go | 323 ++++++++++++++++++ api/server.go | 2 + docker-compose.yml | 3 +- web/src/App.tsx | 17 +- web/src/components/auth/LoginPage.tsx | 11 +- .../auth/OnboardingModeSelector.tsx | 75 ++++ web/src/components/common/HeaderBar.tsx | 28 ++ web/src/components/modals/SetupPage.tsx | 14 +- web/src/components/trader/AITradersPage.tsx | 73 +++- .../components/trader/BeginnerGuideCards.tsx | 169 +++++++++ .../components/trader/ConfigStatusGrid.tsx | 14 + .../components/trader/ModelConfigModal.tsx | 312 +++++++++++------ web/src/contexts/AuthContext.tsx | 88 ++--- web/src/i18n/translations.ts | 15 + web/src/lib/api/config.ts | 22 ++ web/src/lib/onboarding.ts | 28 ++ web/src/pages/BeginnerOnboardingPage.tsx | 264 ++++++++++++++ web/src/pages/SettingsPage.tsx | 87 +++++ web/src/pages/StrategyStudioPage.tsx | 167 ++++----- web/src/types/config.ts | 25 ++ 22 files changed, 1504 insertions(+), 253 deletions(-) create mode 100644 api/handler_onboarding.go create mode 100644 web/src/components/auth/OnboardingModeSelector.tsx create mode 100644 web/src/components/trader/BeginnerGuideCards.tsx create mode 100644 web/src/lib/onboarding.ts create mode 100644 web/src/pages/BeginnerOnboardingPage.tsx diff --git a/.gitignore b/.gitignore index db7745d5e0..cae87c9e05 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ Thumbs.db *.tmp *.bak *.backup +.cache/ +.gh-config/ # 环境变量 .env diff --git a/api/handler_ai_model.go b/api/handler_ai_model.go index 48889d6ae4..4ffce4f2a0 100644 --- a/api/handler_ai_model.go +++ b/api/handler_ai_model.go @@ -10,6 +10,7 @@ import ( "nofx/crypto" "nofx/logger" "nofx/security" + "nofx/wallet" "github.com/gin-gonic/gin" ) @@ -31,6 +32,8 @@ type SafeModelConfig struct { Enabled bool `json:"enabled"` CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive) CustomModelName string `json:"customModelName"` // Custom model name (not sensitive) + WalletAddress string `json:"walletAddress,omitempty"` + BalanceUSDC string `json:"balanceUsdc,omitempty"` } type UpdateModelConfigRequest struct { @@ -75,7 +78,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) { // Convert to safe response structure, remove sensitive information safeModels := make([]SafeModelConfig, len(models)) for i, model := range models { - safeModels[i] = SafeModelConfig{ + safeModel := SafeModelConfig{ ID: model.ID, Name: model.Name, Provider: model.Provider, @@ -83,6 +86,19 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) { CustomAPIURL: model.CustomAPIURL, CustomModelName: model.CustomModelName, } + + if model.Provider == "claw402" { + if privateKey := strings.TrimSpace(model.APIKey.String()); privateKey != "" { + if walletAddress, addrErr := walletAddressFromPrivateKey(privateKey); addrErr == nil { + safeModel.WalletAddress = walletAddress + safeModel.BalanceUSDC = wallet.QueryUSDCBalanceStr(walletAddress) + } else { + logger.Warnf("⚠️ Failed to derive claw402 wallet address for model %s: %v", model.ID, addrErr) + } + } + } + + safeModels[i] = safeModel } c.JSON(http.StatusOK, safeModels) diff --git a/api/handler_onboarding.go b/api/handler_onboarding.go new file mode 100644 index 0000000000..263d1909c4 --- /dev/null +++ b/api/handler_onboarding.go @@ -0,0 +1,323 @@ +package api + +import ( + "bufio" + "encoding/hex" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "nofx/logger" + "nofx/wallet" + + gethcrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/gin-gonic/gin" +) + +type beginnerOnboardingResponse struct { + Address string `json:"address"` + PrivateKey string `json:"private_key"` + Chain string `json:"chain"` + Asset string `json:"asset"` + Provider string `json:"provider"` + DefaultModel string `json:"default_model"` + ConfiguredModelID string `json:"configured_model_id"` + BalanceUSDC string `json:"balance_usdc"` + EnvSaved bool `json:"env_saved"` + EnvPath string `json:"env_path,omitempty"` + ReusedExisting bool `json:"reused_existing"` + EnvWarning string `json:"env_warning,omitempty"` +} + +type currentBeginnerWalletResponse struct { + Found bool `json:"found"` + Address string `json:"address,omitempty"` + BalanceUSDC string `json:"balance_usdc,omitempty"` + Source string `json:"source,omitempty"` + Claw402Status string `json:"claw402_status"` +} + +func (s *Server) handleBeginnerOnboarding(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"}) + return + } + + privateKey, address, configuredModelID, reusedExisting, err := s.resolveBeginnerWallet(userID) + if err != nil { + logger.Errorf("Failed to resolve beginner wallet for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare beginner wallet"}) + return + } + + if !reusedExisting { + if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", "deepseek"); err != nil { + logger.Errorf("Failed to save beginner claw402 config for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save beginner model configuration"}) + return + } + + configuredModelID, err = s.findConfiguredClaw402ModelID(userID) + if err != nil { + logger.Warnf("Could not resolve configured claw402 model id for user %s: %v", userID, err) + } + } + + os.Setenv("CLAW402_WALLET_KEY", privateKey) + os.Setenv("CLAW402_WALLET_ADDRESS", address) + os.Setenv("CLAW402_DEFAULT_MODEL", "deepseek") + + envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address) + resp := beginnerOnboardingResponse{ + Address: address, + PrivateKey: privateKey, + Chain: "base", + Asset: "USDC", + Provider: "claw402", + DefaultModel: "deepseek", + ConfiguredModelID: configuredModelID, + BalanceUSDC: wallet.QueryUSDCBalanceStr(address), + EnvSaved: envSaved, + EnvPath: envPath, + ReusedExisting: reusedExisting, + } + if envErr != nil { + resp.EnvWarning = envErr.Error() + logger.Warnf("Beginner wallet env persistence warning for user %s: %v", userID, envErr) + } + + c.JSON(http.StatusOK, resp) +} + +func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"}) + return + } + claw402Status := checkClaw402Health() + + models, err := s.store.AIModel().List(userID) + if err != nil { + logger.Errorf("Failed to load current beginner wallet for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load current wallet"}) + return + } + + for _, model := range models { + if model == nil || model.Provider != "claw402" { + continue + } + + privateKey := strings.TrimSpace(model.APIKey.String()) + if privateKey == "" { + continue + } + + address, addrErr := walletAddressFromPrivateKey(privateKey) + if addrErr != nil { + logger.Warnf("Failed to derive current beginner wallet for user %s: %v", userID, addrErr) + continue + } + + c.JSON(http.StatusOK, currentBeginnerWalletResponse{ + Found: true, + Address: address, + BalanceUSDC: wallet.QueryUSDCBalanceStr(address), + Source: "model", + Claw402Status: claw402Status, + }) + return + } + + address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS")) + if address != "" { + c.JSON(http.StatusOK, currentBeginnerWalletResponse{ + Found: true, + Address: address, + BalanceUSDC: wallet.QueryUSDCBalanceStr(address), + Source: "env", + Claw402Status: claw402Status, + }) + return + } + + c.JSON(http.StatusOK, currentBeginnerWalletResponse{ + Found: false, + Claw402Status: claw402Status, + }) +} + +func (s *Server) resolveBeginnerWallet(userID string) (privateKey string, address string, configuredModelID string, reused bool, err error) { + models, err := s.store.AIModel().List(userID) + if err != nil { + return "", "", "", false, err + } + + for _, model := range models { + if model == nil || model.Provider != "claw402" { + continue + } + existingKey := strings.TrimSpace(model.APIKey.String()) + if existingKey == "" { + continue + } + + addr, addrErr := walletAddressFromPrivateKey(existingKey) + if addrErr != nil { + logger.Warnf("Existing claw402 key for user %s is invalid, regenerating: %v", userID, addrErr) + break + } + + return existingKey, addr, model.ID, true, nil + } + + privateKeyObj, genErr := gethcrypto.GenerateKey() + if genErr != nil { + return "", "", "", false, genErr + } + + addr := gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey) + keyHex := "0x" + hex.EncodeToString(gethcrypto.FromECDSA(privateKeyObj)) + return keyHex, addr.Hex(), "", false, nil +} + +func (s *Server) findConfiguredClaw402ModelID(userID string) (string, error) { + models, err := s.store.AIModel().List(userID) + if err != nil { + return "", err + } + + for _, model := range models { + if model != nil && model.Provider == "claw402" { + return model.ID, nil + } + } + + return "", fmt.Errorf("claw402 model not found") +} + +func walletAddressFromPrivateKey(privateKey string) (string, error) { + key := strings.TrimSpace(privateKey) + if !strings.HasPrefix(key, "0x") { + return "", fmt.Errorf("private key must start with 0x") + } + if len(key) != 66 { + return "", fmt.Errorf("private key must be 66 characters") + } + + privateKeyObj, err := gethcrypto.HexToECDSA(strings.TrimPrefix(key, "0x")) + if err != nil { + return "", err + } + + return gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey).Hex(), nil +} + +func persistBeginnerWalletEnv(privateKey string, address string) (bool, string, error) { + paths := uniqueEnvPaths([]string{ + ".env", + filepath.Join(".", ".env"), + "/app/.env", + }) + + var lastErr error + for _, path := range paths { + if path == "" { + continue + } + + if err := upsertEnvFile(path, map[string]string{ + "CLAW402_WALLET_KEY": privateKey, + "CLAW402_WALLET_ADDRESS": address, + "CLAW402_DEFAULT_MODEL": "deepseek", + }); err != nil { + lastErr = err + continue + } + + return true, path, nil + } + + if lastErr == nil { + lastErr = fmt.Errorf("no writable .env path found") + } + return false, "", lastErr +} + +func uniqueEnvPaths(paths []string) []string { + seen := make(map[string]struct{}, len(paths)) + result := make([]string, 0, len(paths)) + for _, path := range paths { + clean := filepath.Clean(path) + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + result = append(result, clean) + } + return result +} + +func upsertEnvFile(path string, values map[string]string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + existingLines := make([]string, 0) + if file, err := os.Open(path); err == nil { + scanner := bufio.NewScanner(file) + for scanner.Scan() { + existingLines = append(existingLines, scanner.Text()) + } + file.Close() + if err := scanner.Err(); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + + remaining := make(map[string]string, len(values)) + for key, value := range values { + remaining[key] = value + } + + updatedLines := make([]string, 0, len(existingLines)+len(values)) + for _, line := range existingLines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || !strings.Contains(line, "=") { + updatedLines = append(updatedLines, line) + continue + } + + parts := strings.SplitN(line, "=", 2) + key := strings.TrimSpace(parts[0]) + value, ok := remaining[key] + if !ok { + updatedLines = append(updatedLines, line) + continue + } + + updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value)) + delete(remaining, key) + } + + for key, value := range remaining { + updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value)) + } + + content := strings.Join(updatedLines, "\n") + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + return err + } + + return nil +} diff --git a/api/server.go b/api/server.go index f9377934e0..4f5ea098e4 100644 --- a/api/server.go +++ b/api/server.go @@ -122,6 +122,8 @@ func (s *Server) setupRoutes() { { // Logout (add to blacklist) s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout) + s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding) + s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet) // User account management s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password", diff --git a/docker-compose.yml b/docker-compose.yml index 82723977cf..7d36c2c1a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - "${NOFX_BACKEND_PORT:-8080}:8080" - "6060:6060" # pprof profiling volumes: + - ./.env:/app/.env - ./data:/app/data - /etc/localtime:/etc/localtime:ro env_file: @@ -49,4 +50,4 @@ services: networks: nofx-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/web/src/App.tsx b/web/src/App.tsx index 17a9c2b1d6..6c0b04be29 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ import { FAQPage } from './pages/FAQPage' import { StrategyStudioPage } from './pages/StrategyStudioPage' import { StrategyMarketPage } from './pages/StrategyMarketPage' import { DataPage } from './pages/DataPage' +import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage' import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay' import HeaderBar from './components/common/HeaderBar' import { LanguageProvider, useLanguage } from './contexts/LanguageContext' @@ -22,6 +23,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext' import { ConfirmDialogProvider } from './components/common/ConfirmDialog' import { t } from './i18n/translations' import { useSystemConfig } from './hooks/useSystemConfig' +import { getUserMode } from './lib/onboarding' import { OFFICIAL_LINKS } from './constants/branding' import type { @@ -132,6 +134,8 @@ function App() { } const [lastUpdate, setLastUpdate] = useState('--:--:--') const [decisionsLimit, setDecisionsLimit] = useState(5) + const hasPersistedAuth = + !!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user') // 监听URL变化,同步页面状态 useEffect(() => { @@ -347,6 +351,17 @@ function App() { } return } + if (route === '/welcome') { + if ((!user || !token) && !hasPersistedAuth) { + window.location.href = '/login' + return null + } + if (getUserMode() !== 'beginner') { + window.location.href = '/traders' + return null + } + return + } if (route === '/faq') { return (
} if (route === '/settings') { - if (!user || !token) { + if ((!user || !token) && !hasPersistedAuth) { window.location.href = '/login' return null } diff --git a/web/src/components/auth/LoginPage.tsx b/web/src/components/auth/LoginPage.tsx index d7fe38453b..2f9111325a 100644 --- a/web/src/components/auth/LoginPage.tsx +++ b/web/src/components/auth/LoginPage.tsx @@ -5,6 +5,8 @@ import { useAuth } from '../../contexts/AuthContext' import { useLanguage } from '../../contexts/LanguageContext' import { t } from '../../i18n/translations' import { DeepVoidBackground } from '../common/DeepVoidBackground' +import { OnboardingModeSelector } from './OnboardingModeSelector' +import type { UserMode } from '../../lib/onboarding' export function LoginPage() { const { language } = useLanguage() @@ -15,6 +17,7 @@ export function LoginPage() { const [error, setError] = useState('') const [loading, setLoading] = useState(false) const [expiredToastId, setExpiredToastId] = useState(null) + const [mode, setMode] = useState('beginner') useEffect(() => { if (sessionStorage.getItem('from401') === 'true') { @@ -28,7 +31,7 @@ export function LoginPage() { e.preventDefault() setError('') setLoading(true) - const result = await login(email, password) + const result = await login(email, password, mode) setLoading(false) if (result.success) { if (expiredToastId) toast.dismiss(expiredToastId) @@ -109,6 +112,12 @@ export function LoginPage() {
+ + {/* Error */} {error && (

diff --git a/web/src/components/auth/OnboardingModeSelector.tsx b/web/src/components/auth/OnboardingModeSelector.tsx new file mode 100644 index 0000000000..5d403a3ae2 --- /dev/null +++ b/web/src/components/auth/OnboardingModeSelector.tsx @@ -0,0 +1,75 @@ +import type { UserMode } from '../../lib/onboarding' + +interface OnboardingModeSelectorProps { + language: string + mode: UserMode + onChange: (mode: UserMode) => void +} + +export function OnboardingModeSelector({ + language, + mode, + onChange, +}: OnboardingModeSelectorProps) { + const isZh = language === 'zh' + + const options: Array<{ + id: UserMode + title: string + badge?: string + description: string + }> = [ + { + id: 'beginner', + title: isZh ? '新手模式' : 'Beginner Mode', + badge: isZh ? '推荐' : 'Recommended', + description: isZh + ? '自动生成 Base 钱包,默认接入 Claw402 + DeepSeek,最快完成首次启动。' + : 'Generate a Base wallet automatically and start with Claw402 + DeepSeek by default.', + }, + { + id: 'advanced', + title: isZh ? '老手模式' : 'Advanced Mode', + description: isZh + ? '保持现在的完整配置流程,你自己决定模型、钱包和交易所。' + : 'Keep the full manual flow and configure models, wallets, and exchanges yourself.', + }, + ] + + return ( +

+
+ {isZh ? '使用模式' : 'Experience'} +
+
+ {options.map((option) => { + const selected = option.id === mode + return ( + + ) + })} +
+
+ ) +} diff --git a/web/src/components/common/HeaderBar.tsx b/web/src/components/common/HeaderBar.tsx index 1b580b5303..3922392be5 100644 --- a/web/src/components/common/HeaderBar.tsx +++ b/web/src/components/common/HeaderBar.tsx @@ -4,6 +4,12 @@ import { motion, AnimatePresence } from 'framer-motion' import { Menu, X, ChevronDown, Settings } from 'lucide-react' import { t, type Language } from '../../i18n/translations' import { OFFICIAL_LINKS } from '../../constants/branding' +import { + getPostAuthPath, + getUserMode, + setUserMode, + type UserMode, +} from '../../lib/onboarding' type Page = | 'competition' @@ -44,8 +50,21 @@ export default function HeaderBar({ const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false) const [userDropdownOpen, setUserDropdownOpen] = useState(false) + const [userMode, setUserModeState] = useState(() => getUserMode() ?? 'advanced') const dropdownRef = useRef(null) const userDropdownRef = useRef(null) + + const navigateInApp = (path: string) => { + navigate(path) + window.dispatchEvent(new PopStateEvent('popstate')) + } + + const handleSwitchMode = (nextMode: UserMode) => { + setUserMode(nextMode) + setUserModeState(nextMode) + setUserDropdownOpen(false) + navigateInApp(getPostAuthPath(nextMode)) + } // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -216,6 +235,15 @@ export default function HeaderBar({ Settings + {onLogout && (
+ + {/* Error */} {error && (

diff --git a/web/src/components/trader/AITradersPage.tsx b/web/src/components/trader/AITradersPage.tsx index 37d630f37b..84bac72fdd 100644 --- a/web/src/components/trader/AITradersPage.tsx +++ b/web/src/components/trader/AITradersPage.tsx @@ -18,6 +18,7 @@ import { TelegramConfigModal } from './TelegramConfigModal' import { ModelConfigModal } from './ModelConfigModal' import { ConfigStatusGrid } from './ConfigStatusGrid' import { TradersList } from './TradersList' +import { BeginnerGuideCards } from './BeginnerGuideCards' import { Bot, Plus, @@ -25,6 +26,12 @@ import { } from 'lucide-react' import { confirmToast } from '../../lib/notify' import { toast } from 'sonner' +import { + getBeginnerWalletAddress, + getUserMode, + setBeginnerWalletAddress as persistBeginnerWalletAddress, +} from '../../lib/onboarding' +import type { Strategy } from '../../types' interface AITradersPageProps { onTraderSelect?: (traderId: string) => void @@ -48,6 +55,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const [visibleTraderAddresses, setVisibleTraderAddresses] = useState>(new Set()) const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState>(new Set()) const [copiedId, setCopiedId] = useState(null) + const [quickSetupLoading, setQuickSetupLoading] = useState(false) + const [beginnerWalletAddress, setBeginnerWalletAddress] = useState(() => getBeginnerWalletAddress()) + const isBeginnerMode = getUserMode() === 'beginner' + + const navigateInApp = (path: string) => { + navigate(path) + window.dispatchEvent(new PopStateEvent('popstate')) + } // Toggle wallet address visibility for a trader const toggleTraderAddressVisibility = (traderId: string) => { @@ -91,6 +106,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { api.getTraders, { refreshInterval: 5000 } ) + const { data: strategies } = useSWR( + user && token ? 'strategies' : null, + api.getStrategies, + { refreshInterval: 30000 } + ) useEffect(() => { const loadConfigs = async () => { @@ -115,6 +135,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { api.getSupportedModels(), ]) setAllModels(modelConfigs) + const clawWalletAddress = + modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null + if (clawWalletAddress) { + setBeginnerWalletAddress(clawWalletAddress) + persistBeginnerWalletAddress(clawWalletAddress) + } setAllExchanges(exchangeConfigs) setSupportedModels(models) } catch (error) { @@ -616,6 +642,36 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { setShowExchangeModal(true) } + const handleQuickSetupClaw402 = async () => { + if (quickSetupLoading) return + + try { + setQuickSetupLoading(true) + const result = await api.prepareBeginnerOnboarding() + setBeginnerWalletAddress(result.address) + const refreshedModels = await api.getModelConfigs() + setAllModels(refreshedModels) + toast.success( + language === 'zh' + ? 'Claw402 已默认配置为 DeepSeek' + : 'Claw402 is configured with DeepSeek by default' + ) + } catch (error) { + console.error('Failed to quick setup claw402:', error) + toast.error( + language === 'zh' + ? '一键配置 Claw402 失败' + : 'Failed to quick setup Claw402' + ) + } finally { + setQuickSetupLoading(false) + } + } + + const claw402Configured = configuredModels.some((model) => model.provider === 'claw402') + const hasStrategies = (strategies?.length || 0) > 0 + const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0 + return (

@@ -687,6 +743,21 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
+ {isBeginnerMode ? ( + 0} + strategyReady={hasStrategies} + canCreateTrader={canCreateTrader} + walletAddress={beginnerWalletAddress} + onQuickSetupClaw402={handleQuickSetupClaw402} + onOpenExchange={handleAddExchange} + onOpenStrategy={() => navigateInApp('/strategy')} + onCreateTrader={() => setShowCreateModal(true)} + /> + ) : null} + {/* Configuration Status Grid */} navigate(path)} + onNavigate={navigateInApp} onEditTrader={handleEditTrader} onToggleTrader={handleToggleTrader} onToggleCompetition={handleToggleCompetition} diff --git a/web/src/components/trader/BeginnerGuideCards.tsx b/web/src/components/trader/BeginnerGuideCards.tsx new file mode 100644 index 0000000000..cf6e06c0dc --- /dev/null +++ b/web/src/components/trader/BeginnerGuideCards.tsx @@ -0,0 +1,169 @@ +import { Brain, Landmark, Rocket, Sparkles } from 'lucide-react' + +interface BeginnerGuideCardsProps { + language: string + claw402Ready: boolean + exchangeReady: boolean + strategyReady: boolean + canCreateTrader: boolean + walletAddress?: string | null + onQuickSetupClaw402: () => void + onOpenExchange: () => void + onOpenStrategy: () => void + onCreateTrader: () => void +} + +function truncateAddress(address: string) { + if (address.length <= 12) return address + return `${address.slice(0, 6)}...${address.slice(-4)}` +} + +export function BeginnerGuideCards({ + language, + claw402Ready, + exchangeReady, + strategyReady, + canCreateTrader, + walletAddress, + onQuickSetupClaw402, + onOpenExchange, + onOpenStrategy, + onCreateTrader, +}: BeginnerGuideCardsProps) { + const isZh = language === 'zh' + + const cards = [ + { + key: 'model', + icon: Brain, + title: isZh ? '1. 极速模型' : '1. Fast AI', + desc: isZh + ? '默认就是 Claw402 + DeepSeek。第一次不用挑模型,先跑起来。' + : 'Start with Claw402 + DeepSeek. No model picking needed for the first run.', + meta: walletAddress + ? isZh + ? `钱包 ${truncateAddress(walletAddress)}` + : `Wallet ${truncateAddress(walletAddress)}` + : isZh + ? 'Base 链 USDC 按次付费' + : 'Pay per call with Base USDC', + ready: claw402Ready, + actionLabel: claw402Ready + ? isZh ? '已配置' : 'Configured' + : isZh ? '一键配置' : 'One-click setup', + onAction: onQuickSetupClaw402, + disabled: claw402Ready, + }, + { + key: 'exchange', + icon: Landmark, + title: isZh ? '2. 连接交易所' : '2. Add Exchange', + desc: isZh + ? '交易所接好以后,AI 才能真正下单。' + : 'Connect an exchange so the AI can actually place trades.', + meta: exchangeReady + ? isZh ? '已准备好' : 'Ready' + : isZh ? 'Binance / OKX / Bybit / Hyperliquid' : 'Binance / OKX / Bybit / Hyperliquid', + ready: exchangeReady, + actionLabel: exchangeReady + ? isZh ? '继续管理' : 'Manage' + : isZh ? '去配置' : 'Configure', + onAction: onOpenExchange, + disabled: false, + }, + { + key: 'strategy', + icon: Sparkles, + title: isZh ? '3. 选择策略' : '3. Pick Strategy', + desc: isZh + ? '先用默认策略也可以,后面再慢慢细调。' + : 'You can start with a default strategy and fine-tune later.', + meta: strategyReady + ? isZh ? '已有策略可用' : 'Strategy ready' + : isZh ? '可选,但建议提前看一眼' : 'Optional, but worth a quick look', + ready: strategyReady, + actionLabel: isZh ? '打开策略页' : 'Open strategy', + onAction: onOpenStrategy, + disabled: false, + }, + { + key: 'trader', + icon: Rocket, + title: isZh ? '4. 创建 Trader' : '4. Create Trader', + desc: isZh + ? '最后一步,把模型和交易所绑在一起,就能开始运行。' + : 'Last step: bind your model and exchange, then start running.', + meta: canCreateTrader + ? isZh ? '已经可以创建' : 'Ready to create' + : isZh ? '先完成前两步' : 'Finish the first two steps first', + ready: canCreateTrader, + actionLabel: isZh ? '立即创建' : 'Create now', + onAction: onCreateTrader, + disabled: !canCreateTrader, + }, + ] + + return ( +
+
+
+
+ {isZh ? '新手引导' : 'Quickstart'} +
+

+ {isZh ? '先按这 4 步走,最快上手' : 'Follow these 4 steps to get started fast'} +

+
+
+ {isZh ? '老手模式不会看到这块' : 'Hidden in advanced mode'} +
+
+ +
+ {cards.map((card) => { + const Icon = card.icon + return ( +
+
+
+ +
+ + {card.ready ? (isZh ? '已就绪' : 'Ready') : (isZh ? '待完成' : 'Pending')} + +
+ +

{card.title}

+

+ {card.desc} +

+
{card.meta}
+ + +
+ ) + })} +
+
+ ) +} diff --git a/web/src/components/trader/ConfigStatusGrid.tsx b/web/src/components/trader/ConfigStatusGrid.tsx index fb6c31977e..75dc3d31b9 100644 --- a/web/src/components/trader/ConfigStatusGrid.tsx +++ b/web/src/components/trader/ConfigStatusGrid.tsx @@ -92,6 +92,20 @@ export function ConfigStatusGrid({
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
+ {model.provider === 'claw402' && (model.balanceUsdc || model.walletAddress) ? ( +
+ {model.balanceUsdc ? ( + + {model.balanceUsdc} USDC + + ) : null} + {model.walletAddress ? ( + + {truncateAddress(model.walletAddress)} + + ) : null} +
+ ) : null}
diff --git a/web/src/components/trader/ModelConfigModal.tsx b/web/src/components/trader/ModelConfigModal.tsx index 7b8780a937..5124e69eb3 100644 --- a/web/src/components/trader/ModelConfigModal.tsx +++ b/web/src/components/trader/ModelConfigModal.tsx @@ -4,6 +4,7 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react' import type { AIModel } from '../../types' import type { Language } from '../../i18n/translations' import { t } from '../../i18n/translations' +import { api } from '../../lib/api' import { getModelIcon } from '../common/ModelIcons' import { ModelStepIndicator } from './ModelStepIndicator' import { ModelCard } from './ModelCard' @@ -12,6 +13,7 @@ import { AI_PROVIDER_CONFIG, getShortName, } from './model-constants' +import { getBeginnerWalletAddress } from '../../lib/onboarding' interface ModelConfigModalProps { allModels: AIModel[] @@ -42,20 +44,22 @@ export function ModelConfigModal({ const [apiKey, setApiKey] = useState('') const [baseUrl, setBaseUrl] = useState('') const [modelName, setModelName] = useState('') + const configuredModel = + configuredModels?.find((model) => model.id === selectedModelId) || null // Always prefer allModels (supportedModels) for provider/id lookup; // fall back to configuredModels for edit mode details (apiKey etc.) const selectedModel = - allModels?.find((m) => m.id === selectedModelId) || - configuredModels?.find((m) => m.id === selectedModelId) + allModels?.find((m) => m.id === selectedModelId) || configuredModel useEffect(() => { - if (editingModelId && selectedModel) { - setApiKey(selectedModel.apiKey || '') - setBaseUrl(selectedModel.customApiUrl || '') - setModelName(selectedModel.customModelName || '') + const modelDetails = configuredModel || selectedModel + if (editingModelId && modelDetails) { + setApiKey(modelDetails.apiKey || '') + setBaseUrl(modelDetails.customApiUrl || '') + setModelName(modelDetails.customModelName || '') } - }, [editingModelId, selectedModel]) + }, [editingModelId, configuredModel, selectedModel]) const handleSelectModel = (modelId: string) => { setSelectedModelId(modelId) @@ -79,7 +83,18 @@ export function ModelConfigModal({ const availableModels = allModels || [] const configuredIds = new Set(configuredModels?.map(m => m.id) || []) - const stepLabels = [t('modelConfig.selectModel', language), t('modelConfig.configureApi', language)] + const isClaw402Selected = selectedModel?.provider === 'claw402' || selectedModel?.id === 'claw402' + const stepLabels = [ + t('modelConfig.selectModel', language), + t( + !selectedModel + ? 'modelConfig.configure' + : isClaw402Selected + ? 'modelConfig.configureWallet' + : 'modelConfig.configure', + language + ), + ] return (
@@ -143,6 +158,7 @@ export function ModelConfigModal({ void language: Language }) { + const [showOtherProviders, setShowOtherProviders] = useState(false) + const claw402Model = availableModels.find((m) => m.provider === 'claw402') + const otherProviders = availableModels.filter((m) => m.provider !== 'claw402') + return (
@@ -196,12 +216,11 @@ function ModelSelectionStep({
{/* Claw402 Featured Card */} - {availableModels.some(m => m.provider === 'claw402') && ( + {claw402Model && (
- {configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && ( + {configuredIds.has(claw402Model.id) && (
)}
@@ -235,23 +254,57 @@ function ModelSelectionStep({ GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
+
+ {t('modelConfig.claw402EntryDesc', language)} +
)} -
- {availableModels.filter(m => m.provider !== 'claw402').map((model) => ( - onSelectModel(model.id)} - configured={configuredIds.has(model.id)} - /> - ))} -
-
- {t('modelConfig.modelsConfigured', language)} -
+ {otherProviders.length > 0 && ( +
+ + + {showOtherProviders && ( +
+
+ {otherProviders.map((model) => ( + onSelectModel(model.id)} + configured={configuredIds.has(model.id)} + /> + ))} +
+
+ {t('modelConfig.modelsConfigured', language)} +
+
+ )} +
+ )}
) } @@ -259,6 +312,7 @@ function ModelSelectionStep({ function Claw402ConfigForm({ apiKey, modelName, + configuredModel, editingModelId, onApiKeyChange, onModelNameChange, @@ -268,6 +322,7 @@ function Claw402ConfigForm({ }: { apiKey: string modelName: string + configuredModel: AIModel | null editingModelId: string | null onApiKeyChange: (value: string) => void onModelNameChange: (value: string) => void @@ -278,14 +333,21 @@ function Claw402ConfigForm({ const [walletAddress, setWalletAddress] = useState('') const [copiedAddr, setCopiedAddr] = useState(false) const [showDeposit, setShowDeposit] = useState(false) - const [showNewWalletBackup, setShowNewWalletBackup] = useState(false) - const [newWalletKey, setNewWalletKey] = useState('') const [usdcBalance, setUsdcBalance] = useState(null) const [keyError, setKeyError] = useState('') const [validating, setValidating] = useState(false) const [claw402Status, setClaw402Status] = useState(null) const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null) const [testing, setTesting] = useState(false) + const [serverWalletAddress, setServerWalletAddress] = useState('') + const [serverWalletBalance, setServerWalletBalance] = useState(null) + const localWalletAddress = getBeginnerWalletAddress()?.trim() || '' + const configuredWalletAddress = + configuredModel?.walletAddress?.trim() || localWalletAddress || serverWalletAddress + const resolvedWalletAddress = walletAddress || configuredWalletAddress + const resolvedUsdcBalance = + usdcBalance ?? configuredModel?.balanceUsdc ?? serverWalletBalance ?? null + const hasExistingWallet = Boolean(configuredWalletAddress) // Client-side validation helper const getClientError = (key: string): string => { @@ -298,8 +360,36 @@ function Claw402ConfigForm({ const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey) - // Truncate address for display + useEffect(() => { + if (hasExistingWallet) { + setShowDeposit(true) + } + }, [hasExistingWallet]) + useEffect(() => { + if (configuredModel?.walletAddress || localWalletAddress || serverWalletAddress) { + return + } + + let cancelled = false + void api + .getCurrentBeginnerWallet() + .then((result) => { + setClaw402Status(result.claw402_status || 'unknown') + if (cancelled || !result.found || !result.address) { + return + } + setServerWalletAddress(result.address) + setServerWalletBalance(result.balance_usdc || null) + }) + .catch(() => { + // Ignore silently: this is a best-effort fallback for showing the current wallet. + }) + + return () => { + cancelled = true + } + }, [configuredModel?.walletAddress, localWalletAddress, serverWalletAddress]) // Debounced validation when apiKey changes useEffect(() => { @@ -347,6 +437,23 @@ function Claw402ConfigForm({ setTesting(true) setTestResult(null) try { + if (!apiKey && hasExistingWallet) { + const result = await api.getCurrentBeginnerWallet() + setClaw402Status(result.claw402_status || 'unknown') + if (result.found && result.address) { + setWalletAddress(result.address) + setUsdcBalance(result.balance_usdc || '0.00') + setShowDeposit(true) + } + setTestResult({ + status: result.claw402_status === 'ok' ? 'ok' : 'error', + message: result.claw402_status === 'ok' + ? t('modelConfig.claw402Connected', language) + : t('modelConfig.claw402Unreachable', language), + }) + return + } + const res = await fetch('/api/wallet/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -374,7 +481,7 @@ function Claw402ConfigForm({ } } - const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0 + const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0 return (
@@ -396,6 +503,25 @@ function Claw402ConfigForm({ ))}
+
+ + {claw402Status ? ( +
+ {claw402Status === 'ok' + ? t('modelConfig.claw402Connected', language) + : t('modelConfig.claw402Unreachable', language)} +
+ ) : null} +
{/* Step 1: Select AI Model */} @@ -467,6 +593,33 @@ function Claw402ConfigForm({
+ {hasExistingWallet && ( +
+
+ {language === 'zh' ? '已自动提取当前钱包' : 'Current wallet loaded automatically'} +
+
+ {language === 'zh' + ? '你现在可以直接查看当前钱包地址、余额和充值二维码。只有在想更换钱包时,才需要重新输入新的私钥。' + : 'You can view the current wallet address, balance, and deposit QR code right away. Only enter a new private key if you want to replace this wallet.'} +
+ {!configuredModel?.walletAddress && localWalletAddress ? ( +
+ {language === 'zh' + ? '当前地址来自本地已保存的新手钱包。' + : 'This address comes from the locally saved beginner wallet.'} +
+ ) : null} + {!configuredModel?.walletAddress && !localWalletAddress && serverWalletAddress ? ( +
+ {language === 'zh' + ? '当前地址来自后端保存的钱包配置。' + : 'This address comes from the wallet saved on the server.'} +
+ ) : null} +
+ )} +
{t('modelConfig.walletPrivateKey', language)} @@ -476,72 +629,30 @@ function Claw402ConfigForm({ type="password" value={apiKey} onChange={(e) => onApiKeyChange(e.target.value)} - placeholder="0x..." + placeholder={ + hasExistingWallet + ? language === 'zh' + ? '如需切换钱包,请手动输入新的私钥' + : 'Enter a new private key only if you want to switch wallets' + : '0x...' + } className="flex-1 px-4 py-3 rounded-xl font-mono text-sm" style={{ background: '#0B0E11', border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139', color: '#EAECEF', }} - required + required={!hasExistingWallet} /> - {!apiKey && ( - - )}
- {/* New wallet backup warning */} - {showNewWalletBackup && newWalletKey && ( -
-
- 🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'} -
-
- {language === 'zh' - ? '这是你的钱包私钥,丢失后无法恢复,钱包里的资产将永久丢失。请复制并安全保存。' - : 'This is your wallet private key. If lost, it cannot be recovered and all assets will be permanently lost. Copy and save it securely.'} -
-
- - {newWalletKey} - - -
-
-
✅ {language === 'zh' ? '建议保存到密码管理器(1Password / Bitwarden)' : 'Save to a password manager (1Password / Bitwarden)'}
-
✅ {language === 'zh' ? '或抄在纸上放安全的地方' : 'Or write it down and store it safely'}
-
❌ {language === 'zh' ? '不要截图发给别人' : 'Do NOT screenshot or share with anyone'}
-
+ {hasExistingWallet && !apiKey ? ( +
+ {language === 'zh' + ? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。' + : 'This screen keeps using the wallet created and saved the first time. Enter a new private key manually only if you want to switch wallets.'}
- )} + ) : null}
🔒 @@ -552,7 +663,7 @@ function Claw402ConfigForm({
{/* Wallet Validation Results */} - {apiKey && ( + {(apiKey || hasExistingWallet) && (
{/* Validating spinner */} {validating && ( @@ -571,7 +682,7 @@ function Claw402ConfigForm({ )} {/* Success: address + balance + status */} - {walletAddress && !validating && !keyError && ( + {resolvedWalletAddress && !validating && !keyError && ( <>
@@ -581,7 +692,7 @@ function Claw402ConfigForm({
- {walletAddress} + {resolvedWalletAddress}
⚠️ {language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
- {usdcBalance !== null && ( + {resolvedUsdcBalance !== null && (
💰 0 ? '#00E096' : '#F59E0B' }}> - {t('modelConfig.usdcBalance', language)}: ${usdcBalance} + {t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance}
)} + {!apiKey && hasExistingWallet && ( +
+ {language === 'zh' + ? '当前正在使用这个钱包充值。若要切换钱包,再输入新的私钥并保存即可。' + : 'This wallet is currently used for funding. Enter a new private key only if you want to switch wallets.'} +
+ )} {claw402Status && (
{claw402Status === 'ok' ? '🟢' : '🔴'} @@ -662,11 +780,11 @@ function Claw402ConfigForm({ )} {/* Test Connection button */} - {isKeyValid && !validating && ( + {(isKeyValid || hasExistingWallet) && !validating && ( + +
+
+
+
+ {isZh ? '当前余额' : 'Current Balance'} +
+
+ {data.balance_usdc} USDC +
+
+ {isZh ? 'Base 链钱包余额' : 'Base wallet balance'} +
+
+ +
+
+
+ +
+
+
+
+ {isZh ? '钱包私钥' : 'Wallet Private Key'} +
+
+ {isZh ? '请先备份,再进入下一步。' : 'Back this up before you continue.'} +
+
+ +
+
+ {showPrivateKey ? data.private_key : '0x' + '•'.repeat(64)} +
+ +
+ +
+
+ {data.env_saved + ? isZh + ? `已同步保存到环境文件:${data.env_path || '.env'}` + : `Also saved to env: ${data.env_path || '.env'}` + : isZh + ? '当前运行环境没有成功写回 .env,但产品已完成默认配置。' + : 'The app is configured, but this runtime could not write back to .env.'} +
+ {data.env_warning ?
{data.env_warning}
: null} +
+ + +
+ ) : null} + +
+
+ + ) +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index d278e1cafd..78c5080862 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -4,6 +4,12 @@ import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, P import { useAuth } from '../contexts/AuthContext' import { useLanguage } from '../contexts/LanguageContext' import { api } from '../lib/api' +import { + getPostAuthPath, + getUserMode, + setUserMode, + type UserMode, +} from '../lib/onboarding' import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal' import { TelegramConfigModal } from '../components/trader/TelegramConfigModal' import { ModelConfigModal } from '../components/trader/ModelConfigModal' @@ -15,6 +21,7 @@ export function SettingsPage() { const { user } = useAuth() const { language } = useLanguage() const [activeTab, setActiveTab] = useState('account') + const [userMode, setUserModeState] = useState(() => getUserMode() ?? 'advanced') // Account state const [newPassword, setNewPassword] = useState('') @@ -81,6 +88,26 @@ export function SettingsPage() { } } + const handleSwitchMode = (nextMode: UserMode) => { + if (nextMode === userMode) { + return + } + + setUserMode(nextMode) + setUserModeState(nextMode) + toast.success( + language === 'zh' + ? `已切换到${nextMode === 'beginner' ? '新手模式' : '老手模式'}` + : nextMode === 'beginner' + ? 'Switched to beginner mode' + : 'Switched to advanced mode' + ) + + const nextPath = getPostAuthPath(nextMode) + window.history.pushState({}, '', nextPath) + window.dispatchEvent(new PopStateEvent('popstate')) + } + const handleSaveModel = async ( modelId: string, apiKey: string, @@ -281,6 +308,66 @@ export function SettingsPage() {

{user?.email}

+
+
+
+

+ {language === 'zh' ? '使用模式' : 'Usage Mode'} +

+

+ {language === 'zh' + ? '新手模式会显示钱包引导和 4 步卡片;老手模式保持原来的专业界面。' + : 'Beginner mode shows wallet onboarding and quickstart cards. Advanced mode keeps the original pro workflow.'} +

+
+ + {userMode === 'beginner' + ? language === 'zh' ? '当前:新手模式' : 'Current: Beginner' + : language === 'zh' ? '当前:老手模式' : 'Current: Advanced'} + +
+ +
+ + + +
+
+

Change Password

diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index 8bf10a7163..fbc90f36c5 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -29,7 +29,6 @@ import { Download, Upload, Globe, - X, } from 'lucide-react' import type { Strategy, StrategyConfig, AIModel } from '../types' import { confirmToast, notify } from '../lib/notify' @@ -39,10 +38,8 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor' import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor' import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor' import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor' -import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar' import { DeepVoidBackground } from '../components/common/DeepVoidBackground' import { t } from '../i18n/translations' -import { NofxSelect } from '../components/ui/select' const API_BASE = import.meta.env.VITE_API_BASE || '' @@ -55,7 +52,6 @@ export function StrategyStudioPage() { const [editingConfig, setEditingConfig] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) - const [tokenOverflow, setTokenOverflow] = useState(false) const [error, setError] = useState(null) const [hasChanges, setHasChanges] = useState(false) @@ -382,10 +378,6 @@ export function StrategyStudioPage() { // Save strategy const handleSaveStrategy = async () => { if (!token || !selectedStrategy || !editingConfig) return - if (tokenOverflow && currentStrategyType === 'ai_trading') { - notify.error(tr('tokenExceedWarning')) - return - } setIsSaving(true) try { // Always sync the config language with the current interface language @@ -413,17 +405,7 @@ export function StrategyStudioPage() { if (!response.ok) throw new Error('Failed to save strategy') setHasChanges(false) notify.success(tr('strategySaved')) - const savedId = selectedStrategy.id await fetchStrategies() - // Stay on the strategy we just saved instead of jumping to active - setStrategies(prev => { - const saved = prev.find(s => s.id === savedId) - if (saved) { - setSelectedStrategy(saved) - setEditingConfig(saved.config) - } - return prev - }) } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { @@ -659,7 +641,7 @@ export function StrategyStudioPage() {
-

{tr('title')}

+

{tr('strategyStudio')}

{tr('subtitle')}

@@ -774,24 +756,34 @@ export function StrategyStudioPage() { {selectedStrategy && editingConfig ? (
{/* Strategy Name & Actions */} -
-
-
- { - setSelectedStrategy({ ...selectedStrategy, name: e.target.value }) - setHasChanges(true) - }} - disabled={selectedStrategy.is_default} - className="text-lg font-bold bg-transparent border-none outline-none flex-1 min-w-0 text-nofx-text placeholder-nofx-text-muted" - /> - {hasChanges && ( - ● {tr('unsaved')} - )} -
-
+
+
+ { + setSelectedStrategy({ ...selectedStrategy, name: e.target.value }) + setHasChanges(true) + }} + disabled={selectedStrategy.is_default} + className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted" + /> + { + setSelectedStrategy({ ...selectedStrategy, description: e.target.value }) + setHasChanges(true) + }} + disabled={selectedStrategy.is_default} + placeholder={tr('addDescription')} + className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1" + /> + {hasChanges && ( + ● {tr('unsaved')} + )} +
+
{!selectedStrategy.is_active && ( )} - {!selectedStrategy.is_default && hasChanges && ( - - )} {!selectedStrategy.is_default && ( )}
-
- { - setSelectedStrategy({ ...selectedStrategy, description: e.target.value }) - setHasChanges(true) - }} - disabled={selectedStrategy.is_default} - placeholder={tr('addDescription')} - className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1" - />
- {/* Token Estimate Bar */} - {currentStrategyType === 'ai_trading' && ( -
- -
- )} - {/* Strategy Type Selector */} {editingConfig && (
@@ -857,12 +818,9 @@ export function StrategyStudioPage() {
{aiModels.length > 0 ? ( - setSelectedModelId(val)} - options={aiModels.map((model) => ({ - value: model.id, - label: `${model.name} (${model.provider})`, - }))} + onChange={(e) => setSelectedModelId(e.target.value)} className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text" - /> + > + {aiModels.map((model) => ( + + ))} + ) : (
{tr('noModel')} @@ -1067,16 +1025,15 @@ export function StrategyStudioPage() { )}
- setSelectedVariant(val)} - options={[ - { value: 'balanced', label: tr('balanced') }, - { value: 'aggressive', label: tr('aggressive') }, - { value: 'conservative', label: tr('conservative') }, - ]} + onChange={(e) => setSelectedVariant(e.target.value)} className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text" - /> + > + + + +
@@ -387,7 +387,7 @@ export function CoinSourceEditor({ onChange({ ...config, oi_top_limit: parseInt(val) || 10 }) } disabled={disabled} - options={[1, 2, 3].map(n => ({ value: n, label: String(n) }))} + options={[3, 5, 10, 20, 30, 40, 50].map(n => ({ value: n, label: String(n) }))} className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text" />
@@ -441,7 +441,7 @@ export function CoinSourceEditor({ onChange({ ...config, oi_low_limit: parseInt(val) || 10 }) } disabled={disabled} - options={[1, 2, 3].map(n => ({ value: n, label: String(n) }))} + options={[3, 5, 10, 20, 30, 40, 50].map(n => ({ value: n, label: String(n) }))} className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text" />
@@ -495,7 +495,7 @@ export function CoinSourceEditor({ value={config.ai500_limit || 10} onChange={(val) => !disabled && onChange({ ...config, ai500_limit: parseInt(val) || 10 })} disabled={disabled} - options={[5, 10, 15, 20, 30, 50].map(n => ({ value: n, label: String(n) }))} + options={[3, 5, 10, 20, 30, 40, 50].map(n => ({ value: n, label: String(n) }))} className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text" />
@@ -535,7 +535,7 @@ export function CoinSourceEditor({ value={config.oi_top_limit || 10} onChange={(val) => !disabled && onChange({ ...config, oi_top_limit: parseInt(val) || 10 })} disabled={disabled} - options={[5, 10, 15, 20, 30, 50].map(n => ({ value: n, label: String(n) }))} + options={[3, 5, 10, 20, 30, 40, 50].map(n => ({ value: n, label: String(n) }))} className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text" />
@@ -575,7 +575,7 @@ export function CoinSourceEditor({ value={config.oi_low_limit || 10} onChange={(val) => !disabled && onChange({ ...config, oi_low_limit: parseInt(val) || 10 })} disabled={disabled} - options={[5, 10, 15, 20, 30, 50].map(n => ({ value: n, label: String(n) }))} + options={[3, 5, 10, 20, 30, 40, 50].map(n => ({ value: n, label: String(n) }))} className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text" />
diff --git a/web/src/components/strategy/TokenEstimateBar.tsx b/web/src/components/strategy/TokenEstimateBar.tsx index 9dd33a7e57..ec4a5707db 100644 --- a/web/src/components/strategy/TokenEstimateBar.tsx +++ b/web/src/components/strategy/TokenEstimateBar.tsx @@ -21,10 +21,10 @@ interface TokenEstimateResult { interface TokenEstimateBarProps { config: StrategyConfig | null language: Language - onOverflowChange?: (overflow: boolean) => void + onTokenCountChange?: (total: number) => void } -export function TokenEstimateBar({ config, language, onOverflowChange }: TokenEstimateBarProps) { +export function TokenEstimateBar({ config, language, onTokenCountChange }: TokenEstimateBarProps) { const [estimate, setEstimate] = useState(null) const [isLoading, setIsLoading] = useState(false) const debounceRef = useRef | null>(null) @@ -52,6 +52,7 @@ export function TokenEstimateBar({ config, language, onOverflowChange }: TokenEs if (response.ok) { const data = await response.json() setEstimate(data) + onTokenCountChange?.(data.total) } } catch { // silently ignore — non-critical UI element @@ -67,15 +68,6 @@ export function TokenEstimateBar({ config, language, onOverflowChange }: TokenEs } }, [config]) - useEffect(() => { - if (!estimate) { - onOverflowChange?.(false) - return - } - const maxPct = estimate.model_limits.reduce((max, ml) => Math.max(max, ml.usage_pct), 0) - onOverflowChange?.(maxPct >= 100) - }, [estimate, onOverflowChange]) - if (!config) return null if (isLoading && !estimate) { @@ -89,14 +81,8 @@ export function TokenEstimateBar({ config, language, onOverflowChange }: TokenEs if (!estimate) return null - // Find the strictest model (smallest context limit = highest usage_pct) - const strictest = estimate.model_limits.reduce( - (max, ml) => (ml.usage_pct > max.usage_pct ? ml : max), - estimate.model_limits[0] - ) - if (!strictest) return null - - const pct = strictest.usage_pct + // Display based on 200K reference + const pct = Math.round(estimate.total * 100 / 200000) const barWidth = Math.min(pct, 100) let barColor = '#0ECB81' // green @@ -109,8 +95,6 @@ export function TokenEstimateBar({ config, language, onOverflowChange }: TokenEs textColor = '#F0B90B' } - const exceedWarning = pct >= 100 ? tr('tokenExceedWarning') : null - return (
@@ -129,15 +113,10 @@ export function TokenEstimateBar({ config, language, onOverflowChange }: TokenEs
- {tr('tokenTooltip')} ({strictest.name} {(strictest.context_limit / 1000).toFixed(0)}K) + {tr('tokenTooltip')} (~{estimate.total.toLocaleString()} / 200K)
- {exceedWarning && ( -

- {exceedWarning} -

- )}
) } diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index fded60207e..1db615d2e2 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -1087,9 +1087,9 @@ export const translations = { generatePromptPreview: 'Click to generate prompt preview', runAiTestHint: 'Click to run AI test', tokenEstimate: 'Token Estimate', - tokenExceedWarning: 'Exceeds context limit. Reduce coins or timeframes.', + tokenExceedWarning: 'Token estimate exceeds 128K. AI requests may fail for some models.', tokenEstimating: 'Estimating...', - tokenTooltip: 'Based on strictest model', + tokenTooltip: 'Based on 200K context', }, // Metric Tooltip @@ -2388,9 +2388,9 @@ export const translations = { generatePromptPreview: '点击生成 Prompt 预览', runAiTestHint: '点击运行 AI 测试', tokenEstimate: 'Token 预估', - tokenExceedWarning: '超出上下文限制,建议减少币种或时间框架', + tokenExceedWarning: 'Token 估算超过 128K,部分模型请求可能失败', tokenEstimating: '预估中...', - tokenTooltip: '基于最严格模型计算', + tokenTooltip: '基于 200K 上下文计算', }, // Metric Tooltip @@ -3491,9 +3491,9 @@ export const translations = { generatePromptPreview: 'Klik untuk generate pratinjau prompt', runAiTestHint: 'Klik untuk menjalankan uji AI', tokenEstimate: 'Estimasi Token', - tokenExceedWarning: 'Melebihi batas konteks. Kurangi koin atau timeframe.', + tokenExceedWarning: 'Estimasi token melebihi 128K. Permintaan AI mungkin gagal untuk beberapa model.', tokenEstimating: 'Mengestimasi...', - tokenTooltip: 'Berdasarkan model paling ketat', + tokenTooltip: 'Berdasarkan konteks 200K', }, // Metric Tooltip diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index a46c271481..17a9579f35 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -38,6 +38,7 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor' import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor' import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor' import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor' +import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar' import { DeepVoidBackground } from '../components/common/DeepVoidBackground' import { t } from '../i18n/translations' @@ -52,6 +53,7 @@ export function StrategyStudioPage() { const [editingConfig, setEditingConfig] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) + const [estimatedTokens, setEstimatedTokens] = useState(0) const [error, setError] = useState(null) const [hasChanges, setHasChanges] = useState(false) @@ -397,6 +399,10 @@ export function StrategyStudioPage() { // Save strategy const handleSaveStrategy = async () => { if (!token || !selectedStrategy || !editingConfig) return + if (estimatedTokens >= 128000 && currentStrategyType === 'ai_trading') { + notify.warning(tr('tokenExceedWarning')) + // continue with save + } setIsSaving(true) try { // Always sync the config language with the current interface language @@ -826,6 +832,13 @@ export function StrategyStudioPage() {
+ {/* Token Estimate Bar */} + {currentStrategyType === 'ai_trading' && ( +
+ +
+ )} + {/* Strategy Type Selector */} {editingConfig && (
From b0be49569c2cc24f2a15cd2dbe0a37c0f9eb3f3a Mon Sep 17 00:00:00 2001 From: Dean Date: Fri, 27 Mar 2026 16:05:39 +0800 Subject: [PATCH 09/26] feat: implement default strategy creation for new users --- api/handler_user.go | 85 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/api/handler_user.go b/api/handler_user.go index 1b22010ca3..5c4bf5864f 100644 --- a/api/handler_user.go +++ b/api/handler_user.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "net/http" "strings" "time" @@ -214,10 +215,86 @@ func (s *Server) handleResetPassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"}) } -// initUserDefaultConfigs Initialize default model and exchange configs for new user +// initUserDefaultConfigs Initialize default configs for new user func (s *Server) initUserDefaultConfigs(userID string) error { - // Commented out auto-creation of default configs, let users add manually - // This way new users won't have config items automatically after registration - logger.Infof("User %s registration completed, waiting for manual AI model and exchange configuration", userID) + if err := s.createDefaultStrategies(userID); err != nil { + logger.Warnf("Failed to create default strategies for user %s: %v", userID, err) + // Non-fatal: user can create strategies manually + } + logger.Infof("✓ User %s registration completed with default strategies", userID) + return nil +} + +func (s *Server) createDefaultStrategies(userID string) error { + type strategyDef struct { + name string + description string + isActive bool + applyConfig func(*store.StrategyConfig) + } + + definitions := []strategyDef{ + { + name: "均衡策略", + description: "系统默认策略。均衡风险收益,适合大多数市场环境。5倍杠杆,最多3个仓位。", + isActive: true, + applyConfig: func(c *store.StrategyConfig) { + // Uses default config as-is + }, + }, + { + name: "稳健策略", + description: "系统默认策略。低杠杆保守操作,优先保护本金。3倍杠杆,专注主流资产。", + isActive: false, + applyConfig: func(c *store.StrategyConfig) { + c.RiskControl.BTCETHMaxLeverage = 3 + c.RiskControl.AltcoinMaxLeverage = 3 + c.RiskControl.BTCETHMaxPositionValueRatio = 3.0 + c.RiskControl.AltcoinMaxPositionValueRatio = 0.5 + c.RiskControl.MinConfidence = 80 + c.RiskControl.MinRiskRewardRatio = 4.0 + c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"} + c.Indicators.Klines.PrimaryTimeframe = "15m" + }, + }, + { + name: "积极策略", + description: "系统默认策略。高杠杆主动交易,更广泛的币种选择,适合经验丰富的交易者。10倍杠杆,最多5个仓位。", + isActive: false, + applyConfig: func(c *store.StrategyConfig) { + c.RiskControl.BTCETHMaxLeverage = 10 + c.RiskControl.AltcoinMaxLeverage = 7 + c.RiskControl.MaxPositions = 5 + c.RiskControl.AltcoinMaxPositionValueRatio = 2.0 + c.RiskControl.MinConfidence = 70 + c.CoinSource.AI500Limit = 5 + c.CoinSource.UseOITop = true + c.CoinSource.OITopLimit = 5 + c.Indicators.Klines.SelectedTimeframes = []string{"3m", "15m", "1h"} + c.Indicators.Klines.PrimaryTimeframe = "3m" + }, + }, + } + + for _, def := range definitions { + config := store.GetDefaultStrategyConfig("zh") + def.applyConfig(&config) + + strategy := &store.Strategy{ + ID: uuid.New().String(), + UserID: userID, + Name: def.name, + Description: def.description, + IsActive: def.isActive, + IsDefault: false, + } + if err := strategy.SetConfig(&config); err != nil { + return fmt.Errorf("failed to set config for strategy %q: %w", def.name, err) + } + if err := s.store.Strategy().Create(strategy); err != nil { + return fmt.Errorf("failed to create strategy %q: %w", def.name, err) + } + logger.Infof(" ✓ Created default strategy: %s (active=%v)", def.name, def.isActive) + } return nil } From 1c378007ee76c48db30092725ba6e75b0c96eba2 Mon Sep 17 00:00:00 2001 From: Dean Date: Fri, 27 Mar 2026 20:57:39 +0800 Subject: [PATCH 10/26] fix: show -- instead of 0 when account data fetch fails on dashboard Replace zero-value fallback with undefined, pass accountFailed prop to distinguish load failure from initial loading skeleton. --- web/src/App.tsx | 6 ++--- web/src/pages/TraderDashboardPage.tsx | 32 +++++++++++++++++---------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index fbdcb5ff53..412d8f1686 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -322,10 +322,7 @@ function App() { const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId) - // When polling has permanently failed, provide zero-value data instead of keeping skeleton - const effectiveAccount = (accountPollOff && !account) - ? { total_equity: 0, available_balance: 0, total_pnl: 0, total_pnl_pct: 0, position_count: 0, margin_used: 0, margin_used_pct: 0 } as AccountInfo - : account + const effectiveAccount = account const effectivePositions = (positionsPollOff && !positions) ? [] as Position[] : positions const effectiveDecisions = (decisionsPollOff && !decisions) ? [] as DecisionRecord[] : decisions @@ -545,6 +542,7 @@ function App() { selectedTrader={selectedTrader} status={status} account={effectiveAccount} + accountFailed={accountPollOff} positions={effectivePositions} decisions={effectiveDecisions} decisionsLimit={decisionsLimit} diff --git a/web/src/pages/TraderDashboardPage.tsx b/web/src/pages/TraderDashboardPage.tsx index b5f9e9d61c..ff7386bf25 100644 --- a/web/src/pages/TraderDashboardPage.tsx +++ b/web/src/pages/TraderDashboardPage.tsx @@ -103,6 +103,7 @@ interface TraderDashboardPageProps { onNavigateToTraders: () => void status?: SystemStatus account?: AccountInfo + accountFailed?: boolean positions?: Position[] decisions?: DecisionRecord[] decisionsLimit: number @@ -117,6 +118,7 @@ export function TraderDashboardPage({ selectedTrader, status, account, + accountFailed, positions, decisions, decisionsLimit, @@ -488,6 +490,12 @@ export function TraderDashboardPage({ EQ::{account.total_equity?.toFixed(2)} PNL::{account.total_pnl?.toFixed(2)}
+ ) : accountFailed ? ( +
+ LAST_UPDATE::-- + EQ::-- + PNL::-- +
) : (
@@ -501,37 +509,37 @@ export function TraderDashboardPage({
0} icon="💰" - loading={!account} + loading={!account && !accountFailed} /> = 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) || '0.00'}`} + value={accountFailed && !account ? '--' : `${account?.total_pnl !== undefined && account.total_pnl >= 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) ?? '--'}`} unit="USDT" - change={account?.total_pnl_pct || 0} + change={account ? (account.total_pnl_pct || 0) : undefined} positive={(account?.total_pnl ?? 0) >= 0} icon="📈" - loading={!account} + loading={!account && !accountFailed} />
From 39782600a96ca9b01bdb4d6c4db85a9eeda3fa44 Mon Sep 17 00:00:00 2001 From: Dean Date: Fri, 27 Mar 2026 21:01:41 +0800 Subject: [PATCH 11/26] docs: add token estimation analysis for candidate coin limits --- docs/token-estimation.zh-CN.md | 137 +++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/token-estimation.zh-CN.md diff --git a/docs/token-estimation.zh-CN.md b/docs/token-estimation.zh-CN.md new file mode 100644 index 0000000000..8f77ed6361 --- /dev/null +++ b/docs/token-estimation.zh-CN.md @@ -0,0 +1,137 @@ +# 📊 Token 估算分析与候选币种上限指南 + +> 版本:v1.0 | 更新:2026-03-27 +> 适用:策略配置 · 模型选择 · 候选币种数量决策 + +--- + +## 目录 + +- [Token 估算公式](#-token-估算公式) +- [系统提示词的准确性分析](#-系统提示词的准确性分析) +- [典型配置下的安全币种数量](#-典型配置下的安全币种数量) +- [模型上限参考](#-模型上限参考) +- [MaxCandidateCoins 常量说明](#-maxcandidatecoins-常量说明) + +--- + +## 📐 Token 估算公式 + +代码入口:`store/strategy.go` → `EstimateTokens()` + +整体结构: + +``` +total = (staticTokens + N × perCoinTokens) × 1.15 +``` + +其中 `1.15` 为 15% 安全边际。 + +### 静态部分(与候选币数量无关) + +``` +SystemPrompt = baseChars / 2(zh)或 / 4(en) + baseChars ≈ 3000(zh)/ 4000(en)+ 自定义提示段落长度 + +FixedOverhead = 200 tokens(时间戳、账户信息、章节标题) + +RankingData = (OILimit × 60 + NetFlowLimit × 80 + PriceLimit × durations × 40) / 4 + +staticTokens = SystemPrompt + FixedOverhead + RankingData + ≈ 1500 + 200 + 650 = 2350 tokens(默认中文配置) +``` + +### 每枚币的 Token 开销 + +``` +# 每行指标额外字符数(I) +I = EnableEMA×20 + EnableMACD×30 + EnableRSI×15 + + EnableATR×15 + EnableBOLL×25 + EnableVolume×10 + +# 每枚币的市场数据 token +marketPerCoin = (T × K × (80 + I) + 100) / 4 + ↑ T=时间框架数 K=每TF K线数 + ↑ 100 = OI + 资金费率固定开销 + +# 每枚币的量化数据 token +quantPerCoin = (EnableQuantOI×300 + EnableQuantNetflow×300) / 4 + +perCoinTokens = marketPerCoin + quantPerCoin +``` + +### 反向公式:最大安全币数 + +``` +budget = modelContextLimit × 0.80 / 1.15 +maxSafeCoins = floor((budget - staticTokens) / perCoinTokens) +``` + +--- + +## 📊 典型配置下的安全币种数量 + +**基准:131K 模型(DeepSeek / Grok / Qwen)**,80% 警戒线 + +### 三种配置的 perCoinTokens + +| 配置 | T | K | I | quantPerCoin | perCoinTokens | +| ------------------------------------------ | --- | --- | --- | ------------ | ------------- | +| **最小**(单TF,无指标,无量化) | 1 | 10 | 0 | 0 | **225** | +| **默认**(3TF,仅Volume,QuantOI+Netflow) | 3 | 20 | 10 | 600 | **1525** | +| **最大**(4TF,全部指标,全量化) | 4 | 30 | 115 | 600 | **6025** | + +### 各模型下的最大安全币数 + +| 模型上限 | 最小配置 | 默认配置 | 最大配置 | +| ------------------------------ | ----------- | --------------- | ----------- | +| 131K(DeepSeek / Grok / Qwen) | ≥50(封顶) | **58** | **14** | +| 128K(OpenAI GPT-4) | ≥50(封顶) | **57** | **14** | +| 200K(Claude) | ≥50(封顶) | **89 → 封顶50** | **22** | +| 1M(Gemini / Minimax) | ≥50(封顶) | ≥50(封顶) | ≥50(封顶) | + +--- + +## 🤖 模型上限参考 + +来源:`store/strategy.go` → `ModelContextLimits` + +| 模型 | Context 上限 | 80% 警戒线 | +| -------- | ------------ | ---------- | +| deepseek | 131,072 | 104,858 | +| openai | 128,000 | 102,400 | +| claude | 200,000 | 160,000 | +| qwen | 131,072 | 104,858 | +| gemini | 1,000,000 | 800,000 | +| grok | 131,072 | 104,858 | +| kimi | 131,072 | 104,858 | +| minimax | 1,000,000 | 800,000 | + +--- + +## 🔒 MaxCandidateCoins 常量说明 + +来源:`store/strategy.go` 第 14-20 行 + +```go +const ( + MaxCandidateCoins = 50 // UI 硬限制:用户最多设定的候选币数量 + MaxPositions = 3 // 最大同时持仓数 + MaxTimeframes = 4 // 最大时间框架数 + MinKlineCount = 10 // 最少 K 线数 + MaxKlineCount = 30 // 最多 K 线数 +) +``` + +### 为什么 MaxCandidateCoins = 50? + +- **默认配置**下 50 枚币约用 **~8,000 tokens**(~6% of 131K),完全安全 +- **极端配置**(4TF + 全指标)50 枚币会超过 131K 限制,但 **runtime token-blocking** 会在分析前拦截并报错 +- 因此 50 是合理的 UI 上限:一方面给用户足够灵活性,另一方面依赖运行时保护防止真正的溢出 + +### 建议使用范围 + +| 用户类型 | 建议配置 | 最大建议币数 | +| ------------------- | ----------------------- | ------------ | +| 新手 / 使用默认配置 | 3TF, K=20, 仅 Volume | 10-20 枚 | +| 进阶 / 启用部分指标 | 3TF, K=20, EMA+MACD+RSI | 10-15 枚 | +| 高级 / 全部指标 | 3-4TF, K=20-30, 全指标 | 5-10 枚 | From 1d897f635e02fbc434567cd8241e2cba36a9a7f4 Mon Sep 17 00:00:00 2001 From: Dean Date: Fri, 27 Mar 2026 21:11:12 +0800 Subject: [PATCH 12/26] feat: localize default strategy names by UI language at registration - Pass `lang` from register request body to createDefaultStrategies - Support zh/en/id locales for strategy names and descriptions - Wrap strategy creation in a transaction to prevent partial writes - Frontend sends current UI language in register request body - Strategy list UI: 2-line clamp, unselected border, larger spacing, smaller font for non-zh --- api/handler_user.go | 81 ++++++++++++++++++++++------ web/src/contexts/AuthContext.tsx | 5 +- web/src/pages/StrategyStudioPage.tsx | 8 +-- 3 files changed, 73 insertions(+), 21 deletions(-) diff --git a/api/handler_user.go b/api/handler_user.go index 5c4bf5864f..2a3d502f91 100644 --- a/api/handler_user.go +++ b/api/handler_user.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "gorm.io/gorm" ) // handleLogout Add current token to blacklist @@ -60,6 +61,7 @@ func (s *Server) handleRegister(c *gin.Context) { var req struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` + Lang string `json:"lang"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -67,6 +69,11 @@ func (s *Server) handleRegister(c *gin.Context) { return } + lang := req.Lang + if lang != "zh" && lang != "id" { + lang = "en" + } + // Check if email already exists _, err = s.store.User().GetByEmail(req.Email) if err == nil { @@ -103,7 +110,7 @@ func (s *Server) handleRegister(c *gin.Context) { } // Initialize default model and exchange configs for user - err = s.initUserDefaultConfigs(user.ID) + err = s.initUserDefaultConfigs(user.ID, lang) if err != nil { logger.Infof("Failed to initialize user default configs: %v", err) } @@ -216,8 +223,8 @@ func (s *Server) handleResetPassword(c *gin.Context) { } // initUserDefaultConfigs Initialize default configs for new user -func (s *Server) initUserDefaultConfigs(userID string) error { - if err := s.createDefaultStrategies(userID); err != nil { +func (s *Server) initUserDefaultConfigs(userID string, lang string) error { + if err := s.createDefaultStrategies(userID, lang); err != nil { logger.Warnf("Failed to create default strategies for user %s: %v", userID, err) // Non-fatal: user can create strategies manually } @@ -225,7 +232,35 @@ func (s *Server) initUserDefaultConfigs(userID string) error { return nil } -func (s *Server) createDefaultStrategies(userID string) error { +func (s *Server) createDefaultStrategies(userID string, lang string) error { + type strategyI18n struct { + name, description string + } + type strategyLocale struct { + balanced, conservative, aggressive strategyI18n + } + locales := map[string]strategyLocale{ + "zh": { + balanced: strategyI18n{"均衡策略", "系统默认策略。均衡风险收益,适合大多数市场环境。5倍杠杆,最多3个仓位。"}, + conservative: strategyI18n{"稳健策略", "系统默认策略。低杠杆保守操作,优先保护本金。3倍杠杆,专注主流资产。"}, + aggressive: strategyI18n{"积极策略", "系统默认策略。高杠杆主动交易,更广泛的币种选择,适合经验丰富的交易者。10倍杠杆,最多5个仓位。"}, + }, + "en": { + balanced: strategyI18n{"Balanced Strategy", "System default strategy. Balanced risk-reward, suitable for most market conditions. 5x leverage, up to 3 positions."}, + conservative: strategyI18n{"Conservative Strategy", "System default strategy. Low-leverage conservative trading, capital preservation first. 3x leverage, focused on major assets."}, + aggressive: strategyI18n{"Aggressive Strategy", "System default strategy. High-leverage active trading, wider asset selection, for experienced traders. 10x leverage, up to 5 positions."}, + }, + "id": { + balanced: strategyI18n{"Strategi Seimbang", "Strategi default sistem. Risiko-reward seimbang, cocok untuk sebagian besar kondisi pasar. Leverage 5x, hingga 3 posisi."}, + conservative: strategyI18n{"Strategi Konservatif", "Strategi default sistem. Trading konservatif leverage rendah, utamakan perlindungan modal. Leverage 3x, fokus aset utama."}, + aggressive: strategyI18n{"Strategi Agresif", "Strategi default sistem. Trading aktif leverage tinggi, pilihan aset lebih luas, untuk trader berpengalaman. Leverage 10x, hingga 5 posisi."}, + }, + } + locale, ok := locales[lang] + if !ok { + locale = locales["en"] + } + type strategyDef struct { name string description string @@ -235,16 +270,16 @@ func (s *Server) createDefaultStrategies(userID string) error { definitions := []strategyDef{ { - name: "均衡策略", - description: "系统默认策略。均衡风险收益,适合大多数市场环境。5倍杠杆,最多3个仓位。", + name: locale.balanced.name, + description: locale.balanced.description, isActive: true, applyConfig: func(c *store.StrategyConfig) { // Uses default config as-is }, }, { - name: "稳健策略", - description: "系统默认策略。低杠杆保守操作,优先保护本金。3倍杠杆,专注主流资产。", + name: locale.conservative.name, + description: locale.conservative.description, isActive: false, applyConfig: func(c *store.StrategyConfig) { c.RiskControl.BTCETHMaxLeverage = 3 @@ -258,8 +293,8 @@ func (s *Server) createDefaultStrategies(userID string) error { }, }, { - name: "积极策略", - description: "系统默认策略。高杠杆主动交易,更广泛的币种选择,适合经验丰富的交易者。10倍杠杆,最多5个仓位。", + name: locale.aggressive.name, + description: locale.aggressive.description, isActive: false, applyConfig: func(c *store.StrategyConfig) { c.RiskControl.BTCETHMaxLeverage = 10 @@ -276,8 +311,16 @@ func (s *Server) createDefaultStrategies(userID string) error { }, } + // GetDefaultStrategyConfig only supports zh/en; map id -> en + configLang := lang + if lang == "id" { + configLang = "en" + } + + // Pre-build all strategy objects before opening the transaction + var strategies []*store.Strategy for _, def := range definitions { - config := store.GetDefaultStrategyConfig("zh") + config := store.GetDefaultStrategyConfig(configLang) def.applyConfig(&config) strategy := &store.Strategy{ @@ -291,10 +334,16 @@ func (s *Server) createDefaultStrategies(userID string) error { if err := strategy.SetConfig(&config); err != nil { return fmt.Errorf("failed to set config for strategy %q: %w", def.name, err) } - if err := s.store.Strategy().Create(strategy); err != nil { - return fmt.Errorf("failed to create strategy %q: %w", def.name, err) - } - logger.Infof(" ✓ Created default strategy: %s (active=%v)", def.name, def.isActive) + strategies = append(strategies, strategy) } - return nil + + return s.store.Transaction(func(tx *gorm.DB) error { + for _, strategy := range strategies { + if err := tx.Create(strategy).Error; err != nil { + return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err) + } + logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive) + } + return nil + }) } diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 803946b421..928daf6cfd 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -3,6 +3,7 @@ import { flushSync } from 'react-dom' import { getSystemConfig } from '../lib/config' import { reset401Flag, httpClient } from '../lib/httpClient' import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding' +import { useLanguage } from './LanguageContext' interface User { id: string @@ -41,6 +42,7 @@ interface AuthContextType { const AuthContext = createContext(undefined) export function AuthProvider({ children }: { children: React.ReactNode }) { + const { language } = useLanguage() const [user, setUser] = useState(null) const [token, setToken] = useState(null) const [isLoading, setIsLoading] = useState(true) @@ -208,7 +210,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { email: string password: string beta_code?: string - } = { email, password } + lang?: string + } = { email, password, lang: language } if (betaCode) { requestBody.beta_code = betaCode } diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index 17a9579f35..5e4ee2558f 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -706,7 +706,7 @@ export function StrategyStudioPage() {
-
+
{strategies.map((strategy) => (
-
- {strategy.name} +
+ {strategy.name}
) : accountFailed ? ( -
- LAST_UPDATE::-- - EQ::-- - PNL::-- -
+ DATA_FETCH::FAILED — 账户数据请求失败,请检查连接 ) : (
From f83f2b1c180886e8aa9fb15f188bf23071a0e9d1 Mon Sep 17 00:00:00 2001 From: Dean Date: Fri, 27 Mar 2026 21:49:34 +0800 Subject: [PATCH 14/26] style: apply gofmt to api/strategy.go and store/strategy.go --- api/strategy.go | 11 +++++------ store/strategy.go | 32 ++++++++++++++++---------------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/api/strategy.go b/api/strategy.go index 64dd4e8e21..1985c3844e 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -164,8 +164,8 @@ func (s *Server) handleCreateStrategy(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` Description string `json:"description"` - Lang string `json:"lang"` // "zh" or "en", used when config is omitted - Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted + Lang string `json:"lang"` // "zh" or "en", used when config is omitted + Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted } if err := c.ShouldBindJSON(&req); err != nil { @@ -452,9 +452,9 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) { } var req struct { - Config store.StrategyConfig `json:"config" binding:"required"` - AccountEquity float64 `json:"account_equity"` - PromptVariant string `json:"prompt_variant"` + Config store.StrategyConfig `json:"config" binding:"required"` + AccountEquity float64 `json:"account_equity"` + PromptVariant string `json:"prompt_variant"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -697,4 +697,3 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string) return response, nil } - diff --git a/store/strategy.go b/store/strategy.go index 2ef062bd4d..573deb48cf 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -73,8 +73,8 @@ type Strategy struct { Description string `gorm:"default:''" json:"description"` IsActive bool `gorm:"column:is_active;default:false;index" json:"is_active"` IsDefault bool `gorm:"column:is_default;default:false" json:"is_default"` - IsPublic bool `gorm:"column:is_public;default:false;index" json:"is_public"` // whether visible in strategy market - ConfigVisible bool `gorm:"column:config_visible;default:true" json:"config_visible"` // whether config details are visible + IsPublic bool `gorm:"column:is_public;default:false;index" json:"is_public"` // whether visible in strategy market + ConfigVisible bool `gorm:"column:config_visible;default:true" json:"config_visible"` // whether config details are visible Config string `gorm:"not null;default:'{}'" json:"config"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -191,7 +191,7 @@ type IndicatorConfig struct { EnableMACD bool `json:"enable_macd"` EnableRSI bool `json:"enable_rsi"` EnableATR bool `json:"enable_atr"` - EnableBOLL bool `json:"enable_boll"` // Bollinger Bands + EnableBOLL bool `json:"enable_boll"` // Bollinger Bands EnableVolume bool `json:"enable_volume"` EnableOI bool `json:"enable_oi"` // open interest EnableFundingRate bool `json:"enable_funding_rate"` // funding rate @@ -249,10 +249,10 @@ type KlineConfig struct { // ExternalDataSource external data source configuration type ExternalDataSource struct { - Name string `json:"name"` // data source name - Type string `json:"type"` // type: "api" | "webhook" - URL string `json:"url"` // API URL - Method string `json:"method"` // HTTP method + Name string `json:"name"` // data source name + Type string `json:"type"` // type: "api" | "webhook" + URL string `json:"url"` // API URL + Method string `json:"method"` // HTTP method Headers map[string]string `json:"headers,omitempty"` DataPath string `json:"data_path,omitempty"` // JSON data path RefreshSecs int `json:"refresh_secs,omitempty"` // refresh interval (seconds) @@ -360,15 +360,15 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { PriceRankingLimit: 10, }, RiskControl: RiskControlConfig{ - MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED) - BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided) - AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided) - BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED) - AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED) - MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED) - MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED) - MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided) - MinConfidence: 75, // Min 75% confidence (AI guided) + MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED) + BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided) + AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided) + BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED) + AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED) + MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED) + MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED) + MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided) + MinConfidence: 75, // Min 75% confidence (AI guided) }, } From fbca4166a13121d9516b23ed28d1da6aa9654224 Mon Sep 17 00:00:00 2001 From: Dean Date: Fri, 27 Mar 2026 22:34:51 +0800 Subject: [PATCH 15/26] fix: reduce candidate coin limit to 10, fix Select scroll and flash - Lower MaxCandidateCoins from 50 to 10 (backend) - Update CoinSourceEditor: options 1-10, default 3, max static coins 10 - Fix NofxSelect dropdown closing on internal scroll - Fix NofxSelect position flash on open (useLayoutEffect) --- store/strategy.go | 2 +- .../components/strategy/CoinSourceEditor.tsx | 50 +++++++++---------- web/src/components/ui/select.tsx | 9 ++-- 3 files changed, 32 insertions(+), 29 deletions(-) diff --git a/store/strategy.go b/store/strategy.go index 573deb48cf..7888ce1f79 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -12,7 +12,7 @@ import ( // Hard limits to prevent token explosion in AI requests const ( - MaxCandidateCoins = 50 + MaxCandidateCoins = 10 MaxPositions = 3 MaxTimeframes = 4 MinKlineCount = 10 diff --git a/web/src/components/strategy/CoinSourceEditor.tsx b/web/src/components/strategy/CoinSourceEditor.tsx index 14cf19368e..20ba34653c 100644 --- a/web/src/components/strategy/CoinSourceEditor.tsx +++ b/web/src/components/strategy/CoinSourceEditor.tsx @@ -33,16 +33,16 @@ export function CoinSourceEditor({ let totalLimit = 0 if (config.use_ai500) { - sources.push(`AI500(${config.ai500_limit || 10})`) - totalLimit += config.ai500_limit || 10 + sources.push(`AI500(${config.ai500_limit || 3})`) + totalLimit += config.ai500_limit || 3 } if (config.use_oi_top) { - sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 10})`) - totalLimit += config.oi_top_limit || 10 + sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 3})`) + totalLimit += config.oi_top_limit || 3 } if (config.use_oi_low) { - sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 10})`) - totalLimit += config.oi_low_limit || 10 + sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 3})`) + totalLimit += config.oi_low_limit || 3 } if ((config.static_coins || []).length > 0) { sources.push(`${ts(coinSource.custom, language)}(${config.static_coins?.length || 0})`) @@ -71,7 +71,7 @@ export function CoinSourceEditor({ return xyzDexAssets.has(base) } - const MAX_STATIC_COINS = 50 + const MAX_STATIC_COINS = 10 const showToast = (msg: string) => { const toast = document.createElement('div') @@ -327,13 +327,13 @@ export function CoinSourceEditor({ {ts(coinSource.ai500Limit, language)}: !disabled && - onChange({ ...config, ai500_limit: parseInt(val) || 10 }) + onChange({ ...config, ai500_limit: parseInt(val) || 3 }) } disabled={disabled} - options={[3, 5, 10, 20, 30, 40, 50].map(n => ({ value: n, label: String(n) }))} + options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))} className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text" />
@@ -381,13 +381,13 @@ export function CoinSourceEditor({ {ts(coinSource.oiTopLimit, language)}: !disabled && - onChange({ ...config, oi_top_limit: parseInt(val) || 10 }) + onChange({ ...config, oi_top_limit: parseInt(val) || 3 }) } disabled={disabled} - options={[3, 5, 10, 20, 30, 40, 50].map(n => ({ value: n, label: String(n) }))} + options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))} className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text" />
@@ -435,13 +435,13 @@ export function CoinSourceEditor({ {ts(coinSource.oiLowLimit, language)}: !disabled && - onChange({ ...config, oi_low_limit: parseInt(val) || 10 }) + onChange({ ...config, oi_low_limit: parseInt(val) || 3 }) } disabled={disabled} - options={[3, 5, 10, 20, 30, 40, 50].map(n => ({ value: n, label: String(n) }))} + options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))} className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text" />
@@ -492,10 +492,10 @@ export function CoinSourceEditor({
Limit: !disabled && onChange({ ...config, ai500_limit: parseInt(val) || 10 })} + value={config.ai500_limit || 3} + onChange={(val) => !disabled && onChange({ ...config, ai500_limit: parseInt(val) || 3 })} disabled={disabled} - options={[3, 5, 10, 20, 30, 40, 50].map(n => ({ value: n, label: String(n) }))} + options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))} className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text" />
@@ -532,10 +532,10 @@ export function CoinSourceEditor({
Limit: !disabled && onChange({ ...config, oi_top_limit: parseInt(val) || 10 })} + value={config.oi_top_limit || 3} + onChange={(val) => !disabled && onChange({ ...config, oi_top_limit: parseInt(val) || 3 })} disabled={disabled} - options={[3, 5, 10, 20, 30, 40, 50].map(n => ({ value: n, label: String(n) }))} + options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))} className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text" />
@@ -572,10 +572,10 @@ export function CoinSourceEditor({
Limit: !disabled && onChange({ ...config, oi_low_limit: parseInt(val) || 10 })} + value={config.oi_low_limit || 3} + onChange={(val) => !disabled && onChange({ ...config, oi_low_limit: parseInt(val) || 3 })} disabled={disabled} - options={[3, 5, 10, 20, 30, 40, 50].map(n => ({ value: n, label: String(n) }))} + options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))} className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text" />
diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx index ae1e13dc08..a7e4699cf8 100644 --- a/web/src/components/ui/select.tsx +++ b/web/src/components/ui/select.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect, useCallback } from 'react' +import { useRef, useState, useLayoutEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import { ChevronDown } from 'lucide-react' import { cn } from '../../lib/cn' @@ -30,7 +30,7 @@ export function NofxSelect({ value, onChange, options, disabled, className, styl setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }) }, []) - useEffect(() => { + useLayoutEffect(() => { if (!open) return updatePos() const handleClose = (e: MouseEvent) => { @@ -39,7 +39,10 @@ export function NofxSelect({ value, onChange, options, disabled, className, styl if (dropdownRef.current?.contains(target)) return setOpen(false) } - const handleScroll = () => setOpen(false) + const handleScroll = (e: Event) => { + if (dropdownRef.current?.contains(e.target as Node)) return + setOpen(false) + } document.addEventListener('mousedown', handleClose) window.addEventListener('scroll', handleScroll, true) return () => { From 2e2598e4e0f87c7dd5005ed47629fef6bafe538d Mon Sep 17 00:00:00 2001 From: Dean Date: Sat, 28 Mar 2026 00:04:14 +0800 Subject: [PATCH 16/26] fix: update token limits and error handling in Trader Dashboard --- docs/token-estimation.zh-CN.md | 22 ++++++++--------- store/strategy.go | 34 ++++++++++++++++++++------- web/src/App.tsx | 6 +++-- web/src/i18n/translations.ts | 9 +++++++ web/src/pages/TraderDashboardPage.tsx | 16 ++++++++++++- 5 files changed, 64 insertions(+), 23 deletions(-) diff --git a/docs/token-estimation.zh-CN.md b/docs/token-estimation.zh-CN.md index 8f77ed6361..b5bb5a56bd 100644 --- a/docs/token-estimation.zh-CN.md +++ b/docs/token-estimation.zh-CN.md @@ -82,12 +82,12 @@ maxSafeCoins = floor((budget - staticTokens) / perCoinTokens) ### 各模型下的最大安全币数 -| 模型上限 | 最小配置 | 默认配置 | 最大配置 | -| ------------------------------ | ----------- | --------------- | ----------- | -| 131K(DeepSeek / Grok / Qwen) | ≥50(封顶) | **58** | **14** | -| 128K(OpenAI GPT-4) | ≥50(封顶) | **57** | **14** | -| 200K(Claude) | ≥50(封顶) | **89 → 封顶50** | **22** | -| 1M(Gemini / Minimax) | ≥50(封顶) | ≥50(封顶) | ≥50(封顶) | +| 模型上限 | 最小配置 | 默认配置 | 最大配置 | +| ------------------------------ | ------------ | ------------ | ----------- | +| 131K(DeepSeek / Grok / Qwen) | ≥10(封顶) | ≥10(封顶) | **14** | +| 128K(OpenAI GPT-4) | ≥10(封顶) | ≥10(封顶) | **14** | +| 200K(Claude) | ≥10(封顶) | ≥10(封顶) | ≥10(封顶) | +| 1M(Gemini / Minimax) | ≥10(封顶) | ≥10(封顶) | ≥10(封顶) | --- @@ -114,7 +114,7 @@ maxSafeCoins = floor((budget - staticTokens) / perCoinTokens) ```go const ( - MaxCandidateCoins = 50 // UI 硬限制:用户最多设定的候选币数量 + MaxCandidateCoins = 10 // UI 硬限制:用户最多设定的候选币数量 MaxPositions = 3 // 最大同时持仓数 MaxTimeframes = 4 // 最大时间框架数 MinKlineCount = 10 // 最少 K 线数 @@ -122,11 +122,11 @@ const ( ) ``` -### 为什么 MaxCandidateCoins = 50? +### 为什么 MaxCandidateCoins = 10? -- **默认配置**下 50 枚币约用 **~8,000 tokens**(~6% of 131K),完全安全 -- **极端配置**(4TF + 全指标)50 枚币会超过 131K 限制,但 **runtime token-blocking** 会在分析前拦截并报错 -- 因此 50 是合理的 UI 上限:一方面给用户足够灵活性,另一方面依赖运行时保护防止真正的溢出 +- **默认配置**下 10 枚币约用 **~15,000 tokens**(~12% of 131K),完全安全 +- **极端配置**(4TF + 全指标)10 枚币约用 **~60,000 tokens**(~46% of 131K),仍有充足余量 +- 因此 10 是保守且安全的 UI 上限:在所有模型和配置组合下均不会触发 token 限制 ### 建议使用范围 diff --git a/store/strategy.go b/store/strategy.go index 7888ce1f79..19bc95260f 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -602,16 +602,28 @@ type ModelLimit struct { Level string `json:"level"` // "ok" | "warning" | "danger" } +// Context window sizes (tokens) for each model family +const ( + contextLimitDeepSeek = 131_072 // 128K + contextLimitOpenAI = 128_000 // 128K + contextLimitClaude = 200_000 // 200K + contextLimitQwen = 131_072 // 128K + contextLimitGemini = 1_000_000 // 1M + contextLimitGrok = 131_072 // 128K + contextLimitKimi = 131_072 // 128K + contextLimitMinimax = 1_000_000 // 1M +) + // ModelContextLimits maps provider names to their context window sizes (in tokens) var ModelContextLimits = map[string]int{ - "deepseek": 131072, - "openai": 128000, - "claude": 200000, - "qwen": 131072, - "gemini": 1000000, - "grok": 131072, - "kimi": 131072, - "minimax": 1000000, + "deepseek": contextLimitDeepSeek, + "openai": contextLimitOpenAI, + "claude": contextLimitClaude, + "qwen": contextLimitQwen, + "gemini": contextLimitGemini, + "grok": contextLimitGrok, + "kimi": contextLimitKimi, + "minimax": contextLimitMinimax, } // GetContextLimit returns the context limit for a given provider @@ -619,7 +631,7 @@ func GetContextLimit(provider string) int { if limit, ok := ModelContextLimits[provider]; ok { return limit } - return 131072 // safe default + return contextLimitDeepSeek // safe default } // GetContextLimitForClient returns context limit for a provider+model pair. @@ -639,6 +651,10 @@ func GetContextLimitForClient(provider, model string) int { return ModelContextLimits["kimi"] case strings.HasPrefix(model, "qwen"): return ModelContextLimits["qwen"] + case strings.HasPrefix(model, "minimax"): + return ModelContextLimits["minimax"] + case strings.HasPrefix(model, "deepseek"): + return ModelContextLimits["deepseek"] default: return ModelContextLimits["deepseek"] } diff --git a/web/src/App.tsx b/web/src/App.tsx index 412d8f1686..3173fb97f2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -323,8 +323,8 @@ function App() { const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId) const effectiveAccount = account - const effectivePositions = (positionsPollOff && !positions) ? [] as Position[] : positions - const effectiveDecisions = (decisionsPollOff && !decisions) ? [] as DecisionRecord[] : decisions + const effectivePositions = positions + const effectiveDecisions = decisions // Handle routing useEffect(() => { @@ -544,7 +544,9 @@ function App() { account={effectiveAccount} accountFailed={accountPollOff} positions={effectivePositions} + positionsFailed={positionsPollOff} decisions={effectiveDecisions} + decisionsFailed={decisionsPollOff} decisionsLimit={decisionsLimit} onDecisionsLimitChange={setDecisionsLimit} stats={stats} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 1db615d2e2..12d8541259 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -1167,6 +1167,9 @@ export const translations = { close: 'Close', showingPositions: 'Showing {shown} of {total} positions', perPage: 'Per page', + accountFetchFailed: 'DATA_FETCH::FAILED — Account data unavailable, check connection', + positionsFetchFailed: 'Position data unavailable', + decisionsFetchFailed: 'Decision data unavailable', }, // AITradersPage toast messages @@ -2467,6 +2470,9 @@ export const translations = { close: '平仓', showingPositions: '显示 {shown} / {total} 个持仓', perPage: '每页', + accountFetchFailed: 'DATA_FETCH::FAILED — 账户数据请求失败,请检查连接', + positionsFetchFailed: '持仓数据请求失败', + decisionsFetchFailed: '决策记录请求失败', }, aiTradersToast: { @@ -3570,6 +3576,9 @@ export const translations = { close: 'Tutup', showingPositions: 'Menampilkan {shown} dari {total} posisi', perPage: 'Per halaman', + accountFetchFailed: 'DATA_FETCH::FAILED — Data akun tidak tersedia, periksa koneksi', + positionsFetchFailed: 'Data posisi tidak tersedia', + decisionsFetchFailed: 'Data keputusan tidak tersedia', }, aiTradersToast: { diff --git a/web/src/pages/TraderDashboardPage.tsx b/web/src/pages/TraderDashboardPage.tsx index c8db254976..53f545d877 100644 --- a/web/src/pages/TraderDashboardPage.tsx +++ b/web/src/pages/TraderDashboardPage.tsx @@ -105,7 +105,9 @@ interface TraderDashboardPageProps { account?: AccountInfo accountFailed?: boolean positions?: Position[] + positionsFailed?: boolean decisions?: DecisionRecord[] + decisionsFailed?: boolean decisionsLimit: number onDecisionsLimitChange: (limit: number) => void stats?: Statistics @@ -120,7 +122,9 @@ export function TraderDashboardPage({ account, accountFailed, positions, + positionsFailed, decisions, + decisionsFailed, decisionsLimit, onDecisionsLimitChange, lastUpdate, @@ -491,7 +495,7 @@ export function TraderDashboardPage({ PNL::{account.total_pnl?.toFixed(2)}
) : accountFailed ? ( - DATA_FETCH::FAILED — 账户数据请求失败,请检查连接 + {t('traderDashboard.accountFetchFailed', language)} ) : (
@@ -723,6 +727,11 @@ export function TraderDashboardPage({
)}
+ ) : positionsFailed ? ( +
+
⚠️
+
{t('traderDashboard.positionsFetchFailed', language)}
+
) : (
📊
@@ -776,6 +785,11 @@ export function TraderDashboardPage({ decisions.map((decision, i) => ( )) + ) : decisionsFailed ? ( +
+
⚠️
+
{t('traderDashboard.decisionsFetchFailed', language)}
+
) : (
🧠
From 7464dfa8927db28573f54df6660233f8053069d3 Mon Sep 17 00:00:00 2001 From: Dean Date: Sat, 28 Mar 2026 00:15:36 +0800 Subject: [PATCH 17/26] docs: update token estimation values for candidate coins in Chinese documentation --- docs/token-estimation.zh-CN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/token-estimation.zh-CN.md b/docs/token-estimation.zh-CN.md index b5bb5a56bd..eb8aa5e732 100644 --- a/docs/token-estimation.zh-CN.md +++ b/docs/token-estimation.zh-CN.md @@ -124,8 +124,8 @@ const ( ### 为什么 MaxCandidateCoins = 10? -- **默认配置**下 10 枚币约用 **~15,000 tokens**(~12% of 131K),完全安全 -- **极端配置**(4TF + 全指标)10 枚币约用 **~60,000 tokens**(~46% of 131K),仍有充足余量 +- **默认配置**下 10 枚币约用 **~20,000 tokens**(~15% of 131K),完全安全 +- **极端配置**(4TF + 全指标)10 枚币约用 **~72,000 tokens**(~55% of 131K),仍有充足余量 - 因此 10 是保守且安全的 UI 上限:在所有模型和配置组合下均不会触发 token 限制 ### 建议使用范围 From 9176aa9844fb61207223b67055b69450e367278b Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Sat, 28 Mar 2026 00:29:12 +0800 Subject: [PATCH 18/26] fix(deps): resolve 11 npm vulnerabilities in frontend dependencies Update react-router, rollup, picomatch, and yaml to patched versions. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/package-lock.json | 361 ++++++++++++++++++++++++------------------ 1 file changed, 203 insertions(+), 158 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index bd3d7f5eca..881bf8cf7e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1010,9 +1010,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -1021,9 +1021,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1084,9 +1084,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -1105,9 +1105,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1728,9 +1728,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", "cpu": [ "arm" ], @@ -1742,9 +1742,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", "cpu": [ "arm64" ], @@ -1756,9 +1756,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", "cpu": [ "arm64" ], @@ -1770,9 +1770,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", "cpu": [ "x64" ], @@ -1784,9 +1784,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", "cpu": [ "arm64" ], @@ -1798,9 +1798,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", "cpu": [ "x64" ], @@ -1812,9 +1812,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", "cpu": [ "arm" ], @@ -1826,9 +1826,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", "cpu": [ "arm" ], @@ -1840,9 +1840,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", "cpu": [ "arm64" ], @@ -1854,9 +1854,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", "cpu": [ "arm64" ], @@ -1868,9 +1868,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", "cpu": [ "loong64" ], @@ -1882,9 +1896,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", "cpu": [ "ppc64" ], @@ -1896,9 +1924,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", "cpu": [ "riscv64" ], @@ -1910,9 +1938,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", "cpu": [ "riscv64" ], @@ -1924,9 +1952,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", "cpu": [ "s390x" ], @@ -1938,9 +1966,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", "cpu": [ "x64" ], @@ -1952,9 +1980,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", "cpu": [ "x64" ], @@ -1965,10 +1993,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", "cpu": [ "arm64" ], @@ -1980,9 +2022,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", "cpu": [ "arm64" ], @@ -1994,9 +2036,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", "cpu": [ "ia32" ], @@ -2008,9 +2050,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", "cpu": [ "x64" ], @@ -2022,9 +2064,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", "cpu": [ "x64" ], @@ -2694,9 +2736,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3029,13 +3071,13 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -3070,9 +3112,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -4285,9 +4327,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4296,9 +4338,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4349,9 +4391,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4383,9 +4425,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4640,9 +4682,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5933,9 +5975,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -6095,13 +6137,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -6497,9 +6539,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6968,9 +7010,9 @@ } }, "node_modules/react-router": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", - "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", + "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6990,12 +7032,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", - "integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", + "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", "license": "MIT", "dependencies": { - "react-router": "7.10.1" + "react-router": "7.13.2" }, "engines": { "node": ">=20.0.0" @@ -7247,9 +7289,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7263,28 +7305,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" } }, @@ -8094,9 +8139,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8541,9 +8586,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8632,9 +8677,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8932,9 +8977,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { From cab58afe6d39e01e47cbac8547725accdfe459b7 Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Sat, 28 Mar 2026 14:14:20 +0800 Subject: [PATCH 19/26] fix: guard short trader ID, i18n setup page, simplify onboarding UX - main.go: prevent panic when trader ID < 8 chars - SetupPage: add zh/en i18n labels - BeginnerOnboardingPage: show private key by default, simplify code --- main.go | 8 +- web/src/components/modals/SetupPage.tsx | 167 +++++++++++-- web/src/pages/BeginnerOnboardingPage.tsx | 283 +++++++++-------------- 3 files changed, 251 insertions(+), 207 deletions(-) diff --git a/main.go b/main.go index 7fa7295cbc..a2c4f44140 100644 --- a/main.go +++ b/main.go @@ -118,8 +118,12 @@ func main() { if t.IsRunning { status = "✅ Running" } - logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s", - t.Name, t.ID[:8], status, t.AIModelID, t.ExchangeID) + idShort := t.ID + if len(idShort) > 8 { + idShort = idShort[:8] + } + logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s", + t.Name, idShort, status, t.AIModelID, t.ExchangeID) } } diff --git a/web/src/components/modals/SetupPage.tsx b/web/src/components/modals/SetupPage.tsx index f43b848ff5..19d9635fec 100644 --- a/web/src/components/modals/SetupPage.tsx +++ b/web/src/components/modals/SetupPage.tsx @@ -1,14 +1,62 @@ import React, { useState } from 'react' -import { Eye, EyeOff } from 'lucide-react' +import { Eye, EyeOff, Globe } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' -import { DeepVoidBackground } from '../common/DeepVoidBackground' import { invalidateSystemConfig } from '../../lib/config' import { OnboardingModeSelector } from '../auth/OnboardingModeSelector' import type { UserMode } from '../../lib/onboarding' import { useLanguage } from '../../contexts/LanguageContext' +import type { Language } from '../../i18n/translations' + +const labels = { + zh: { + welcome: '欢迎使用 NOFX', + subtitle: '创建账号开始使用', + email: '邮箱', + emailPlaceholder: 'you@example.com', + password: '密码', + passwordPlaceholder: '至少 8 个字符', + passwordError: '密码至少需要 8 个字符', + submit: '开始使用', + submitting: '创建中...', + setupFailed: '创建失败,请重试', + singleUser: '单用户系统 — 这是唯一的账号', + }, + en: { + welcome: 'Welcome to NOFX', + subtitle: 'Create your account to get started', + email: 'Email', + emailPlaceholder: 'you@example.com', + password: 'Password', + passwordPlaceholder: 'At least 8 characters', + passwordError: 'Password must be at least 8 characters', + submit: 'Get Started', + submitting: 'Creating account...', + setupFailed: 'Setup failed, please try again', + singleUser: 'Single-user system — this is the only account', + }, + id: { + welcome: 'Selamat Datang di NOFX', + subtitle: 'Buat akun untuk memulai', + email: 'Email', + emailPlaceholder: 'you@example.com', + password: 'Kata Sandi', + passwordPlaceholder: 'Minimal 8 karakter', + passwordError: 'Kata sandi minimal 8 karakter', + submit: 'Mulai', + submitting: 'Membuat akun...', + setupFailed: 'Gagal membuat akun, coba lagi', + singleUser: 'Sistem pengguna tunggal — ini satu-satunya akun', + }, +} as const + +const langOptions: { value: Language; label: string }[] = [ + { value: 'en', label: 'English' }, + { value: 'zh', label: '中文' }, + { value: 'id', label: 'Bahasa' }, +] export function SetupPage() { - const { language } = useLanguage() + const { language, setLanguage } = useLanguage() const { register } = useAuth() const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -17,11 +65,13 @@ export function SetupPage() { const [loading, setLoading] = useState(false) const [mode, setMode] = useState('beginner') + const l = labels[language as keyof typeof labels] || labels.en + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError('') if (password.length < 8) { - setError('Password must be at least 8 characters') + setError(l.passwordError) return } setLoading(true) @@ -30,40 +80,100 @@ export function SetupPage() { if (result.success) { invalidateSystemConfig() } else { - setError(result.message || 'Setup failed, please try again') + setError(result.message || l.setupFailed) } } return ( - -
-
+
+ {/* Decorative background - simulates the main app behind a modal */} + + {/* Grid */} +
+
+
+ + {/* Glow spots */} +
+
+
+
+
+ + {/* Faux UI elements in background to simulate the app */} +
+ {/* Fake header bar */} +
+
+
+
+
+
+
+ {/* Fake content cards */} +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+
+
+ + {/* Blur overlay */} +
+ + {/* Language switcher */} +
+
+ + {langOptions.map((opt) => ( + + ))} +
+
+ + {/* Modal card */} +
+
{/* Logo + Title */} -
-
+
+
-
- NOFX +
+ NOFX
-

Welcome to NOFX

-

Create your account to get started

+

{l.welcome}

+

{l.subtitle}

{/* Card */} -
+
{/* Email */}
- + setEmail(e.target.value)} - className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" - placeholder="you@example.com" + className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder={l.emailPlaceholder} required autoFocus /> @@ -71,14 +181,14 @@ export function SetupPage() { {/* Password */}
- +
setPassword(e.target.value)} - className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" - placeholder="At least 8 characters" + className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder={l.passwordPlaceholder} required />

- Single-user system — this is the only account + {l.singleUser}

- + + +
) } diff --git a/web/src/pages/BeginnerOnboardingPage.tsx b/web/src/pages/BeginnerOnboardingPage.tsx index a133ced2cf..17672eb04a 100644 --- a/web/src/pages/BeginnerOnboardingPage.tsx +++ b/web/src/pages/BeginnerOnboardingPage.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { Copy, Eye, EyeOff, RefreshCw, Shield, Wallet, Sparkles } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { Copy, Eye, EyeOff, RefreshCw, Shield, Wallet } from 'lucide-react' import { QRCodeSVG } from 'qrcode.react' import { toast } from 'sonner' import { DeepVoidBackground } from '../components/common/DeepVoidBackground' @@ -13,64 +13,33 @@ export function BeginnerOnboardingPage() { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState('') - const [showPrivateKey, setShowPrivateKey] = useState(false) + const [showPrivateKey, setShowPrivateKey] = useState(true) const [refreshingBalance, setRefreshingBalance] = useState(false) const hasRequestedRef = useRef(false) const isZh = language === 'zh' const loadOnboarding = async (showLoading: boolean) => { - if (showLoading) { - setLoading(true) - } else { - setRefreshingBalance(true) - } - + if (showLoading) setLoading(true) + else setRefreshingBalance(true) setError('') try { const result = await api.prepareBeginnerOnboarding() setData(result) setBeginnerWalletAddress(result.address) } catch (err) { - setError( - err instanceof Error - ? err.message - : isZh - ? '新手钱包准备失败' - : 'Failed to prepare beginner wallet' - ) + setError(err instanceof Error ? err.message : isZh ? '新手钱包准备失败' : 'Failed to prepare beginner wallet') } finally { - if (showLoading) { - setLoading(false) - } else { - setRefreshingBalance(false) - } + if (showLoading) setLoading(false) + else setRefreshingBalance(false) } } useEffect(() => { - if (hasRequestedRef.current) { - return - } + if (hasRequestedRef.current) return hasRequestedRef.current = true void loadOnboarding(true) }, []) - const hints = useMemo( - () => - isZh - ? [ - '这是你的专属 Base 钱包,只用于后续调用大模型。', - '请保存私钥。丢失后无法恢复。', - '只往这个地址充值 Base 链 USDC,不要充到别的链。', - ] - : [ - 'This dedicated Base wallet is only used to pay for model calls.', - 'Save the private key now. It cannot be recovered later.', - 'Deposit USDC on Base only. Do not send funds from another chain.', - ], - [isZh] - ) - const copyText = async (value: string, label: string) => { try { await navigator.clipboard.writeText(value) @@ -87,177 +56,131 @@ export function BeginnerOnboardingPage() { return ( -
-
-
-
-
- -
-
-
- {isZh ? '新手保护' : 'Beginner Guard'} -
-

- {isZh ? '钱包已经帮你准备好了' : 'Your wallet is ready'} -

-
+
+ {/* Header - compact */} +
+
+ +
+
+
+ {isZh ? '新手保护' : 'Beginner Guard'}
+

+ {isZh ? '钱包已经帮你准备好了' : 'Your wallet is ready'} +

+
+
+ Claw402 + DeepSeek · {isZh ? '按次付费' : 'Pay per call'} +
+
-

- {isZh - ? '我们已经为你生成了一个专属钱包,并默认接入 Claw402 + DeepSeek。你现在只需要保存私钥,然后往这个地址充值 Base 链 USDC,后面调用大模型时会自动从这里扣费。' - : 'We generated a dedicated wallet for you and preconfigured Claw402 + DeepSeek. Save the private key, then deposit Base USDC to this address so future model calls can be paid automatically.'} -

- -
- {hints.map((hint) => ( -
- -
{hint}
-
- ))} -
+ {error ? ( +
{error}
+ ) : null} -
-
- - {isZh ? '为什么要充值?' : 'Why fund this wallet?'} -
-

- {isZh - ? '这里只负责大模型调用费用,不会自动替你充值交易所。先充少量 USDC 就够了,通常 $5-$10 可以用很久。' - : 'This wallet only covers LLM usage costs. It does not fund your exchange automatically. A small amount of USDC is enough to get started, usually $5-$10.'} -

+ {/* Main card */} +
+ {loading ? ( +
+ {isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'}
- - {error ? ( -
- {error} -
- ) : null} -
- -
- {loading ? ( -
- {isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'} -
- ) : data ? ( -
-
-
- {isZh ? '默认模型' : 'Default Model'} -
-
Claw402 + DeepSeek
-
- {isZh ? '按次付费,无需 API Key' : 'Pay per call, no API key needed'} -
+ ) : data ? ( +
+ {/* Left: QR + Balance */} +
+
+
- -
-
- -
-
- {isZh ? '充值地址(Base 链 USDC)' : 'Deposit Address (Base USDC)'} -
-
- {data.address} +
+ {isZh ? '充值地址(Base USDC)' : 'Deposit (Base USDC)'} +
+
+
+
{data.balance_usdc} USDC
+
+
+ {isZh ? '$5-$10 可以用很久' : '$5-$10 lasts a long time'} +
+
-
-
-
-
- {isZh ? '当前余额' : 'Current Balance'} -
-
- {data.balance_usdc} USDC -
-
- {isZh ? 'Base 链钱包余额' : 'Base wallet balance'} -
-
- + {/* Right: Address + Key + Action */} +
+ {/* Address */} +
+
+ + {isZh ? '钱包地址' : 'Wallet Address'} +
+
+
+ {data.address}
+
-
-
-
-
- {isZh ? '钱包私钥' : 'Wallet Private Key'} -
-
- {isZh ? '请先备份,再进入下一步。' : 'Back this up before you continue.'} -
-
+ {/* Private Key */} +
+
+ + {isZh ? '私钥 — 请立即备份' : 'Private Key — back up now'}
-
- {showPrivateKey ? data.private_key : '0x' + '•'.repeat(64)} +
+
+ {showPrivateKey ? data.private_key : '0x' + '•'.repeat(64)} +
+
-
-
-
- {data.env_saved - ? isZh - ? `已同步保存到环境文件:${data.env_path || '.env'}` - : `Also saved to env: ${data.env_path || '.env'}` - : isZh - ? '当前运行环境没有成功写回 .env,但产品已完成默认配置。' - : 'The app is configured, but this runtime could not write back to .env.'} -
- {data.env_warning ?
{data.env_warning}
: null} + {/* Tips */} +
+ {isZh + ? '• 此钱包仅用于大模型调用费用,不会自动充值交易所 • 私钥丢失后无法恢复 • 只充 Base 链 USDC' + : '• This wallet only covers LLM costs, not exchange funding • Private key cannot be recovered • Base USDC only'}
+ {/* Continue */}
- ) : null} -
-
+
+ ) : null} +
) From 55db7473182b5ce3feaee5a4146140fed02f5f8c Mon Sep 17 00:00:00 2001 From: Zavier Date: Sat, 28 Mar 2026 16:09:04 +0800 Subject: [PATCH 20/26] feat: refine beginner wallet onboarding modal (#1438) Co-authored-by: Codex --- web/src/App.tsx | 15 +- web/src/pages/BeginnerOnboardingPage.tsx | 314 ++++++++++++++--------- 2 files changed, 205 insertions(+), 124 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 3173fb97f2..34fbef5c1d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -65,6 +65,7 @@ function App() { const path = window.location.pathname const hash = window.location.hash.slice(1) // 去掉 # + if (path === '/welcome') return 'traders' if (path === '/traders' || hash === 'traders') return 'traders' if (path === '/strategy' || hash === 'strategy') return 'strategy' if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market' @@ -157,7 +158,9 @@ function App() { const params = new URLSearchParams(window.location.search) const traderParam = params.get('trader') - if (path === '/traders' || hash === 'traders') { + if (path === '/welcome') { + setCurrentPage('traders') + } else if (path === '/traders' || hash === 'traders') { setCurrentPage('traders') } else if (path === '/strategy' || hash === 'strategy') { setCurrentPage('strategy') @@ -337,7 +340,9 @@ function App() { // Set current page based on route for consistent navigation state useEffect(() => { - if (route === '/competition') { + if (route === '/welcome') { + setCurrentPage('traders') + } else if (route === '/competition') { setCurrentPage('competition') } else if (route === '/traders') { setCurrentPage('traders') @@ -346,6 +351,9 @@ function App() { } }, [route]) + const showBeginnerOnboarding = + route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' + // Show loading spinner while checking auth or config if (isLoading || configLoading) { return ( @@ -391,7 +399,6 @@ function App() { window.location.href = '/traders' return null } - return } if (route === '/faq') { return ( @@ -695,6 +702,8 @@ function App() { onClose={() => setLoginOverlayOpen(false)} featureName={loginOverlayFeature} /> + + {showBeginnerOnboarding && }
) } diff --git a/web/src/pages/BeginnerOnboardingPage.tsx b/web/src/pages/BeginnerOnboardingPage.tsx index 17672eb04a..a961f631a0 100644 --- a/web/src/pages/BeginnerOnboardingPage.tsx +++ b/web/src/pages/BeginnerOnboardingPage.tsx @@ -1,8 +1,13 @@ -import { useEffect, useRef, useState } from 'react' -import { Copy, Eye, EyeOff, RefreshCw, Shield, Wallet } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ArrowRight, + Copy, + RefreshCw, + Shield, + Wallet, +} from 'lucide-react' import { QRCodeSVG } from 'qrcode.react' import { toast } from 'sonner' -import { DeepVoidBackground } from '../components/common/DeepVoidBackground' import { useLanguage } from '../contexts/LanguageContext' import { api } from '../lib/api' import type { BeginnerOnboardingResponse } from '../types' @@ -13,33 +18,55 @@ export function BeginnerOnboardingPage() { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState('') - const [showPrivateKey, setShowPrivateKey] = useState(true) const [refreshingBalance, setRefreshingBalance] = useState(false) const hasRequestedRef = useRef(false) const isZh = language === 'zh' const loadOnboarding = async (showLoading: boolean) => { - if (showLoading) setLoading(true) - else setRefreshingBalance(true) + if (showLoading) { + setLoading(true) + } else { + setRefreshingBalance(true) + } + setError('') try { const result = await api.prepareBeginnerOnboarding() setData(result) setBeginnerWalletAddress(result.address) } catch (err) { - setError(err instanceof Error ? err.message : isZh ? '新手钱包准备失败' : 'Failed to prepare beginner wallet') + setError( + err instanceof Error + ? err.message + : isZh + ? '新手钱包准备失败' + : 'Failed to prepare beginner wallet' + ) } finally { - if (showLoading) setLoading(false) - else setRefreshingBalance(false) + if (showLoading) { + setLoading(false) + } else { + setRefreshingBalance(false) + } } } useEffect(() => { - if (hasRequestedRef.current) return + if (hasRequestedRef.current) { + return + } hasRequestedRef.current = true void loadOnboarding(true) }, []) + const noticeText = useMemo( + () => + isZh + ? '此钱包仅用于大模型调用费用,不会自动充到交易所。私钥丢失后无法恢复,只充 Base 链 USDC。' + : 'This wallet only pays for model calls. It does not fund your exchange automatically. The private key cannot be recovered, and you should only deposit Base USDC.', + [isZh] + ) + const copyText = async (value: string, label: string) => { try { await navigator.clipboard.writeText(value) @@ -55,133 +82,178 @@ export function BeginnerOnboardingPage() { } return ( - -
- {/* Header - compact */} -
-
- -
-
-
- {isZh ? '新手保护' : 'Beginner Guard'} +
+
+
+
+
+
+
+ +
+
+
+ {isZh ? '新手保护' : 'Beginner Guard'} +
+

+ {isZh ? '钱包已经帮你准备好了' : 'Your wallet is ready'} +

+
-

- {isZh ? '钱包已经帮你准备好了' : 'Your wallet is ready'} -

-
-
- Claw402 + DeepSeek · {isZh ? '按次付费' : 'Pay per call'} -
-
- {error ? ( -
{error}
- ) : null} - - {/* Main card */} -
- {loading ? ( -
- {isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'} +
+ Claw402 + DeepSeek · + {isZh ? '按次付费' : 'Pay per call'}
- ) : data ? ( -
- {/* Left: QR + Balance */} -
-
- -
-
- {isZh ? '充值地址(Base USDC)' : 'Deposit (Base USDC)'} -
-
-
-
{data.balance_usdc} USDC
-
- -
-
- {isZh ? '$5-$10 可以用很久' : '$5-$10 lasts a long time'} -
+
+ +
+ {loading ? ( +
+ {isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'}
+ ) : data ? ( +
+
+
+
+ +
- {/* Right: Address + Key + Action */} -
- {/* Address */} -
-
- - {isZh ? '钱包地址' : 'Wallet Address'} -
-
-
- {data.address} +
+ {isZh ? '充值地址(Base USDC)' : 'Deposit address (Base USDC)'} +
+ +
+
+
+ {data.balance_usdc} + USDC +
+
+ +
+ +
+ {isZh ? '$5-$10 可以用很久' : '$5-$10 usually lasts a long time'}
-
-
+
- {/* Private Key */} -
-
- - {isZh ? '私钥 — 请立即备份' : 'Private Key — back up now'} - +
+
+ +
+
+ + {isZh ? '私钥,请立即备份' : 'Private key, back it up now'} +
+
+
+
{data.private_key}
+
+
+ +
+
+
+ +
- {showPrivateKey ? : } - -
-
-
- {showPrivateKey ? data.private_key : '0x' + '•'.repeat(64)} + + {noticeText}
+ + {data.env_warning ? ( +
+ {data.env_warning} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + -
-
- {/* Tips */} -
- {isZh - ? '• 此钱包仅用于大模型调用费用,不会自动充值交易所 • 私钥丢失后无法恢复 • 只充 Base 链 USDC' - : '• This wallet only covers LLM costs, not exchange funding • Private key cannot be recovered • Base USDC only'} -
- - {/* Continue */} - + {data.env_saved ? ( +
+ {isZh + ? `钱包信息已同步保存到 ${data.env_path || '.env'}` + : `Wallet details were also saved to ${data.env_path || '.env'}`} +
+ ) : null} +
+
-
- ) : null} - + ) : null} +
+
- +
) } From fb0bd13f51ebc5e6ab78401f19de83e591202119 Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Mon, 30 Mar 2026 14:02:50 +0800 Subject: [PATCH 21/26] fix: division by zero guard, logout redirect, onboarding close button - auto_trader_risk: skip drawdown check when entryPrice <= 0 - AuthContext: redirect to / on logout - App.tsx: simplify data page navigation - BeginnerOnboardingPage: add close button to overlay --- trader/auto_trader_risk.go | 6 ++++++ web/src/App.tsx | 14 +------------- web/src/contexts/AuthContext.tsx | 2 ++ web/src/pages/BeginnerOnboardingPage.tsx | 9 +++++++++ 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/trader/auto_trader_risk.go b/trader/auto_trader_risk.go index b8c94fb36d..937b1839d1 100644 --- a/trader/auto_trader_risk.go +++ b/trader/auto_trader_risk.go @@ -49,6 +49,12 @@ func (at *AutoTrader) checkPositionDrawdown() { quantity = -quantity // Short position quantity is negative, convert to positive } + // Guard: skip if entry price is zero (prevents division by zero panic) + if entryPrice <= 0 { + logger.Warnf("⚠️ Drawdown monitoring: %s %s has zero entry price, skipping", symbol, side) + continue + } + // Calculate current P&L percentage leverage := 10 // Default value if lev, ok := pos["leverage"].(float64); ok { diff --git a/web/src/App.tsx b/web/src/App.tsx index 34fbef5c1d..df4afab21b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -451,19 +451,7 @@ function App() { // Data page - publicly accessible with embedded dashboard if (route === '/data') { const dataPageNavigate = (page: Page) => { - const pathMap: Record = { - 'data': '/data', - 'competition': '/competition', - 'strategy-market': '/strategy-market', - 'traders': '/traders', - 'trader': '/dashboard', - 'strategy': '/strategy', - 'faq': '/faq', - } - const path = pathMap[page] - if (path) { - window.location.href = path - } + navigateToPage(page) } return (
+
From 1d6e99c74acfc055f9fbfb5e204bd94055b04969 Mon Sep 17 00:00:00 2001 From: deanokk Date: Mon, 30 Mar 2026 21:04:43 +0800 Subject: [PATCH 22/26] feat(beginner): protect default AI model and prevent repeated onboarding (#1444) Co-authored-by: Dean --- web/src/App.tsx | 4 +- .../components/trader/BeginnerGuideCards.tsx | 60 ++++++++++++++----- .../components/trader/ModelConfigModal.tsx | 5 +- web/src/lib/onboarding.ts | 9 +++ web/src/pages/BeginnerOnboardingPage.tsx | 3 +- 5 files changed, 61 insertions(+), 20 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index df4afab21b..af5d884d81 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,7 +23,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext' import { ConfirmDialogProvider } from './components/common/ConfirmDialog' import { t } from './i18n/translations' import { useSystemConfig } from './hooks/useSystemConfig' -import { getUserMode } from './lib/onboarding' +import { getUserMode, hasCompletedBeginnerOnboarding } from './lib/onboarding' import { OFFICIAL_LINKS } from './constants/branding' import type { @@ -352,7 +352,7 @@ function App() { }, [route]) const showBeginnerOnboarding = - route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' + route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' && !hasCompletedBeginnerOnboarding() // Show loading spinner while checking auth or config if (isLoading || configLoading) { diff --git a/web/src/components/trader/BeginnerGuideCards.tsx b/web/src/components/trader/BeginnerGuideCards.tsx index cf6e06c0dc..26f00f6df1 100644 --- a/web/src/components/trader/BeginnerGuideCards.tsx +++ b/web/src/components/trader/BeginnerGuideCards.tsx @@ -49,8 +49,12 @@ export function BeginnerGuideCards({ : 'Pay per call with Base USDC', ready: claw402Ready, actionLabel: claw402Ready - ? isZh ? '已配置' : 'Configured' - : isZh ? '一键配置' : 'One-click setup', + ? isZh + ? '已配置' + : 'Configured' + : isZh + ? '一键配置' + : 'One-click setup', onAction: onQuickSetupClaw402, disabled: claw402Ready, }, @@ -62,12 +66,20 @@ export function BeginnerGuideCards({ ? '交易所接好以后,AI 才能真正下单。' : 'Connect an exchange so the AI can actually place trades.', meta: exchangeReady - ? isZh ? '已准备好' : 'Ready' - : isZh ? 'Binance / OKX / Bybit / Hyperliquid' : 'Binance / OKX / Bybit / Hyperliquid', + ? isZh + ? '已准备好' + : 'Ready' + : isZh + ? 'Binance / OKX / Bybit / Hyperliquid' + : 'Binance / OKX / Bybit / Hyperliquid', ready: exchangeReady, actionLabel: exchangeReady - ? isZh ? '继续管理' : 'Manage' - : isZh ? '去配置' : 'Configure', + ? isZh + ? '继续管理' + : 'Manage' + : isZh + ? '去配置' + : 'Configure', onAction: onOpenExchange, disabled: false, }, @@ -79,8 +91,12 @@ export function BeginnerGuideCards({ ? '先用默认策略也可以,后面再慢慢细调。' : 'You can start with a default strategy and fine-tune later.', meta: strategyReady - ? isZh ? '已有策略可用' : 'Strategy ready' - : isZh ? '可选,但建议提前看一眼' : 'Optional, but worth a quick look', + ? isZh + ? '已有策略可用' + : 'Strategy ready' + : isZh + ? '可选,但建议提前看一眼' + : 'Optional, but worth a quick look', ready: strategyReady, actionLabel: isZh ? '打开策略页' : 'Open strategy', onAction: onOpenStrategy, @@ -94,8 +110,12 @@ export function BeginnerGuideCards({ ? '最后一步,把模型和交易所绑在一起,就能开始运行。' : 'Last step: bind your model and exchange, then start running.', meta: canCreateTrader - ? isZh ? '已经可以创建' : 'Ready to create' - : isZh ? '先完成前两步' : 'Finish the first two steps first', + ? isZh + ? '已经可以创建' + : 'Ready to create' + : isZh + ? '先完成前两步' + : 'Finish the first two steps first', ready: canCreateTrader, actionLabel: isZh ? '立即创建' : 'Create now', onAction: onCreateTrader, @@ -111,12 +131,14 @@ export function BeginnerGuideCards({ {isZh ? '新手引导' : 'Quickstart'}

- {isZh ? '先按这 4 步走,最快上手' : 'Follow these 4 steps to get started fast'} + {isZh + ? '先按这 4 步走,最快上手' + : 'Follow these 4 steps to get started fast'}

-
+ {/*
{isZh ? '老手模式不会看到这块' : 'Hidden in advanced mode'} -
+
*/}
@@ -138,11 +160,19 @@ export function BeginnerGuideCards({ : 'bg-zinc-800 text-zinc-400' }`} > - {card.ready ? (isZh ? '已就绪' : 'Ready') : (isZh ? '待完成' : 'Pending')} + {card.ready + ? isZh + ? '已就绪' + : 'Ready' + : isZh + ? '待完成' + : 'Pending'}
-

{card.title}

+

+ {card.title} +

{card.desc}

diff --git a/web/src/components/trader/ModelConfigModal.tsx b/web/src/components/trader/ModelConfigModal.tsx index 5124e69eb3..51fa370a35 100644 --- a/web/src/components/trader/ModelConfigModal.tsx +++ b/web/src/components/trader/ModelConfigModal.tsx @@ -13,7 +13,7 @@ import { AI_PROVIDER_CONFIG, getShortName, } from './model-constants' -import { getBeginnerWalletAddress } from '../../lib/onboarding' +import { getBeginnerWalletAddress, getUserMode } from '../../lib/onboarding' interface ModelConfigModalProps { allModels: AIModel[] @@ -84,6 +84,7 @@ export function ModelConfigModal({ const availableModels = allModels || [] const configuredIds = new Set(configuredModels?.map(m => m.id) || []) const isClaw402Selected = selectedModel?.provider === 'claw402' || selectedModel?.id === 'claw402' + const isBeginnerDefaultModel = isClaw402Selected && getUserMode() === 'beginner' const stepLabels = [ t('modelConfig.selectModel', language), t( @@ -117,7 +118,7 @@ export function ModelConfigModal({
- {editingModelId && ( + {editingModelId && !isBeginnerDefaultModel && ( + ))} +
+ ) +} diff --git a/web/src/components/modals/SetupPage.tsx b/web/src/components/modals/SetupPage.tsx index 19d9635fec..45c22fb58a 100644 --- a/web/src/components/modals/SetupPage.tsx +++ b/web/src/components/modals/SetupPage.tsx @@ -1,10 +1,11 @@ -import React, { useState } from 'react' -import { Eye, EyeOff, Globe } from 'lucide-react' +import React, { useState, useEffect } from 'react' +import { Eye, EyeOff } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' import { invalidateSystemConfig } from '../../lib/config' import { OnboardingModeSelector } from '../auth/OnboardingModeSelector' import type { UserMode } from '../../lib/onboarding' import { useLanguage } from '../../contexts/LanguageContext' +import { LanguageSwitcher } from '../common/LanguageSwitcher' import type { Language } from '../../i18n/translations' const labels = { @@ -49,14 +50,8 @@ const labels = { }, } as const -const langOptions: { value: Language; label: string }[] = [ - { value: 'en', label: 'English' }, - { value: 'zh', label: '中文' }, - { value: 'id', label: 'Bahasa' }, -] - export function SetupPage() { - const { language, setLanguage } = useLanguage() + const { language } = useLanguage() const { register } = useAuth() const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -65,6 +60,15 @@ export function SetupPage() { const [loading, setLoading] = useState(false) const [mode, setMode] = useState('beginner') + // Clean up any stale auth/onboarding state on setup page load + useEffect(() => { + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + localStorage.removeItem('user_id') + localStorage.removeItem('nofx_beginner_onboarding_completed') + localStorage.removeItem('nofx_beginner_wallet_address') + }, []) + const l = labels[language as keyof typeof labels] || labels.en const handleSubmit = async (e: React.FormEvent) => { @@ -124,26 +128,7 @@ export function SetupPage() { {/* Blur overlay */}
- {/* Language switcher */} -
-
- - {langOptions.map((opt) => ( - - ))} -
-
+ {/* Modal card */}
diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index fe62c49faa..af2bc2f306 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react' import { flushSync } from 'react-dom' -import { getSystemConfig } from '../lib/config' +import { getSystemConfig, invalidateSystemConfig } from '../lib/config' import { reset401Flag, httpClient } from '../lib/httpClient' import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding' import { useLanguage } from './LanguageContext' @@ -225,6 +225,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }>('/api/register', requestBody) if (result.success && result.data) { + // Clear stale onboarding state so new users always see the welcome flow + localStorage.removeItem('nofx_beginner_onboarding_completed') + localStorage.removeItem('nofx_beginner_wallet_address') + const userInfo = { id: result.data.user_id, email: result.data.email } handlePostAuthSuccess(result.data.token, userInfo, mode) @@ -290,6 +294,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setToken(null) localStorage.removeItem('auth_token') localStorage.removeItem('auth_user') + invalidateSystemConfig() window.history.pushState({}, '', '/') window.dispatchEvent(new PopStateEvent('popstate')) } From d250aed26a1e8f4b9ea24c18ca80869c2ebab2dd Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Tue, 31 Mar 2026 16:12:01 +0800 Subject: [PATCH 24/26] fix: auto re-fetch system config after invalidation - invalidateSystemConfig() now dispatches a custom event - useSystemConfig() listens for the event and re-fetches automatically - Fixes stale initialized=false after register/logout causing incorrect redirect to SetupPage --- web/src/hooks/useSystemConfig.ts | 19 ++++++++++++++++--- web/src/lib/config.ts | 1 + 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/web/src/hooks/useSystemConfig.ts b/web/src/hooks/useSystemConfig.ts index 4c33620bce..237089b2e3 100644 --- a/web/src/hooks/useSystemConfig.ts +++ b/web/src/hooks/useSystemConfig.ts @@ -1,13 +1,15 @@ -import { useEffect, useState } from 'react' -import { getSystemConfig, type SystemConfig } from '../lib/config' +import { useEffect, useState, useCallback } from 'react' +import { getSystemConfig, invalidateSystemConfig, type SystemConfig } from '../lib/config' export function useSystemConfig() { const [config, setConfig] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [fetchKey, setFetchKey] = useState(0) useEffect(() => { let mounted = true + setLoading(true) getSystemConfig() .then((data) => { if (!mounted) return @@ -23,7 +25,18 @@ export function useSystemConfig() { return () => { mounted = false } + }, [fetchKey]) + + // Listen for invalidation events and re-fetch automatically + useEffect(() => { + const handler = () => setFetchKey((k) => k + 1) + window.addEventListener('system-config-invalidated', handler) + return () => window.removeEventListener('system-config-invalidated', handler) + }, []) + + const refresh = useCallback(() => { + invalidateSystemConfig() }, []) - return { config, loading, error } + return { config, loading, error, refresh } } diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 54d138e08b..ed4b2c67ac 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -26,4 +26,5 @@ export function getSystemConfig(): Promise { export function invalidateSystemConfig() { cachedConfig = null configPromise = null + window.dispatchEvent(new Event('system-config-invalidated')) } From 287280857b251a0c35335003522bd423c972b391 Mon Sep 17 00:00:00 2001 From: Zavier Date: Tue, 31 Mar 2026 20:40:12 +0800 Subject: [PATCH 25/26] perf: reduce frontend login and dashboard friction (#1447) Co-authored-by: apple --- web/src/App.tsx | 5 --- web/src/components/charts/ChartTabs.tsx | 3 -- .../components/common/DeepVoidBackground.tsx | 44 +++++++++++-------- .../components/common/LanguageSwitcher.tsx | 1 - web/src/components/modals/SetupPage.tsx | 1 - web/src/components/trader/AITradersPage.tsx | 5 --- 6 files changed, 26 insertions(+), 33 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index af5d884d81..b53bcc0c08 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -55,11 +55,6 @@ function App() { const { config: systemConfig, loading: configLoading } = useSystemConfig() const [route, setRoute] = useState(window.location.pathname) - // Debug log - useEffect(() => { - console.log('[App] Mounted. Route:', window.location.pathname); - }, []); - // 从URL路径读取初始页面状态(支持刷新保持页面) const getInitialPage = (): Page => { const path = window.location.pathname diff --git a/web/src/components/charts/ChartTabs.tsx b/web/src/components/charts/ChartTabs.tsx index 6692398564..d77e8c13a0 100644 --- a/web/src/components/charts/ChartTabs.tsx +++ b/web/src/components/charts/ChartTabs.tsx @@ -123,7 +123,6 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C // Auto-switch to kline chart when symbol selected externally useEffect(() => { if (selectedSymbol) { - console.log('[ChartTabs] Symbol selected:', selectedSymbol, 'updateKey:', updateKey) setChartSymbol(selectedSymbol) setActiveTab('kline') } @@ -143,8 +142,6 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C } } - console.log('[ChartTabs] rendering, activeTab:', activeTab) - return (
diff --git a/web/src/components/common/DeepVoidBackground.tsx b/web/src/components/common/DeepVoidBackground.tsx index 6ab2357a8f..bb29f06e67 100644 --- a/web/src/components/common/DeepVoidBackground.tsx +++ b/web/src/components/common/DeepVoidBackground.tsx @@ -9,27 +9,35 @@ interface DeepVoidBackgroundProps extends React.HTMLAttributes { export function DeepVoidBackground({ children, className = '', disableAnimation = false, ...props }: DeepVoidBackgroundProps) { return (
- {/* BACKGROUND LAYERS */} + {/* Background layers: use a much lighter static stack when animations are disabled */} + {disableAnimation ? ( + <> +
+
+ + ) : ( + <> + {/* 1. Grain/Noise Texture */} +
- {/* 1. Grain/Noise Texture */} -
+ {/* 2. Grid System */} +
+
+
+
- {/* 2. Grid System */} -
-
-
-
- - {/* 3. Ambient Glow Spots */} -
-
-
-
+ {/* 3. Ambient Glow Spots */} +
+
+
+
- {/* 4. CRT/Scanline Overlay */} -
-
-
+ {/* 4. CRT/Scanline Overlay */} +
+
+
+ + )} {/* Content Layer */}
diff --git a/web/src/components/common/LanguageSwitcher.tsx b/web/src/components/common/LanguageSwitcher.tsx index 28dec7c08c..12070979b8 100644 --- a/web/src/components/common/LanguageSwitcher.tsx +++ b/web/src/components/common/LanguageSwitcher.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { Globe } from 'lucide-react' import { useLanguage } from '../../contexts/LanguageContext' import type { Language } from '../../i18n/translations' diff --git a/web/src/components/modals/SetupPage.tsx b/web/src/components/modals/SetupPage.tsx index 45c22fb58a..724974a287 100644 --- a/web/src/components/modals/SetupPage.tsx +++ b/web/src/components/modals/SetupPage.tsx @@ -6,7 +6,6 @@ import { OnboardingModeSelector } from '../auth/OnboardingModeSelector' import type { UserMode } from '../../lib/onboarding' import { useLanguage } from '../../contexts/LanguageContext' import { LanguageSwitcher } from '../common/LanguageSwitcher' -import type { Language } from '../../i18n/translations' const labels = { zh: { diff --git a/web/src/components/trader/AITradersPage.tsx b/web/src/components/trader/AITradersPage.tsx index 84bac72fdd..de26058cfd 100644 --- a/web/src/components/trader/AITradersPage.tsx +++ b/web/src/components/trader/AITradersPage.tsx @@ -259,7 +259,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } const handleSaveEditTrader = async (data: CreateTraderRequest) => { - console.log('🔥🔥🔥 handleSaveEditTrader CALLED with data:', data) if (!editingTrader) return try { @@ -287,10 +286,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { show_in_competition: data.show_in_competition, } - console.log('🔥 handleSaveEditTrader - data:', data) - console.log('🔥 handleSaveEditTrader - data.strategy_id:', data.strategy_id) - console.log('🔥 handleSaveEditTrader - request:', request) - await api.updateTrader(editingTrader.trader_id, request) toast.success(t('aiTradersToast.saved', language)) setShowEditModal(false) From 99375420202fd933b8d02cb15e98e9a5195def1e Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Tue, 31 Mar 2026 22:16:46 +0800 Subject: [PATCH 26/26] docs: add MiniMax to AI models and beginner mode to setup across all i18n READMEs Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 7 ++++++- docs/i18n/ja/README.md | 3 ++- docs/i18n/ko/README.md | 3 ++- docs/i18n/ru/README.md | 3 ++- docs/i18n/uk/README.md | 3 ++- docs/i18n/vi/README.md | 3 ++- docs/i18n/zh-CN/README.md | 7 ++++++- 7 files changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 534593b1a5..313dd21f79 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ No accounts. No API keys. No prepaid credits. One wallet, every model. | Feature | Description | |:--------|:------------| -| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — switch anytime | +| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — switch anytime | | **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **Strategy Studio** | Visual builder — coin sources, indicators, risk controls | | **AI Competition** | AIs compete in real-time, leaderboard ranks performance | @@ -110,6 +110,7 @@ Crypto · US Stocks · Forex · Metals | **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) | | **Grok** | ✅ | [Get API Key](https://console.x.ai) | | **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [Get API Key](https://platform.minimaxi.com) | ### AI Models (x402 Mode — No API Key) @@ -211,6 +212,10 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas ## Setup +**Beginner mode**: First-time users get a guided onboarding flow — select beginner mode at registration and the system walks you through AI, exchange, and strategy setup step by step. + +**Advanced mode**: + 1. **AI** — Add API keys or configure x402 wallet 2. **Exchange** — Connect exchange API credentials 3. **Strategy** — Build in Strategy Studio diff --git a/docs/i18n/ja/README.md b/docs/i18n/ja/README.md index ab02527a65..763fa7b21f 100644 --- a/docs/i18n/ja/README.md +++ b/docs/i18n/ja/README.md @@ -69,7 +69,7 @@ x402 フロー: | 機能 | 説明 | |:--------|:------------| -| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — いつでも切替 | +| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — いつでも切替 | | **マルチ取引所** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 | | **AI ディベートアリーナ** | 複数 AI が取引を議論(ブル vs ベア vs アナリスト)、投票、実行 | @@ -112,6 +112,7 @@ x402 フロー: | **Gemini** | ✅ | [API キー取得](https://aistudio.google.com) | | **Grok** | ✅ | [API キー取得](https://console.x.ai) | | **Kimi** | ✅ | [API キー取得](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [API キー取得](https://platform.minimaxi.com) | ### AI モデル (x402 モード — API キー不要) diff --git a/docs/i18n/ko/README.md b/docs/i18n/ko/README.md index fd66d8e3cc..41b3069777 100644 --- a/docs/i18n/ko/README.md +++ b/docs/i18n/ko/README.md @@ -69,7 +69,7 @@ x402 플로우: | 기능 | 설명 | |:--------|:------------| -| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — 언제든 전환 | +| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — 언제든 전환 | | **멀티 거래소** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 | | **AI 토론 아레나** | 여러 AI가 거래 토론 (강세 vs 약세 vs 분석가), 투표, 실행 | @@ -112,6 +112,7 @@ x402 플로우: | **Gemini** | ✅ | [API 키 받기](https://aistudio.google.com) | | **Grok** | ✅ | [API 키 받기](https://console.x.ai) | | **Kimi** | ✅ | [API 키 받기](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [API 키 받기](https://platform.minimaxi.com) | ### AI 모델 (x402 모드 — API 키 불필요) diff --git a/docs/i18n/ru/README.md b/docs/i18n/ru/README.md index 2dc6363ebe..bded307f8e 100644 --- a/docs/i18n/ru/README.md +++ b/docs/i18n/ru/README.md @@ -69,7 +69,7 @@ x402 процесс: | Функция | Описание | |:--------|:------------| -| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключение в любой момент | +| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — переключение в любой момент | | **Мульти-биржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков | | **AI Арена дебатов** | Несколько AI обсуждают сделки (Бык vs Медведь vs Аналитик), голосуют, исполняют | @@ -112,6 +112,7 @@ x402 процесс: | **Gemini** | ✅ | [Получить](https://aistudio.google.com) | | **Grok** | ✅ | [Получить](https://console.x.ai) | | **Kimi** | ✅ | [Получить](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [Получить](https://platform.minimaxi.com) | ### AI Модели (Режим x402 — без API ключей) diff --git a/docs/i18n/uk/README.md b/docs/i18n/uk/README.md index 1bf1ef7aa6..30a8b2ce9d 100644 --- a/docs/i18n/uk/README.md +++ b/docs/i18n/uk/README.md @@ -69,7 +69,7 @@ x402 процес: | Функція | Опис | |:--------|:------------| -| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикання будь-коли | +| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — перемикання будь-коли | | **Мульти-біржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків | | **AI Арена дебатів** | Кілька AI обговорюють угоди, голосують, виконують | @@ -112,6 +112,7 @@ x402 процес: | **Gemini** | ✅ | [Отримати](https://aistudio.google.com) | | **Grok** | ✅ | [Отримати](https://console.x.ai) | | **Kimi** | ✅ | [Отримати](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [Отримати](https://platform.minimaxi.com) | ### AI Моделі (Режим x402 — без API ключів) diff --git a/docs/i18n/vi/README.md b/docs/i18n/vi/README.md index cd507a0877..e91e467986 100644 --- a/docs/i18n/vi/README.md +++ b/docs/i18n/vi/README.md @@ -69,7 +69,7 @@ Không tài khoản. Không API key. Không trả trước. Một ví, tất c | Tính năng | Mô tả | |:--------|:------------| -| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — chuyển đổi bất cứ lúc nào | +| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — chuyển đổi bất cứ lúc nào | | **Đa Sàn** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **Strategy Studio** | Trình xây dựng trực quan — nguồn coin, chỉ báo, kiểm soát rủi ro | | **AI Competition** | AI cạnh tranh thời gian thực, bảng xếp hạng hiệu suất | @@ -110,6 +110,7 @@ Crypto · Cổ phiếu Mỹ · Forex · Kim loại | **Gemini** | ✅ | [Lấy API Key](https://aistudio.google.com) | | **Grok** | ✅ | [Lấy API Key](https://console.x.ai) | | **Kimi** | ✅ | [Lấy API Key](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [Lấy API Key](https://platform.minimaxi.com) | ### Mô hình AI (Chế độ x402 — Không cần API Key) diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index e24b99ea33..eb224bebeb 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -71,7 +71,7 @@ x402 流程: | 功能 | 描述 | |:--------|:------------| -| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi — 随时切换 | +| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi、MiniMax — 随时切换 | | **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter | | **策略工作室** | 可视化构建器 — 币种来源、指标、风控 | | **AI 竞赛** | AI 实时竞争,排行榜排名 | @@ -113,6 +113,7 @@ x402 流程: | **Gemini** | ✅ | [获取 API Key](https://aistudio.google.com) | | **Grok** | ✅ | [获取 API Key](https://console.x.ai) | | **Kimi** | ✅ | [获取 API Key](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [获取 API Key](https://platform.minimaxi.com) | ### AI 模型 (x402 模式 — 无需 API Key) @@ -170,6 +171,10 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas ## 配置 +**新手模式**:首次使用的用户可以在注册时选择新手模式,系统会引导你逐步完成 AI、交易所和策略的配置。 + +**进阶模式**: + 1. **AI** — 添加 API Key 或配置 x402 钱包 2. **交易所** — 连接交易所 API 凭证 3. **策略** — 在策略工作室构建