feat: add Grok, OpenAI, Claude, Gemini, Kimi AI providers
- Add new MCP clients for Grok (xAI), OpenAI, Claude, Gemini, Kimi - Update auto_trader, backtest, and strategy to support all providers - Add provider icons and fix SVG gradient conflicts - Add API application links and hints in model config modal - Show model version in AI model list cards - Add Chinese/English translations for provider hints - Remove deprecated traders component files
@@ -1263,6 +1263,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
|||||||
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false},
|
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false},
|
||||||
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false},
|
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false},
|
||||||
{ID: "grok", Name: "Grok AI", Provider: "grok", 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)
|
c.JSON(http.StatusOK, defaultModels)
|
||||||
return
|
return
|
||||||
@@ -2305,10 +2306,15 @@ func (s *Server) initUserDefaultConfigs(userID string) error {
|
|||||||
|
|
||||||
// handleGetSupportedModels Get list of AI models supported by the system
|
// handleGetSupportedModels Get list of AI models supported by the system
|
||||||
func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
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{}{
|
supportedModels := []map[string]interface{}{
|
||||||
{"id": "deepseek", "name": "DeepSeek", "provider": "deepseek"},
|
{"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"},
|
||||||
{"id": "qwen", "name": "Qwen", "provider": "qwen"},
|
{"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)
|
c.JSON(http.StatusOK, supportedModels)
|
||||||
|
|||||||
@@ -544,6 +544,21 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
|||||||
case "deepseek":
|
case "deepseek":
|
||||||
aiClient = mcp.NewDeepSeekClient()
|
aiClient = mcp.NewDeepSeekClient()
|
||||||
aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName)
|
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:
|
default:
|
||||||
// Use generic client
|
// Use generic client
|
||||||
aiClient = mcp.NewClient()
|
aiClient = mcp.NewClient()
|
||||||
|
|||||||
@@ -36,6 +36,41 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er
|
|||||||
qc := mcp.NewQwenClientWithOptions()
|
qc := mcp.NewQwenClientWithOptions()
|
||||||
qc.(*mcp.QwenClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
qc.(*mcp.QwenClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||||
return qc, nil
|
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":
|
case "custom":
|
||||||
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
||||||
return nil, fmt.Errorf("custom provider requires base_url, api key and 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
|
cp := *c.Client
|
||||||
return &cp
|
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
|
// Fall back to a new default client
|
||||||
return mcp.NewClient().(*mcp.Client)
|
return mcp.NewClient().(*mcp.Client)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 258 KiB |
@@ -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
|
switch aiModel {
|
||||||
if config.AIModel == "custom" {
|
case "claude":
|
||||||
// Use custom API
|
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)
|
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||||||
logger.Infof("🤖 [%s] Using custom AI API: %s (model: %s)", config.Name, 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)
|
default: // deepseek or empty
|
||||||
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)
|
|
||||||
mcpClient = mcp.NewDeepSeekClient()
|
mcpClient = mcp.NewDeepSeekClient()
|
||||||
mcpClient.SetAPIKey(config.DeepSeekKey, config.CustomAPIURL, config.CustomModelName)
|
apiKey := config.DeepSeekKey
|
||||||
if config.CustomAPIURL != "" || config.CustomModelName != "" {
|
if apiKey == "" {
|
||||||
logger.Infof("🤖 [%s] Using DeepSeek AI (custom URL: %s, model: %s)", config.Name, config.CustomAPIURL, config.CustomModelName)
|
apiKey = config.CustomAPIKey
|
||||||
} else {
|
|
||||||
logger.Infof("🤖 [%s] Using DeepSeek AI", config.Name)
|
|
||||||
}
|
}
|
||||||
|
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
|
// Set default trading platform
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Claude</title>
|
||||||
|
<path fill="#D97757" d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 301 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Google Gemini</title>
|
||||||
|
<path fill="#4285F4" d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 424 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Grok</title>
|
||||||
|
<path fill="#1D9BF0" d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 687 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Kimi</title>
|
||||||
|
<path fill="#6366F1" d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z"/>
|
||||||
|
<path fill="#6366F1" d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 639 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>OpenAI</title>
|
||||||
|
<path fill="#10A37F" d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -27,6 +27,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { confirmToast } from '../lib/notify'
|
import { confirmToast } from '../lib/notify'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -51,6 +52,49 @@ function getShortName(fullName: string): string {
|
|||||||
return parts.length > 1 ? parts[parts.length - 1] : fullName
|
return parts.length > 1 ? parts[parts.length - 1] : fullName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI Provider configuration - default models and API links
|
||||||
|
const AI_PROVIDER_CONFIG: Record<string, {
|
||||||
|
defaultModel: string
|
||||||
|
apiUrl: string
|
||||||
|
apiName: string
|
||||||
|
}> = {
|
||||||
|
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 {
|
interface AITradersPageProps {
|
||||||
onTraderSelect?: (traderId: string) => void
|
onTraderSelect?: (traderId: string) => void
|
||||||
}
|
}
|
||||||
@@ -815,6 +859,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
>
|
>
|
||||||
{getShortName(model.name)}
|
{getShortName(model.name)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: '#F0B90B' }}>
|
||||||
|
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
|
||||||
|
</div>
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||||
{inUse
|
{inUse
|
||||||
? t('inUse', language)
|
? t('inUse', language)
|
||||||
@@ -1362,7 +1409,7 @@ function ModelConfigModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<div className="font-semibold" style={{ color: '#EAECEF' }}>
|
<div className="font-semibold" style={{ color: '#EAECEF' }}>
|
||||||
{getShortName(selectedModel.name)}
|
{getShortName(selectedModel.name)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1371,6 +1418,29 @@ function ModelConfigModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Default model info and API link */}
|
||||||
|
{AI_PROVIDER_CONFIG[selectedModel.provider] && (
|
||||||
|
<div className="mt-3 pt-3" style={{ borderTop: '1px solid #2B3139' }}>
|
||||||
|
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||||
|
{t('defaultModel', language)}: <span style={{ color: '#F0B90B' }}>{AI_PROVIDER_CONFIG[selectedModel.provider].defaultModel}</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={AI_PROVIDER_CONFIG[selectedModel.provider].apiUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs hover:underline"
|
||||||
|
style={{ color: '#F0B90B' }}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
{t('applyApiKey', language)} → {AI_PROVIDER_CONFIG[selectedModel.provider].apiName}
|
||||||
|
</a>
|
||||||
|
{selectedModel.provider === 'kimi' && (
|
||||||
|
<div className="mt-2 text-xs p-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||||
|
⚠️ {t('kimiApiNote', language)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1427,13 +1497,13 @@ function ModelConfigModal({
|
|||||||
className="block text-sm font-semibold mb-2"
|
className="block text-sm font-semibold mb-2"
|
||||||
style={{ color: '#EAECEF' }}
|
style={{ color: '#EAECEF' }}
|
||||||
>
|
>
|
||||||
Model Name (可选)
|
{t('customModelName', language)}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={modelName}
|
value={modelName}
|
||||||
onChange={(e) => setModelName(e.target.value)}
|
onChange={(e) => setModelName(e.target.value)}
|
||||||
placeholder="例如: deepseek-chat, qwen3-max, gpt-5"
|
placeholder={t('customModelNamePlaceholder', language)}
|
||||||
className="w-full px-3 py-2 rounded"
|
className="w-full px-3 py-2 rounded"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
@@ -1442,7 +1512,7 @@ function ModelConfigModal({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||||
留空使用默认模型名称
|
{t('leaveBlankForDefaultModel', language)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ interface IconProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI model colors for fallback display
|
||||||
|
const MODEL_COLORS: Record<string, string> = {
|
||||||
|
deepseek: '#4A90E2',
|
||||||
|
qwen: '#9B59B6',
|
||||||
|
claude: '#D97757',
|
||||||
|
kimi: '#6366F1',
|
||||||
|
gemini: '#4285F4',
|
||||||
|
grok: '#000000',
|
||||||
|
openai: '#10A37F',
|
||||||
|
}
|
||||||
|
|
||||||
// 获取AI模型图标的函数
|
// 获取AI模型图标的函数
|
||||||
export const getModelIcon = (modelType: string, props: IconProps = {}) => {
|
export const getModelIcon = (modelType: string, props: IconProps = {}) => {
|
||||||
// 支持完整ID或类型名
|
// 支持完整ID或类型名
|
||||||
@@ -18,6 +29,21 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
|
|||||||
case 'qwen':
|
case 'qwen':
|
||||||
iconPath = '/icons/qwen.svg'
|
iconPath = '/icons/qwen.svg'
|
||||||
break
|
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:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -29,7 +55,12 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
|
|||||||
width={props.width || 24}
|
width={props.width || 24}
|
||||||
height={props.height || 24}
|
height={props.height || 24}
|
||||||
className={props.className}
|
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'
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<void>
|
|
||||||
onDelete: (modelId: string) => Promise<void>
|
|
||||||
onClose: () => void
|
|
||||||
language: Language
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelConfigModal({
|
|
||||||
allModels,
|
|
||||||
configuredModels,
|
|
||||||
editingModelId,
|
|
||||||
onSave,
|
|
||||||
onDelete,
|
|
||||||
onClose,
|
|
||||||
language,
|
|
||||||
}: ModelConfigModalProps) {
|
|
||||||
const [selectedModelId, setSelectedModelId] = useState(editingModelId || '')
|
|
||||||
const [apiKey, setApiKey] = useState('')
|
|
||||||
const [baseUrl, setBaseUrl] = useState('')
|
|
||||||
const [modelName, setModelName] = useState('')
|
|
||||||
|
|
||||||
// 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从所有支持的模型中查找
|
|
||||||
const selectedModel = editingModelId
|
|
||||||
? configuredModels?.find((m) => m.id === selectedModelId)
|
|
||||||
: allModels?.find((m) => m.id === selectedModelId)
|
|
||||||
|
|
||||||
// 如果是编辑现有模型,初始化API Key、Base URL和Model Name
|
|
||||||
useEffect(() => {
|
|
||||||
if (editingModelId && selectedModel) {
|
|
||||||
setApiKey(selectedModel.apiKey || '')
|
|
||||||
setBaseUrl(selectedModel.customApiUrl || '')
|
|
||||||
setModelName(selectedModel.customModelName || '')
|
|
||||||
}
|
|
||||||
}, [editingModelId, selectedModel])
|
|
||||||
|
|
||||||
const [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 (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
className="bg-gray-800 rounded-lg w-full max-w-lg relative my-8"
|
|
||||||
style={{
|
|
||||||
background: '#1E2329',
|
|
||||||
maxHeight: 'calc(100vh - 4rem)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between p-6 pb-4 sticky top-0 z-10"
|
|
||||||
style={{ background: '#1E2329' }}
|
|
||||||
>
|
|
||||||
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
|
||||||
{editingModelId
|
|
||||||
? t('editAIModel', language)
|
|
||||||
: t('addAIModel', language)}
|
|
||||||
</h3>
|
|
||||||
{editingModelId && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onDelete(editingModelId)}
|
|
||||||
className="p-2 rounded hover:bg-red-100 transition-colors"
|
|
||||||
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
|
|
||||||
title={t('delete', language)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="px-6 pb-6">
|
|
||||||
<div
|
|
||||||
className="space-y-4 overflow-y-auto"
|
|
||||||
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
|
||||||
>
|
|
||||||
{!editingModelId && (
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-semibold mb-2"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
{t('selectModel', language)}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedModelId}
|
|
||||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded"
|
|
||||||
style={{
|
|
||||||
background: '#0B0E11',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
color: '#EAECEF',
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">{t('pleaseSelectModel', language)}</option>
|
|
||||||
{availableModels.map((model) => (
|
|
||||||
<option key={model.id} value={model.id}>
|
|
||||||
{getShortName(model.name)} ({model.provider})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedModel && (
|
|
||||||
<div
|
|
||||||
className="p-4 rounded"
|
|
||||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-8 h-8 flex items-center justify-center">
|
|
||||||
{getModelIcon(selectedModel.provider || selectedModel.id, {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
}) || (
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
selectedModel.id === 'deepseek'
|
|
||||||
? '#60a5fa'
|
|
||||||
: '#c084fc',
|
|
||||||
color: '#fff',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedModel.name[0]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold" style={{ color: '#EAECEF' }}>
|
|
||||||
{getShortName(selectedModel.name)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
|
||||||
{selectedModel.provider} • {selectedModel.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedModel && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-semibold mb-2"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
API Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => 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
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-semibold mb-2"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
{t('customBaseURL', language)}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={baseUrl}
|
|
||||||
onChange={(e) => 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',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
|
||||||
{t('leaveBlankForDefault', language)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-semibold mb-2"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
Model Name (可选)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={modelName}
|
|
||||||
onChange={(e) => 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',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
|
||||||
留空使用默认模型名称
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="p-4 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.1)',
|
|
||||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-sm font-semibold mb-2"
|
|
||||||
style={{ color: '#F0B90B' }}
|
|
||||||
>
|
|
||||||
ℹ️ {t('information', language)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-xs space-y-1"
|
|
||||||
style={{ color: '#848E9C' }}
|
|
||||||
>
|
|
||||||
<div>{t('modelConfigInfo1', language)}</div>
|
|
||||||
<div>{t('modelConfigInfo2', language)}</div>
|
|
||||||
<div>{t('modelConfigInfo3', language)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex gap-3 mt-6 pt-4 sticky bottom-0"
|
|
||||||
style={{ background: '#1E2329' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
|
||||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
|
||||||
>
|
|
||||||
{t('cancel', language)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!selectedModel || !apiKey.trim() || isSaving}
|
|
||||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50"
|
|
||||||
style={{ background: '#F0B90B', color: '#000' }}
|
|
||||||
>
|
|
||||||
{isSaving ? t('saving', language) || '保存中...' : t('saveConfig', language)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { Tooltip } from './Tooltip'
|
|
||||||
export { ModelConfigModal } from './ModelConfigModal'
|
|
||||||
export { ExchangeConfigModal } from './ExchangeConfigModal'
|
|
||||||
export { getModelDisplayName, getShortName } from './utils'
|
|
||||||
@@ -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 (
|
|
||||||
<div className="binance-card p-3 md:p-4">
|
|
||||||
<h3
|
|
||||||
className="text-base md:text-lg font-semibold mb-3 flex items-center gap-2"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
<Brain className="w-4 h-4 md:w-5 md:h-5" style={{ color: '#60a5fa' }} />
|
|
||||||
{t('aiModels', language)}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2 md:space-y-3">
|
|
||||||
{configuredModels.map((model) => {
|
|
||||||
const inUse = isModelInUse(model.id)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={model.id}
|
|
||||||
className={`flex items-center justify-between p-2 md:p-3 rounded transition-all ${
|
|
||||||
inUse
|
|
||||||
? 'cursor-not-allowed'
|
|
||||||
: 'cursor-pointer hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
|
||||||
onClick={() => onModelClick(model.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 md:gap-3">
|
|
||||||
<div className="w-7 h-7 md:w-8 md:h-8 flex items-center justify-center flex-shrink-0">
|
|
||||||
{getModelIcon(model.provider || model.id, {
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
}) || (
|
|
||||||
<div
|
|
||||||
className="w-7 h-7 md:w-8 md:h-8 rounded-full flex items-center justify-center text-xs md:text-sm font-bold"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
model.id === 'deepseek' ? '#60a5fa' : '#c084fc',
|
|
||||||
color: '#fff',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getShortName(model.name)[0]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div
|
|
||||||
className="font-semibold text-sm md:text-base truncate"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
{getShortName(model.name)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
|
||||||
{inUse
|
|
||||||
? t('inUse', language)
|
|
||||||
: model.enabled
|
|
||||||
? t('enabled', language)
|
|
||||||
: t('configured', language)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`w-2.5 h-2.5 md:w-3 md:h-3 rounded-full flex-shrink-0 ${model.enabled ? 'bg-green-400' : 'bg-gray-500'}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{configuredModels.length === 0 && (
|
|
||||||
<div
|
|
||||||
className="text-center py-6 md:py-8"
|
|
||||||
style={{ color: '#848E9C' }}
|
|
||||||
>
|
|
||||||
<Brain className="w-10 h-10 md:w-12 md:h-12 mx-auto mb-2 opacity-50" />
|
|
||||||
<div className="text-xs md:text-sm">
|
|
||||||
{t('noModelsConfigured', language)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="binance-card p-3 md:p-4">
|
|
||||||
<h3
|
|
||||||
className="text-base md:text-lg font-semibold mb-3 flex items-center gap-2"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
<Landmark
|
|
||||||
className="w-4 h-4 md:w-5 md:h-5"
|
|
||||||
style={{ color: '#F0B90B' }}
|
|
||||||
/>
|
|
||||||
{t('exchanges', language)}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2 md:space-y-3">
|
|
||||||
{configuredExchanges.map((exchange) => {
|
|
||||||
const inUse = isExchangeInUse(exchange.id)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={exchange.id}
|
|
||||||
className={`flex items-center justify-between p-2 md:p-3 rounded transition-all ${
|
|
||||||
inUse
|
|
||||||
? 'cursor-not-allowed'
|
|
||||||
: 'cursor-pointer hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
|
||||||
onClick={() => onExchangeClick(exchange.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 md:gap-3">
|
|
||||||
<div className="w-7 h-7 md:w-8 md:h-8 flex items-center justify-center flex-shrink-0">
|
|
||||||
{getExchangeIcon(exchange.exchange_type, { width: 28, height: 28 })}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div
|
|
||||||
className="font-semibold text-sm md:text-base truncate"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
{exchange.exchange_type?.toUpperCase() || getShortName(exchange.name)}
|
|
||||||
<span className="text-xs font-normal ml-1.5" style={{ color: '#F0B90B' }}>
|
|
||||||
- {exchange.account_name || 'Default'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
|
||||||
{exchange.type?.toUpperCase() || 'CEX'} •{' '}
|
|
||||||
{inUse
|
|
||||||
? t('inUse', language)
|
|
||||||
: exchange.enabled
|
|
||||||
? t('enabled', language)
|
|
||||||
: t('configured', language)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`w-2.5 h-2.5 md:w-3 md:h-3 rounded-full flex-shrink-0 ${exchange.enabled ? 'bg-green-400' : 'bg-gray-500'}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{configuredExchanges.length === 0 && (
|
|
||||||
<div
|
|
||||||
className="text-center py-6 md:py-8"
|
|
||||||
style={{ color: '#848E9C' }}
|
|
||||||
>
|
|
||||||
<Landmark className="w-10 h-10 md:w-12 md:h-12 mx-auto mb-2 opacity-50" />
|
|
||||||
<div className="text-xs md:text-sm">
|
|
||||||
{t('noExchangesConfigured', language)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
|
|
||||||
<div className="flex items-center gap-3 md:gap-4">
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
|
||||||
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Bot className="w-5 h-5 md:w-6 md:h-6" style={{ color: '#000' }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1
|
|
||||||
className="text-xl md:text-2xl font-bold flex items-center gap-2"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
{t('aiTraders', language)}
|
|
||||||
<span
|
|
||||||
className="text-xs font-normal px-2 py-1 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
color: '#F0B90B',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tradersCount} {t('active', language)}
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
|
||||||
{t('manageAITraders', language)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 md:gap-3 w-full md:w-auto overflow-hidden flex-wrap md:flex-nowrap">
|
|
||||||
<button
|
|
||||||
onClick={onAddModel}
|
|
||||||
className="px-3 md:px-4 py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 md:gap-2 whitespace-nowrap"
|
|
||||||
style={{
|
|
||||||
background: '#2B3139',
|
|
||||||
color: '#EAECEF',
|
|
||||||
border: '1px solid #474D57',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
|
||||||
{t('aiModels', language)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onAddExchange}
|
|
||||||
className="px-3 md:px-4 py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 md:gap-2 whitespace-nowrap"
|
|
||||||
style={{
|
|
||||||
background: '#2B3139',
|
|
||||||
color: '#EAECEF',
|
|
||||||
border: '1px solid #474D57',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
|
||||||
{t('exchanges', language)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onCreateTrader}
|
|
||||||
disabled={!canCreateTrader}
|
|
||||||
className="px-3 md:px-4 py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 md:gap-2 whitespace-nowrap"
|
|
||||||
style={{
|
|
||||||
background: canCreateTrader ? '#F0B90B' : '#2B3139',
|
|
||||||
color: canCreateTrader ? '#000' : '#848E9C',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
{t('createTrader', language)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="text-center py-12 md:py-16" style={{ color: '#848E9C' }}>
|
|
||||||
<Bot className="w-16 h-16 md:w-24 md:h-24 mx-auto mb-3 md:mb-4 opacity-50" />
|
|
||||||
<div className="text-base md:text-lg font-semibold mb-2">
|
|
||||||
{t('noTraders', language)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs md:text-sm mb-3 md:mb-4">
|
|
||||||
{t('createFirstTrader', language)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 md:space-y-4">
|
|
||||||
{traders.map((trader) => (
|
|
||||||
<div
|
|
||||||
key={trader.trader_id}
|
|
||||||
className="flex flex-col md:flex-row md:items-center justify-between p-3 md:p-4 rounded transition-all hover:translate-y-[-1px] gap-3 md:gap-4"
|
|
||||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 md:gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<PunkAvatar
|
|
||||||
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
|
|
||||||
size={48}
|
|
||||||
className="rounded-lg hidden md:block"
|
|
||||||
/>
|
|
||||||
<PunkAvatar
|
|
||||||
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
|
|
||||||
size={40}
|
|
||||||
className="rounded-lg md:hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div
|
|
||||||
className="font-bold text-base md:text-lg truncate"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
{trader.trader_name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-xs md:text-sm truncate"
|
|
||||||
style={{
|
|
||||||
color: trader.ai_model.includes('deepseek')
|
|
||||||
? '#60a5fa'
|
|
||||||
: '#c084fc',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getModelDisplayName(
|
|
||||||
trader.ai_model.split('_').pop() || trader.ai_model
|
|
||||||
)}{' '}
|
|
||||||
Model • {getExchangeDisplayName(trader.exchange_id)}
|
|
||||||
<span style={{ color: '#F0B90B' }}> • {trader.strategy_name || 'No Strategy'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 md:gap-4 flex-wrap md:flex-nowrap">
|
|
||||||
{/* Status */}
|
|
||||||
<div className="text-center">
|
|
||||||
<div
|
|
||||||
className={`px-2 md:px-3 py-1 rounded text-xs font-bold ${
|
|
||||||
trader.is_running
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
style={
|
|
||||||
trader.is_running
|
|
||||||
? {
|
|
||||||
background: 'rgba(14, 203, 129, 0.1)',
|
|
||||||
color: '#0ECB81',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
background: 'rgba(246, 70, 93, 0.1)',
|
|
||||||
color: '#F6465D',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{trader.is_running
|
|
||||||
? t('running', language)
|
|
||||||
: t('stopped', language)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions: 禁止换行,超出横向滚动 */}
|
|
||||||
<div className="flex gap-1.5 md:gap-2 flex-nowrap overflow-x-auto items-center">
|
|
||||||
<button
|
|
||||||
onClick={() => onTraderSelect(trader.trader_id)}
|
|
||||||
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 whitespace-nowrap"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(99, 102, 241, 0.1)',
|
|
||||||
color: '#6366F1',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BarChart3 className="w-3 h-3 md:w-4 md:h-4" />
|
|
||||||
{t('view', language)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onEditTrader(trader.trader_id)}
|
|
||||||
disabled={trader.is_running}
|
|
||||||
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap flex items-center gap-1"
|
|
||||||
style={{
|
|
||||||
background: trader.is_running
|
|
||||||
? 'rgba(132, 142, 156, 0.1)'
|
|
||||||
: 'rgba(255, 193, 7, 0.1)',
|
|
||||||
color: trader.is_running ? '#848E9C' : '#FFC107',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="w-3 h-3 md:w-4 md:h-4" />
|
|
||||||
{t('edit', language)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
onToggleTrader(trader.trader_id, trader.is_running || false)
|
|
||||||
}
|
|
||||||
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 whitespace-nowrap"
|
|
||||||
style={
|
|
||||||
trader.is_running
|
|
||||||
? {
|
|
||||||
background: 'rgba(246, 70, 93, 0.1)',
|
|
||||||
color: '#F6465D',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
background: 'rgba(14, 203, 129, 0.1)',
|
|
||||||
color: '#0ECB81',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{trader.is_running ? t('stop', language) : t('start', language)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{onToggleCompetition && (
|
|
||||||
<button
|
|
||||||
onClick={() => onToggleCompetition(trader.trader_id, trader.show_in_competition ?? true)}
|
|
||||||
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 whitespace-nowrap flex items-center gap-1"
|
|
||||||
style={
|
|
||||||
trader.show_in_competition !== false
|
|
||||||
? {
|
|
||||||
background: 'rgba(14, 203, 129, 0.1)',
|
|
||||||
color: '#0ECB81',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
background: 'rgba(132, 142, 156, 0.1)',
|
|
||||||
color: '#848E9C',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
title={trader.show_in_competition !== false ? '在竞技场显示' : '在竞技场隐藏'}
|
|
||||||
>
|
|
||||||
{trader.show_in_competition !== false ? (
|
|
||||||
<Eye className="w-3 h-3 md:w-4 md:h-4" />
|
|
||||||
) : (
|
|
||||||
<EyeOff className="w-3 h-3 md:w-4 md:h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onDeleteTrader(trader.trader_id)}
|
|
||||||
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(246, 70, 93, 0.1)',
|
|
||||||
color: '#F6465D',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -518,10 +518,16 @@ export const translations = {
|
|||||||
'Custom API base URL, e.g.: https://api.openai.com/v1',
|
'Custom API base URL, e.g.: https://api.openai.com/v1',
|
||||||
leaveBlankForDefault: 'Leave blank to use default API address',
|
leaveBlankForDefault: 'Leave blank to use default API address',
|
||||||
modelConfigInfo1:
|
modelConfigInfo1:
|
||||||
'• API Key will be encrypted and stored, please ensure it is valid',
|
'• For official API, only API Key is required, leave other fields blank',
|
||||||
modelConfigInfo2: '• Base URL is used for custom API server address',
|
modelConfigInfo2: '• Custom Base URL and Model Name only needed for third-party proxies',
|
||||||
modelConfigInfo3:
|
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',
|
saveConfig: 'Save Configuration',
|
||||||
editExchange: 'Edit Exchange',
|
editExchange: 'Edit Exchange',
|
||||||
addExchange: 'Add Exchange',
|
addExchange: 'Add Exchange',
|
||||||
@@ -1506,9 +1512,15 @@ export const translations = {
|
|||||||
customBaseURL: 'Base URL (可选)',
|
customBaseURL: 'Base URL (可选)',
|
||||||
customBaseURLPlaceholder: '自定义API基础URL,如: https://api.openai.com/v1',
|
customBaseURLPlaceholder: '自定义API基础URL,如: https://api.openai.com/v1',
|
||||||
leaveBlankForDefault: '留空则使用默认API地址',
|
leaveBlankForDefault: '留空则使用默认API地址',
|
||||||
modelConfigInfo1: '• API Key将被加密存储,请确保密钥有效',
|
modelConfigInfo1: '• 使用官方 API 时,只需填写 API Key,其他字段留空即可',
|
||||||
modelConfigInfo2: '• Base URL用于自定义API服务器地址',
|
modelConfigInfo2: '• 自定义 Base URL 和 Model Name 仅在使用第三方代理时需要填写',
|
||||||
modelConfigInfo3: '• 删除配置后,使用此模型的交易员将无法正常工作',
|
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: '保存配置',
|
saveConfig: '保存配置',
|
||||||
editExchange: '编辑交易所',
|
editExchange: '编辑交易所',
|
||||||
addExchange: '添加交易所',
|
addExchange: '添加交易所',
|
||||||
|
|||||||