feat(agent): add NOFXi agent chat workflow (#1495)

- Add NOFXi agent backend: central brain, planner runtime, skill routing,
  memory/state handling, config validation, and action execution
- Add agent chat page with SSE streaming, step/status panels, and
  user preferences
- Extend trader/model/exchange/strategy APIs and store for agent-driven
  configuration
- Add stopCh guard in async maintenance goroutine to prevent leak on Stop()
- Add timeout context for trader diagnosis LLM calls
- Add TargetRef nil guards in all execute*Action handlers
- Add ensureHistory() for nil-safe history access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shinchan-zhai
2026-05-11 16:52:38 +08:00
120 changed files with 23558 additions and 7004 deletions

View File

@@ -1,9 +1,4 @@
interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
import type { AgentStep } from '../../types/agent'
interface AgentStepPanelProps {
steps?: AgentStep[]
@@ -23,6 +18,16 @@ export function AgentStepPanel({ steps, visible }: AgentStepPanelProps) {
return null
}
const sanitizedSteps = steps.filter((step) => {
const label = step.label.trim().toLowerCase()
const detail = (step.detail || '').trim().toLowerCase()
return !(label.startsWith('tool:') || detail === 'central_brain')
})
if (sanitizedSteps.length === 0) {
return null
}
return (
<div
style={{
@@ -46,7 +51,7 @@ export function AgentStepPanel({ steps, visible }: AgentStepPanelProps) {
Live Run
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{steps.map((step) => {
{sanitizedSteps.map((step) => {
const style = statusStyles[step.status]
return (
<div

View File

@@ -1,5 +1,12 @@
import { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react'
import { ArrowUp } from 'lucide-react'
import {
useRef,
useState,
useCallback,
useEffect,
useImperativeHandle,
forwardRef,
} from 'react'
import { ArrowUp, Square } from 'lucide-react'
export interface ChatInputHandle {
focus: () => void
@@ -10,43 +17,60 @@ export interface ChatInputHandle {
interface ChatInputProps {
language: string
loading: boolean
value: string
onChange: (value: string) => void
onSend: (text: string) => void
onStop: () => void
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
function ChatInput({ language, loading, onSend }, ref) {
const [input, setInput] = useState('')
function ChatInput(
{ language, loading, value, onChange, onSend, onStop },
ref
) {
const [composing, setComposing] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
setInput('')
if (inputRef.current) inputRef.current.style.height = 'auto'
},
getValue: () => input,
}))
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => {
onChange('')
if (inputRef.current) inputRef.current.style.height = 'auto'
},
getValue: () => value,
}),
[onChange, value]
)
const resizeInput = useCallback(() => {
const el = inputRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
}, [])
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value)
const el = e.target
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
onChange(e.target.value)
},
[]
[onChange]
)
const handleSend = () => {
const msg = input.trim()
const msg = value.trim()
if (!msg || loading) return
setInput('')
onChange('')
if (inputRef.current) inputRef.current.style.height = 'auto'
onSend(msg)
inputRef.current?.focus()
}
useEffect(() => {
resizeInput()
}, [resizeInput, value])
// Keyboard shortcut: Cmd+K to focus
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -84,7 +108,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
>
<textarea
ref={inputRef}
value={input}
value={value}
onChange={handleInputChange}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
@@ -115,26 +139,40 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
}}
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
onClick={loading ? onStop : handleSend}
disabled={!loading && !value.trim()}
title={
loading
? language === 'zh'
? '停止当前回复'
: 'Stop current response'
: language === 'zh'
? '发送'
: 'Send'
}
style={{
width: 36,
height: 36,
borderRadius: 12,
border: 'none',
background:
loading || !input.trim()
background: loading
? 'rgba(239,68,68,0.16)'
: !value.trim()
? 'rgba(255,255,255,0.04)'
: 'linear-gradient(135deg, #F0B90B, #d4a30a)',
color: loading || !input.trim() ? '#3c3c52' : '#000',
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
color: loading ? '#f87171' : !value.trim() ? '#3c3c52' : '#000',
cursor: !loading && !value.trim() ? 'not-allowed' : 'pointer',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
<ArrowUp size={16} strokeWidth={2.5} />
{loading ? (
<Square size={13} strokeWidth={2.6} fill="currentColor" />
) : (
<ArrowUp size={16} strokeWidth={2.5} />
)}
</button>
</div>
<div

View File

@@ -2,22 +2,7 @@ import { forwardRef } from 'react'
import { motion } from 'framer-motion'
import { AgentStepPanel } from './AgentStepPanel'
import { renderMessageContent } from './MessageRenderer'
interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
interface Message {
id: string
role: 'user' | 'bot'
text: string
time: string
streaming?: boolean
steps?: AgentStep[]
}
import type { AgentMessage as Message, AgentStep } from '../../types/agent'
interface ChatMessagesProps {
messages: Message[]
@@ -25,7 +10,14 @@ interface ChatMessagesProps {
function hasMeaningfulExecutionSteps(steps?: AgentStep[]) {
if (!steps || steps.length === 0) return false
return steps.some((step) => step.status !== 'planning')
return steps.some((step) => {
const label = step.label.trim().toLowerCase()
const detail = (step.detail || '').trim().toLowerCase()
if (label.startsWith('tool:') || detail === 'central_brain') {
return false
}
return step.status !== 'planning'
})
}
export const ChatMessages = forwardRef<HTMLDivElement, ChatMessagesProps>(

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { httpClient } from '../../lib/httpClient'
// icons reserved for future use
interface TickerData {
@@ -25,8 +26,11 @@ export function MarketTicker() {
const fetchTickers = async () => {
try {
// Batch fetch: single API call for all symbols
const res = await fetch(`/api/agent/tickers?symbols=${SYMBOLS.join(',')}`)
const data = await res.json()
const res = await httpClient.request<TickerData[]>(
`/api/agent/tickers?symbols=${SYMBOLS.join(',')}`,
{ silent: true }
)
const data = res.data
const map: Record<string, TickerData> = {}
if (Array.isArray(data)) {
data.forEach((r: TickerData) => {
@@ -49,7 +53,11 @@ export function MarketTicker() {
const formatPrice = (price: string) => {
const n = parseFloat(price)
if (n >= 1000) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (n >= 1000)
return n.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
if (n >= 1) return n.toFixed(2)
return n.toFixed(4)
}
@@ -76,13 +84,15 @@ export function MarketTicker() {
height: 56,
}}
>
<div style={{
width: '60%',
height: 10,
background: 'rgba(255,255,255,0.04)',
borderRadius: 4,
animation: 'pulse 1.5s infinite',
}} />
<div
style={{
width: '60%',
height: 10,
background: 'rgba(255,255,255,0.04)',
borderRadius: 4,
animation: 'pulse 1.5s infinite',
}}
/>
</div>
))}
<style>{`
@@ -104,7 +114,11 @@ export function MarketTicker() {
const isUp = pct > 0
const isDown = pct < 0
const color = isUp ? '#00e5a0' : isDown ? '#F6465D' : '#6c6c82'
const bgColor = isUp ? 'rgba(0,229,160,0.06)' : isDown ? 'rgba(246,70,93,0.06)' : 'rgba(108,108,130,0.06)'
const bgColor = isUp
? 'rgba(0,229,160,0.06)'
: isDown
? 'rgba(246,70,93,0.06)'
: 'rgba(108,108,130,0.06)'
const label = sym.replace('USDT', '')
const icon = SYMBOL_ICONS[label] || label[0]
@@ -149,7 +163,14 @@ export function MarketTicker() {
{icon}
</div>
<div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', letterSpacing: '-0.01em' }}>
<div
style={{
fontSize: 12.5,
fontWeight: 600,
color: '#e0e0ec',
letterSpacing: '-0.01em',
}}
>
{label}
</div>
<div style={{ fontSize: 10, color: '#4c4c62' }}>
@@ -158,16 +179,27 @@ export function MarketTicker() {
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', fontFamily: '"IBM Plex Mono", monospace', letterSpacing: '-0.02em' }}>
<div
style={{
fontSize: 12.5,
fontWeight: 600,
color: '#e0e0ec',
fontFamily: '"IBM Plex Mono", monospace',
letterSpacing: '-0.02em',
}}
>
${formatPrice(t.lastPrice)}
</div>
<div style={{
fontSize: 10.5,
fontWeight: 600,
color,
fontFamily: '"IBM Plex Mono", monospace',
}}>
{isUp ? '+' : ''}{pct.toFixed(2)}%
<div
style={{
fontSize: 10.5,
fontWeight: 600,
color,
fontFamily: '"IBM Plex Mono", monospace',
}}
>
{isUp ? '+' : ''}
{pct.toFixed(2)}%
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import useSWR from 'swr'
import { useEffect } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { ArrowUpRight, ArrowDownRight, Wallet } from 'lucide-react'
@@ -7,7 +8,7 @@ import type { Position, TraderInfo } from '../../types'
export function PositionsPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
const { data: traders, mutate: mutateTraders } = useSWR<TraderInfo[]>(
user && token ? 'agent-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
@@ -17,12 +18,21 @@ export function PositionsPanel() {
const runningTrader = traders?.find((t) => t.is_running)
const traderId = runningTrader?.trader_id
const { data: positions } = useSWR<Position[]>(
const { data: positions, mutate: mutatePositions } = useSWR<Position[]>(
traderId ? `agent-positions-${traderId}` : null,
() => api.getPositions(traderId),
{ refreshInterval: 15000, shouldRetryOnError: false }
)
useEffect(() => {
const handleRefresh = () => {
void mutateTraders()
void mutatePositions()
}
window.addEventListener('agent-config-refresh', handleRefresh)
return () => window.removeEventListener('agent-config-refresh', handleRefresh)
}, [mutatePositions, mutateTraders])
if (!user || !token) {
return (
<div

View File

@@ -1,4 +1,5 @@
import useSWR from 'swr'
import { useEffect } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { Activity, CircleOff, Bot } from 'lucide-react'
@@ -7,12 +8,20 @@ import type { TraderInfo } from '../../types'
export function TraderStatusPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
const { data: traders, mutate } = useSWR<TraderInfo[]>(
user && token ? 'agent-sidebar-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
useEffect(() => {
const handleRefresh = () => {
void mutate()
}
window.addEventListener('agent-config-refresh', handleRefresh)
return () => window.removeEventListener('agent-config-refresh', handleRefresh)
}, [mutate])
if (!user || !token) {
return (
<div

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { httpClient, ApiError } from '../../lib/httpClient'
interface Preference {
id: string
@@ -27,12 +28,11 @@ export function UserPreferencesPanel({ token, language }: Props) {
setLoading(true)
try {
const res = await fetch('/api/agent/preferences', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to load preferences')
const data = await res.json()
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
const resp = await httpClient.get<{ preferences: Preference[] }>(
'/api/agent/preferences'
)
const prefs = resp.data?.preferences
setPreferences(Array.isArray(prefs) ? prefs : [])
setError(null)
} catch {
setError(language === 'zh' ? '加载偏好失败' : 'Failed to load')
@@ -70,21 +70,19 @@ export function UserPreferencesPanel({ token, language }: Props) {
setSaving(true)
try {
const res = await fetch('/api/agent/preferences', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'save failed')
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
const resp = await httpClient.post<{ preferences: Preference[] }>(
'/api/agent/preferences',
{ text }
)
const prefs = resp.data?.preferences
setPreferences(Array.isArray(prefs) ? prefs : [])
setDraft('')
setError(null)
} catch {
setError(language === 'zh' ? '保存偏好失败' : 'Failed to save')
} catch (err) {
const message = err instanceof ApiError ? err.message : ''
setError(
message || (language === 'zh' ? '保存偏好失败' : 'Failed to save')
)
} finally {
setSaving(false)
}
@@ -94,13 +92,11 @@ export function UserPreferencesPanel({ token, language }: Props) {
if (!token || saving) return
setSaving(true)
try {
const res = await fetch(`/api/agent/preferences/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'delete failed')
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
const resp = await httpClient.delete<{ preferences: Preference[] }>(
`/api/agent/preferences/${encodeURIComponent(id)}`
)
const prefs = resp.data?.preferences
setPreferences(Array.isArray(prefs) ? prefs : [])
setError(null)
} catch {
setError(language === 'zh' ? '删除偏好失败' : 'Failed to delete')
@@ -123,7 +119,14 @@ export function UserPreferencesPanel({ token, language }: Props) {
<div style={{ color: '#d7d7e0', fontSize: 12, fontWeight: 600 }}>
{language === 'zh' ? '长期偏好' : 'Persistent Preferences'}
</div>
<div style={{ color: '#77778d', fontSize: 11, lineHeight: 1.5, marginTop: 4 }}>
<div
style={{
color: '#77778d',
fontSize: 11,
lineHeight: 1.5,
marginTop: 4,
}}
>
{language === 'zh'
? '把长期偏好固定下来,比如“默认用中文回答”或“优先关注 BTC 和 ETH”。'
: 'Pin durable preferences the agent should keep in mind, like answering in Chinese or focusing on BTC and ETH.'}
@@ -138,7 +141,11 @@ export function UserPreferencesPanel({ token, language }: Props) {
onKeyDown={(e) => {
if (e.key === 'Enter') void addPreference()
}}
placeholder={language === 'zh' ? '例如:默认用中文回答,优先关注 BTC、ETH' : 'Example: Answer in Chinese and focus on BTC, ETH'}
placeholder={
language === 'zh'
? '例如:默认用中文回答,优先关注 BTC、ETH'
: 'Example: Answer in Chinese and focus on BTC, ETH'
}
style={{
flex: 1,
background: 'rgba(255,255,255,0.03)',
@@ -154,7 +161,10 @@ export function UserPreferencesPanel({ token, language }: Props) {
onClick={() => void addPreference()}
disabled={!draft.trim() || saving}
style={{
background: draft.trim() && !saving ? 'rgba(240,185,11,0.12)' : 'rgba(255,255,255,0.04)',
background:
draft.trim() && !saving
? 'rgba(240,185,11,0.12)'
: 'rgba(255,255,255,0.04)',
color: draft.trim() && !saving ? '#F0B90B' : '#6d6d82',
border: '1px solid rgba(240,185,11,0.14)',
borderRadius: 8,
@@ -168,7 +178,9 @@ export function UserPreferencesPanel({ token, language }: Props) {
</div>
{error && (
<div style={{ color: '#f08a8a', fontSize: 11, marginBottom: 8 }}>{error}</div>
<div style={{ color: '#f08a8a', fontSize: 11, marginBottom: 8 }}>
{error}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
@@ -196,7 +208,14 @@ export function UserPreferencesPanel({ token, language }: Props) {
border: '1px solid rgba(255,255,255,0.04)',
}}
>
<div style={{ flex: 1, color: '#d7d7e0', fontSize: 12, lineHeight: 1.5 }}>
<div
style={{
flex: 1,
color: '#d7d7e0',
fontSize: 12,
lineHeight: 1.5,
}}
>
{pref.text}
</div>
<button

View File

@@ -116,8 +116,6 @@ export default function HeaderBar({
path: ROUTES.agent,
label: 'Agent',
requiresAuth: false,
badge: 'Beta',
hidden: true,
},
{
page: 'data',
@@ -453,8 +451,6 @@ export default function HeaderBar({
path: ROUTES.agent,
label: 'Agent',
requiresAuth: false,
badge: 'Beta',
hidden: true,
},
{
page: 'data',

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap } from 'lucide-react'
import type { CoinSourceConfig } from '../../types'
import { coinSource, ts } from '../../i18n/strategy-translations'
import { NofxSelect } from '../ui/select'
@@ -27,31 +27,6 @@ export function CoinSourceEditor({
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
] as const
// Calculate mixed mode summary
const getMixedSummary = () => {
const sources: string[] = []
let totalLimit = 0
if (config.use_ai500) {
sources.push(`AI500(${config.ai500_limit || 3})`)
totalLimit += config.ai500_limit || 3
}
if (config.use_oi_top) {
sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 3})`)
totalLimit += config.oi_top_limit || 3
}
if (config.use_oi_low) {
sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 3})`)
totalLimit += config.oi_low_limit || 3
}
if ((config.static_coins || []).length > 0) {
sources.push(`${ts(coinSource.custom, language)}(${config.static_coins?.length || 0})`)
totalLimit += config.static_coins?.length || 0
}
return { sources, totalLimit }
}
// xyz dex assets (stocks, forex, commodities) - should NOT get USDT suffix
const xyzDexAssets = new Set([
// Stocks
@@ -453,228 +428,6 @@ export function CoinSourceEditor({
</div>
</div>
)}
{/* Mixed Mode - Unified Card Selector */}
{config.source_type === 'mixed' && (
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="flex items-center gap-2 mb-4">
<Shuffle className="w-4 h-4 text-blue-400" />
<span className="text-sm font-medium text-nofx-text">
{ts(coinSource.mixedConfig, language)}
</span>
</div>
{/* 4 Source Cards in 2x2 Grid */}
<div className="grid grid-cols-2 gap-3 mb-4">
{/* AI500 Card */}
<div
className={`p-3 rounded-lg border transition-all cursor-pointer ${
config.use_ai500
? 'bg-nofx-gold/10 border-nofx-gold/50'
: 'bg-nofx-bg border-nofx-border hover:border-nofx-gold/30'
}`}
onClick={() => !disabled && onChange({ ...config, use_ai500: !config.use_ai500 })}
>
<div className="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={config.use_ai500}
onChange={(e) => !disabled && onChange({ ...config, use_ai500: e.target.checked })}
disabled={disabled}
className="w-4 h-4 rounded accent-nofx-gold"
onClick={(e) => e.stopPropagation()}
/>
<Database className="w-4 h-4 text-nofx-gold" />
<span className="text-sm font-medium text-nofx-text">AI500</span>
<NofxOSBadge />
</div>
{config.use_ai500 && (
<div className="flex items-center gap-2 mt-2 pl-6">
<span className="text-xs text-nofx-text-muted">Limit:</span>
<NofxSelect
value={config.ai500_limit || 3}
onChange={(val) => !disabled && onChange({ ...config, ai500_limit: parseInt(val) || 3 })}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
</div>
)}
</div>
{/* OI Top Card */}
<div
className={`p-3 rounded-lg border transition-all cursor-pointer ${
config.use_oi_top
? 'bg-nofx-success/10 border-nofx-success/50'
: 'bg-nofx-bg border-nofx-border hover:border-nofx-success/30'
}`}
onClick={() => !disabled && onChange({ ...config, use_oi_top: !config.use_oi_top })}
>
<div className="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={config.use_oi_top}
onChange={(e) => !disabled && onChange({ ...config, use_oi_top: e.target.checked })}
disabled={disabled}
className="w-4 h-4 rounded accent-nofx-success"
onClick={(e) => e.stopPropagation()}
/>
<TrendingUp className="w-4 h-4 text-nofx-success" />
<span className="text-sm font-medium text-nofx-text">
{ts(coinSource.oiIncreaseLabel, language)}
</span>
</div>
<p className="text-xs text-nofx-text-muted pl-6 mb-1">
{ts(coinSource.forLong, language)}
</p>
{config.use_oi_top && (
<div className="flex items-center gap-2 mt-2 pl-6">
<span className="text-xs text-nofx-text-muted">Limit:</span>
<NofxSelect
value={config.oi_top_limit || 3}
onChange={(val) => !disabled && onChange({ ...config, oi_top_limit: parseInt(val) || 3 })}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
</div>
)}
</div>
{/* OI Low Card */}
<div
className={`p-3 rounded-lg border transition-all cursor-pointer ${
config.use_oi_low
? 'bg-nofx-danger/10 border-nofx-danger/50'
: 'bg-nofx-bg border-nofx-border hover:border-nofx-danger/30'
}`}
onClick={() => !disabled && onChange({ ...config, use_oi_low: !config.use_oi_low })}
>
<div className="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={config.use_oi_low}
onChange={(e) => !disabled && onChange({ ...config, use_oi_low: e.target.checked })}
disabled={disabled}
className="w-4 h-4 rounded accent-red-500"
onClick={(e) => e.stopPropagation()}
/>
<TrendingDown className="w-4 h-4 text-nofx-danger" />
<span className="text-sm font-medium text-nofx-text">
{ts(coinSource.oiDecreaseLabel, language)}
</span>
</div>
<p className="text-xs text-nofx-text-muted pl-6 mb-1">
{ts(coinSource.forShort, language)}
</p>
{config.use_oi_low && (
<div className="flex items-center gap-2 mt-2 pl-6">
<span className="text-xs text-nofx-text-muted">Limit:</span>
<NofxSelect
value={config.oi_low_limit || 3}
onChange={(val) => !disabled && onChange({ ...config, oi_low_limit: parseInt(val) || 3 })}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
</div>
)}
</div>
{/* Static/Custom Card */}
<div
className={`p-3 rounded-lg border transition-all cursor-pointer ${
(config.static_coins || []).length > 0
? 'bg-gray-500/10 border-gray-500/50'
: 'bg-nofx-bg border-nofx-border hover:border-gray-500/30'
}`}
>
<div className="flex items-center gap-2 mb-2">
<List className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium text-nofx-text">
{ts(coinSource.custom, language)}
</span>
{(config.static_coins || []).length > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-500/20 text-gray-400">
{config.static_coins?.length}
</span>
)}
</div>
<div className="flex flex-wrap gap-1 mt-2">
{(config.static_coins || []).slice(0, 3).map((coin) => (
<span
key={coin}
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-nofx-bg-lighter text-nofx-text"
>
{coin}
{!disabled && (
<button
onClick={(e) => {
e.stopPropagation()
handleRemoveCoin(coin)
}}
className="hover:text-red-400 transition-colors"
>
<X className="w-2.5 h-2.5" />
</button>
)}
</span>
))}
{(config.static_coins || []).length > 3 && (
<span className="text-xs text-nofx-text-muted">
+{(config.static_coins?.length || 0) - 3}
</span>
)}
</div>
{!disabled && (
<div className="flex gap-1 mt-2">
<input
type="text"
value={newCoin}
onChange={(e) => setNewCoin(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') handleAddCoin()
}}
onClick={(e) => e.stopPropagation()}
placeholder="BTC, ETH..."
className="flex-1 px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
<button
onClick={(e) => {
e.stopPropagation()
handleAddCoin()
}}
className="px-2 py-1 rounded text-xs bg-nofx-gold text-black hover:bg-yellow-500"
>
<Plus className="w-3 h-3" />
</button>
</div>
)}
</div>
</div>
{/* Summary */}
{(() => {
const { sources, totalLimit } = getMixedSummary()
if (sources.length === 0) return null
return (
<div className="p-2 rounded bg-nofx-bg border border-nofx-border">
<div className="flex items-center justify-between text-xs">
<span className="text-nofx-text-muted">{ts(coinSource.mixedSummary, language)}:</span>
<span className="text-nofx-text font-medium">
{sources.join(' + ')}
</span>
</div>
<div className="text-xs text-nofx-text-muted mt-1">
{ts(coinSource.maxCoins, language)} {totalLimit} {ts(coinSource.coins, language)}
</div>
</div>
)
})()}
</div>
)}
</div>
)
}

View File

@@ -38,7 +38,7 @@ export function RiskControlEditor({
<div className="grid grid-cols-1 gap-4 mb-4">
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.maxPositions, language)}
@@ -46,22 +46,14 @@ export function RiskControlEditor({
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.maxPositionsDesc, language)}
</p>
<input
type="number"
value={config.max_positions ?? 3}
onChange={(e) =>
updateField('max_positions', parseInt(e.target.value) || 3)
}
disabled={disabled}
min={1}
max={3}
className="w-32 px-3 py-2 rounded"
style={{
background: '#1E2329',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
<div className="flex items-center gap-3">
<span className="font-mono text-lg" style={{ color: '#0ECB81' }}>
{config.max_positions ?? 3}
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
</div>
@@ -156,24 +148,15 @@ export function RiskControlEditor({
{ts(riskControl.btcEthPositionValueRatioDesc, language)}
</p>
<div className="flex items-center gap-2">
<input
type="range"
value={config.btc_eth_max_position_value_ratio ?? 5}
onChange={(e) =>
updateField('btc_eth_max_position_value_ratio', parseFloat(e.target.value))
}
disabled={disabled}
min={0.5}
max={10}
step={0.5}
className="flex-1 accent-green-500"
/>
<span
className="w-12 text-center font-mono"
style={{ color: '#0ECB81' }}
>
{config.btc_eth_max_position_value_ratio ?? 5}x
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
@@ -188,24 +171,15 @@ export function RiskControlEditor({
{ts(riskControl.altcoinPositionValueRatioDesc, language)}
</p>
<div className="flex items-center gap-2">
<input
type="range"
value={config.altcoin_max_position_value_ratio ?? 1}
onChange={(e) =>
updateField('altcoin_max_position_value_ratio', parseFloat(e.target.value))
}
disabled={disabled}
min={0.5}
max={10}
step={0.5}
className="flex-1 accent-green-500"
/>
<span
className="w-12 text-center font-mono"
style={{ color: '#0ECB81' }}
>
{config.altcoin_max_position_value_ratio ?? 1}x
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
</div>
@@ -264,20 +238,12 @@ export function RiskControlEditor({
{ts(riskControl.maxMarginUsageDesc, language)}
</p>
<div className="flex items-center gap-2">
<input
type="range"
value={(config.max_margin_usage ?? 0.9) * 100}
onChange={(e) =>
updateField('max_margin_usage', parseInt(e.target.value) / 100)
}
disabled={disabled}
min={10}
max={100}
className="flex-1 accent-green-500"
/>
<span className="w-12 text-center font-mono" style={{ color: '#0ECB81' }}>
{Math.round((config.max_margin_usage ?? 0.9) * 100)}%
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
</div>
@@ -295,7 +261,7 @@ export function RiskControlEditor({
<div className="grid grid-cols-2 gap-4">
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.minPositionSize, language)}
@@ -303,26 +269,16 @@ export function RiskControlEditor({
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.minPositionSizeDesc, language)}
</p>
<div className="flex items-center">
<input
type="number"
value={config.min_position_size ?? 12}
onChange={(e) =>
updateField('min_position_size', parseFloat(e.target.value) || 12)
}
disabled={disabled}
min={10}
max={1000}
className="w-24 px-3 py-2 rounded"
style={{
background: '#1E2329',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
<div className="flex items-center gap-2">
<span className="font-mono text-lg" style={{ color: '#0ECB81' }}>
{config.min_position_size ?? 12}
</span>
<span className="ml-2" style={{ color: '#848E9C' }}>
USDT
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>

View File

@@ -45,12 +45,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [allModels, setAllModels] = useState<AIModel[]>([])
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<
Set<string>
>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<
Set<string>
>(new Set())
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
const [copiedId, setCopiedId] = useState<string | null>(null)
const loadConfigs = async () => {
@@ -76,7 +72,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
// Toggle wallet address visibility for a trader
const toggleTraderAddressVisibility = (traderId: string) => {
setVisibleTraderAddresses((prev) => {
setVisibleTraderAddresses(prev => {
const next = new Set(prev)
if (next.has(traderId)) {
next.delete(traderId)
@@ -89,7 +85,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
// Toggle wallet address visibility for an exchange
const toggleExchangeAddressVisibility = (exchangeId: string) => {
setVisibleExchangeAddresses((prev) => {
setVisibleExchangeAddresses(prev => {
const next = new Set(prev)
if (next.has(exchangeId)) {
next.delete(exchangeId)
@@ -184,8 +180,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
const getExchangeUsageInfo = (exchangeId: string) => {
const usingTraders =
traders?.filter((tr) => tr.exchange_id === exchangeId) || []
const usingTraders = traders?.filter((tr) => tr.exchange_id === exchangeId) || []
const runningCount = usingTraders.filter((tr) => tr.is_running).length
const totalCount = usingTraders.length
return { runningCount, totalCount, usingTraders }
@@ -265,7 +260,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
ai_model_id: data.ai_model_id,
exchange_id: data.exchange_id,
strategy_id: data.strategy_id,
initial_balance: data.initial_balance,
scan_interval_minutes: data.scan_interval_minutes,
is_cross_margin: data.is_cross_margin,
show_in_competition: data.show_in_competition,
@@ -303,10 +297,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
try {
if (running) {
await api.stopTrader(traderId)
toast.success(t('aiTradersToast.stopped', language))
toast.success(t('aiTradersToast.stopped', language))
} else {
await api.startTrader(traderId)
toast.success(t('aiTradersToast.started', language))
toast.success(t('aiTradersToast.started', language))
}
await mutateTraders()
@@ -316,18 +310,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
}
const handleToggleCompetition = async (
traderId: string,
currentShowInCompetition: boolean
) => {
const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => {
try {
const newValue = !currentShowInCompetition
await api.toggleCompetition(traderId, newValue)
toast.success(
newValue
? t('aiTradersToast.showInCompetition', language)
: t('aiTradersToast.hideInCompetition', language)
)
toast.success(newValue ? t('aiTradersToast.showInCompetition', language) : t('aiTradersToast.hideInCompetition', language))
await mutateTraders()
} catch (error) {
@@ -464,12 +451,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
allModels?.map((m) =>
m.id === modelId
? {
...m,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}
...m,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}
: m
) || []
} else {
@@ -584,7 +571,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
await api.updateExchangeConfigsEncrypted(request)
toast.success(t('aiTradersToast.exchangeConfigUpdated', language))
toast.success(t('aiTradersToast.exchangeConfigUpdated', language))
} else {
const createRequest = {
exchange_type: exchangeType,
@@ -605,7 +592,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
await api.createExchangeEncrypted(createRequest)
toast.success(t('aiTradersToast.exchangeCreated', language))
toast.success(t('aiTradersToast.exchangeCreated', language))
}
const refreshedExchanges = await api.getExchangeConfigs()
@@ -688,10 +675,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<button
onClick={() => setShowCreateModal(true)}
disabled={
configuredModels.length === 0 ||
configuredExchanges.length === 0
}
disabled={configuredModels.length === 0 || configuredExchanges.length === 0}
className="group relative px-6 py-2 rounded text-xs font-bold font-mono uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap overflow-hidden bg-nofx-gold text-black hover:bg-yellow-400 shadow-[0_0_20px_rgba(240,185,11,0.2)] hover:shadow-[0_0_30px_rgba(240,185,11,0.4)]"
>
<span className="relative z-10 flex items-center gap-2">

View File

@@ -4,12 +4,11 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react'
import type { AIModel } from '../../types'
import type { Language } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { api } from '../../lib/api'
import { getBeginnerWalletAddress } from '../../lib/onboarding'
import { getModelIcon } from '../common/ModelIcons'
import { ModelStepIndicator } from './ModelStepIndicator'
import { ModelCard } from './ModelCard'
import {
BLOCKRUN_MODELS,
CLAW402_MODELS,
AI_PROVIDER_CONFIG,
DEFAULT_CLAW402_MODEL,
@@ -51,8 +50,6 @@ export function ModelConfigModal({
const selectedModel =
allModels?.find((m) => m.id === selectedModelId) ||
configuredModels?.find((m) => m.id === selectedModelId)
const configuredModel =
configuredModels?.find((m) => m.id === selectedModelId) || null
useEffect(() => {
if (editingModelId && selectedModel) {
@@ -78,33 +75,13 @@ export function ModelConfigModal({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedModelId) return
const key = apiKey.trim()
// Allow empty key when editing an existing model (backend preserves existing key)
if (!key && !editingModelId) return
const trimmedModelName = modelName.trim()
const resolvedModelName =
selectedModel?.provider === 'claw402'
? trimmedModelName || DEFAULT_CLAW402_MODEL
: trimmedModelName || undefined
onSave(selectedModelId, key, baseUrl.trim() || undefined, resolvedModelName)
if (!selectedModelId || !apiKey.trim()) return
onSave(selectedModelId, apiKey.trim(), baseUrl.trim() || undefined, modelName.trim() || undefined)
}
const availableModels = allModels || []
const configuredIds = new Set(configuredModels?.map((m) => m.id) || [])
const isClaw402Selected =
selectedModel?.provider === 'claw402' || selectedModel?.id === 'claw402'
const stepLabels = [
t('modelConfig.selectModel', language),
t(
!selectedModel
? 'modelConfig.configure'
: isClaw402Selected
? 'modelConfig.configureWallet'
: 'modelConfig.configure',
language
),
]
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
const stepLabels = [t('modelConfig.selectModel', language), t('modelConfig.configureApi', language)]
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
@@ -192,22 +169,18 @@ export function ModelConfigModal({
)}
{/* Step 1: Configure — Claw402 Dedicated UI */}
{(currentStep === 1 || editingModelId) &&
selectedModel &&
(selectedModel.provider === 'claw402' ||
selectedModel.id === 'claw402') && (
<Claw402ConfigForm
apiKey={apiKey}
modelName={modelName}
configuredModel={configuredModel}
editingModelId={editingModelId}
onApiKeyChange={setApiKey}
onModelNameChange={setModelName}
onBack={handleBack}
onSubmit={handleSubmit}
language={language}
/>
)}
{(currentStep === 1 || editingModelId) && selectedModel && (selectedModel.provider === 'claw402' || selectedModel.id === 'claw402') && (
<Claw402ConfigForm
apiKey={apiKey}
modelName={modelName}
editingModelId={editingModelId}
onApiKeyChange={setApiKey}
onModelNameChange={setModelName}
onBack={handleBack}
onSubmit={handleSubmit}
language={language}
/>
)}
{/* Step 1: Configure — Standard Providers (non-claw402) */}
{(currentStep === 1 || editingModelId) &&
@@ -249,14 +222,6 @@ function ModelSelectionStep({
onSelectModel: (modelId: string) => void
language: Language
}) {
const [showOtherProviders, setShowOtherProviders] = useState(false)
const claw402Model =
availableModels.find((model) => model.provider === 'claw402') || null
const otherProviders = availableModels.filter(
(model) =>
model.provider !== 'claw402' && !model.provider?.startsWith('blockrun')
)
return (
<div className="space-y-4">
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
@@ -264,11 +229,12 @@ function ModelSelectionStep({
</div>
{/* Claw402 Featured Card */}
{claw402Model && (
{availableModels.some(m => m.provider === 'claw402') && (
<button
type="button"
onClick={() => {
onSelectModel(claw402Model.id)
const claw = availableModels.find(m => m.provider === 'claw402')
if (claw) onSelectModel(claw.id)
}}
className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]"
style={{
@@ -313,11 +279,8 @@ function ModelSelectionStep({
</div>
</div>
<div className="flex items-center gap-2">
{configuredIds.has(claw402Model.id) && (
<div
className="w-2 h-2 rounded-full"
style={{ background: '#00E096' }}
/>
{configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && (
<div className="w-2 h-2 rounded-full" style={{ background: '#00E096' }} />
)}
<div
className="px-3 py-1.5 rounded-full text-xs font-bold"
@@ -342,68 +305,41 @@ function ModelSelectionStep({
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
</span>
</div>
<div
className="mt-4 ml-[52px] text-[11px]"
style={{ color: '#A0AEC0' }}
>
{t('modelConfig.claw402EntryDesc', language)}
</div>
</button>
)}
{otherProviders.length > 0 && (
<div className="rounded-xl border border-white/10 bg-black/20 overflow-hidden">
<button
type="button"
onClick={() => setShowOtherProviders((prev) => !prev)}
className="w-full flex items-center justify-between px-4 py-4 text-left transition-all hover:bg-white/5"
>
<div>
<div
className="text-sm font-semibold"
style={{ color: '#EAECEF' }}
>
{t('modelConfig.otherApiEntry', language)}
</div>
<div className="mt-1 text-xs" style={{ color: '#848E9C' }}>
{t('modelConfig.otherApiEntryDesc', language)}
</div>
</div>
<div className="flex items-center gap-3">
<span
className="rounded-full border border-white/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.2em]"
style={{ color: '#A0AEC0' }}
>
{otherProviders.length} API
</span>
<span className="text-sm" style={{ color: '#60A5FA' }}>
{showOtherProviders ? '' : '+'}
</span>
</div>
</button>
{showOtherProviders && (
<div className="border-t border-white/5 px-4 py-4">
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{otherProviders.map((model) => (
<ModelCard
key={model.id}
model={model}
selected={selectedModelId === model.id}
onClick={() => onSelectModel(model.id)}
configured={configuredIds.has(model.id)}
/>
))}
</div>
<div
className="text-xs text-center pt-3"
style={{ color: '#848E9C' }}
>
{t('modelConfig.modelsConfigured', language)}
</div>
</div>
)}
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{availableModels.filter(m => !m.provider?.startsWith('blockrun') && m.provider !== 'claw402').map((model) => (
<ModelCard
key={model.id}
model={model}
selected={selectedModelId === model.id}
onClick={() => onSelectModel(model.id)}
configured={configuredIds.has(model.id)}
/>
))}
</div>
{availableModels.some(m => m.provider?.startsWith('blockrun')) && (
<>
<div className="flex items-center gap-3 pt-2">
<div className="flex-1 h-px" style={{ background: '#2B3139' }} />
<span className="text-xs font-medium px-2" style={{ color: '#848E9C' }}>
{t('modelConfig.viaBlockrunWallet', language)}
</span>
<div className="flex-1 h-px" style={{ background: '#2B3139' }} />
</div>
<div className="grid grid-cols-2 gap-3">
{availableModels.filter(m => m.provider?.startsWith('blockrun')).map((model) => (
<ModelCard
key={model.id}
model={model}
selected={selectedModelId === model.id}
onClick={() => onSelectModel(model.id)}
configured={configuredIds.has(model.id)}
/>
))}
</div>
</>
)}
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
{t('modelConfig.modelsConfigured', language)}
@@ -415,7 +351,6 @@ function ModelSelectionStep({
function Claw402ConfigForm({
apiKey,
modelName,
configuredModel,
editingModelId,
onApiKeyChange,
onModelNameChange,
@@ -425,7 +360,6 @@ function Claw402ConfigForm({
}: {
apiKey: string
modelName: string
configuredModel: AIModel | null
editingModelId: string | null
onApiKeyChange: (value: string) => void
onModelNameChange: (value: string) => void
@@ -447,19 +381,6 @@ function Claw402ConfigForm({
message: string
} | null>(null)
const [testing, setTesting] = useState(false)
const [serverWalletAddress, setServerWalletAddress] = useState('')
const [serverWalletBalance, setServerWalletBalance] = useState<string | null>(
null
)
const localWalletAddress = getBeginnerWalletAddress()?.trim() || ''
const configuredWalletAddress =
configuredModel?.walletAddress?.trim() ||
localWalletAddress ||
serverWalletAddress
const resolvedWalletAddress = walletAddress || configuredWalletAddress
const resolvedUsdcBalance =
usdcBalance ?? configuredModel?.balanceUsdc ?? serverWalletBalance ?? null
const hasExistingWallet = Boolean(configuredWalletAddress)
// Client-side validation helper
const getClientError = (key: string): string => {
@@ -478,39 +399,8 @@ function Claw402ConfigForm({
apiKey.startsWith('0x') &&
/^0x[0-9a-fA-F]{64}$/.test(apiKey)
useEffect(() => {
if (hasExistingWallet) {
setShowDeposit(true)
}
}, [hasExistingWallet])
// Truncate address for display
useEffect(() => {
if (
configuredModel?.walletAddress ||
localWalletAddress ||
serverWalletAddress
) {
return
}
let cancelled = false
void api
.getCurrentBeginnerWallet()
.then((result) => {
setClaw402Status(result.claw402_status || 'unknown')
if (cancelled || !result.found || !result.address) {
return
}
setServerWalletAddress(result.address)
setServerWalletBalance(result.balance_usdc || null)
})
.catch(() => {
// Ignore silently: this is a best-effort fallback for showing the current wallet.
})
return () => {
cancelled = true
}
}, [configuredModel?.walletAddress, localWalletAddress, serverWalletAddress])
// Debounced validation when apiKey changes
useEffect(() => {
@@ -558,24 +448,6 @@ function Claw402ConfigForm({
setTesting(true)
setTestResult(null)
try {
if (!apiKey && hasExistingWallet) {
const result = await api.getCurrentBeginnerWallet()
setClaw402Status(result.claw402_status || 'unknown')
if (result.found && result.address) {
setWalletAddress(result.address)
setUsdcBalance(result.balance_usdc || '0.00')
setShowDeposit(true)
}
setTestResult({
status: result.claw402_status === 'ok' ? 'ok' : 'error',
message:
result.claw402_status === 'ok'
? t('modelConfig.claw402Connected', language)
: t('modelConfig.claw402Unreachable', language),
})
return
}
const res = await fetch('/api/wallet/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -654,34 +526,6 @@ function Claw402ConfigForm({
)
)}
</div>
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
<button
type="button"
onClick={handleTestConnection}
disabled={testing || (!hasExistingWallet && !isKeyValid)}
className="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-semibold transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:opacity-50"
style={{
background: 'rgba(37, 99, 235, 0.15)',
border: '1px solid rgba(37, 99, 235, 0.3)',
color: '#60A5FA',
}}
>
<span>🔗</span>
{testing
? t('modelConfig.testingConnection', language)
: t('modelConfig.testConnection', language)}
</button>
{claw402Status ? (
<div
className="text-xs"
style={{ color: claw402Status === 'ok' ? '#00E096' : '#F59E0B' }}
>
{claw402Status === 'ok'
? t('modelConfig.claw402Connected', language)
: t('modelConfig.claw402Unreachable', language)}
</div>
) : null}
</div>
</div>
{/* Step 1: Select AI Model */}
@@ -698,7 +542,7 @@ function Claw402ConfigForm({
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{CLAW402_MODELS.map((m) => {
const isSelected = (modelName || DEFAULT_CLAW402_MODEL) === m.id
const isSelected = (modelName || 'deepseek') === m.id
return (
<button
key={m.id}
@@ -803,46 +647,6 @@ function Claw402ConfigForm({
</div>
</div>
{hasExistingWallet && (
<div
className="p-3 rounded-xl"
style={{
background: 'rgba(0, 224, 150, 0.05)',
border: '1px solid rgba(0, 224, 150, 0.18)',
}}
>
<div
className="text-xs font-semibold mb-1.5"
style={{ color: '#00E096' }}
>
{language === 'zh'
? '已自动提取当前钱包'
: 'Current wallet loaded automatically'}
</div>
<div className="text-[11px] leading-5" style={{ color: '#A0AEC0' }}>
{language === 'zh'
? '你现在可以直接查看当前钱包地址、余额和充值二维码。只有在想更换钱包时,才需要重新输入新的私钥。'
: 'You can view the current wallet address, balance, and deposit QR code right away. Only enter a new private key if you want to replace this wallet.'}
</div>
{!configuredModel?.walletAddress && localWalletAddress ? (
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前地址来自本地已保存的新手钱包。'
: 'This address comes from the locally saved beginner wallet.'}
</div>
) : null}
{!configuredModel?.walletAddress &&
!localWalletAddress &&
serverWalletAddress ? (
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前地址来自后端保存的钱包配置。'
: 'This address comes from the wallet saved on the server.'}
</div>
) : null}
</div>
)}
<div className="space-y-1.5">
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
{t('modelConfig.walletPrivateKey', language)}
@@ -852,13 +656,7 @@ function Claw402ConfigForm({
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder={
hasExistingWallet
? language === 'zh'
? '已保存,如需更换钱包请重新输入私钥'
: 'Already saved. Enter a new private key only to replace the wallet.'
: '0x...'
}
placeholder="0x..."
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
style={{
background: '#0B0E11',
@@ -869,33 +667,24 @@ function Claw402ConfigForm({
: '1px solid #2B3139',
color: '#EAECEF',
}}
required={!hasExistingWallet}
required
/>
{!apiKey && (
<button
type="button"
onClick={async () => {
try {
const res = await fetch('/api/wallet/generate', {
method: 'POST',
})
const res = await fetch('/api/wallet/generate', { method: 'POST' })
const data = await res.json()
if (data.private_key) {
onApiKeyChange(data.private_key)
setShowNewWalletBackup(true)
setNewWalletKey(data.private_key)
}
} catch {
/* ignore */
}
} catch { /* ignore */ }
}}
className="shrink-0 px-3 py-3 rounded-xl text-xs font-semibold transition-all hover:scale-[1.02]"
style={{
background: 'linear-gradient(135deg, #2563EB, #7C3AED)',
color: '#fff',
border: 'none',
cursor: 'pointer',
}}
style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff', border: 'none', cursor: 'pointer' }}
>
{language === 'zh' ? '🔑 创建钱包' : '🔑 Create Wallet'}
</button>
@@ -904,21 +693,9 @@ function Claw402ConfigForm({
{/* New wallet backup warning */}
{showNewWalletBackup && newWalletKey && (
<div
className="p-3 rounded-xl"
style={{
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.3)',
}}
>
<div
className="text-xs font-bold mb-2"
style={{ color: '#EF4444' }}
>
🚨{' '}
{language === 'zh'
? '重要:请立即备份私钥!'
: 'Important: Backup your private key NOW!'}
<div className="p-3 rounded-xl" style={{ background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
<div className="text-xs font-bold mb-2" style={{ color: '#EF4444' }}>
🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'}
</div>
<div className="text-[11px] mb-2" style={{ color: '#F87171' }}>
{language === 'zh'
@@ -926,10 +703,7 @@ function Claw402ConfigForm({
: 'This is your wallet private key. If lost, it cannot be recovered and all assets will be permanently lost. Copy and save it securely.'}
</div>
<div className="flex items-center gap-2 mb-2">
<code
className="text-[10px] font-mono break-all select-all flex-1 p-2 rounded"
style={{ background: '#0B0E11', color: '#F87171' }}
>
<code className="text-[10px] font-mono break-all select-all flex-1 p-2 rounded" style={{ background: '#0B0E11', color: '#F87171' }}>
{newWalletKey}
</code>
<button
@@ -940,38 +714,15 @@ function Claw402ConfigForm({
setTimeout(() => setCopiedAddr(false), 2000)
}}
className="shrink-0 text-[10px] px-2 py-1 rounded"
style={{
background: 'rgba(239,68,68,0.15)',
color: '#F87171',
border: 'none',
cursor: 'pointer',
}}
style={{ background: 'rgba(239,68,68,0.15)', color: '#F87171', border: 'none', cursor: 'pointer' }}
>
{copiedAddr ? '✅ Copied' : '📋 Copy Key'}
</button>
</div>
<div
className="text-[10px] space-y-1"
style={{ color: '#848E9C' }}
>
<div>
{' '}
{language === 'zh'
? '建议保存到密码管理器1Password / Bitwarden'
: 'Save to a password manager (1Password / Bitwarden)'}
</div>
<div>
{' '}
{language === 'zh'
? '或抄在纸上放安全的地方'
: 'Or write it down and store it safely'}
</div>
<div>
{' '}
{language === 'zh'
? '不要截图发给别人'
: 'Do NOT screenshot or share with anyone'}
</div>
<div className="text-[10px] space-y-1" style={{ color: '#848E9C' }}>
<div> {language === 'zh' ? '建议保存到密码管理器1Password / Bitwarden' : 'Save to a password manager (1Password / Bitwarden)'}</div>
<div> {language === 'zh' ? '或抄在纸上放安全的地方' : 'Or write it down and store it safely'}</div>
<div> {language === 'zh' ? '不要截图发给别人' : 'Do NOT screenshot or share with anyone'}</div>
</div>
</div>
)}
@@ -1042,30 +793,16 @@ function Claw402ConfigForm({
{copiedAddr ? '✅' : '📋'}
</button>
</div>
<code
className="text-[11px] font-mono block select-all"
style={{ color: '#60A5FA' }}
>
{resolvedWalletAddress}
</code>
<div
className="text-[10px] mt-1.5"
style={{ color: '#F59E0B' }}
>
{' '}
{language === 'zh'
? '请确认这是你的钱包地址(可在 MetaMask 中核对)'
: 'Please confirm this is your wallet address (verify in MetaMask)'}
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<div className="text-[10px] mt-1.5" style={{ color: '#F59E0B' }}>
{language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
</div>
</div>
{usdcBalance !== null && (
<div className="flex items-center gap-2 text-xs">
<span>💰</span>
<span
style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}
>
{t('modelConfig.usdcBalance', language)}: $
{resolvedUsdcBalance}
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
{t('modelConfig.usdcBalance', language)}: ${usdcBalance}
</span>
<button
type="button"
@@ -1106,15 +843,8 @@ function Claw402ConfigForm({
: 'Deposit USDC (Base Chain)'}
</div>
<div className="flex gap-3 items-start mb-3">
<div
className="shrink-0 p-1.5 rounded-lg"
style={{ background: '#fff' }}
>
<QRCodeSVG
value={resolvedWalletAddress}
size={80}
level="M"
/>
<div className="shrink-0 p-1.5 rounded-lg" style={{ background: '#fff' }}>
<QRCodeSVG value={walletAddress} size={80} level="M" />
</div>
<div className="flex-1 min-w-0">
<div
@@ -1125,12 +855,7 @@ function Claw402ConfigForm({
? '扫码或复制地址转账'
: 'Scan QR or copy address to transfer'}
</div>
<code
className="text-[10px] font-mono break-all select-all block mb-1.5"
style={{ color: '#60A5FA' }}
>
{resolvedWalletAddress}
</code>
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<button
type="button"
onClick={() => {
@@ -1204,7 +929,7 @@ function Claw402ConfigForm({
)}
{/* Test Connection button */}
{(isKeyValid || hasExistingWallet) && !validating && (
{isKeyValid && !validating && (
<button
type="button"
onClick={handleTestConnection}
@@ -1289,15 +1014,9 @@ function Claw402ConfigForm({
</button>
<button
type="submit"
disabled={!isKeyValid && !hasExistingWallet}
disabled={!isKeyValid}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{
background:
isKeyValid || hasExistingWallet
? 'linear-gradient(135deg, #2563EB, #7C3AED)'
: '#2B3139',
color: '#fff',
}}
style={{ background: isKeyValid ? 'linear-gradient(135deg, #2563EB, #7C3AED)' : '#2B3139', color: '#fff' }}
>
{'🚀 ' + t('modelConfig.startTrading', language)}
</button>
@@ -1401,14 +1120,9 @@ function StandardProviderConfigForm({
{editingModelId && selectedModel && 'has_api_key' in selectedModel && (
<div
className="p-3 rounded-xl text-xs"
style={{
background: 'rgba(14, 203, 129, 0.08)',
border: '1px solid rgba(14, 203, 129, 0.2)',
color: '#9FE8C5',
}}
style={{ background: 'rgba(14, 203, 129, 0.08)', border: '1px solid rgba(14, 203, 129, 0.2)', color: '#9FE8C5' }}
>
{selectedModel.has_api_key ? '已配置 API Key' : '未配置 API Key'}
{selectedModel.has_api_key ? '已配置 API Key' : '未配置 API Key'}
</div>
)}
@@ -1443,10 +1157,10 @@ function StandardProviderConfigForm({
editingModelId && selectedModel.has_api_key
? '已保存,如需更换请重新输入'
: selectedModel.provider === 'blockrun-base'
? '0x... (EVM private key)'
: selectedModel.provider === 'blockrun-sol'
? 'bs58 encoded key (Solana)'
: t('enterAPIKey', language)
? '0x... (EVM private key)'
: selectedModel.provider === 'blockrun-sol'
? 'bs58 encoded key (Solana)'
: t('enterAPIKey', language)
}
className="w-full px-4 py-3 rounded-xl"
style={{
@@ -1458,26 +1172,12 @@ function StandardProviderConfigForm({
/>
</div>
{/* Custom Base URL */}
{/* Custom Base URL (hidden for BlockRun) */}
{!selectedModel.provider?.startsWith('blockrun') && (
<div className="space-y-2">
<label
className="flex items-center gap-2 text-sm font-semibold"
style={{ color: '#EAECEF' }}
>
<svg
className="w-4 h-4"
style={{ color: '#A78BFA' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
{t('customBaseURL', language)}
</label>
@@ -1487,11 +1187,7 @@ function StandardProviderConfigForm({
onChange={(e) => onBaseUrlChange(e.target.value)}
placeholder={t('customBaseURLPlaceholder', language)}
className="w-full px-4 py-3 rounded-xl"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
/>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefault', language)}
@@ -1499,26 +1195,12 @@ function StandardProviderConfigForm({
</div>
)}
{/* Custom Model Name */}
{/* Custom Model Name (hidden for BlockRun) */}
{!selectedModel.provider?.startsWith('blockrun') && (
<div className="space-y-2">
<label
className="flex items-center gap-2 text-sm font-semibold"
style={{ color: '#EAECEF' }}
>
<svg
className="w-4 h-4"
style={{ color: '#A78BFA' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
{t('customModelName', language)}
</label>
@@ -1528,11 +1210,7 @@ function StandardProviderConfigForm({
onChange={(e) => onModelNameChange(e.target.value)}
placeholder={t('customModelNamePlaceholder', language)}
className="w-full px-4 py-3 rounded-xl"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
/>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefaultModel', language)}
@@ -1540,6 +1218,40 @@ function StandardProviderConfigForm({
</div>
)}
{/* BlockRun Model Selector */}
{selectedModel.provider?.startsWith('blockrun') && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{t('modelConfig.selectModelLabel', language)}
</label>
<div className="grid grid-cols-2 gap-2">
{BLOCKRUN_MODELS.map((m) => {
const isSelected = (modelName || BLOCKRUN_MODELS[0].id) === m.id
return (
<button
key={m.id}
type="button"
onClick={() => onModelNameChange(m.id)}
className="flex flex-col items-start px-3 py-2 rounded-xl text-left transition-all"
style={{
background: isSelected ? 'rgba(37, 99, 235, 0.2)' : '#0B0E11',
border: isSelected ? '1px solid #2563EB' : '1px solid #2B3139',
}}
>
<span className="text-xs font-semibold" style={{ color: isSelected ? '#60A5FA' : '#EAECEF' }}>
{m.name}
</span>
<span className="text-[10px]" style={{ color: '#848E9C' }}>{m.desc}</span>
</button>
)
})}
</div>
</div>
)}
{/* Info Box */}
<div
className="p-4 rounded-xl"

View File

@@ -1,8 +1,7 @@
import { useState, useEffect } from 'react'
import type { AIModel, Exchange, CreateTraderRequest, ExchangeAccountStateResponse, Strategy } from '../../types'
import type { AIModel, Exchange, CreateTraderRequest, Strategy, TraderConfigData } 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'
import { NofxSelect } from '../ui/select'
@@ -13,6 +12,17 @@ function getShortName(fullName: string): string {
return parts.length > 1 ? parts[parts.length - 1] : fullName
}
function getStrategyAIConfig(strategy: Strategy) {
return strategy.config.ai_config || (
strategy.config.coin_source && strategy.config.risk_control
? {
coin_source: strategy.config.coin_source,
risk_control: strategy.config.risk_control,
}
: null
)
}
// 交易所注册链接配置
const EXCHANGE_REGISTRATION_LINKS: Record<string, { url: string; hasReferral?: boolean }> = {
binance: { url: 'https://www.binance.com/join?ref=NOFXENG', hasReferral: true },
@@ -22,9 +32,6 @@ const EXCHANGE_REGISTRATION_LINKS: Record<string, { url: string; hasReferral?: b
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
@@ -35,7 +42,6 @@ interface FormState {
is_cross_margin: boolean
show_in_competition: boolean
scan_interval_minutes: number
initial_balance?: number
}
interface TraderConfigModalProps {
@@ -69,8 +75,6 @@ export function TraderConfigModal({
})
const [isSaving, setIsSaving] = useState(false)
const [strategies, setStrategies] = useState<Strategy[]>([])
const [isFetchingBalance, setIsFetchingBalance] = useState(false)
const [balanceFetchError, setBalanceFetchError] = useState<string>('')
// 获取用户的策略列表
useEffect(() => {
@@ -125,64 +129,7 @@ export function TraderConfigModal({
}
const handleExchangeChange = (exchangeId: string) => {
setBalanceFetchError('')
setFormData((prev) => {
if (prev.exchange_id === exchangeId) {
return prev
}
const next: FormState = { ...prev, exchange_id: exchangeId }
// Exchange balance belongs to the selected exchange, not the trader record.
// Clear the old baseline so we don't carry Exchange B's balance into Exchange A.
if (isEditMode) {
next.initial_balance = undefined
}
return next
})
}
const handleFetchCurrentBalance = async () => {
if (!isEditMode) {
setBalanceFetchError(t('fetchBalanceEditModeOnly', language))
return
}
if (!formData.exchange_id) {
setBalanceFetchError(t('balanceFetchFailed', language))
return
}
setIsFetchingBalance(true)
setBalanceFetchError('')
try {
const result = await httpClient.get<ExchangeAccountStateResponse>('/api/exchanges/account-state')
const selectedState = result.data?.states?.[formData.exchange_id]
if (result.success && selectedState?.status === 'ok') {
const currentBalance =
selectedState.total_equity ??
selectedState.available_balance ??
0
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
toast.success(t('balanceFetched', language))
} else {
setBalanceFetchError(
selectedState?.error_message || result.message || t('balanceFetchFailed', language)
)
}
} catch (error) {
console.error(t('balanceFetchFailed', language) + ':', error)
setBalanceFetchError(
error instanceof Error && error.message
? error.message
: t('balanceFetchNetworkError', language)
)
} finally {
setIsFetchingBalance(false)
}
setFormData((prev) => ({ ...prev, exchange_id: exchangeId }))
}
const handleSave = async () => {
@@ -200,11 +147,6 @@ export function TraderConfigModal({
scan_interval_minutes: formData.scan_interval_minutes,
}
// 只在编辑模式时包含initial_balance
if (isEditMode && formData.initial_balance !== undefined) {
saveData.initial_balance = formData.initial_balance
}
await onSave(saveData)
} catch (error) {
console.error(t('saveFailed', language) + ':', error)
@@ -383,16 +325,28 @@ export function TraderConfigModal({
<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' : '混合'}
{selectedStrategy.config.strategy_type === 'grid_trading' && selectedStrategy.config.grid_config ? (
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>{language === 'zh' ? '交易对' : 'Symbol'}: {selectedStrategy.config.grid_config.symbol || '-'}</div>
<div>{language === 'zh' ? '网格数' : 'Grids'}: {selectedStrategy.config.grid_config.grid_count}</div>
</div>
<div>
{t('marginLimit', language)}: {((selectedStrategy.config.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
</div>
</div>
) : (() => {
const aiConfig = getStrategyAIConfig(selectedStrategy)
if (!aiConfig) return null
return (
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>
{t('coinSource', language)}: {aiConfig.coin_source.source_type === 'static' ? '固定币种' :
aiConfig.coin_source.source_type === 'ai500' ? 'AI500' :
aiConfig.coin_source.source_type === 'oi_top' ? 'OI Top' :
aiConfig.coin_source.source_type === 'oi_low' ? 'OI Low' : '-'}
</div>
<div>
{t('marginLimit', language)}: {((aiConfig.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
</div>
</div>
)
})()}
</div>
)}
</div>
@@ -495,68 +449,26 @@ export function TraderConfigModal({
</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>
)}
<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>
{/* 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>