Files
nofx/web/src/components/TraderConfigModal.tsx
tinkle-community 3168a18c0d feat(telegram): add AI agent bot with streaming and account context
- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and  placeholder
- Account state injection at conversation start (models, exchanges,
  strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection
2026-03-08 00:19:38 +08:00

574 lines
23 KiB
TypeScript

import { useState, useEffect } from 'react'
import type { AIModel, Exchange, CreateTraderRequest, Strategy } from '../types'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import { toast } from 'sonner'
import { Pencil, Plus, X as IconX, Sparkles, ExternalLink, UserPlus } from 'lucide-react'
import { httpClient } from '../lib/httpClient'
// 提取下划线后面的名称部分
function getShortName(fullName: string): string {
const parts = fullName.split('_')
return parts.length > 1 ? parts[parts.length - 1] : fullName
}
// 交易所注册链接配置
const EXCHANGE_REGISTRATION_LINKS: Record<string, { url: string; hasReferral?: boolean }> = {
binance: { url: 'https://www.binance.com/join?ref=NOFXENG', hasReferral: true },
okx: { url: 'https://www.okx.com/join/1865360', hasReferral: true },
bybit: { url: 'https://partner.bybit.com/b/83856', hasReferral: true },
hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true },
aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true },
lighter: { url: 'https://app.lighter.xyz/?referral=68151432', hasReferral: true },
}
import type { TraderConfigData } from '../types'
// 表单内部状态类型
interface FormState {
trader_id?: string
trader_name: string
ai_model: string
exchange_id: string
strategy_id: string
is_cross_margin: boolean
show_in_competition: boolean
scan_interval_minutes: number
initial_balance?: number
}
interface TraderConfigModalProps {
isOpen: boolean
onClose: () => void
traderData?: TraderConfigData | null
isEditMode?: boolean
availableModels?: AIModel[]
availableExchanges?: Exchange[]
onSave?: (data: CreateTraderRequest) => Promise<void>
}
export function TraderConfigModal({
isOpen,
onClose,
traderData,
isEditMode = false,
availableModels = [],
availableExchanges = [],
onSave,
}: TraderConfigModalProps) {
const { language } = useLanguage()
const [formData, setFormData] = useState<FormState>({
trader_name: '',
ai_model: '',
exchange_id: '',
strategy_id: '',
is_cross_margin: true,
show_in_competition: true,
scan_interval_minutes: 3,
})
const [isSaving, setIsSaving] = useState(false)
const [strategies, setStrategies] = useState<Strategy[]>([])
const [isFetchingBalance, setIsFetchingBalance] = useState(false)
const [balanceFetchError, setBalanceFetchError] = useState<string>('')
// 获取用户的策略列表
useEffect(() => {
const fetchStrategies = async () => {
try {
const result = await httpClient.get<{ strategies: Strategy[] }>('/api/strategies')
if (result.success && result.data?.strategies) {
const strategyList = result.data.strategies
setStrategies(strategyList)
// 如果没有选择策略,默认选中激活的策略
if (!formData.strategy_id && !isEditMode) {
const activeStrategy = strategyList.find(s => s.is_active)
if (activeStrategy) {
setFormData(prev => ({ ...prev, strategy_id: activeStrategy.id }))
} else if (strategyList.length > 0) {
setFormData(prev => ({ ...prev, strategy_id: strategyList[0].id }))
}
}
}
} catch (error) {
console.error('Failed to fetch strategies:', error)
}
}
if (isOpen) {
fetchStrategies()
}
}, [isOpen])
useEffect(() => {
if (traderData) {
setFormData({
...traderData,
strategy_id: traderData.strategy_id || '',
})
} else if (!isEditMode) {
setFormData({
trader_name: '',
ai_model: availableModels[0]?.id || '',
exchange_id: availableExchanges[0]?.id || '',
strategy_id: '',
is_cross_margin: true,
show_in_competition: true,
scan_interval_minutes: 3,
})
}
}, [traderData, isEditMode, availableModels, availableExchanges])
if (!isOpen) return null
const handleInputChange = (field: keyof FormState, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const handleFetchCurrentBalance = async () => {
if (!isEditMode || !traderData?.trader_id) {
setBalanceFetchError(t('fetchBalanceEditModeOnly', language))
return
}
setIsFetchingBalance(true)
setBalanceFetchError('')
try {
const result = await httpClient.get<{
total_equity?: number
balance?: number
}>(`/api/account?trader_id=${traderData.trader_id}`)
if (result.success && result.data) {
const currentBalance =
result.data.total_equity || result.data.balance || 0
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
toast.success(t('balanceFetched', language))
} else {
throw new Error(result.message || t('balanceFetchFailed', language))
}
} catch (error) {
console.error(t('balanceFetchFailed', language) + ':', error)
setBalanceFetchError(t('balanceFetchNetworkError', language))
} finally {
setIsFetchingBalance(false)
}
}
const handleSave = async () => {
if (!onSave) return
setIsSaving(true)
try {
const saveData: CreateTraderRequest = {
name: formData.trader_name,
ai_model_id: formData.ai_model,
exchange_id: formData.exchange_id,
strategy_id: formData.strategy_id,
is_cross_margin: formData.is_cross_margin,
show_in_competition: formData.show_in_competition,
scan_interval_minutes: formData.scan_interval_minutes,
}
// 只在编辑模式时包含initial_balance
if (isEditMode && formData.initial_balance !== undefined) {
saveData.initial_balance = formData.initial_balance
}
await toast.promise(onSave(saveData), {
loading: t('saving', language),
success: t('saveSuccess', language),
error: t('saveFailed', language),
})
onClose()
} catch (error) {
console.error(t('saveFailed', language) + ':', error)
} finally {
setIsSaving(false)
}
}
const selectedStrategy = strategies.find(s => s.id === formData.strategy_id)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm p-4 overflow-y-auto">
<div
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-2xl w-full my-8"
style={{ maxHeight: 'calc(100vh - 4rem)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35] sticky top-0 z-10 rounded-t-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#F0B90B] to-[#E1A706] flex items-center justify-center text-black">
{isEditMode ? (
<Pencil className="w-5 h-5" />
) : (
<Plus className="w-5 h-5" />
)}
</div>
<div>
<h2 className="text-xl font-bold text-[#EAECEF]">
{isEditMode ? t('editTrader', language) : t('createTrader', language)}
</h2>
<p className="text-sm text-[#848E9C] mt-1">
{isEditMode ? t('editTraderConfig', language) : t('selectStrategyAndConfigParams', language)}
</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg text-[#848E9C] hover:text-[#EAECEF] hover:bg-[#2B3139] transition-colors flex items-center justify-center"
>
<IconX className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div
className="p-6 space-y-6 overflow-y-auto"
style={{ maxHeight: 'calc(100vh - 16rem)' }}
>
{/* Basic Info */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
<span className="text-[#F0B90B]">1</span> {t('basicConfig', language)}
</h3>
<div className="space-y-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('traderNameRequired', language)}
</label>
<input
type="text"
value={formData.trader_name}
onChange={(e) =>
handleInputChange('trader_name', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
placeholder={t('enterTraderNamePlaceholder', language)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('aiModelRequired', language)}
</label>
<select
value={formData.ai_model}
onChange={(e) =>
handleInputChange('ai_model', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{availableModels.map((model) => (
<option key={model.id} value={model.id}>
{getShortName(model.name || model.id).toUpperCase()}
</option>
))}
</select>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('exchangeRequired', language)}
</label>
<select
value={formData.exchange_id}
onChange={(e) =>
handleInputChange('exchange_id', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{availableExchanges.map((exchange) => (
<option key={exchange.id} value={exchange.id}>
{getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()}
{exchange.account_name ? ` - ${exchange.account_name}` : ''}
</option>
))}
</select>
{/* Exchange Registration Link */}
{formData.exchange_id && (() => {
// Find the selected exchange to get its type
const selectedExchange = availableExchanges.find(e => e.id === formData.exchange_id)
const exchangeType = selectedExchange?.exchange_type?.toLowerCase() || ''
const regLink = EXCHANGE_REGISTRATION_LINKS[exchangeType]
if (!regLink) return null
return (
<a
href={regLink.url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1.5 text-xs text-[#848E9C] hover:text-[#F0B90B] transition-colors"
>
<UserPlus className="w-3.5 h-3.5" />
<span>{t('noExchangeAccount', language)}</span>
{regLink.hasReferral && (
<span className="px-1.5 py-0.5 bg-[#F0B90B]/10 text-[#F0B90B] rounded text-[10px]">
{t('discount', language)}
</span>
)}
<ExternalLink className="w-3 h-3" />
</a>
)
})()}
</div>
</div>
</div>
</div>
{/* Strategy Selection */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
<span className="text-[#F0B90B]">2</span> {t('selectTradingStrategy', language)}
<Sparkles className="w-4 h-4 text-[#F0B90B]" />
</h3>
<div className="space-y-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('useStrategy', language)}
</label>
<select
value={formData.strategy_id}
onChange={(e) =>
handleInputChange('strategy_id', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
<option value="">{t('noStrategyManual', language)}</option>
{strategies.map((strategy) => (
<option key={strategy.id} value={strategy.id}>
{strategy.name}
{strategy.is_active ? t('strategyActive', language) : ''}
{strategy.is_default ? t('strategyDefault', language) : ''}
</option>
))}
</select>
{strategies.length === 0 && (
<p className="text-xs text-[#848E9C] mt-2">
{t('noStrategyHint', language)}
</p>
)}
</div>
{/* Strategy Preview */}
{selectedStrategy && (
<div className="mt-3 p-4 bg-[#1E2329] border border-[#2B3139] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-[#F0B90B] text-sm font-medium">
{t('strategyDetails', language)}
</span>
{selectedStrategy.is_active && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
{t('activating', language)}
</span>
)}
</div>
<p className="text-sm text-[#848E9C] mb-2">
{selectedStrategy.description || (language === 'zh' ? '无描述' : 'No description')}
</p>
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>
{t('coinSource', language)}: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' :
selectedStrategy.config.coin_source.source_type === 'ai500' ? 'AI500' :
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
</div>
<div>
{t('marginLimit', language)}: {((selectedStrategy.config.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
</div>
</div>
</div>
)}
</div>
</div>
{/* Trading Parameters */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
<span className="text-[#F0B90B]">3</span> {t('tradingParams', language)}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('marginMode', language)}
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleInputChange('is_cross_margin', true)}
className={`flex-1 px-3 py-2 rounded text-sm ${
formData.is_cross_margin
? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
{t('crossMargin', language)}
</button>
<button
type="button"
onClick={() =>
handleInputChange('is_cross_margin', false)
}
className={`flex-1 px-3 py-2 rounded text-sm ${
!formData.is_cross_margin
? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
{t('isolatedMargin', language)}
</button>
</div>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('aiScanInterval', language)}
</label>
<input
type="number"
value={formData.scan_interval_minutes}
onChange={(e) => {
const parsedValue = Number(e.target.value)
const safeValue = Number.isFinite(parsedValue)
? Math.max(3, parsedValue)
: 3
handleInputChange('scan_interval_minutes', safeValue)
}}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="3"
max="60"
step="1"
/>
<p className="text-xs text-gray-500 mt-1">
{t('scanIntervalRecommend', language)}
</p>
</div>
</div>
{/* Competition visibility */}
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('competitionDisplay', language)}
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleInputChange('show_in_competition', true)}
className={`flex-1 px-3 py-2 rounded text-sm ${
formData.show_in_competition
? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
{t('show', language)}
</button>
<button
type="button"
onClick={() => handleInputChange('show_in_competition', false)}
className={`flex-1 px-3 py-2 rounded text-sm ${
!formData.show_in_competition
? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
{t('hide', language)}
</button>
</div>
<p className="text-xs text-[#848E9C] mt-1">
{t('hiddenInCompetition', language)}
</p>
</div>
{/* Initial Balance (Edit mode only) */}
{isEditMode && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]">
{t('initialBalanceLabel', language)}
</label>
<button
type="button"
onClick={handleFetchCurrentBalance}
disabled={isFetchingBalance}
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors disabled:bg-[#848E9C] disabled:cursor-not-allowed"
>
{isFetchingBalance ? t('fetching', language) : t('fetchCurrentBalance', language)}
</button>
</div>
<input
type="number"
value={formData.initial_balance || 0}
onChange={(e) =>
handleInputChange(
'initial_balance',
Number(e.target.value)
)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="100"
step="0.01"
/>
<p className="text-xs text-[#848E9C] mt-1">
{t('balanceUpdateHint', language)}
</p>
{balanceFetchError && (
<p className="text-xs text-red-500 mt-1">
{balanceFetchError}
</p>
)}
</div>
)}
{/* Create mode info */}
{!isEditMode && (
<div className="p-3 bg-[#1E2329] border border-[#2B3139] rounded flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4 text-[#F0B90B]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>
<span className="text-sm text-[#848E9C]">
{t('autoFetchBalanceInfo', language)}
</span>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35] sticky bottom-0 z-10 rounded-b-xl">
<button
onClick={onClose}
className="px-6 py-3 bg-[#2B3139] text-[#EAECEF] rounded-lg hover:bg-[#404750] transition-all duration-200 border border-[#404750]"
>
{t('cancel', language)}
</button>
{onSave && (
<button
onClick={handleSave}
disabled={
isSaving ||
!formData.trader_name ||
!formData.ai_model ||
!formData.exchange_id
}
className="px-8 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 disabled:bg-[#848E9C] disabled:cursor-not-allowed font-medium shadow-lg"
>
{isSaving ? t('saving', language) : isEditMode ? t('editTrader', language) : t('createTraderButton', language)}
</button>
)}
</div>
</div>
</div>
)
}