Files
nofx/web/src/components/AITradersPage.tsx
T
tinkle-community e5703ffab6 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
2025-12-11 15:16:59 +08:00

1571 lines
56 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import useSWR from 'swr'
import { api } from '../lib/api'
import type {
TraderInfo,
CreateTraderRequest,
AIModel,
Exchange,
} from '../types'
import { useLanguage } from '../contexts/LanguageContext'
import { t, type Language } from '../i18n/translations'
import { useAuth } from '../contexts/AuthContext'
import { getExchangeIcon } from './ExchangeIcons'
import { getModelIcon } from './ModelIcons'
import { TraderConfigModal } from './TraderConfigModal'
import { ExchangeConfigModal } from './traders/ExchangeConfigModal'
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
import {
Bot,
Brain,
Landmark,
BarChart3,
Trash2,
Plus,
Users,
Pencil,
Eye,
EyeOff,
ExternalLink,
} from 'lucide-react'
import { confirmToast } from '../lib/notify'
import { toast } from 'sonner'
// 获取友好的AI模型名称
function getModelDisplayName(modelId: string): string {
switch (modelId.toLowerCase()) {
case 'deepseek':
return 'DeepSeek'
case 'qwen':
return 'Qwen'
case 'claude':
return 'Claude'
default:
return modelId.toUpperCase()
}
}
// 提取下划线后面的名称部分
function getShortName(fullName: string): string {
const parts = fullName.split('_')
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 {
onTraderSelect?: (traderId: string) => void
}
// Helper function to get exchange display name from exchange ID (UUID)
function getExchangeDisplayName(exchangeId: string | undefined, exchanges: Exchange[]): string {
if (!exchangeId) return 'Unknown'
const exchange = exchanges.find(e => e.id === exchangeId)
if (!exchange) return exchangeId.substring(0, 8).toUpperCase() + '...' // Show truncated UUID if not found
const typeName = exchange.exchange_type?.toUpperCase() || exchange.name
return exchange.account_name ? `${typeName} - ${exchange.account_name}` : typeName
}
export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const { language } = useLanguage()
const { user, token } = useAuth()
const navigate = useNavigate()
const [showCreateModal, setShowCreateModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [showModelModal, setShowModelModal] = useState(false)
const [showExchangeModal, setShowExchangeModal] = useState(false)
const [editingModel, setEditingModel] = useState<string | null>(null)
const [editingExchange, setEditingExchange] = useState<string | null>(null)
const [editingTrader, setEditingTrader] = useState<any>(null)
const [allModels, setAllModels] = useState<AIModel[]>([])
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
const [supportedExchanges, setSupportedExchanges] = useState<Exchange[]>([])
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
user && token ? 'traders' : null,
api.getTraders,
{ refreshInterval: 5000 }
)
// 加载AI模型和交易所配置
useEffect(() => {
const loadConfigs = async () => {
if (!user || !token) {
// 未登录时只加载公开的支持模型和交易所
try {
const [supportedModels, supportedExchanges] = await Promise.all([
api.getSupportedModels(),
api.getSupportedExchanges(),
])
setSupportedModels(supportedModels)
setSupportedExchanges(supportedExchanges)
} catch (err) {
console.error('Failed to load supported configs:', err)
}
return
}
try {
const [
modelConfigs,
exchangeConfigs,
supportedModels,
supportedExchanges,
] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getSupportedModels(),
api.getSupportedExchanges(),
])
setAllModels(modelConfigs)
setAllExchanges(exchangeConfigs)
setSupportedModels(supportedModels)
setSupportedExchanges(supportedExchanges)
} catch (error) {
console.error('Failed to load configs:', error)
}
}
loadConfigs()
}, [user, token])
// 只显示已配置的模型和交易所
// 注意:后端返回的数据不包含敏感信息(apiKey等),所以通过其他字段判断是否已配置
const configuredModels =
allModels?.filter((m) => {
// 如果模型已启用,说明已配置
// 或者有自定义API URL,也说明已配置
return m.enabled || (m.customApiUrl && m.customApiUrl.trim() !== '')
}) || []
const configuredExchanges =
allExchanges?.filter((e) => {
// Aster 交易所检查特殊字段
if (e.id === 'aster') {
return e.asterUser && e.asterUser.trim() !== ''
}
// Hyperliquid 需要检查钱包地址(后端会返回这个字段)
if (e.id === 'hyperliquid') {
return e.hyperliquidWalletAddr && e.hyperliquidWalletAddr.trim() !== ''
}
// 其他交易所:如果已启用,说明已配置(后端返回的已配置交易所会有 enabled: true
return e.enabled
}) || []
// 只在创建交易员时使用已启用且配置完整的
// 注意:后端返回的数据不包含敏感信息,所以只检查 enabled 状态和必要的非敏感字段
const enabledModels = allModels?.filter((m) => m.enabled) || []
const enabledExchanges =
allExchanges?.filter((e) => {
if (!e.enabled) return false
// Aster 交易所需要特殊字段(后端会返回这些非敏感字段)
if (e.id === 'aster') {
return (
e.asterUser &&
e.asterUser.trim() !== '' &&
e.asterSigner &&
e.asterSigner.trim() !== ''
)
}
// Hyperliquid 需要钱包地址(后端会返回这个字段)
if (e.id === 'hyperliquid') {
return e.hyperliquidWalletAddr && e.hyperliquidWalletAddr.trim() !== ''
}
// 其他交易所:如果已启用,说明已配置完整(后端只返回已配置的交易所)
return true
}) || []
// 检查模型是否正在被运行中的交易员使用(用于UI禁用)
const isModelInUse = (modelId: string) => {
return traders?.some((t) => t.ai_model === modelId && t.is_running)
}
// 检查交易所是否正在被运行中的交易员使用(用于UI禁用)
const isExchangeInUse = (exchangeId: string) => {
return traders?.some((t) => t.exchange_id === exchangeId && t.is_running)
}
// 检查模型是否被任何交易员使用(包括停止状态的)
const isModelUsedByAnyTrader = (modelId: string) => {
return traders?.some((t) => t.ai_model === modelId) || false
}
// 检查交易所是否被任何交易员使用(包括停止状态的)
const isExchangeUsedByAnyTrader = (exchangeId: string) => {
return traders?.some((t) => t.exchange_id === exchangeId) || false
}
// 获取使用特定模型的交易员列表
const getTradersUsingModel = (modelId: string) => {
return traders?.filter((t) => t.ai_model === modelId) || []
}
// 获取使用特定交易所的交易员列表
const getTradersUsingExchange = (exchangeId: string) => {
return traders?.filter((t) => t.exchange_id === exchangeId) || []
}
const handleCreateTrader = async (data: CreateTraderRequest) => {
try {
const model = allModels?.find((m) => m.id === data.ai_model_id)
const exchange = allExchanges?.find((e) => e.id === data.exchange_id)
if (!model?.enabled) {
toast.error(t('modelNotConfigured', language))
return
}
if (!exchange?.enabled) {
toast.error(t('exchangeNotConfigured', language))
return
}
await toast.promise(api.createTrader(data), {
loading: '正在创建…',
success: '创建成功',
error: '创建失败',
})
setShowCreateModal(false)
// Immediately refresh traders list for better UX
await mutateTraders()
} catch (error) {
console.error('Failed to create trader:', error)
toast.error(t('createTraderFailed', language))
}
}
const handleEditTrader = async (traderId: string) => {
try {
const traderConfig = await api.getTraderConfig(traderId)
setEditingTrader(traderConfig)
setShowEditModal(true)
} catch (error) {
console.error('Failed to fetch trader config:', error)
toast.error(t('getTraderConfigFailed', language))
}
}
const handleSaveEditTrader = async (data: CreateTraderRequest) => {
if (!editingTrader) return
try {
const model = enabledModels?.find((m) => m.id === data.ai_model_id)
const exchange = enabledExchanges?.find((e) => e.id === data.exchange_id)
if (!model) {
toast.error(t('modelConfigNotExist', language))
return
}
if (!exchange) {
toast.error(t('exchangeConfigNotExist', language))
return
}
const request = {
name: data.name,
ai_model_id: data.ai_model_id,
exchange_id: data.exchange_id,
initial_balance: data.initial_balance,
scan_interval_minutes: data.scan_interval_minutes,
btc_eth_leverage: data.btc_eth_leverage,
altcoin_leverage: data.altcoin_leverage,
trading_symbols: data.trading_symbols,
custom_prompt: data.custom_prompt,
override_base_prompt: data.override_base_prompt,
system_prompt_template: data.system_prompt_template,
is_cross_margin: data.is_cross_margin,
use_coin_pool: data.use_coin_pool,
use_oi_top: data.use_oi_top,
}
await toast.promise(api.updateTrader(editingTrader.trader_id, request), {
loading: '正在保存…',
success: '保存成功',
error: '保存失败',
})
setShowEditModal(false)
setEditingTrader(null)
// Immediately refresh traders list for better UX
await mutateTraders()
} catch (error) {
console.error('Failed to update trader:', error)
toast.error(t('updateTraderFailed', language))
}
}
const handleDeleteTrader = async (traderId: string) => {
{
const ok = await confirmToast(t('confirmDeleteTrader', language))
if (!ok) return
}
try {
await toast.promise(api.deleteTrader(traderId), {
loading: '正在删除…',
success: '删除成功',
error: '删除失败',
})
// Immediately refresh traders list for better UX
await mutateTraders()
} catch (error) {
console.error('Failed to delete trader:', error)
toast.error(t('deleteTraderFailed', language))
}
}
const handleToggleTrader = async (traderId: string, running: boolean) => {
try {
if (running) {
await toast.promise(api.stopTrader(traderId), {
loading: '正在停止…',
success: '已停止',
error: '停止失败',
})
} else {
await toast.promise(api.startTrader(traderId), {
loading: '正在启动…',
success: '已启动',
error: '启动失败',
})
}
// Immediately refresh traders list to update running status
await mutateTraders()
} catch (error) {
console.error('Failed to toggle trader:', error)
toast.error(t('operationFailed', language))
}
}
const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => {
try {
const newValue = !currentShowInCompetition
await toast.promise(api.toggleCompetition(traderId, newValue), {
loading: '正在更新…',
success: newValue ? '已在竞技场显示' : '已在竞技场隐藏',
error: '更新失败',
})
// Immediately refresh traders list to update status
await mutateTraders()
} catch (error) {
console.error('Failed to toggle competition visibility:', error)
toast.error(t('operationFailed', language))
}
}
const handleModelClick = (modelId: string) => {
if (!isModelInUse(modelId)) {
setEditingModel(modelId)
setShowModelModal(true)
}
}
const handleExchangeClick = (exchangeId: string) => {
if (!isExchangeInUse(exchangeId)) {
setEditingExchange(exchangeId)
setShowExchangeModal(true)
}
}
// 通用删除配置处理函数
const handleDeleteConfig = async <T extends { id: string }>(config: {
id: string
type: 'model' | 'exchange'
checkInUse: (id: string) => boolean
getUsingTraders: (id: string) => any[]
cannotDeleteKey: string
confirmDeleteKey: string
allItems: T[] | undefined
clearFields: (item: T) => T
buildRequest: (items: T[]) => any
updateApi: (request: any) => Promise<void>
refreshApi: () => Promise<T[]>
setItems: (items: T[]) => void
closeModal: () => void
errorKey: string
}) => {
// 检查是否有交易员正在使用
if (config.checkInUse(config.id)) {
const usingTraders = config.getUsingTraders(config.id)
const traderNames = usingTraders.map((t) => t.trader_name).join(', ')
toast.error(
`${t(config.cannotDeleteKey, language)} · ${t('tradersUsing', language)}: ${traderNames} · ${t('pleaseDeleteTradersFirst', language)}`
)
return
}
{
const ok = await confirmToast(t(config.confirmDeleteKey, language))
if (!ok) return
}
try {
const updatedItems =
config.allItems?.map((item) =>
item.id === config.id ? config.clearFields(item) : item
) || []
const request = config.buildRequest(updatedItems)
await toast.promise(config.updateApi(request), {
loading: '正在更新配置…',
success: '配置已更新',
error: '更新配置失败',
})
// 重新获取用户配置以确保数据同步
const refreshedItems = await config.refreshApi()
config.setItems(refreshedItems)
config.closeModal()
} catch (error) {
console.error(`Failed to delete ${config.type} config:`, error)
toast.error(t(config.errorKey, language))
}
}
const handleDeleteModelConfig = async (modelId: string) => {
await handleDeleteConfig({
id: modelId,
type: 'model',
checkInUse: isModelUsedByAnyTrader,
getUsingTraders: getTradersUsingModel,
cannotDeleteKey: 'cannotDeleteModelInUse',
confirmDeleteKey: 'confirmDeleteModel',
allItems: allModels,
clearFields: (m) => ({
...m,
apiKey: '',
customApiUrl: '',
customModelName: '',
enabled: false,
}),
buildRequest: (models) => ({
models: Object.fromEntries(
models.map((model) => [
model.provider,
{
enabled: model.enabled,
api_key: model.apiKey || '',
custom_api_url: model.customApiUrl || '',
custom_model_name: model.customModelName || '',
},
])
),
}),
updateApi: api.updateModelConfigs,
refreshApi: api.getModelConfigs,
setItems: (items) => {
// 使用函数式更新确保状态正确更新
setAllModels([...items])
},
closeModal: () => {
setShowModelModal(false)
setEditingModel(null)
},
errorKey: 'deleteConfigFailed',
})
}
const handleSaveModelConfig = async (
modelId: string,
apiKey: string,
customApiUrl?: string,
customModelName?: string
) => {
try {
// 创建或更新用户的模型配置
const existingModel = allModels?.find((m) => m.id === modelId)
let updatedModels
// 找到要配置的模型(优先从已配置列表,其次从支持列表)
const modelToUpdate =
existingModel || supportedModels?.find((m) => m.id === modelId)
if (!modelToUpdate) {
toast.error(t('modelNotExist', language))
return
}
if (existingModel) {
// 更新现有配置
updatedModels =
allModels?.map((m) =>
m.id === modelId
? {
...m,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}
: m
) || []
} else {
// 添加新配置
const newModel = {
...modelToUpdate,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}
updatedModels = [...(allModels || []), newModel]
}
const request = {
models: Object.fromEntries(
updatedModels.map((model) => [
model.provider, // 使用 provider 而不是 id
{
enabled: model.enabled,
api_key: model.apiKey || '',
custom_api_url: model.customApiUrl || '',
custom_model_name: model.customModelName || '',
},
])
),
}
await toast.promise(api.updateModelConfigs(request), {
loading: '正在更新模型配置…',
success: '模型配置已更新',
error: '更新模型配置失败',
})
// 重新获取用户配置以确保数据同步
const refreshedModels = await api.getModelConfigs()
setAllModels(refreshedModels)
setShowModelModal(false)
setEditingModel(null)
} catch (error) {
console.error('Failed to save model config:', error)
toast.error(t('saveConfigFailed', language))
}
}
const handleDeleteExchangeConfig = async (exchangeId: string) => {
// 检查是否有trader在使用此交易所账户
if (isExchangeUsedByAnyTrader(exchangeId)) {
const tradersUsing = getTradersUsingExchange(exchangeId)
toast.error(
`${t('cannotDeleteExchangeInUse', language)}: ${tradersUsing.join(', ')}`
)
return
}
// 确认删除
const ok = await confirmToast(t('confirmDeleteExchange', language))
if (!ok) return
try {
await toast.promise(api.deleteExchange(exchangeId), {
loading: language === 'zh' ? '正在删除交易所账户…' : 'Deleting exchange account...',
success: language === 'zh' ? '交易所账户已删除' : 'Exchange account deleted',
error: language === 'zh' ? '删除交易所账户失败' : 'Failed to delete exchange account',
})
// 重新获取用户配置以确保数据同步
const refreshedExchanges = await api.getExchangeConfigs()
setAllExchanges(refreshedExchanges)
setShowExchangeModal(false)
setEditingExchange(null)
} catch (error) {
console.error('Failed to delete exchange config:', error)
toast.error(t('deleteExchangeConfigFailed', language))
}
}
const handleSaveExchangeConfig = async (
exchangeId: string | null, // null for creating new account
exchangeType: string,
accountName: string,
apiKey: string,
secretKey?: string,
passphrase?: string,
testnet?: boolean,
hyperliquidWalletAddr?: string,
asterUser?: string,
asterSigner?: string,
asterPrivateKey?: string,
lighterWalletAddr?: string,
lighterPrivateKey?: string,
lighterApiKeyPrivateKey?: string
) => {
try {
if (exchangeId) {
// 更新现有账户配置
const existingExchange = allExchanges?.find((e) => e.id === exchangeId)
if (!existingExchange) {
toast.error(t('exchangeNotExist', language))
return
}
const request = {
exchanges: {
[exchangeId]: {
enabled: true,
api_key: apiKey || '',
secret_key: secretKey || '',
passphrase: passphrase || '',
testnet: testnet || false,
hyperliquid_wallet_addr: hyperliquidWalletAddr || '',
aster_user: asterUser || '',
aster_signer: asterSigner || '',
aster_private_key: asterPrivateKey || '',
lighter_wallet_addr: lighterWalletAddr || '',
lighter_private_key: lighterPrivateKey || '',
lighter_api_key_private_key: lighterApiKeyPrivateKey || '',
},
},
}
await toast.promise(api.updateExchangeConfigsEncrypted(request), {
loading: language === 'zh' ? '正在更新交易所配置…' : 'Updating exchange config...',
success: language === 'zh' ? '交易所配置已更新' : 'Exchange config updated',
error: language === 'zh' ? '更新交易所配置失败' : 'Failed to update exchange config',
})
} else {
// 创建新账户
const createRequest = {
exchange_type: exchangeType,
account_name: accountName,
enabled: true,
api_key: apiKey || '',
secret_key: secretKey || '',
passphrase: passphrase || '',
testnet: testnet || false,
hyperliquid_wallet_addr: hyperliquidWalletAddr || '',
aster_user: asterUser || '',
aster_signer: asterSigner || '',
aster_private_key: asterPrivateKey || '',
lighter_wallet_addr: lighterWalletAddr || '',
lighter_private_key: lighterPrivateKey || '',
lighter_api_key_private_key: lighterApiKeyPrivateKey || '',
}
await toast.promise(api.createExchangeEncrypted(createRequest), {
loading: language === 'zh' ? '正在创建交易所账户…' : 'Creating exchange account...',
success: language === 'zh' ? '交易所账户已创建' : 'Exchange account created',
error: language === 'zh' ? '创建交易所账户失败' : 'Failed to create exchange account',
})
}
// 重新获取用户配置以确保数据同步
const refreshedExchanges = await api.getExchangeConfigs()
setAllExchanges(refreshedExchanges)
setShowExchangeModal(false)
setEditingExchange(null)
} catch (error) {
console.error('Failed to save exchange config:', error)
toast.error(t('saveConfigFailed', language))
}
}
const handleAddModel = () => {
setEditingModel(null)
setShowModelModal(true)
}
const handleAddExchange = () => {
setEditingExchange(null)
setShowExchangeModal(true)
}
return (
<div className="space-y-4 md:space-y-6 animate-fade-in">
{/* Header */}
<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',
}}
>
{traders?.length || 0} {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={handleAddModel}
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={handleAddExchange}
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={() => setShowCreateModal(true)}
disabled={
configuredModels.length === 0 || configuredExchanges.length === 0
}
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:
configuredModels.length > 0 && configuredExchanges.length > 0
? '#F0B90B'
: '#2B3139',
color:
configuredModels.length > 0 && configuredExchanges.length > 0
? '#000'
: '#848E9C',
}}
>
<Plus className="w-4 h-4" />
{t('createTrader', language)}
</button>
</div>
</div>
{/* Configuration Status */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
{/* AI Models */}
<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={() => handleModelClick(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: '#F0B90B' }}>
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
</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>
{/* Exchanges */}
<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={() => handleExchangeClick(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 || exchange.id, { 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>
</div>
{/* Traders List */}
<div className="binance-card p-4 md:p-6">
<div className="flex items-center justify-between mb-4 md:mb-5">
<h2
className="text-lg md:text-xl font-bold flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
<Users
className="w-5 h-5 md:w-6 md:h-6"
style={{ color: '#F0B90B' }}
/>
{t('currentTraders', language)}
</h2>
</div>
{isTradersLoading ? (
/* Loading Skeleton */
<div className="space-y-3 md:space-y-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex flex-col md:flex-row md:items-center justify-between p-3 md:p-4 rounded gap-3 md:gap-4 animate-pulse"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<div className="flex items-center gap-3 md:gap-4">
<div className="w-10 h-10 md:w-12 md:h-12 rounded-full skeleton"></div>
<div className="min-w-0 space-y-2">
<div className="skeleton h-5 w-32"></div>
<div className="skeleton h-3 w-24"></div>
</div>
</div>
<div className="flex items-center gap-3 md:gap-4">
<div className="skeleton h-6 w-16"></div>
<div className="skeleton h-6 w-16"></div>
<div className="skeleton h-8 w-20"></div>
</div>
</div>
))}
</div>
) : traders && traders.length > 0 ? (
<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, allExchanges)}
</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="text-xs mb-1" style={{ color: '#848E9C' }}>
{t('status', language)}
</div> */}
<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={() => {
if (onTraderSelect) {
onTraderSelect(trader.trader_id)
} else {
navigate(`/dashboard?trader=${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={() => handleEditTrader(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={() =>
handleToggleTrader(
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>
<button
onClick={() => handleToggleCompetition(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={() => handleDeleteTrader(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>
) : (
<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>
{(configuredModels.length === 0 ||
configuredExchanges.length === 0) && (
<div className="text-xs md:text-sm text-yellow-500">
{configuredModels.length === 0 &&
configuredExchanges.length === 0
? t('configureModelsAndExchangesFirst', language)
: configuredModels.length === 0
? t('configureModelsFirst', language)
: t('configureExchangesFirst', language)}
</div>
)}
</div>
)}
</div>
{/* Create Trader Modal */}
{showCreateModal && (
<TraderConfigModal
isOpen={showCreateModal}
isEditMode={false}
availableModels={enabledModels}
availableExchanges={enabledExchanges}
onSave={handleCreateTrader}
onClose={() => setShowCreateModal(false)}
/>
)}
{/* Edit Trader Modal */}
{showEditModal && editingTrader && (
<TraderConfigModal
isOpen={showEditModal}
isEditMode={true}
traderData={editingTrader}
availableModels={enabledModels}
availableExchanges={enabledExchanges}
onSave={handleSaveEditTrader}
onClose={() => {
setShowEditModal(false)
setEditingTrader(null)
}}
/>
)}
{/* Model Configuration Modal */}
{showModelModal && (
<ModelConfigModal
allModels={supportedModels}
configuredModels={allModels}
editingModelId={editingModel}
onSave={handleSaveModelConfig}
onDelete={handleDeleteModelConfig}
onClose={() => {
setShowModelModal(false)
setEditingModel(null)
}}
language={language}
/>
)}
{/* Exchange Configuration Modal */}
{showExchangeModal && (
<ExchangeConfigModal
allExchanges={supportedExchanges}
editingExchangeId={editingExchange}
onSave={handleSaveExchangeConfig}
onDelete={handleDeleteExchangeConfig}
onClose={() => {
setShowExchangeModal(false)
setEditingExchange(null)
}}
language={language}
/>
)}
</div>
)
}
// Model Configuration Modal Component
function ModelConfigModal({
allModels,
configuredModels,
editingModelId,
onSave,
onDelete,
onClose,
language,
}: {
allModels: AIModel[]
configuredModels: AIModel[]
editingModelId: string | null
onSave: (
modelId: string,
apiKey: string,
baseUrl?: string,
modelName?: string
) => void
onDelete: (modelId: string) => void
onClose: () => void
language: Language
}) {
const [selectedModelId, setSelectedModelId] = useState(editingModelId || '')
const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [modelName, setModelName] = useState('')
// 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从所有支持的模型中查找
const selectedModel = editingModelId
? configuredModels?.find((m) => m.id === selectedModelId)
: allModels?.find((m) => m.id === selectedModelId)
// 如果是编辑现有模型,初始化API Key、Base URL和Model Name
useEffect(() => {
if (editingModelId && selectedModel) {
setApiKey(selectedModel.apiKey || '')
setBaseUrl(selectedModel.customApiUrl || '')
setModelName(selectedModel.customModelName || '')
}
}, [editingModelId, selectedModel])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedModelId || !apiKey.trim()) return
onSave(
selectedModelId,
apiKey.trim(),
baseUrl.trim() || undefined,
modelName.trim() || undefined
)
}
// 可选择的模型列表(所有支持的模型)
const availableModels = allModels || []
return (
<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 className="flex-1">
<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>
{/* 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>
)}
{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' }}
>
{t('customModelName', language)}
</label>
<input
type="text"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder={t('customModelNamePlaceholder', 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('leaveBlankForDefaultModel', language)}
</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()}
className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('saveConfig', language)}
</button>
</div>
</form>
</div>
</div>
)
}