mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 21:12:00 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
15
web/src/types/agent.ts
Normal 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[]
|
||||
}
|
||||
@@ -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 // 是否在竞技场显示
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user