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>

View File

@@ -12,7 +12,6 @@ export const coinSource = {
ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider', es: 'Proveedor AI500' },
oi_top: { zh: 'OI 持仓增加', en: 'OI Increase', es: 'Aumento OI' },
oi_low: { zh: 'OI 持仓减少', en: 'OI Decrease', es: 'Disminución OI' },
mixed: { zh: '混合模式', en: 'Mixed Mode', es: 'Modo Mixto' },
staticCoins: { zh: '自定义币种', en: 'Custom Coins', es: 'Monedas Personalizadas' },
addCoin: { zh: '添加币种', en: 'Add Coin', es: 'Agregar Moneda' },
useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider', es: 'Habilitar AI500' },
@@ -22,8 +21,6 @@ export const coinSource = {
useOILow: { zh: '启用 OI 持仓减少榜', en: 'Enable OI Decrease', es: 'Habilitar Disminución OI' },
oiLowLimit: { zh: '数量上限', en: 'Limit', es: 'Límite' },
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins', es: 'Especificar monedas manualmente' },
mixedConfig: { zh: '组合数据源配置', en: 'Combined Sources Configuration', es: 'Configuración Combinada' },
mixedSummary: { zh: '已选组合', en: 'Selected Sources', es: 'Fuentes Seleccionadas' },
maxCoins: { zh: '最多', en: 'Up to', es: 'Hasta' },
coins: { zh: '个币种', en: 'coins', es: 'monedas' },
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration', es: 'Configuración de Fuente' },
@@ -34,7 +31,6 @@ export const coinSource = {
ai500Desc: { zh: '使用 AI500 智能筛选的热门币种', en: 'Use AI500 smart-filtered popular coins', es: 'Monedas filtradas por AI500' },
oi_topDesc: { zh: '持仓增加榜,适合做多', en: 'OI increase ranking, for long', es: 'Ranking OI creciente, para largo' },
oi_lowDesc: { zh: '持仓减少榜,适合做空', en: 'OI decrease ranking, for short', es: 'Ranking OI decreciente, para corto' },
mixedDesc: { zh: '组合多种数据源', en: 'Combine multiple sources', es: 'Combinar fuentes múltiples' },
oiIncreaseShort: { zh: 'OI增', en: 'OI↑', es: 'OI↑' },
oiDecreaseShort: { zh: 'OI减', en: 'OI↓', es: 'OI↓' },
custom: { zh: '自定义', en: 'Custom', es: 'Personalizado' },

View File

@@ -15,6 +15,10 @@ export function chatStorageKey(userId?: string) {
return `nofxi-agent-chat:${userId || 'guest'}`
}
export function chatDraftStorageKey(userId?: string) {
return `nofxi-agent-chat-draft:${userId || 'guest'}`
}
export function getStoredAuthUserId(storage: Storage = window.localStorage) {
try {
const raw = storage.getItem('auth_user')
@@ -65,8 +69,38 @@ export function persistAgentMessages<T>(
storage.setItem(chatStorageKey(userId), JSON.stringify(messages))
}
export function loadAgentDraft(storage: Storage, userId?: string) {
try {
return storage.getItem(chatDraftStorageKey(userId)) || ''
} catch {
return ''
}
}
export function persistAgentDraft(
storage: Storage,
userId: string | undefined,
draft: string
) {
storage.setItem(chatDraftStorageKey(userId), draft)
}
export function clearAgentDraft(storage: Storage, userId?: string) {
for (const key of [
chatDraftStorageKey(userId),
chatDraftStorageKey('guest'),
]) {
storage.removeItem(key)
}
}
export function prepareAgentMessagesForPersistence<
T extends { streaming?: boolean; text?: string; steps?: unknown[]; time?: string }
T extends {
streaming?: boolean
text?: string
steps?: unknown[]
time?: string
},
>(messages: T[]): T[] {
return messages.map((message) => {
if (!message.streaming) {
@@ -89,7 +123,10 @@ export function migrateAgentMessages(storage: Storage, userId?: string) {
const targetMessages = loadMessagesFromKey(storage, targetKey)
if (targetMessages.length > 0) return
for (const sourceKey of [chatStorageKey('guest'), LEGACY_AGENT_CHAT_STORAGE_KEY]) {
for (const sourceKey of [
chatStorageKey('guest'),
LEGACY_AGENT_CHAT_STORAGE_KEY,
]) {
const sourceMessages = loadMessagesFromKey(storage, sourceKey)
if (sourceMessages.length === 0) continue
storage.setItem(targetKey, JSON.stringify(sourceMessages))
@@ -101,4 +138,5 @@ export function clearAgentMessages(storage: Storage, userId?: string) {
for (const key of candidateStorageKeys(userId)) {
storage.removeItem(key)
}
clearAgentDraft(storage, userId)
}

View File

@@ -19,26 +19,370 @@ import { WelcomeScreen } from '../components/agent/WelcomeScreen'
import { ChatMessages } from '../components/agent/ChatMessages'
import { ChatInput, type ChatInputHandle } from '../components/agent/ChatInput'
import { UserPreferencesPanel } from '../components/agent/UserPreferencesPanel'
import {
useAgentChatStore,
type AgentMessage as Message,
type AgentStep,
} from '../stores/agentChatStore'
import { useAgentChatStore } from '../stores/agentChatStore'
import type { AgentMessage as Message, AgentStep } from '../types/agent'
import {
chatStorageKey,
clearAgentMessages,
getStoredAuthUserId,
loadAgentDraft,
loadAgentMessages,
migrateAgentMessages,
prepareAgentMessagesForPersistence,
persistAgentDraft,
persistAgentMessages,
} from '../lib/agentChatStorage'
let msgIdCounter = 0
let activeStreamAbortController: AbortController | null = null
let activeStreamReader: ReadableStreamDefaultReader<Uint8Array> | null = null
function nextId() {
return `msg-${Date.now()}-${++msgIdCounter}`
}
function cleanupActiveAgentStream() {
activeStreamAbortController?.abort()
activeStreamAbortController = null
void activeStreamReader?.cancel().catch(() => {
// Ignore stream cancellation races during teardown.
})
activeStreamReader = null
}
function stopActiveAgentStream(userId?: string, language = 'zh') {
if (!activeStreamAbortController && !activeStreamReader) return
const stoppedText =
language === 'zh' ? '已中止当前回复。' : 'Stopped the current response.'
const now = new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
patchMessagesInStore(
(prev) =>
prev.map((m) => {
if (m.role !== 'bot' || !m.streaming) return m
const text = m.text?.trim()
? `${m.text.trimEnd()}\n\n${stoppedText}`
: stoppedText
return {
...m,
text,
streaming: false,
time: m.time || now,
}
}),
userId
)
cleanupActiveAgentStream()
useAgentChatStore.getState().setLoading(false)
}
function persistMessagesSnapshotForUser(userId?: string) {
const { hydrated, messages } = useAgentChatStore.getState()
if (!hydrated) return
const persistable = prepareAgentMessagesForPersistence(messages).slice(-100)
persistAgentMessages(window.localStorage, userId, persistable)
}
function replaceMessagesInStore(nextMessages: Message[], userId?: string) {
useAgentChatStore.getState().setMessages(nextMessages)
persistMessagesSnapshotForUser(userId)
}
function patchMessagesInStore(
updater: (prev: Message[]) => Message[],
userId?: string
) {
const nextMessages = updater(useAgentChatStore.getState().messages)
useAgentChatStore.getState().updateMessages(() => nextMessages)
persistMessagesSnapshotForUser(userId)
}
async function runAgentStream(params: {
text: string
token?: string | null
language: string
storageUserId?: string
onDone?: () => void
}) {
const { text, token, language, storageUserId, onDone } = params
if (!text || useAgentChatStore.getState().loading) return
const time = new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
const userMsg: Message = { id: nextId(), role: 'user', text, time }
const botId = nextId()
const nextConversation: Message[] = [
userMsg,
{
id: botId,
role: 'bot',
text: '',
time: '',
streaming: true,
},
]
replaceMessagesInStore(
text.trim() === '/clear'
? nextConversation
: [...useAgentChatStore.getState().messages, ...nextConversation],
storageUserId
)
useAgentChatStore.getState().setLoading(true)
if (text.trim() === '/clear') {
try {
clearAgentMessages(window.localStorage, storageUserId)
useAgentChatStore.getState().setDraftText('')
} catch {
// Ignore storage cleanup failure.
}
}
let controller: AbortController | null = null
try {
activeStreamAbortController?.abort()
controller = new AbortController()
activeStreamAbortController = controller
const res = await fetch('/api/agent/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ message: text, lang: language }),
signal: controller.signal,
})
if (!res.ok) {
const errData = await res.json().catch(() => ({}))
throw new Error(errData.error || `Server error (${res.status})`)
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
if (!reader) throw new Error('No response body')
activeStreamReader = reader
controller.signal.addEventListener(
'abort',
() => {
void reader.cancel().catch(() => {
// Ignore double-cancel races.
})
},
{ once: true }
)
let buffer = ''
let finalText = ''
let stepCounter = 0
const now = () =>
new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
const mergeStreamText = (current: string, incoming: string) => {
if (!incoming) return current
if (!current) return incoming
if (incoming === current) return current
if (incoming.startsWith(current)) return incoming
if (current.startsWith(incoming)) return current
return current + incoming
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let eventType = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim()
} else if (line.startsWith('data: ') && eventType) {
const rawData = line.slice(6)
let data: string
try {
data = JSON.parse(rawData)
} catch {
eventType = ''
continue
}
if (eventType === 'delta') {
finalText = mergeStreamText(finalText, data)
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId ? { ...m, text: finalText, time: now() } : m
),
storageUserId
)
} else if (eventType === 'plan') {
const parsedSteps = parsePlanSteps(data)
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: parsedSteps.length > 0 ? parsedSteps : m.steps,
time: now(),
}
: m
),
storageUserId
)
} else if (eventType === 'step_start') {
stepCounter += 1
const nextStep = parseStepEvent(data, stepCounter)
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, nextStep),
time: now(),
}
: m
),
storageUserId
)
} else if (eventType === 'step_complete') {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: markLatestRunningCompleted(m.steps, data),
time: now(),
}
: m
),
storageUserId
)
} else if (eventType === 'replan') {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, {
id: `replan-${Date.now()}`,
label: data,
status: 'replanned',
detail: data,
}),
time: now(),
}
: m
),
storageUserId
)
} else if (eventType === 'done') {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
text: finalText || m.text || data,
time: now(),
streaming: false,
}
: m
),
storageUserId
)
} else if (eventType === 'error') {
throw new Error(data)
}
eventType = ''
}
}
}
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId && m.streaming
? {
...m,
text: finalText || m.text || 'No response',
streaming: false,
time: now(),
}
: m
),
storageUserId
)
window.dispatchEvent(new CustomEvent('agent-preferences-refresh'))
window.dispatchEvent(new CustomEvent('agent-config-refresh'))
} catch (e: any) {
if (e.name === 'AbortError') {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
streaming: false,
time:
m.time ||
new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
}
: m
),
storageUserId
)
} else {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
text: '⚠️ Error: ' + e.message,
time: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
streaming: false,
}
: m
),
storageUserId
)
}
}
if (controller && activeStreamAbortController === controller) {
activeStreamAbortController = null
}
if (activeStreamReader) {
try {
activeStreamReader.releaseLock()
} catch {
// Ignore lock-release races when the stream is already closed.
}
activeStreamReader = null
}
useAgentChatStore.getState().setLoading(false)
onDone?.()
}
function appendStep(
existing: AgentStep[] | undefined,
step: AgentStep
@@ -63,7 +407,9 @@ function parsePlanSteps(data: string): AgentStep[] {
}
function parseStepEvent(data: string, fallbackIndex: number): AgentStep {
const match = data.match(/Step\s+(\d+)\/(\d+):\s+(.+)$/i) || data.match(/步骤\s+(\d+)\/(\d+):\s+(.+)$/)
const match =
data.match(/Step\s+(\d+)\/(\d+):\s+(.+)$/i) ||
data.match(/步骤\s+(\d+)\/(\d+):\s+(.+)$/)
if (match) {
const id = `action-${match[1]}`
return {
@@ -81,7 +427,10 @@ function parseStepEvent(data: string, fallbackIndex: number): AgentStep {
}
}
function markLatestRunningCompleted(existing: AgentStep[] | undefined, detail: string): AgentStep[] {
function markLatestRunningCompleted(
existing: AgentStep[] | undefined,
detail: string
): AgentStep[] {
const prev = existing ?? []
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].status === 'running') {
@@ -96,20 +445,20 @@ function markLatestRunningCompleted(existing: AgentStep[] | undefined, detail: s
export function AgentChatPage() {
const { language } = useLanguage()
const { token, user } = useAuth()
const [storageUserId, setStorageUserId] = useState<string | undefined>(() => getStoredAuthUserId())
const [storageUserId, setStorageUserId] = useState<string | undefined>(() =>
getStoredAuthUserId()
)
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
const storageKey = chatStorageKey(user?.id || storageUserId)
const messages = useAgentChatStore((state) => state.messages)
const draftText = useAgentChatStore((state) => state.draftText)
const loading = useAgentChatStore((state) => state.loading)
const historyHydrated = useAgentChatStore((state) => state.hydrated)
const activeUserId = useAgentChatStore((state) => state.activeUserId)
const setMessages = useAgentChatStore((state) => state.setMessages)
const updateMessages = useAgentChatStore((state) => state.updateMessages)
const setLoading = useAgentChatStore((state) => state.setLoading)
const resetForUser = useAgentChatStore((state) => state.resetForUser)
const setDraftText = useAgentChatStore((state) => state.setDraftText)
const messagesEndRef = useRef<HTMLDivElement>(null)
const chatInputRef = useRef<ChatInputHandle>(null)
const abortRef = useRef<AbortController | null>(null)
// Sidebar section collapse state
const [sections, setSections] = useState({
@@ -145,38 +494,47 @@ export function AgentChatPage() {
nextUserId,
loadAgentMessages<Message>(window.localStorage, nextUserId).messages
)
}, [activeUserId, historyHydrated, resetForUser, storageKey, storageUserId, user?.id])
setDraftText(loadAgentDraft(window.localStorage, nextUserId))
}, [
activeUserId,
historyHydrated,
resetForUser,
setDraftText,
storageKey,
storageUserId,
user?.id,
])
// Persist chat history locally so page navigation does not wipe the conversation.
useEffect(() => {
if (!historyHydrated) return
try {
const persistable = prepareAgentMessagesForPersistence(messages).slice(-100)
persistAgentMessages(window.localStorage, user?.id || storageUserId, persistable)
const persistable =
prepareAgentMessagesForPersistence(messages).slice(-100)
persistAgentMessages(
window.localStorage,
user?.id || storageUserId,
persistable
)
} catch {
// Ignore storage failures and keep the chat usable.
}
}, [historyHydrated, messages, storageKey, storageUserId, user?.id])
const persistMessagesSnapshot = (nextMessages: Message[]) => {
const persistable = prepareAgentMessagesForPersistence(nextMessages).slice(-100)
persistAgentMessages(window.localStorage, user?.id || storageUserId, persistable)
}
const replaceMessages = (nextMessages: Message[]) => {
setMessages(nextMessages)
if (historyHydrated) {
persistMessagesSnapshot(nextMessages)
// Persist the unsent draft so navigating away from the Agent page does not
// wipe what the user was typing.
useEffect(() => {
if (!historyHydrated) return
try {
persistAgentDraft(
window.localStorage,
user?.id || storageUserId,
draftText
)
} catch {
// Ignore storage failures and keep typing responsive.
}
}
const patchMessages = (updater: (prev: Message[]) => Message[]) => {
const nextMessages = updater(useAgentChatStore.getState().messages)
updateMessages(() => nextMessages)
if (useAgentChatStore.getState().hydrated) {
persistMessagesSnapshot(nextMessages)
}
}
}, [draftText, historyHydrated, storageKey, storageUserId, user?.id])
// Responsive sidebar
useEffect(() => {
@@ -187,6 +545,12 @@ export function AgentChatPage() {
return () => window.removeEventListener('resize', handleResize)
}, [])
useEffect(() => {
const handlePageHide = () => cleanupActiveAgentStream()
window.addEventListener('pagehide', handlePageHide)
return () => window.removeEventListener('pagehide', handlePageHide)
}, [])
// Escape to close sidebar on mobile
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -199,253 +563,38 @@ export function AgentChatPage() {
}, [])
const send = async (text: string) => {
if (!text || loading) return
const time = new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
await runAgentStream({
text,
token,
language,
storageUserId: user?.id || storageUserId,
onDone: () => chatInputRef.current?.focus(),
})
const userMsg: Message = { id: nextId(), role: 'user', text, time }
const botId = nextId()
const nextConversation: Message[] = [
userMsg,
{
id: botId,
role: 'bot',
text: '',
time: '',
streaming: true,
},
]
replaceMessages(
text.trim() === '/clear'
? nextConversation
: [...useAgentChatStore.getState().messages, ...nextConversation]
)
setLoading(true)
}
if (text.trim() === '/clear') {
try {
clearAgentMessages(window.localStorage, user?.id || storageUserId)
} catch {
// Ignore storage cleanup failure.
}
}
try {
// Abort any in-flight request
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const res = await fetch('/api/agent/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ message: text, lang: language, user_key: user?.id }),
signal: controller.signal,
})
if (!res.ok) {
const errData = await res.json().catch(() => ({}))
throw new Error(errData.error || `Server error (${res.status})`)
}
// Real SSE streaming
const reader = res.body?.getReader()
const decoder = new TextDecoder()
if (!reader) throw new Error('No response body')
let buffer = ''
let finalText = ''
let stepCounter = 0
const now = () =>
new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || '' // Keep incomplete line in buffer
let eventType = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim()
} else if (line.startsWith('data: ') && eventType) {
const rawData = line.slice(6)
let data: string
try {
data = JSON.parse(rawData)
} catch {
// Ignore malformed SSE data lines
eventType = ''
continue
}
if (eventType === 'delta') {
// data is the accumulated text so far
finalText = data
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? { ...m, text: data, time: now() }
: m
)
)
} else if (eventType === 'plan') {
const parsedSteps = parsePlanSteps(data)
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: parsedSteps.length > 0 ? parsedSteps : m.steps,
time: now(),
}
: m
)
)
} else if (eventType === 'step_start') {
stepCounter += 1
const nextStep = parseStepEvent(data, stepCounter)
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, nextStep),
time: now(),
}
: m
)
)
} else if (eventType === 'step_complete') {
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: markLatestRunningCompleted(m.steps, data),
time: now(),
}
: m
)
)
} else if (eventType === 'replan') {
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, {
id: `replan-${Date.now()}`,
label: data,
status: 'replanned',
detail: data,
}),
time: now(),
}
: m
)
)
} else if (
eventType === 'tool'
) {
// Show tool being called as a status indicator
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, {
id: `tool-${Date.now()}`,
label: `Tool: ${data}`,
status: 'running',
detail: data,
}),
time: now(),
}
: m
)
)
} else if (eventType === 'done') {
finalText = data
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? { ...m, text: data, time: now(), streaming: false }
: m
)
)
} else if (eventType === 'error') {
throw new Error(data)
}
eventType = ''
}
}
}
// If stream ended without a "done" event, mark as done
patchMessages((prev) =>
prev.map((m) =>
m.id === botId && m.streaming
? {
...m,
text: finalText || m.text || 'No response',
streaming: false,
time: now(),
}
: m
)
)
window.dispatchEvent(new CustomEvent('agent-preferences-refresh'))
window.dispatchEvent(new CustomEvent('agent-config-refresh'))
} catch (e: any) {
if (e.name === 'AbortError') {
// Request was cancelled (e.g. user sent a new message), clean up silently
patchMessages((prev) => prev.filter((m) => m.id !== botId))
} else {
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
text: '⚠️ Error: ' + e.message,
time: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
streaming: false,
}
: m
)
)
}
}
setLoading(false)
const stopCurrentResponse = () => {
stopActiveAgentStream(user?.id || storageUserId, language)
chatInputRef.current?.focus()
}
const quickActions = language === 'zh'
? [
{ label: '💼 持仓', cmd: '/positions' },
{ label: '💰 余额', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
{ label: '🧹 清除记忆', cmd: '/clear' },
{ label: '❓ 帮助', cmd: '/help' },
]
: [
{ label: '💼 Positions', cmd: '/positions' },
{ label: '💰 Balance', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
{ label: '🧹 Clear', cmd: '/clear' },
{ label: '❓ Help', cmd: '/help' },
]
const quickActions =
language === 'zh'
? [
{ label: '💼 持仓', cmd: '/positions' },
{ label: '💰 余额', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
{ label: '📊 系统状态', cmd: '/status' },
{ label: '🧹 清除记忆', cmd: '/clear' },
{ label: '❓ 帮助', cmd: '/help' },
]
: [
{ label: '💼 Positions', cmd: '/positions' },
{ label: '💰 Balance', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
{ label: '📊 Status', cmd: '/status' },
{ label: '🧹 Clear', cmd: '/clear' },
{ label: '❓ Help', cmd: '/help' },
]
const sidebarSections = [
{
@@ -548,8 +697,12 @@ export function AgentChatPage() {
transition: 'color 0.2s',
}}
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
onMouseEnter={(e) => { e.currentTarget.style.color = '#8a8aa0' }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#4c4c62' }}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#8a8aa0'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#4c4c62'
}}
>
{sidebarOpen ? (
<PanelRightClose size={18} />
@@ -580,7 +733,10 @@ export function AgentChatPage() {
ref={chatInputRef}
language={language}
loading={loading}
value={draftText}
onChange={setDraftText}
onSend={send}
onStop={stopCurrentResponse}
/>
</div>
@@ -648,7 +804,8 @@ export function AgentChatPage() {
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.03)'
e.currentTarget.style.background =
'rgba(255,255,255,0.03)'
e.currentTarget.style.color = '#a0a0b0'
}}
onMouseLeave={(e) => {
@@ -658,7 +815,12 @@ export function AgentChatPage() {
>
{section.icon}
<span>{section.title}</span>
<span style={{ marginLeft: 'auto', transition: 'transform 0.2s' }}>
<span
style={{
marginLeft: 'auto',
transition: 'transform 0.2s',
}}
>
{sections[section.key] ? (
<ChevronDown size={14} />
) : (
@@ -675,9 +837,7 @@ export function AgentChatPage() {
transition={{ duration: 0.15 }}
style={{ overflow: 'hidden', padding: '0 4px' }}
>
<div style={{ paddingTop: 4 }}>
{section.component}
</div>
<div style={{ paddingTop: 4 }}>{section.component}</div>
</motion.div>
)}
</AnimatePresence>

View File

@@ -1,16 +1,6 @@
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import {
User,
Cpu,
Building2,
MessageCircle,
Eye,
EyeOff,
ChevronRight,
Plus,
Pencil,
} from 'lucide-react'
import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, Pencil } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { api } from '../lib/api'
@@ -117,9 +107,7 @@ export function SettingsPage() {
toast.success('Password updated successfully')
setNewPassword('')
} catch (err) {
toast.error(
err instanceof Error ? err.message : 'Failed to update password'
)
toast.error(err instanceof Error ? err.message : 'Failed to update password')
} finally {
setChangingPassword(false)
}
@@ -135,48 +123,33 @@ export function SettingsPage() {
const existingModel = configuredModels.find((m) => m.id === modelId)
const modelTemplate = supportedModels.find((m) => m.id === modelId)
const modelToUpdate = existingModel || modelTemplate
if (!modelToUpdate) {
toast.error('Model not found')
return
}
if (!modelToUpdate) { toast.error('Model not found'); return }
let updatedModels: AIModel[]
if (existingModel) {
updatedModels = configuredModels.map((m) =>
m.id === modelId
? {
...m,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}
? { ...m, apiKey, customApiUrl: customApiUrl || '', customModelName: customModelName || '', enabled: true }
: m
)
} else {
updatedModels = [
...configuredModels,
{
...modelToUpdate,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
},
]
updatedModels = [...configuredModels, {
...modelToUpdate,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}]
}
const request = {
models: Object.fromEntries(
updatedModels.map((m) => [
m.provider,
{
enabled: m.enabled,
api_key: m.apiKey || '',
custom_api_url: m.customApiUrl || '',
custom_model_name: m.customModelName || '',
},
])
updatedModels.map((m) => [m.provider, {
enabled: m.enabled,
api_key: m.apiKey || '',
custom_api_url: m.customApiUrl || '',
custom_model_name: m.customModelName || '',
}])
),
}
await api.updateModelConfigs(request)
@@ -192,27 +165,16 @@ export function SettingsPage() {
const handleDeleteModel = async (modelId: string) => {
try {
const updatedModels = configuredModels.map((m) =>
m.id === modelId
? {
...m,
apiKey: '',
customApiUrl: '',
customModelName: '',
enabled: false,
}
: m
m.id === modelId ? { ...m, apiKey: '', customApiUrl: '', customModelName: '', enabled: false } : m
)
const request = {
models: Object.fromEntries(
updatedModels.map((m) => [
m.provider,
{
enabled: m.enabled,
api_key: m.apiKey || '',
custom_api_url: m.customApiUrl || '',
custom_model_name: m.customModelName || '',
},
])
updatedModels.map((m) => [m.provider, {
enabled: m.enabled,
api_key: m.apiKey || '',
custom_api_url: m.customApiUrl || '',
custom_model_name: m.customModelName || '',
}])
),
}
await api.updateModelConfigs(request)
@@ -264,7 +226,7 @@ export function SettingsPage() {
},
}
await api.updateExchangeConfigsEncrypted(request)
toast.success('Exchange config updated')
toast.success('Exchange config updated')
} else {
const createRequest = {
exchange_type: exchangeType,
@@ -284,7 +246,7 @@ export function SettingsPage() {
lighter_api_key_index: lighterApiKeyIndex || 0,
}
await api.createExchangeEncrypted(createRequest)
toast.success('Exchange account created')
toast.success('Exchange account created')
}
await refreshExchangeConfigs()
setShowExchangeModal(false)
@@ -314,10 +276,7 @@ export function SettingsPage() {
]
return (
<div
className="min-h-screen pt-20 pb-12 px-4"
style={{ background: '#0B0E11' }}
>
<div className="min-h-screen pt-20 pb-12 px-4" style={{ background: '#0B0E11' }}>
<div className="max-w-2xl mx-auto">
<h1 className="text-xl font-bold text-white mb-6">Settings</h1>
@@ -328,10 +287,9 @@ export function SettingsPage() {
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all
${
activeTab === tab.key
? 'bg-nofx-gold text-black'
: 'text-zinc-400 hover:text-white'
${activeTab === tab.key
? 'bg-nofx-gold text-black'
: 'text-zinc-400 hover:text-white'
}`}
>
{tab.icon}
@@ -342,6 +300,7 @@ export function SettingsPage() {
{/* Tab Content */}
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-6">
{/* Account Tab */}
{activeTab === 'account' && (
<div className="space-y-6">
@@ -354,9 +313,7 @@ export function SettingsPage() {
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block text-xs font-medium text-zinc-400 mb-2">
New Password
</label>
<label className="block text-xs font-medium text-zinc-400 mb-2">New Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
@@ -371,11 +328,7 @@ export function SettingsPage() {
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
>
{showPassword ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
@@ -396,14 +349,10 @@ export function SettingsPage() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-400">
{configuredModels.length} model
{configuredModels.length !== 1 ? 's' : ''} configured
{configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured
</p>
<button
onClick={() => {
setEditingModel(null)
setShowModelModal(true)
}}
onClick={() => { setEditingModel(null); setShowModelModal(true) }}
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
>
<Plus size={14} />
@@ -420,10 +369,7 @@ export function SettingsPage() {
{configuredModels.map((model) => (
<button
key={model.id}
onClick={() => {
setEditingModel(model.id)
setShowModelModal(true)
}}
onClick={() => { setEditingModel(model.id); setShowModelModal(true) }}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
>
<div className="flex items-center gap-3">
@@ -441,15 +387,10 @@ export function SettingsPage() {
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}
>
<span className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}>
{model.enabled ? 'Active' : 'Inactive'}
</span>
<Pencil
size={14}
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
/>
<Pencil size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
</div>
</button>
))}
@@ -463,14 +404,10 @@ export function SettingsPage() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-400">
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''}{' '}
connected
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected
</p>
<button
onClick={() => {
setEditingExchange(null)
setShowExchangeModal(true)
}}
onClick={() => { setEditingExchange(null); setShowExchangeModal(true) }}
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
>
<Plus size={14} />
@@ -487,10 +424,7 @@ export function SettingsPage() {
{exchanges.map((exchange) => (
<button
key={exchange.id}
onClick={() => {
setEditingExchange(exchange.id)
setShowExchangeModal(true)
}}
onClick={() => { setEditingExchange(exchange.id); setShowExchangeModal(true) }}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
>
<div className="flex items-center gap-3">
@@ -510,10 +444,7 @@ export function SettingsPage() {
</div>
</div>
</div>
<ChevronRight
size={14}
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
/>
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
</button>
))}
</div>
@@ -525,8 +456,7 @@ export function SettingsPage() {
{activeTab === 'telegram' && (
<div className="space-y-4">
<p className="text-sm text-zinc-400">
Connect a Telegram bot to receive trading notifications and
interact with your traders.
Connect a Telegram bot to receive trading notifications and interact with your traders.
</p>
<button
onClick={() => setShowTelegramModal(true)}
@@ -536,14 +466,9 @@ export function SettingsPage() {
<div className="w-8 h-8 rounded-lg bg-[#0088cc]/20 flex items-center justify-center">
<MessageCircle size={14} className="text-[#0088cc]" />
</div>
<span className="text-sm font-medium text-white">
Configure Telegram Bot
</span>
<span className="text-sm font-medium text-white">Configure Telegram Bot</span>
</div>
<ChevronRight
size={14}
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
/>
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
</button>
</div>
)}
@@ -559,10 +484,7 @@ export function SettingsPage() {
editingModelId={editingModel}
onSave={handleSaveModel}
onDelete={handleDeleteModel}
onClose={() => {
setShowModelModal(false)
setEditingModel(null)
}}
onClose={() => { setShowModelModal(false); setEditingModel(null) }}
language={language}
/>
</div>
@@ -576,10 +498,7 @@ export function SettingsPage() {
editingExchangeId={editingExchange}
onSave={handleSaveExchange}
onDelete={handleDeleteExchange}
onClose={() => {
setShowExchangeModal(false)
setEditingExchange(null)
}}
onClose={() => { setShowExchangeModal(false); setEditingExchange(null) }}
language={language}
/>
</div>

View File

@@ -179,16 +179,17 @@ export function StrategyMarketPage() {
}
const getIndicatorList = (config: any) => {
if (!config?.indicators) return []
const indicatorsConfig = config?.ai_config?.indicators || config?.indicators
if (!indicatorsConfig) return []
const indicators = []
if (config.indicators.enable_ema) indicators.push('EMA')
if (config.indicators.enable_macd) indicators.push('MACD')
if (config.indicators.enable_rsi) indicators.push('RSI')
if (config.indicators.enable_atr) indicators.push('ATR')
if (config.indicators.enable_boll) indicators.push('BOLL')
if (config.indicators.enable_volume) indicators.push('VOL')
if (config.indicators.enable_oi) indicators.push('OI')
if (config.indicators.enable_funding_rate) indicators.push('FR')
if (indicatorsConfig.enable_ema) indicators.push('EMA')
if (indicatorsConfig.enable_macd) indicators.push('MACD')
if (indicatorsConfig.enable_rsi) indicators.push('RSI')
if (indicatorsConfig.enable_atr) indicators.push('ATR')
if (indicatorsConfig.enable_boll) indicators.push('BOLL')
if (indicatorsConfig.enable_volume) indicators.push('VOL')
if (indicatorsConfig.enable_oi) indicators.push('OI')
if (indicatorsConfig.enable_funding_rate) indicators.push('FR')
return indicators
}
@@ -439,7 +440,7 @@ export function StrategyMarketPage() {
</div>
{/* Risk Control */}
{strategy.config.risk_control && (
{(strategy.config.ai_config?.risk_control || strategy.config.risk_control) && (
<div className="flex justify-between items-center text-[10px]">
<div className="flex gap-3">
<div className="flex flex-col">
@@ -447,7 +448,7 @@ export function StrategyMarketPage() {
LEV
</span>
<span className="text-zinc-300 font-bold">
{strategy.config.risk_control
{(strategy.config.ai_config?.risk_control || strategy.config.risk_control)
.btc_eth_max_leverage || '-'}
x
</span>
@@ -457,7 +458,7 @@ export function StrategyMarketPage() {
POS
</span>
<span className="text-zinc-300 font-bold">
{strategy.config.risk_control
{(strategy.config.ai_config?.risk_control || strategy.config.risk_control)
.max_positions || '-'}
</span>
</div>

View File

@@ -30,27 +30,66 @@ import {
Upload,
Globe,
} from 'lucide-react'
import type { Strategy, StrategyConfig, AIModel, GridStrategyConfig } from '../types'
import type {
Strategy,
StrategyConfig,
AIStrategyConfig,
AIModel,
GridStrategyConfig,
} from '../types'
import { confirmToast, notify } from '../lib/notify'
import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor'
import { IndicatorEditor } from '../components/strategy/IndicatorEditor'
import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
import {
GridConfigEditor,
defaultGridConfig,
} from '../components/strategy/GridConfigEditor'
import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar'
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
import { t } from '../i18n/translations'
const API_BASE = import.meta.env.VITE_API_BASE || ''
const getAIConfig = (config: StrategyConfig): AIStrategyConfig | null => {
if (config.ai_config) return config.ai_config
if (config.coin_source && config.indicators && config.risk_control) {
return {
coin_source: config.coin_source,
indicators: config.indicators,
risk_control: config.risk_control,
prompt_sections: config.prompt_sections,
custom_prompt: config.custom_prompt,
}
}
return null
}
const normalizeStrategyConfig = (config: StrategyConfig): StrategyConfig => {
const aiConfig = getAIConfig(config)
const strategyType = config.strategy_type || 'ai_trading'
return {
strategy_type: strategyType,
language: config.language,
ai_config: aiConfig || undefined,
grid_config: config.grid_config,
publish_config: config.publish_config,
}
}
export function StrategyStudioPage() {
const { token } = useAuth()
const { language } = useLanguage()
const [strategies, setStrategies] = useState<Strategy[]>([])
const [selectedStrategy, setSelectedStrategy] = useState<Strategy | null>(null)
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
const [selectedStrategy, setSelectedStrategy] = useState<Strategy | null>(
null
)
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(
null
)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [estimatedTokens, setEstimatedTokens] = useState(0)
@@ -73,7 +112,9 @@ export function StrategyStudioPage() {
})
// Right panel states
const [activeRightTab, setActiveRightTab] = useState<'prompt' | 'test'>('prompt')
const [activeRightTab, setActiveRightTab] = useState<'prompt' | 'test'>(
'prompt'
)
const [promptPreview, setPromptPreview] = useState<{
system_prompt: string
user_prompt?: string
@@ -95,6 +136,8 @@ export function StrategyStudioPage() {
} | null>(null)
const [isRunningAiTest, setIsRunningAiTest] = useState(false)
const gridConfigCacheRef = useRef<Record<string, GridStrategyConfig>>({})
const selectedStrategyIDRef = useRef<string>('')
const hasChangesRef = useRef(false)
const toggleSection = (section: keyof typeof expandedSections) => {
setExpandedSections((prev) => ({
@@ -113,7 +156,7 @@ export function StrategyStudioPage() {
if (response.ok) {
const data = await response.json()
// Backend returns an array, not { models: [] }
const allModels = Array.isArray(data) ? data : (data.models || [])
const allModels = Array.isArray(data) ? data : data.models || []
const enabledModels = allModels.filter((m: AIModel) => m.enabled)
setAiModels(enabledModels)
if (enabledModels.length > 0 && !selectedModelId) {
@@ -134,16 +177,25 @@ export function StrategyStudioPage() {
})
if (!response.ok) throw new Error('Failed to fetch strategies')
const data = await response.json()
setStrategies(data.strategies || [])
const nextStrategies: Strategy[] = data.strategies || []
setStrategies(nextStrategies)
// Select active or first strategy
const active = data.strategies?.find((s: Strategy) => s.is_active)
if (active) {
setSelectedStrategy(active)
setEditingConfig(active.config)
} else if (data.strategies?.length > 0) {
setSelectedStrategy(data.strategies[0])
setEditingConfig(data.strategies[0].config)
const preservedSelection = selectedStrategyIDRef.current
? nextStrategies.find((s) => s.id === selectedStrategyIDRef.current)
: null
const active = nextStrategies.find((s) => s.is_active)
const nextSelected =
preservedSelection || active || nextStrategies[0] || null
setSelectedStrategy(nextSelected)
selectedStrategyIDRef.current = nextSelected?.id || ''
if (!hasChangesRef.current || !preservedSelection) {
setEditingConfig(nextSelected?.config ? normalizeStrategyConfig(nextSelected.config) : null)
}
if (!nextSelected) {
setEditingConfig(null)
setHasChanges(false)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
@@ -157,10 +209,37 @@ export function StrategyStudioPage() {
fetchAiModels()
}, [fetchStrategies, fetchAiModels])
useEffect(() => {
selectedStrategyIDRef.current = selectedStrategy?.id || ''
}, [selectedStrategy?.id])
useEffect(() => {
hasChangesRef.current = hasChanges
}, [hasChanges])
useEffect(() => {
if (!token) return
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
void fetchStrategies()
}
}
window.addEventListener('focus', refreshIfVisible)
document.addEventListener('visibilitychange', refreshIfVisible)
return () => {
window.removeEventListener('focus', refreshIfVisible)
document.removeEventListener('visibilitychange', refreshIfVisible)
}
}, [fetchStrategies, token])
useEffect(() => {
if (!selectedStrategy?.id || !editingConfig?.grid_config) return
gridConfigCacheRef.current[selectedStrategy.id] = { ...editingConfig.grid_config }
gridConfigCacheRef.current[selectedStrategy.id] = {
...editingConfig.grid_config,
}
}, [selectedStrategy?.id, editingConfig?.grid_config])
// Track previous language to detect actual changes
@@ -182,15 +261,20 @@ export function StrategyStudioPage() {
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!response.ok) return
const defaultConfig = await response.json()
const defaultConfig = normalizeStrategyConfig(await response.json())
// Update only the prompt sections and language field
setEditingConfig(prev => {
setEditingConfig((prev) => {
if (!prev) return prev
return {
...prev,
language: language as 'zh' | 'en',
prompt_sections: defaultConfig.prompt_sections,
ai_config: prev.ai_config
? {
...prev.ai_config,
prompt_sections: defaultConfig.ai_config?.prompt_sections,
}
: prev.ai_config,
}
})
setHasChanges(true)
@@ -211,7 +295,7 @@ export function StrategyStudioPage() {
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!configResponse.ok) throw new Error('Failed to fetch default config')
const defaultConfig = await configResponse.json()
const defaultConfig = normalizeStrategyConfig(await configResponse.json())
const response = await fetch(`${API_BASE}/api/strategies`, {
method: 'POST',
@@ -280,14 +364,11 @@ export function StrategyStudioPage() {
// fetch failed — proceed, backend will guard
}
const confirmed = await confirmToast(
tr('confirmDeleteStrategy'),
{
title: tr('confirmDelete'),
okText: tr('delete'),
cancelText: tr('cancel'),
}
)
const confirmed = await confirmToast(tr('confirmDeleteStrategy'), {
title: tr('confirmDelete'),
okText: tr('delete'),
cancelText: tr('cancel'),
})
if (!confirmed) return
try {
@@ -316,16 +397,19 @@ export function StrategyStudioPage() {
const handleDuplicateStrategy = async (id: string) => {
if (!token) return
try {
const response = await fetch(`${API_BASE}/api/strategies/${id}/duplicate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: tr('strategyCopy'),
}),
})
const response = await fetch(
`${API_BASE}/api/strategies/${id}/duplicate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: tr('strategyCopy'),
}),
}
)
if (!response.ok) throw new Error('Failed to duplicate strategy')
await fetchStrategies()
} catch (err) {
@@ -337,10 +421,13 @@ export function StrategyStudioPage() {
const handleActivateStrategy = async (id: string) => {
if (!token) return
try {
const response = await fetch(`${API_BASE}/api/strategies/${id}/activate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const response = await fetch(
`${API_BASE}/api/strategies/${id}/activate`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}
)
if (!response.ok) throw new Error('Failed to activate strategy')
await fetchStrategies()
} catch (err) {
@@ -357,7 +444,9 @@ export function StrategyStudioPage() {
exported_at: new Date().toISOString(),
version: '1.0',
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -370,7 +459,9 @@ export function StrategyStudioPage() {
}
// Import strategy from JSON file
const handleImportStrategy = async (event: React.ChangeEvent<HTMLInputElement>) => {
const handleImportStrategy = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0]
if (!file || !token) return
@@ -420,7 +511,7 @@ export function StrategyStudioPage() {
try {
// Always sync the config language with the current interface language
const configWithLanguage = {
...editingConfig,
...normalizeStrategyConfig(editingConfig),
language: language as 'zh' | 'en',
}
const response = await fetch(
@@ -466,7 +557,26 @@ export function StrategyStudioPage() {
setHasChanges(true)
}
const handleStrategyTypeChange = (strategyType: NonNullable<StrategyConfig['strategy_type']>) => {
const updateAIConfig = <K extends keyof AIStrategyConfig>(
section: K,
value: AIStrategyConfig[K]
) => {
setEditingConfig((prev) => {
if (!prev || !prev.ai_config) return prev
return {
...prev,
ai_config: {
...prev.ai_config,
[section]: value,
},
}
})
setHasChanges(true)
}
const handleStrategyTypeChange = (
strategyType: NonNullable<StrategyConfig['strategy_type']>
) => {
if (selectedStrategy?.is_default) return
const cachedGridConfig = selectedStrategy?.id
@@ -478,12 +588,15 @@ export function StrategyStudioPage() {
if (strategyType === 'ai_trading') {
if (selectedStrategy?.id && prev.grid_config) {
gridConfigCacheRef.current[selectedStrategy.id] = { ...prev.grid_config }
gridConfigCacheRef.current[selectedStrategy.id] = {
...prev.grid_config,
}
}
return {
...prev,
strategy_type: 'ai_trading',
ai_config: getAIConfig(prev) || prev.ai_config,
// Use null so the field is preserved in JSON and backend merge can actually clear it.
grid_config: null,
}
@@ -492,7 +605,9 @@ export function StrategyStudioPage() {
return {
...prev,
strategy_type: 'grid_trading',
grid_config: cachedGridConfig ?? prev.grid_config ?? { ...defaultGridConfig },
ai_config: undefined,
grid_config: cachedGridConfig ??
prev.grid_config ?? { ...defaultGridConfig },
}
})
@@ -506,18 +621,21 @@ export function StrategyStudioPage() {
if (!token || !editingConfig) return
setIsLoadingPrompt(true)
try {
const response = await fetch(`${API_BASE}/api/strategies/preview-prompt`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
config: editingConfig,
account_equity: 1000,
prompt_variant: selectedVariant,
}),
})
const response = await fetch(
`${API_BASE}/api/strategies/preview-prompt`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
config: editingConfig,
account_equity: 1000,
prompt_variant: selectedVariant,
}),
}
)
if (!response.ok) throw new Error('Failed to fetch prompt preview')
const data = await response.json()
setPromptPreview(data)
@@ -576,6 +694,7 @@ export function StrategyStudioPage() {
// Get current strategy type (default to ai_trading if not set)
const currentStrategyType = editingConfig?.strategy_type || 'ai_trading'
const currentAIConfig = editingConfig ? getAIConfig(editingConfig) : null
const configSections = [
// Grid Config - only for grid_trading
@@ -601,10 +720,10 @@ export function StrategyStudioPage() {
color: '#F0B90B',
title: tr('coinSource'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
content: currentAIConfig && (
<CoinSourceEditor
config={editingConfig.coin_source}
onChange={(coinSource) => updateConfig('coin_source', coinSource)}
config={currentAIConfig.coin_source}
onChange={(coinSource) => updateAIConfig('coin_source', coinSource)}
disabled={selectedStrategy?.is_default}
language={language}
/>
@@ -616,10 +735,10 @@ export function StrategyStudioPage() {
color: '#0ECB81',
title: tr('indicators'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
content: currentAIConfig && (
<IndicatorEditor
config={editingConfig.indicators}
onChange={(indicators) => updateConfig('indicators', indicators)}
config={currentAIConfig.indicators}
onChange={(indicators) => updateAIConfig('indicators', indicators)}
disabled={selectedStrategy?.is_default}
language={language}
/>
@@ -631,10 +750,10 @@ export function StrategyStudioPage() {
color: '#F6465D',
title: tr('riskControl'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
content: currentAIConfig && (
<RiskControlEditor
config={editingConfig.risk_control}
onChange={(riskControl) => updateConfig('risk_control', riskControl)}
config={currentAIConfig.risk_control}
onChange={(riskControl) => updateAIConfig('risk_control', riskControl)}
disabled={selectedStrategy?.is_default}
language={language}
/>
@@ -646,10 +765,12 @@ export function StrategyStudioPage() {
color: '#a855f7',
title: tr('promptSections'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
content: currentAIConfig && (
<PromptSectionsEditor
config={editingConfig.prompt_sections}
onChange={(promptSections) => updateConfig('prompt_sections', promptSections)}
config={currentAIConfig.prompt_sections}
onChange={(promptSections) =>
updateAIConfig('prompt_sections', promptSections)
}
disabled={selectedStrategy?.is_default}
language={language}
/>
@@ -661,18 +782,22 @@ export function StrategyStudioPage() {
color: '#60a5fa',
title: tr('customPrompt'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
content: currentAIConfig && (
<div>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{tr('customPromptDesc')}
</p>
<textarea
value={editingConfig.custom_prompt || ''}
onChange={(e) => updateConfig('custom_prompt', e.target.value)}
value={currentAIConfig.custom_prompt || ''}
onChange={(e) => updateAIConfig('custom_prompt', e.target.value)}
disabled={selectedStrategy?.is_default}
placeholder={tr('customPromptPlaceholder')}
className="w-full h-32 px-3 py-2 rounded-lg resize-none font-mono text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
</div>
),
@@ -700,13 +825,14 @@ export function StrategyStudioPage() {
/>
),
},
].filter(section =>
section.forStrategyType === 'both' || section.forStrategyType === currentStrategyType
].filter(
(section) =>
section.forStrategyType === 'both' ||
section.forStrategyType === currentStrategyType
)
return (
<DeepVoidBackground className="h-[calc(100vh-64px)] flex flex-col bg-nofx-bg relative overflow-hidden">
{/* Header */}
{/* Header */}
<div className="flex-shrink-0 px-4 py-3 border-b border-nofx-gold/20 bg-nofx-bg/60 backdrop-blur-md z-10">
@@ -716,14 +842,21 @@ export function StrategyStudioPage() {
<Sparkles className="w-5 h-5 text-black" />
</div>
<div>
<h1 className="text-lg font-bold text-nofx-text">{tr('title')}</h1>
<h1 className="text-lg font-bold text-nofx-text">
{tr('title')}
</h1>
<p className="text-xs text-nofx-text-muted">{tr('subtitle')}</p>
</div>
</div>
{error && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs bg-nofx-danger/10 text-nofx-danger">
{error}
<button onClick={() => setError(null)} className="hover:underline">×</button>
<button
onClick={() => setError(null)}
className="hover:underline"
>
×
</button>
</div>
)}
</div>
@@ -735,10 +868,15 @@ export function StrategyStudioPage() {
<div className="w-48 flex-shrink-0 border-r border-nofx-gold/20 overflow-y-auto bg-nofx-bg/30 backdrop-blur-sm z-10">
<div className="p-2">
<div className="flex items-center justify-between mb-2 px-2">
<span className="text-xs font-medium text-nofx-text-muted">{tr('strategies')}</span>
<span className="text-xs font-medium text-nofx-text-muted">
{tr('strategies')}
</span>
<div className="flex items-center gap-1">
{/* Import button with hidden file input */}
<label className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer text-nofx-text-muted hover:text-white" title={tr('importStrategy')}>
<label
className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer text-nofx-text-muted hover:text-white"
title={tr('importStrategy')}
>
<Upload className="w-4 h-4" />
<input
type="file"
@@ -762,21 +900,29 @@ export function StrategyStudioPage() {
key={strategy.id}
onClick={() => {
setSelectedStrategy(strategy)
setEditingConfig(strategy.config)
setEditingConfig(normalizeStrategyConfig(strategy.config))
setHasChanges(false)
setPromptPreview(null)
setAiTestResult(null)
}}
className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${selectedStrategy?.id === strategy.id
? 'ring-1 ring-nofx-gold/50 bg-nofx-gold/10 shadow-[0_0_15px_rgba(240,185,11,0.1)]'
: 'hover:bg-nofx-bg-lighter/60 ring-1 ring-white/10 hover:ring-nofx-gold/20 bg-transparent'
}`}
className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${
selectedStrategy?.id === strategy.id
? 'ring-1 ring-nofx-gold/50 bg-nofx-gold/10 shadow-[0_0_15px_rgba(240,185,11,0.1)]'
: 'hover:bg-nofx-bg-lighter/60 ring-1 ring-white/10 hover:ring-nofx-gold/20 bg-transparent'
}`}
>
<div className="flex items-start justify-between">
<span className={`line-clamp-2 text-nofx-text ${language === 'zh' ? 'text-sm' : 'text-xs'}`}>{strategy.name}</span>
<span
className={`line-clamp-2 text-nofx-text ${language === 'zh' ? 'text-sm' : 'text-xs'}`}
>
{strategy.name}
</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }}
onClick={(e) => {
e.stopPropagation()
handleExportStrategy(strategy)
}}
className="p-1 rounded hover:bg-white/10 text-nofx-text-muted hover:text-white"
title={tr('export')}
>
@@ -785,17 +931,27 @@ export function StrategyStudioPage() {
{!strategy.is_default && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleDuplicateStrategy(strategy.id) }}
onClick={(e) => {
e.stopPropagation()
handleDuplicateStrategy(strategy.id)
}}
className="p-1 rounded hover:bg-white/10 text-nofx-text-muted hover:text-white"
title={tr('duplicate')}
>
<Copy className="w-3 h-3" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteStrategy(strategy.id) }}
onClick={(e) => {
e.stopPropagation()
handleDeleteStrategy(strategy.id)
}}
disabled={strategy.is_active}
className="p-1 rounded hover:bg-nofx-danger/20 text-nofx-danger disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
title={strategy.is_active ? tr('cannotDeleteActiveStrategy') : tr('deleteTooltip')}
title={
strategy.is_active
? tr('cannotDeleteActiveStrategy')
: tr('deleteTooltip')
}
>
<Trash2 className="w-3 h-3" />
</button>
@@ -838,7 +994,10 @@ export function StrategyStudioPage() {
type="text"
value={selectedStrategy.name}
onChange={(e) => {
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
setSelectedStrategy({
...selectedStrategy,
name: e.target.value,
})
setHasChanges(true)
}}
disabled={selectedStrategy.is_default}
@@ -848,7 +1007,10 @@ export function StrategyStudioPage() {
type="text"
value={selectedStrategy.description || ''}
onChange={(e) => {
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
setSelectedStrategy({
...selectedStrategy,
description: e.target.value,
})
setHasChanges(true)
}}
disabled={selectedStrategy.is_default}
@@ -856,13 +1018,17 @@ export function StrategyStudioPage() {
className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1"
/>
{hasChanges && (
<span className="text-xs text-nofx-gold"> {tr('unsaved')}</span>
<span className="text-xs text-nofx-gold">
{tr('unsaved')}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{!selectedStrategy.is_active && (
<button
onClick={() => handleActivateStrategy(selectedStrategy.id)}
onClick={() =>
handleActivateStrategy(selectedStrategy.id)
}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs transition-colors bg-nofx-success/10 border border-nofx-success/30 text-nofx-success hover:bg-nofx-success/20"
>
<Check className="w-3 h-3" />
@@ -886,7 +1052,11 @@ export function StrategyStudioPage() {
{/* Token Estimate Bar */}
{currentStrategyType === 'ai_trading' && (
<div className="mb-4">
<TokenEstimateBar config={editingConfig} language={language} onTokenCountChange={setEstimatedTokens} />
<TokenEstimateBar
config={editingConfig}
language={language}
onTokenCountChange={setEstimatedTokens}
/>
</div>
)}
@@ -895,23 +1065,30 @@ export function StrategyStudioPage() {
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-medium text-nofx-text">{tr('strategyType')}</span>
<span className="text-sm font-medium text-nofx-text">
{tr('strategyType')}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => handleStrategyTypeChange('ai_trading')}
disabled={selectedStrategy?.is_default}
className={`p-3 rounded-lg border transition-all ${
(!editingConfig.strategy_type || editingConfig.strategy_type === 'ai_trading')
!editingConfig.strategy_type ||
editingConfig.strategy_type === 'ai_trading'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-nofx-border hover:border-nofx-gold/50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<Bot className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-medium text-nofx-text">{tr('aiTrading')}</span>
<span className="text-sm font-medium text-nofx-text">
{tr('aiTrading')}
</span>
</div>
<p className="text-xs text-nofx-text-muted text-left">{tr('aiTradingDesc')}</p>
<p className="text-xs text-nofx-text-muted text-left">
{tr('aiTradingDesc')}
</p>
</button>
<button
onClick={() => handleStrategyTypeChange('grid_trading')}
@@ -923,10 +1100,17 @@ export function StrategyStudioPage() {
}`}
>
<div className="flex items-center gap-2 mb-1">
<Activity className="w-4 h-4" style={{ color: '#0ECB81' }} />
<span className="text-sm font-medium text-nofx-text">{tr('gridTrading')}</span>
<Activity
className="w-4 h-4"
style={{ color: '#0ECB81' }}
/>
<span className="text-sm font-medium text-nofx-text">
{tr('gridTrading')}
</span>
</div>
<p className="text-xs text-nofx-text-muted text-left">{tr('gridTradingDesc')}</p>
<p className="text-xs text-nofx-text-muted text-left">
{tr('gridTradingDesc')}
</p>
</button>
</div>
</div>
@@ -934,32 +1118,34 @@ export function StrategyStudioPage() {
{/* Config Sections */}
<div className="space-y-2">
{configSections.map(({ key, icon: Icon, color, title, content }) => (
<div
key={key}
className="rounded-lg overflow-hidden bg-nofx-bg-lighter border border-nofx-gold/20"
>
<button
onClick={() => toggleSection(key)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
{configSections.map(
({ key, icon: Icon, color, title, content }) => (
<div
key={key}
className="rounded-lg overflow-hidden bg-nofx-bg-lighter border border-nofx-gold/20"
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" style={{ color }} />
<span className="text-sm font-medium text-nofx-text">{title}</span>
</div>
{expandedSections[key] ? (
<ChevronDown className="w-4 h-4 text-nofx-text-muted" />
) : (
<ChevronRight className="w-4 h-4 text-nofx-text-muted" />
<button
onClick={() => toggleSection(key)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" style={{ color }} />
<span className="text-sm font-medium text-nofx-text">
{title}
</span>
</div>
{expandedSections[key] ? (
<ChevronDown className="w-4 h-4 text-nofx-text-muted" />
) : (
<ChevronRight className="w-4 h-4 text-nofx-text-muted" />
)}
</button>
{expandedSections[key] && (
<div className="px-3 pb-3">{content}</div>
)}
</button>
{expandedSections[key] && (
<div className="px-3 pb-3">
{content}
</div>
)}
</div>
))}
</div>
)
)}
</div>
</div>
) : (
@@ -980,16 +1166,22 @@ export function StrategyStudioPage() {
<div className="flex-shrink-0 flex border-b border-nofx-gold/20">
<button
onClick={() => setActiveRightTab('prompt')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${activeRightTab === 'prompt' ? 'border-b-2 border-purple-500 text-purple-500' : 'opacity-60 hover:opacity-100 text-nofx-text-muted'
}`}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
activeRightTab === 'prompt'
? 'border-b-2 border-purple-500 text-purple-500'
: 'opacity-60 hover:opacity-100 text-nofx-text-muted'
}`}
>
<Eye className="w-4 h-4" />
{tr('promptPreview')}
</button>
<button
onClick={() => setActiveRightTab('test')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${activeRightTab === 'test' ? 'border-b-2 border-green-500 text-green-500' : 'opacity-60 hover:opacity-100 text-nofx-text-muted'
}`}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
activeRightTab === 'test'
? 'border-b-2 border-green-500 text-green-500'
: 'opacity-60 hover:opacity-100 text-nofx-text-muted'
}`}
>
<Play className="w-4 h-4" />
{tr('aiTestRun')}
@@ -1017,7 +1209,11 @@ export function StrategyStudioPage() {
disabled={isLoadingPrompt || !editingConfig}
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 bg-purple-600 hover:bg-purple-700 text-white"
>
{isLoadingPrompt ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
{isLoadingPrompt ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
{promptPreview ? tr('refreshPrompt') : tr('loadPrompt')}
</button>
</div>
@@ -1028,15 +1224,23 @@ export function StrategyStudioPage() {
<div className="p-2 rounded-lg bg-nofx-bg border border-nofx-gold/20">
<div className="flex items-center gap-1.5 mb-2">
<Code className="w-3 h-3 text-purple-500" />
<span className="text-xs font-medium text-purple-500">Config</span>
<span className="text-xs font-medium text-purple-500">
Config
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
{Object.entries(promptPreview.config_summary || {}).map(([key, value]) => (
<div key={key}>
<div className="text-nofx-text-muted">{key.replace(/_/g, ' ')}</div>
<div className="text-nofx-text">{String(value)}</div>
</div>
))}
{Object.entries(promptPreview.config_summary || {}).map(
([key, value]) => (
<div key={key}>
<div className="text-nofx-text-muted">
{key.replace(/_/g, ' ')}
</div>
<div className="text-nofx-text">
{String(value)}
</div>
</div>
)
)}
</div>
</div>
@@ -1045,10 +1249,13 @@ export function StrategyStudioPage() {
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<FileText className="w-3 h-3 text-purple-500" />
<span className="text-xs font-medium text-nofx-text">{tr('systemPrompt')}</span>
<span className="text-xs font-medium text-nofx-text">
{tr('systemPrompt')}
</span>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-nofx-bg-lighter text-nofx-text-muted">
{promptPreview.system_prompt.length.toLocaleString()} chars
{promptPreview.system_prompt.length.toLocaleString()}{' '}
chars
</span>
</div>
<pre
@@ -1073,7 +1280,9 @@ export function StrategyStudioPage() {
<div className="space-y-2">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-green-500" />
<span className="text-xs font-medium text-nofx-text">{tr('selectModel')}</span>
<span className="text-xs font-medium text-nofx-text">
{tr('selectModel')}
</span>
</div>
{aiModels.length > 0 ? (
<select
@@ -1105,7 +1314,9 @@ export function StrategyStudioPage() {
</select>
<button
onClick={runAiTest}
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
disabled={
isRunningAiTest || !editingConfig || !selectedModelId
}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:opacity-50 text-white shadow-lg shadow-green-500/20 bg-gradient-to-br from-green-500 to-green-600"
>
{isRunningAiTest ? (
@@ -1121,7 +1332,9 @@ export function StrategyStudioPage() {
)}
</button>
</div>
<p className="text-[10px] text-nofx-text-muted">{tr('testNote')}</p>
<p className="text-[10px] text-nofx-text-muted">
{tr('testNote')}
</p>
</div>
{/* Test Results */}
@@ -1129,7 +1342,9 @@ export function StrategyStudioPage() {
<div className="space-y-3">
{aiTestResult.error ? (
<div className="p-3 rounded-lg bg-nofx-danger/10 border border-nofx-danger/30">
<p className="text-sm text-nofx-danger">{aiTestResult.error}</p>
<p className="text-sm text-nofx-danger">
{aiTestResult.error}
</p>
</div>
) : (
<>
@@ -1137,7 +1352,8 @@ export function StrategyStudioPage() {
<div className="flex items-center gap-2">
<Clock className="w-3 h-3 text-nofx-text-muted" />
<span className="text-xs text-nofx-text-muted">
{tr('duration')}: {(aiTestResult.duration_ms / 1000).toFixed(2)}s
{tr('duration')}:{' '}
{(aiTestResult.duration_ms / 1000).toFixed(2)}s
</span>
</div>
)}
@@ -1147,7 +1363,9 @@ export function StrategyStudioPage() {
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Terminal className="w-3 h-3 text-blue-400" />
<span className="text-xs font-medium text-nofx-text">{tr('userPrompt')} (Input)</span>
<span className="text-xs font-medium text-nofx-text">
{tr('userPrompt')} (Input)
</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
@@ -1163,7 +1381,9 @@ export function StrategyStudioPage() {
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Sparkles className="w-3 h-3 text-nofx-gold" />
<span className="text-xs font-medium text-nofx-text">{tr('reasoning')}</span>
<span className="text-xs font-medium text-nofx-text">
{tr('reasoning')}
</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap bg-nofx-bg border border-nofx-gold/30 text-nofx-text"
@@ -1175,27 +1395,36 @@ export function StrategyStudioPage() {
)}
{/* AI Decisions */}
{aiTestResult.decisions && aiTestResult.decisions.length > 0 && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Activity className="w-3 h-3 text-green-500" />
<span className="text-xs font-medium text-nofx-text">{tr('decisions')}</span>
{aiTestResult.decisions &&
aiTestResult.decisions.length > 0 && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Activity className="w-3 h-3 text-green-500" />
<span className="text-xs font-medium text-nofx-text">
{tr('decisions')}
</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-green-500/30 text-nofx-text"
style={{ maxHeight: '200px' }}
>
{JSON.stringify(
aiTestResult.decisions,
null,
2
)}
</pre>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-green-500/30 text-nofx-text"
style={{ maxHeight: '200px' }}
>
{JSON.stringify(aiTestResult.decisions, null, 2)}
</pre>
</div>
)}
)}
{/* Raw AI Response */}
{aiTestResult.ai_response && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<FileText className="w-3 h-3 text-nofx-text-muted" />
<span className="text-xs font-medium text-nofx-text">{tr('aiOutput')} (Raw)</span>
<span className="text-xs font-medium text-nofx-text">
{tr('aiOutput')} (Raw)
</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap bg-nofx-bg border border-nofx-gold/20 text-nofx-text"

View File

@@ -1,28 +1,15 @@
import { create } from 'zustand'
export interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
export interface AgentMessage {
id: string
role: 'user' | 'bot'
text: string
time: string
streaming?: boolean
steps?: AgentStep[]
}
import type { AgentMessage } from '../types/agent'
interface AgentChatStoreState {
activeUserId?: string
messages: AgentMessage[]
draftText: string
loading: boolean
hydrated: boolean
setActiveUserId: (userId?: string) => void
setMessages: (messages: AgentMessage[]) => void
setDraftText: (draftText: string) => void
updateMessages: (
updater: (messages: AgentMessage[]) => AgentMessage[]
) => void
@@ -34,10 +21,12 @@ interface AgentChatStoreState {
export const useAgentChatStore = create<AgentChatStoreState>((set) => ({
activeUserId: undefined,
messages: [],
draftText: '',
loading: false,
hydrated: false,
setActiveUserId: (userId) => set({ activeUserId: userId }),
setMessages: (messages) => set({ messages }),
setDraftText: (draftText) => set({ draftText }),
updateMessages: (updater) =>
set((state) => ({ messages: updater(state.messages) })),
setLoading: (loading) => set({ loading }),
@@ -46,6 +35,7 @@ export const useAgentChatStore = create<AgentChatStoreState>((set) => ({
set({
activeUserId: userId,
messages,
draftText: '',
loading: false,
hydrated: true,
}),

15
web/src/types/agent.ts Normal file
View File

@@ -0,0 +1,15 @@
export interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
export interface AgentMessage {
id: string
role: 'user' | 'bot'
text: string
time: string
streaming?: boolean
steps?: AgentStep[]
}

View File

@@ -96,7 +96,6 @@ export interface CreateTraderRequest {
ai_model_id: string
exchange_id: string
strategy_id?: string // 策略ID新版使用保存的策略配置
initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新
scan_interval_minutes?: number
is_cross_margin?: boolean
show_in_competition?: boolean // 是否在竞技场显示

View File

@@ -44,13 +44,30 @@ export interface StrategyConfig {
// Language setting: "zh" for Chinese, "en" for English
// Determines the language used for data formatting and prompt generation
language?: 'zh' | 'en';
// AI trading configuration. Legacy flat fields below are accepted only for
// old data returned before the schema was split by strategy type.
ai_config?: AIStrategyConfig;
coin_source?: CoinSourceConfig;
indicators?: IndicatorConfig;
custom_prompt?: string;
risk_control?: RiskControlConfig;
prompt_sections?: PromptSectionsConfig;
// Grid trading configuration (only used when strategy_type is 'grid_trading')
grid_config?: GridStrategyConfig | null;
publish_config?: PublishStrategyConfig;
}
export interface AIStrategyConfig {
coin_source: CoinSourceConfig;
indicators: IndicatorConfig;
custom_prompt?: string;
risk_control: RiskControlConfig;
prompt_sections?: PromptSectionsConfig;
// Grid trading configuration (only used when strategy_type is 'grid_trading')
grid_config?: GridStrategyConfig | null;
}
export interface PublishStrategyConfig {
is_public: boolean;
config_visible: boolean;
}
// Grid trading specific configuration
@@ -88,7 +105,7 @@ export interface GridStrategyConfig {
}
export interface CoinSourceConfig {
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low' | 'mixed';
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low';
static_coins?: string[];
excluded_coins?: string[]; // 排除的币种列表
use_ai500: boolean;