diff --git a/api/server.go b/api/server.go index 5918e1c5..858aedce 100644 --- a/api/server.go +++ b/api/server.go @@ -1263,6 +1263,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) { {ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false}, {ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false}, {ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false}, + {ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false}, } c.JSON(http.StatusOK, defaultModels) return @@ -2305,10 +2306,15 @@ func (s *Server) initUserDefaultConfigs(userID string) error { // handleGetSupportedModels Get list of AI models supported by the system func (s *Server) handleGetSupportedModels(c *gin.Context) { - // Return static list of supported AI models + // Return static list of supported AI models with default versions supportedModels := []map[string]interface{}{ - {"id": "deepseek", "name": "DeepSeek", "provider": "deepseek"}, - {"id": "qwen", "name": "Qwen", "provider": "qwen"}, + {"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"}, + {"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"}, + {"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"}, + {"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-5-20251101"}, + {"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"}, } c.JSON(http.StatusOK, supportedModels) diff --git a/api/strategy.go b/api/strategy.go index f56b9204..3bd97f9b 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -544,6 +544,21 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string) case "deepseek": aiClient = mcp.NewDeepSeekClient() aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName) + case "claude": + aiClient = mcp.NewClaudeClient() + aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName) + case "kimi": + aiClient = mcp.NewKimiClient() + aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName) + case "gemini": + aiClient = mcp.NewGeminiClient() + aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName) + case "grok": + aiClient = mcp.NewGrokClient() + aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName) + case "openai": + aiClient = mcp.NewOpenAIClient() + aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName) default: // Use generic client aiClient = mcp.NewClient() diff --git a/backtest/ai_client.go b/backtest/ai_client.go index 302a86ed..4eeb0855 100644 --- a/backtest/ai_client.go +++ b/backtest/ai_client.go @@ -36,6 +36,41 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er qc := mcp.NewQwenClientWithOptions() qc.(*mcp.QwenClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model) return qc, nil + case "claude": + if cfg.AICfg.APIKey == "" { + return nil, fmt.Errorf("claude provider requires api key") + } + cc := mcp.NewClaudeClientWithOptions() + cc.(*mcp.ClaudeClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model) + return cc, nil + case "kimi": + if cfg.AICfg.APIKey == "" { + return nil, fmt.Errorf("kimi provider requires api key") + } + kc := mcp.NewKimiClientWithOptions() + kc.(*mcp.KimiClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model) + return kc, nil + case "gemini": + if cfg.AICfg.APIKey == "" { + return nil, fmt.Errorf("gemini provider requires api key") + } + gc := mcp.NewGeminiClientWithOptions() + gc.(*mcp.GeminiClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model) + return gc, nil + case "grok": + if cfg.AICfg.APIKey == "" { + return nil, fmt.Errorf("grok provider requires api key") + } + grokC := mcp.NewGrokClientWithOptions() + grokC.(*mcp.GrokClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model) + return grokC, nil + case "openai": + if cfg.AICfg.APIKey == "" { + return nil, fmt.Errorf("openai provider requires api key") + } + oaiC := mcp.NewOpenAIClientWithOptions() + oaiC.(*mcp.OpenAIClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model) + return oaiC, nil case "custom": if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" { return nil, fmt.Errorf("custom provider requires base_url, api key and model") @@ -65,6 +100,31 @@ func cloneBaseClient(base mcp.AIClient) *mcp.Client { cp := *c.Client return &cp } + case *mcp.ClaudeClient: + if c != nil && c.Client != nil { + cp := *c.Client + return &cp + } + case *mcp.KimiClient: + if c != nil && c.Client != nil { + cp := *c.Client + return &cp + } + case *mcp.GeminiClient: + if c != nil && c.Client != nil { + cp := *c.Client + return &cp + } + case *mcp.GrokClient: + if c != nil && c.Client != nil { + cp := *c.Client + return &cp + } + case *mcp.OpenAIClient: + if c != nil && c.Client != nil { + cp := *c.Client + return &cp + } } // Fall back to a new default client return mcp.NewClient().(*mcp.Client) diff --git a/mcp/claude_client.go b/mcp/claude_client.go new file mode 100644 index 00000000..06a9c55b --- /dev/null +++ b/mcp/claude_client.go @@ -0,0 +1,128 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "net/http" +) + +const ( + ProviderClaude = "claude" + DefaultClaudeBaseURL = "https://api.anthropic.com/v1" + DefaultClaudeModel = "claude-opus-4-5-20251101" +) + +type ClaudeClient struct { + *Client +} + +// NewClaudeClient creates Claude client (backward compatible) +func NewClaudeClient() AIClient { + return NewClaudeClientWithOptions() +} + +// NewClaudeClientWithOptions creates Claude client (supports options pattern) +func NewClaudeClientWithOptions(opts ...ClientOption) AIClient { + // 1. Create Claude preset options + claudeOpts := []ClientOption{ + WithProvider(ProviderClaude), + WithModel(DefaultClaudeModel), + WithBaseURL(DefaultClaudeBaseURL), + } + + // 2. Merge user options (user options have higher priority) + allOpts := append(claudeOpts, opts...) + + // 3. Create base client + baseClient := NewClient(allOpts...).(*Client) + + // 4. Create Claude client + claudeClient := &ClaudeClient{ + Client: baseClient, + } + + // 5. Set hooks to point to ClaudeClient (implement dynamic dispatch) + baseClient.hooks = claudeClient + + return claudeClient +} + +func (c *ClaudeClient) SetAPIKey(apiKey string, customURL string, customModel string) { + c.APIKey = apiKey + + if len(apiKey) > 8 { + c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:]) + } + if customURL != "" { + c.BaseURL = customURL + c.logger.Infof("🔧 [MCP] Claude using custom BaseURL: %s", customURL) + } else { + c.logger.Infof("🔧 [MCP] Claude using default BaseURL: %s", c.BaseURL) + } + if customModel != "" { + c.Model = customModel + c.logger.Infof("🔧 [MCP] Claude using custom Model: %s", customModel) + } else { + c.logger.Infof("🔧 [MCP] Claude using default Model: %s", c.Model) + } +} + +// setAuthHeader Claude uses x-api-key header instead of Authorization Bearer +func (c *ClaudeClient) setAuthHeader(reqHeaders http.Header) { + reqHeaders.Set("x-api-key", c.APIKey) + reqHeaders.Set("anthropic-version", "2023-06-01") +} + +// buildUrl Claude uses /messages endpoint +func (c *ClaudeClient) buildUrl() string { + return fmt.Sprintf("%s/messages", c.BaseURL) +} + +// buildMCPRequestBody Claude has different request format +func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any { + requestBody := map[string]any{ + "model": c.Model, + "max_tokens": c.MaxTokens, + "system": systemPrompt, + "messages": []map[string]string{ + {"role": "user", "content": userPrompt}, + }, + } + + return requestBody +} + +// parseMCPResponse Claude has different response format +func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) { + var response struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return "", fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body)) + } + + if response.Error != nil { + return "", fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message) + } + + if len(response.Content) == 0 { + return "", fmt.Errorf("Claude returned empty content, body: %s", string(body)) + } + + // Find text content + for _, content := range response.Content { + if content.Type == "text" { + return content.Text, nil + } + } + + return "", fmt.Errorf("no text content in Claude response") +} diff --git a/mcp/gemini_client.go b/mcp/gemini_client.go new file mode 100644 index 00000000..43b469a4 --- /dev/null +++ b/mcp/gemini_client.go @@ -0,0 +1,71 @@ +package mcp + +import ( + "net/http" +) + +const ( + ProviderGemini = "gemini" + DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai" + DefaultGeminiModel = "gemini-3-pro-preview" +) + +type GeminiClient struct { + *Client +} + +// NewGeminiClient creates Gemini client (backward compatible) +func NewGeminiClient() AIClient { + return NewGeminiClientWithOptions() +} + +// NewGeminiClientWithOptions creates Gemini client (supports options pattern) +func NewGeminiClientWithOptions(opts ...ClientOption) AIClient { + // 1. Create Gemini preset options + geminiOpts := []ClientOption{ + WithProvider(ProviderGemini), + WithModel(DefaultGeminiModel), + WithBaseURL(DefaultGeminiBaseURL), + } + + // 2. Merge user options (user options have higher priority) + allOpts := append(geminiOpts, opts...) + + // 3. Create base client + baseClient := NewClient(allOpts...).(*Client) + + // 4. Create Gemini client + geminiClient := &GeminiClient{ + Client: baseClient, + } + + // 5. Set hooks to point to GeminiClient (implement dynamic dispatch) + baseClient.hooks = geminiClient + + return geminiClient +} + +func (c *GeminiClient) SetAPIKey(apiKey string, customURL string, customModel string) { + c.APIKey = apiKey + + if len(apiKey) > 8 { + c.logger.Infof("🔧 [MCP] Gemini API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:]) + } + if customURL != "" { + c.BaseURL = customURL + c.logger.Infof("🔧 [MCP] Gemini using custom BaseURL: %s", customURL) + } else { + c.logger.Infof("🔧 [MCP] Gemini using default BaseURL: %s", c.BaseURL) + } + if customModel != "" { + c.Model = customModel + c.logger.Infof("🔧 [MCP] Gemini using custom Model: %s", customModel) + } else { + c.logger.Infof("🔧 [MCP] Gemini using default Model: %s", c.Model) + } +} + +// Gemini OpenAI-compatible API uses standard Bearer auth +func (c *GeminiClient) setAuthHeader(reqHeaders http.Header) { + c.Client.setAuthHeader(reqHeaders) +} diff --git a/mcp/grok_client.go b/mcp/grok_client.go new file mode 100644 index 00000000..c08be624 --- /dev/null +++ b/mcp/grok_client.go @@ -0,0 +1,71 @@ +package mcp + +import ( + "net/http" +) + +const ( + ProviderGrok = "grok" + DefaultGrokBaseURL = "https://api.x.ai/v1" + DefaultGrokModel = "grok-3-latest" +) + +type GrokClient struct { + *Client +} + +// NewGrokClient creates Grok client (backward compatible) +func NewGrokClient() AIClient { + return NewGrokClientWithOptions() +} + +// NewGrokClientWithOptions creates Grok client (supports options pattern) +func NewGrokClientWithOptions(opts ...ClientOption) AIClient { + // 1. Create Grok preset options + grokOpts := []ClientOption{ + WithProvider(ProviderGrok), + WithModel(DefaultGrokModel), + WithBaseURL(DefaultGrokBaseURL), + } + + // 2. Merge user options (user options have higher priority) + allOpts := append(grokOpts, opts...) + + // 3. Create base client + baseClient := NewClient(allOpts...).(*Client) + + // 4. Create Grok client + grokClient := &GrokClient{ + Client: baseClient, + } + + // 5. Set hooks to point to GrokClient (implement dynamic dispatch) + baseClient.hooks = grokClient + + return grokClient +} + +func (c *GrokClient) SetAPIKey(apiKey string, customURL string, customModel string) { + c.APIKey = apiKey + + if len(apiKey) > 8 { + c.logger.Infof("🔧 [MCP] Grok API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:]) + } + if customURL != "" { + c.BaseURL = customURL + c.logger.Infof("🔧 [MCP] Grok using custom BaseURL: %s", customURL) + } else { + c.logger.Infof("🔧 [MCP] Grok using default BaseURL: %s", c.BaseURL) + } + if customModel != "" { + c.Model = customModel + c.logger.Infof("🔧 [MCP] Grok using custom Model: %s", customModel) + } else { + c.logger.Infof("🔧 [MCP] Grok using default Model: %s", c.Model) + } +} + +// Grok uses standard OpenAI-compatible API with Bearer auth +func (c *GrokClient) setAuthHeader(reqHeaders http.Header) { + c.Client.setAuthHeader(reqHeaders) +} diff --git a/mcp/kimi_client.go b/mcp/kimi_client.go new file mode 100644 index 00000000..0337b10c --- /dev/null +++ b/mcp/kimi_client.go @@ -0,0 +1,71 @@ +package mcp + +import ( + "net/http" +) + +const ( + ProviderKimi = "kimi" + DefaultKimiBaseURL = "https://api.moonshot.ai/v1" // Global endpoint (use api.moonshot.cn for China) + DefaultKimiModel = "moonshot-v1-auto" +) + +type KimiClient struct { + *Client +} + +// NewKimiClient creates Kimi (Moonshot) client (backward compatible) +func NewKimiClient() AIClient { + return NewKimiClientWithOptions() +} + +// NewKimiClientWithOptions creates Kimi client (supports options pattern) +func NewKimiClientWithOptions(opts ...ClientOption) AIClient { + // 1. Create Kimi preset options + kimiOpts := []ClientOption{ + WithProvider(ProviderKimi), + WithModel(DefaultKimiModel), + WithBaseURL(DefaultKimiBaseURL), + } + + // 2. Merge user options (user options have higher priority) + allOpts := append(kimiOpts, opts...) + + // 3. Create base client + baseClient := NewClient(allOpts...).(*Client) + + // 4. Create Kimi client + kimiClient := &KimiClient{ + Client: baseClient, + } + + // 5. Set hooks to point to KimiClient (implement dynamic dispatch) + baseClient.hooks = kimiClient + + return kimiClient +} + +func (c *KimiClient) SetAPIKey(apiKey string, customURL string, customModel string) { + c.APIKey = apiKey + + if len(apiKey) > 8 { + c.logger.Infof("🔧 [MCP] Kimi API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:]) + } + if customURL != "" { + c.BaseURL = customURL + c.logger.Infof("🔧 [MCP] Kimi using custom BaseURL: %s", customURL) + } else { + c.logger.Infof("🔧 [MCP] Kimi using default BaseURL: %s", c.BaseURL) + } + if customModel != "" { + c.Model = customModel + c.logger.Infof("🔧 [MCP] Kimi using custom Model: %s", customModel) + } else { + c.logger.Infof("🔧 [MCP] Kimi using default Model: %s", c.Model) + } +} + +// Kimi uses standard OpenAI-compatible API, so we just use the base client methods +func (c *KimiClient) setAuthHeader(reqHeaders http.Header) { + c.Client.setAuthHeader(reqHeaders) +} diff --git a/mcp/openai_client.go b/mcp/openai_client.go new file mode 100644 index 00000000..22c81fa9 --- /dev/null +++ b/mcp/openai_client.go @@ -0,0 +1,71 @@ +package mcp + +import ( + "net/http" +) + +const ( + ProviderOpenAI = "openai" + DefaultOpenAIBaseURL = "https://api.openai.com/v1" + DefaultOpenAIModel = "gpt-5.1" +) + +type OpenAIClient struct { + *Client +} + +// NewOpenAIClient creates OpenAI client (backward compatible) +func NewOpenAIClient() AIClient { + return NewOpenAIClientWithOptions() +} + +// NewOpenAIClientWithOptions creates OpenAI client (supports options pattern) +func NewOpenAIClientWithOptions(opts ...ClientOption) AIClient { + // 1. Create OpenAI preset options + openaiOpts := []ClientOption{ + WithProvider(ProviderOpenAI), + WithModel(DefaultOpenAIModel), + WithBaseURL(DefaultOpenAIBaseURL), + } + + // 2. Merge user options (user options have higher priority) + allOpts := append(openaiOpts, opts...) + + // 3. Create base client + baseClient := NewClient(allOpts...).(*Client) + + // 4. Create OpenAI client + openaiClient := &OpenAIClient{ + Client: baseClient, + } + + // 5. Set hooks to point to OpenAIClient (implement dynamic dispatch) + baseClient.hooks = openaiClient + + return openaiClient +} + +func (c *OpenAIClient) SetAPIKey(apiKey string, customURL string, customModel string) { + c.APIKey = apiKey + + if len(apiKey) > 8 { + c.logger.Infof("🔧 [MCP] OpenAI API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:]) + } + if customURL != "" { + c.BaseURL = customURL + c.logger.Infof("🔧 [MCP] OpenAI using custom BaseURL: %s", customURL) + } else { + c.logger.Infof("🔧 [MCP] OpenAI using default BaseURL: %s", c.BaseURL) + } + if customModel != "" { + c.Model = customModel + c.logger.Infof("🔧 [MCP] OpenAI using custom Model: %s", customModel) + } else { + c.logger.Infof("🔧 [MCP] OpenAI using default Model: %s", c.Model) + } +} + +// OpenAI uses standard Bearer auth +func (c *OpenAIClient) setAuthHeader(reqHeaders http.Header) { + c.Client.setAuthHeader(reqHeaders) +} diff --git a/screenshots/config-ai-exchanges.png b/screenshots/config-ai-exchanges.png index e251c116..d77ff068 100644 Binary files a/screenshots/config-ai-exchanges.png and b/screenshots/config-ai-exchanges.png differ diff --git a/trader/auto_trader.go b/trader/auto_trader.go index df649787..07ce274a 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -135,31 +135,65 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au } } - mcpClient := mcp.New() + // Initialize AI client based on provider + var mcpClient mcp.AIClient + aiModel := config.AIModel + if config.UseQwen && aiModel == "" { + aiModel = "qwen" + } - // Initialize AI - if config.AIModel == "custom" { - // Use custom API + switch aiModel { + case "claude": + mcpClient = mcp.NewClaudeClient() + mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) + logger.Infof("🤖 [%s] Using Claude AI", config.Name) + + case "kimi": + mcpClient = mcp.NewKimiClient() + mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) + logger.Infof("🤖 [%s] Using Kimi (Moonshot) AI", config.Name) + + case "gemini": + mcpClient = mcp.NewGeminiClient() + mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) + logger.Infof("🤖 [%s] Using Google Gemini AI", config.Name) + + case "grok": + mcpClient = mcp.NewGrokClient() + mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) + logger.Infof("🤖 [%s] Using xAI Grok AI", config.Name) + + case "openai": + mcpClient = mcp.NewOpenAIClient() + mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) + logger.Infof("🤖 [%s] Using OpenAI", config.Name) + + case "qwen": + mcpClient = mcp.NewQwenClient() + apiKey := config.QwenKey + if apiKey == "" { + apiKey = config.CustomAPIKey + } + mcpClient.SetAPIKey(apiKey, config.CustomAPIURL, config.CustomModelName) + logger.Infof("🤖 [%s] Using Alibaba Cloud Qwen AI", config.Name) + + case "custom": + mcpClient = mcp.New() mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) logger.Infof("🤖 [%s] Using custom AI API: %s (model: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) - } else if config.UseQwen || config.AIModel == "qwen" { - // Use Qwen (supports custom URL and Model) - mcpClient = mcp.NewQwenClient() - mcpClient.SetAPIKey(config.QwenKey, config.CustomAPIURL, config.CustomModelName) - if config.CustomAPIURL != "" || config.CustomModelName != "" { - logger.Infof("🤖 [%s] Using Alibaba Cloud Qwen AI (custom URL: %s, model: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) - } else { - logger.Infof("🤖 [%s] Using Alibaba Cloud Qwen AI", config.Name) - } - } else { - // Default to DeepSeek (supports custom URL and Model) + + default: // deepseek or empty mcpClient = mcp.NewDeepSeekClient() - mcpClient.SetAPIKey(config.DeepSeekKey, config.CustomAPIURL, config.CustomModelName) - if config.CustomAPIURL != "" || config.CustomModelName != "" { - logger.Infof("🤖 [%s] Using DeepSeek AI (custom URL: %s, model: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) - } else { - logger.Infof("🤖 [%s] Using DeepSeek AI", config.Name) + apiKey := config.DeepSeekKey + if apiKey == "" { + apiKey = config.CustomAPIKey } + mcpClient.SetAPIKey(apiKey, config.CustomAPIURL, config.CustomModelName) + logger.Infof("🤖 [%s] Using DeepSeek AI", config.Name) + } + + if config.CustomAPIURL != "" || config.CustomModelName != "" { + logger.Infof("🔧 [%s] Custom config - URL: %s, Model: %s", config.Name, config.CustomAPIURL, config.CustomModelName) } // Set default trading platform diff --git a/web/public/icons/claude.svg b/web/public/icons/claude.svg new file mode 100644 index 00000000..89cc9316 --- /dev/null +++ b/web/public/icons/claude.svg @@ -0,0 +1,4 @@ + + Claude + + diff --git a/web/public/icons/gemini.svg b/web/public/icons/gemini.svg new file mode 100644 index 00000000..facf1a4a --- /dev/null +++ b/web/public/icons/gemini.svg @@ -0,0 +1,4 @@ + + Google Gemini + + diff --git a/web/public/icons/grok.svg b/web/public/icons/grok.svg new file mode 100644 index 00000000..53fce3a1 --- /dev/null +++ b/web/public/icons/grok.svg @@ -0,0 +1,4 @@ + + Grok + + diff --git a/web/public/icons/kimi.svg b/web/public/icons/kimi.svg new file mode 100644 index 00000000..e418461b --- /dev/null +++ b/web/public/icons/kimi.svg @@ -0,0 +1,5 @@ + + Kimi + + + diff --git a/web/public/icons/openai.svg b/web/public/icons/openai.svg new file mode 100644 index 00000000..12d6634e --- /dev/null +++ b/web/public/icons/openai.svg @@ -0,0 +1,4 @@ + + OpenAI + + diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index 86e59ac4..732093f8 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -27,6 +27,7 @@ import { Pencil, Eye, EyeOff, + ExternalLink, } from 'lucide-react' import { confirmToast } from '../lib/notify' import { toast } from 'sonner' @@ -51,6 +52,49 @@ function getShortName(fullName: string): string { return parts.length > 1 ? parts[parts.length - 1] : fullName } +// AI Provider configuration - default models and API links +const AI_PROVIDER_CONFIG: Record = { + deepseek: { + defaultModel: 'deepseek-chat', + apiUrl: 'https://platform.deepseek.com/api_keys', + apiName: 'DeepSeek', + }, + qwen: { + defaultModel: 'qwen3-max', + apiUrl: 'https://dashscope.console.aliyun.com/apiKey', + apiName: 'Alibaba Cloud', + }, + openai: { + defaultModel: 'gpt-5.1', + apiUrl: 'https://platform.openai.com/api-keys', + apiName: 'OpenAI', + }, + claude: { + defaultModel: 'claude-opus-4-5-20251101', + apiUrl: 'https://console.anthropic.com/settings/keys', + apiName: 'Anthropic', + }, + gemini: { + defaultModel: 'gemini-3-pro-preview', + apiUrl: 'https://aistudio.google.com/app/apikey', + apiName: 'Google AI Studio', + }, + grok: { + defaultModel: 'grok-3-latest', + apiUrl: 'https://console.x.ai/', + apiName: 'xAI', + }, + kimi: { + defaultModel: 'moonshot-v1-auto', + apiUrl: 'https://platform.moonshot.ai/console/api-keys', + apiName: 'Moonshot', + }, +} + interface AITradersPageProps { onTraderSelect?: (traderId: string) => void } @@ -815,6 +859,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { > {getShortName(model.name)} +
+ {model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''} +
{inUse ? t('inUse', language) @@ -1362,7 +1409,7 @@ function ModelConfigModal({
)} -
+
{getShortName(selectedModel.name)}
@@ -1371,6 +1418,29 @@ function ModelConfigModal({
+ {/* Default model info and API link */} + {AI_PROVIDER_CONFIG[selectedModel.provider] && ( +
+
+ {t('defaultModel', language)}: {AI_PROVIDER_CONFIG[selectedModel.provider].defaultModel} +
+ + + {t('applyApiKey', language)} → {AI_PROVIDER_CONFIG[selectedModel.provider].apiName} + + {selectedModel.provider === 'kimi' && ( +
+ ⚠️ {t('kimiApiNote', language)} +
+ )} +
+ )} )} @@ -1427,13 +1497,13 @@ function ModelConfigModal({ className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }} > - Model Name (可选) + {t('customModelName', language)} setModelName(e.target.value)} - placeholder="例如: deepseek-chat, qwen3-max, gpt-5" + placeholder={t('customModelNamePlaceholder', language)} className="w-full px-3 py-2 rounded" style={{ background: '#0B0E11', @@ -1442,7 +1512,7 @@ function ModelConfigModal({ }} />
- 留空使用默认模型名称 + {t('leaveBlankForDefaultModel', language)}
diff --git a/web/src/components/ModelIcons.tsx b/web/src/components/ModelIcons.tsx index 78dbd418..42228c5d 100644 --- a/web/src/components/ModelIcons.tsx +++ b/web/src/components/ModelIcons.tsx @@ -4,6 +4,17 @@ interface IconProps { className?: string } +// AI model colors for fallback display +const MODEL_COLORS: Record = { + deepseek: '#4A90E2', + qwen: '#9B59B6', + claude: '#D97757', + kimi: '#6366F1', + gemini: '#4285F4', + grok: '#000000', + openai: '#10A37F', +} + // 获取AI模型图标的函数 export const getModelIcon = (modelType: string, props: IconProps = {}) => { // 支持完整ID或类型名 @@ -18,6 +29,21 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => { case 'qwen': iconPath = '/icons/qwen.svg' break + case 'claude': + iconPath = '/icons/claude.svg' + break + case 'kimi': + iconPath = '/icons/kimi.svg' + break + case 'gemini': + iconPath = '/icons/gemini.svg' + break + case 'grok': + iconPath = '/icons/grok.svg' + break + case 'openai': + iconPath = '/icons/openai.svg' + break default: return null } @@ -29,7 +55,12 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => { width={props.width || 24} height={props.height || 24} className={props.className} - style={{ borderRadius: '50%' }} /> ) } + +// 获取模型颜色(用于没有图标时的fallback) +export const getModelColor = (modelType: string): string => { + const type = modelType.includes('_') ? modelType.split('_').pop() : modelType + return MODEL_COLORS[type || ''] || '#60a5fa' +} diff --git a/web/src/components/traders/ModelConfigModal.tsx b/web/src/components/traders/ModelConfigModal.tsx deleted file mode 100644 index 851b7538..00000000 --- a/web/src/components/traders/ModelConfigModal.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import { useState, useEffect } from 'react' -import { Trash2 } from 'lucide-react' -import { t, type Language } from '../../i18n/translations' -import type { AIModel } from '../../types' -import { getModelIcon } from '../ModelIcons' -import { getShortName } from './utils' - -interface ModelConfigModalProps { - allModels: AIModel[] - configuredModels: AIModel[] - editingModelId: string | null - onSave: ( - modelId: string, - apiKey: string, - baseUrl?: string, - modelName?: string - ) => Promise - onDelete: (modelId: string) => Promise - onClose: () => void - language: Language -} - -export function ModelConfigModal({ - allModels, - configuredModels, - editingModelId, - onSave, - onDelete, - onClose, - language, -}: ModelConfigModalProps) { - const [selectedModelId, setSelectedModelId] = useState(editingModelId || '') - const [apiKey, setApiKey] = useState('') - const [baseUrl, setBaseUrl] = useState('') - const [modelName, setModelName] = useState('') - - // 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从所有支持的模型中查找 - const selectedModel = editingModelId - ? configuredModels?.find((m) => m.id === selectedModelId) - : allModels?.find((m) => m.id === selectedModelId) - - // 如果是编辑现有模型,初始化API Key、Base URL和Model Name - useEffect(() => { - if (editingModelId && selectedModel) { - setApiKey(selectedModel.apiKey || '') - setBaseUrl(selectedModel.customApiUrl || '') - setModelName(selectedModel.customModelName || '') - } - }, [editingModelId, selectedModel]) - - const [isSaving, setIsSaving] = useState(false) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!selectedModelId || !apiKey.trim() || isSaving) return - - setIsSaving(true) - try { - await onSave( - selectedModelId, - apiKey.trim(), - baseUrl.trim() || undefined, - modelName.trim() || undefined - ) - } finally { - setIsSaving(false) - } - } - - // 可选择的模型列表(所有支持的模型) - const availableModels = allModels || [] - - return ( -
-
-
-

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

- {editingModelId && ( - - )} -
- -
-
- {!editingModelId && ( -
- - -
- )} - - {selectedModel && ( -
-
-
- {getModelIcon(selectedModel.provider || selectedModel.id, { - width: 32, - height: 32, - }) || ( -
- {selectedModel.name[0]} -
- )} -
-
-
- {getShortName(selectedModel.name)} -
-
- {selectedModel.provider} • {selectedModel.id} -
-
-
-
- )} - - {selectedModel && ( - <> -
- - setApiKey(e.target.value)} - placeholder={t('enterAPIKey', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- -
- - setBaseUrl(e.target.value)} - placeholder={t('customBaseURLPlaceholder', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - /> -
- {t('leaveBlankForDefault', language)} -
-
- -
- - setModelName(e.target.value)} - placeholder="例如: deepseek-chat, qwen3-max, gpt-5" - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - /> -
- 留空使用默认模型名称 -
-
- -
-
- ℹ️ {t('information', language)} -
-
-
{t('modelConfigInfo1', language)}
-
{t('modelConfigInfo2', language)}
-
{t('modelConfigInfo3', language)}
-
-
- - )} -
- -
- - -
-
-
-
- ) -} diff --git a/web/src/components/traders/index.ts b/web/src/components/traders/index.ts deleted file mode 100644 index 76d6d70f..00000000 --- a/web/src/components/traders/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { Tooltip } from './Tooltip' -export { ModelConfigModal } from './ModelConfigModal' -export { ExchangeConfigModal } from './ExchangeConfigModal' -export { getModelDisplayName, getShortName } from './utils' diff --git a/web/src/components/traders/sections/AIModelsSection.tsx b/web/src/components/traders/sections/AIModelsSection.tsx deleted file mode 100644 index 059c8fa5..00000000 --- a/web/src/components/traders/sections/AIModelsSection.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Brain } from 'lucide-react' -import { t, Language } from '../../../i18n/translations' -import { getModelIcon } from '../../ModelIcons' -import { getShortName } from '../utils' -import type { AIModel } from '../../../types' - -interface AIModelsSectionProps { - language: Language - configuredModels: AIModel[] - isModelInUse: (modelId: string) => boolean - onModelClick: (modelId: string) => void -} - -export function AIModelsSection({ - language, - configuredModels, - isModelInUse, - onModelClick, -}: AIModelsSectionProps) { - return ( -
-

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

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

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

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

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

-

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

-
-
- -
- - - - - -
-
- ) -} diff --git a/web/src/components/traders/sections/TradersGrid.tsx b/web/src/components/traders/sections/TradersGrid.tsx deleted file mode 100644 index 9c14e3a1..00000000 --- a/web/src/components/traders/sections/TradersGrid.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { Bot, BarChart3, Trash2, Pencil, Eye, EyeOff } from 'lucide-react' -import { t, type Language } from '../../../i18n/translations' -import { getModelDisplayName } from '../index' -import type { TraderInfo, Exchange } from '../../../types' -import { PunkAvatar, getTraderAvatar } from '../../PunkAvatar' - -interface TradersGridProps { - language: Language - traders: TraderInfo[] | undefined - exchanges?: Exchange[] - onTraderSelect: (traderId: string) => void - onEditTrader: (traderId: string) => void - onDeleteTrader: (traderId: string) => void - onToggleTrader: (traderId: string, running: boolean) => void - onToggleCompetition?: (traderId: string, showInCompetition: boolean) => void -} - -export function TradersGrid({ - language, - traders, - exchanges = [], - onTraderSelect, - onEditTrader, - onDeleteTrader, - onToggleTrader, - onToggleCompetition, -}: TradersGridProps) { - // Helper function to get exchange display name - const getExchangeDisplayName = (exchangeId: string | undefined) => { - if (!exchangeId) return 'Unknown' - const exchange = exchanges.find(e => e.id === exchangeId) - if (!exchange) return exchangeId.toUpperCase() - const typeName = exchange.exchange_type?.toUpperCase() || exchange.name - return exchange.account_name ? `${typeName} - ${exchange.account_name}` : typeName - } - if (!traders || traders.length === 0) { - return ( -
- -
- {t('noTraders', language)} -
-
- {t('createFirstTrader', language)} -
-
- ) - } - - return ( -
- {traders.map((trader) => ( -
-
-
- - -
-
-
- {trader.trader_name} -
-
- {getModelDisplayName( - trader.ai_model.split('_').pop() || trader.ai_model - )}{' '} - Model • {getExchangeDisplayName(trader.exchange_id)} - • {trader.strategy_name || 'No Strategy'} -
-
-
- -
- {/* Status */} -
-
- {trader.is_running - ? t('running', language) - : t('stopped', language)} -
-
- - {/* Actions: 禁止换行,超出横向滚动 */} -
- - - - - - - {onToggleCompetition && ( - - )} - - -
-
-
- ))} -
- ) -} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index f53b018b..2a9fd4a1 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -518,10 +518,16 @@ export const translations = { 'Custom API base URL, e.g.: https://api.openai.com/v1', leaveBlankForDefault: 'Leave blank to use default API address', modelConfigInfo1: - '• API Key will be encrypted and stored, please ensure it is valid', - modelConfigInfo2: '• Base URL is used for custom API server address', + '• For official API, only API Key is required, leave other fields blank', + modelConfigInfo2: '• Custom Base URL and Model Name only needed for third-party proxies', modelConfigInfo3: - '• After deleting configuration, traders using this model will not work properly', + '• API Key is encrypted and stored securely', + defaultModel: 'Default model', + applyApiKey: 'Apply API Key', + kimiApiNote: 'Kimi requires API Key from international site (moonshot.ai), China region keys are not compatible', + leaveBlankForDefaultModel: 'Leave blank to use default model', + customModelName: 'Model Name (Optional)', + customModelNamePlaceholder: 'e.g.: deepseek-chat, qwen3-max, gpt-4o', saveConfig: 'Save Configuration', editExchange: 'Edit Exchange', addExchange: 'Add Exchange', @@ -1506,9 +1512,15 @@ export const translations = { customBaseURL: 'Base URL (可选)', customBaseURLPlaceholder: '自定义API基础URL,如: https://api.openai.com/v1', leaveBlankForDefault: '留空则使用默认API地址', - modelConfigInfo1: '• API Key将被加密存储,请确保密钥有效', - modelConfigInfo2: '• Base URL用于自定义API服务器地址', - modelConfigInfo3: '• 删除配置后,使用此模型的交易员将无法正常工作', + modelConfigInfo1: '• 使用官方 API 时,只需填写 API Key,其他字段留空即可', + modelConfigInfo2: '• 自定义 Base URL 和 Model Name 仅在使用第三方代理时需要填写', + modelConfigInfo3: '• API Key 加密存储,不会明文展示', + defaultModel: '默认模型', + applyApiKey: '申请 API Key', + kimiApiNote: 'Kimi 需要从国际站申请 API Key (moonshot.ai),中国区 Key 不通用', + leaveBlankForDefaultModel: '留空使用默认模型名称', + customModelName: 'Model Name (可选)', + customModelNamePlaceholder: '例如: deepseek-chat, qwen3-max, gpt-4o', saveConfig: '保存配置', editExchange: '编辑交易所', addExchange: '添加交易所',