feat: port NOFXi agent module onto latest dev base (#1485)

* feat: integrate NOFXi agent into dev

* Enhance NOFXi agent workflow and diagnostics
This commit is contained in:
lky-spec
2026-04-21 23:47:55 +08:00
committed by GitHub
parent 1ba50bdedf
commit 3ca95b294d
88 changed files with 22630 additions and 1143 deletions

View File

@@ -0,0 +1,104 @@
interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
interface AgentStepPanelProps {
steps?: AgentStep[]
visible?: boolean
}
const statusStyles: Record<AgentStep['status'], { dot: string; text: string }> = {
planning: { dot: '#7c3aed', text: '#c4b5fd' },
pending: { dot: 'rgba(255,255,255,0.18)', text: '#818198' },
running: { dot: '#F0B90B', text: '#f6d67a' },
completed: { dot: '#00e5a0', text: '#9cf5d5' },
replanned: { dot: '#38bdf8', text: '#9bdcf7' },
}
export function AgentStepPanel({ steps, visible }: AgentStepPanelProps) {
if (!visible || !steps || steps.length === 0) {
return null
}
return (
<div
style={{
marginBottom: 12,
padding: '10px 12px',
borderRadius: 12,
background: 'linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015))',
border: '1px solid rgba(255,255,255,0.06)',
}}
>
<div
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#7b7b91',
marginBottom: 10,
}}
>
Live Run
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{steps.map((step) => {
const style = statusStyles[step.status]
return (
<div
key={step.id}
style={{
display: 'grid',
gridTemplateColumns: '14px 1fr',
gap: 8,
alignItems: 'start',
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: 999,
marginTop: 5,
background: style.dot,
boxShadow:
step.status === 'running'
? '0 0 0 4px rgba(240,185,11,0.08)'
: 'none',
}}
/>
<div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: style.text,
fontWeight: step.status === 'running' ? 600 : 500,
}}
>
{step.label}
</div>
{step.detail && (
<div
style={{
fontSize: 11.5,
lineHeight: 1.45,
color: '#6e6e86',
marginTop: 2,
}}
>
{step.detail}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,154 @@
import { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react'
import { ArrowUp } from 'lucide-react'
export interface ChatInputHandle {
focus: () => void
clear: () => void
getValue: () => string
}
interface ChatInputProps {
language: string
loading: boolean
onSend: (text: string) => void
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
function ChatInput({ language, loading, onSend }, ref) {
const [input, setInput] = useState('')
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,
}))
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'
},
[]
)
const handleSend = () => {
const msg = input.trim()
if (!msg || loading) return
setInput('')
if (inputRef.current) inputRef.current.style.height = 'auto'
onSend(msg)
inputRef.current?.focus()
}
// Keyboard shortcut: Cmd+K to focus
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
inputRef.current?.focus()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<div
style={{
padding: '12px 16px 20px',
borderTop: '1px solid rgba(255,255,255,0.04)',
background: 'linear-gradient(to top, #09090b 80%, transparent)',
}}
>
<div
className="chat-input-wrapper"
style={{
maxWidth: 720,
margin: '0 auto',
display: 'flex',
gap: 8,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 18,
padding: '4px 4px 4px 16px',
alignItems: 'flex-end',
transition: 'all 0.2s ease',
}}
>
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !composing) {
e.preventDefault()
handleSend()
}
}}
placeholder={
language === 'zh'
? '跟 NOFXi 聊点什么... ⌘K'
: 'Ask NOFXi anything... ⌘K'
}
rows={1}
style={{
flex: 1,
background: 'none',
border: 'none',
color: '#eaeaf0',
fontSize: 13.5,
outline: 'none',
padding: '10px 0',
fontFamily: 'inherit',
resize: 'none',
lineHeight: 1.5,
maxHeight: 150,
}}
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
style={{
width: 36,
height: 36,
borderRadius: 12,
border: 'none',
background:
loading || !input.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',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
<ArrowUp size={16} strokeWidth={2.5} />
</button>
</div>
<div
style={{
maxWidth: 720,
margin: '6px auto 0',
textAlign: 'center',
fontSize: 10,
color: '#1e1e32',
}}
>
NOFXi may make mistakes. Always verify trading decisions.
</div>
</div>
)
}
)

View File

@@ -0,0 +1,151 @@
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[]
}
interface ChatMessagesProps {
messages: Message[]
}
function hasMeaningfulExecutionSteps(steps?: AgentStep[]) {
if (!steps || steps.length === 0) return false
return steps.some((step) => step.status !== 'planning')
}
export const ChatMessages = forwardRef<HTMLDivElement, ChatMessagesProps>(
function ChatMessages({ messages }, ref) {
return (
<div style={{ maxWidth: 720, margin: '0 auto', padding: '0 20px' }}>
{messages.map((m) => (
<motion.div
key={m.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
style={{
display: 'flex',
gap: 12,
marginBottom: 24,
flexDirection: m.role === 'user' ? 'row-reverse' : 'row',
}}
>
{/* Avatar */}
<div
style={{
width: 30,
height: 30,
borderRadius: 10,
display: 'grid',
placeItems: 'center',
fontSize: 14,
flexShrink: 0,
marginTop: 2,
background:
m.role === 'user'
? 'linear-gradient(135deg, rgba(139,92,246,.12), rgba(139,92,246,.04))'
: 'linear-gradient(135deg, rgba(240,185,11,.08), rgba(0,229,160,.04))',
border:
'1px solid ' +
(m.role === 'user'
? 'rgba(139,92,246,.15)'
: 'rgba(240,185,11,.1)'),
}}
>
{m.role === 'user' ? '👤' : '⚡'}
</div>
{/* Message content */}
<div style={{ maxWidth: '78%', minWidth: 0 }}>
{m.role === 'user' ? (
<div
style={{
padding: '10px 16px',
borderRadius: 18,
borderTopRightRadius: 4,
fontSize: 13.5,
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
color: '#fff',
}}
>
{m.text}
</div>
) : (
<div
style={{
padding: '12px 16px',
borderRadius: 18,
borderTopLeftRadius: 4,
fontSize: 13.5,
lineHeight: 1.7,
wordBreak: 'break-word',
background: 'rgba(255,255,255,0.03)',
color: '#dcdce8',
border: '1px solid rgba(255,255,255,0.05)',
}}
>
<AgentStepPanel steps={m.steps} visible={hasMeaningfulExecutionSteps(m.steps)} />
{renderMessageContent(m.text)}
{m.streaming && m.text === '' && (
<div style={{ display: 'flex', gap: 4, padding: '4px 0' }}>
<span className="typing-dot" style={{ animationDelay: '0ms' }} />
<span className="typing-dot" style={{ animationDelay: '150ms' }} />
<span className="typing-dot" style={{ animationDelay: '300ms' }} />
</div>
)}
{m.streaming && m.text !== '' && (
<span
style={{
display: 'inline-block',
width: 2,
height: 15,
background: '#F0B90B',
marginLeft: 1,
borderRadius: 1,
animation: 'blink 0.8s infinite',
verticalAlign: 'text-bottom',
}}
/>
)}
</div>
)}
{m.time && !m.streaming && (
<div
style={{
fontSize: 10,
color: '#2c2c42',
marginTop: 4,
textAlign: m.role === 'user' ? 'right' : 'left',
paddingLeft: m.role === 'bot' ? 4 : 0,
paddingRight: m.role === 'user' ? 4 : 0,
}}
>
{m.role === 'bot' && 'NOFXi · '}{m.time}
</div>
)}
</div>
</motion.div>
))}
<div ref={ref} />
</div>
)
}
)

View File

@@ -0,0 +1,178 @@
import { useState, useEffect } from 'react'
// icons reserved for future use
interface TickerData {
symbol: string
lastPrice: string
priceChangePercent: string
highPrice: string
lowPrice: string
volume: string
}
const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT']
const SYMBOL_ICONS: Record<string, string> = {
BTC: '₿',
ETH: 'Ξ',
SOL: '◎',
}
export function MarketTicker() {
const [tickers, setTickers] = useState<Record<string, TickerData>>({})
const [loading, setLoading] = useState(true)
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 map: Record<string, TickerData> = {}
if (Array.isArray(data)) {
data.forEach((r: TickerData) => {
if (r.lastPrice && r.symbol) map[r.symbol] = r
})
}
setTickers(map)
} catch {
// ignore — will retry on next interval
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTickers()
const interval = setInterval(fetchTickers, 15000)
return () => clearInterval(interval)
}, [])
const formatPrice = (price: string) => {
const n = parseFloat(price)
if (n >= 1000) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (n >= 1) return n.toFixed(2)
return n.toFixed(4)
}
const formatVolume = (vol: string) => {
const n = parseFloat(vol)
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
return n.toFixed(0)
}
if (loading) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SYMBOLS.map((sym) => (
<div
key={sym}
style={{
padding: '12px',
background: 'rgba(255,255,255,0.02)',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.04)',
height: 56,
}}
>
<div style={{
width: '60%',
height: 10,
background: 'rgba(255,255,255,0.04)',
borderRadius: 4,
animation: 'pulse 1.5s infinite',
}} />
</div>
))}
<style>{`
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
`}</style>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SYMBOLS.map((sym) => {
const t = tickers[sym]
if (!t) return null
const pct = parseFloat(t.priceChangePercent)
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 label = sym.replace('USDT', '')
const icon = SYMBOL_ICONS[label] || label[0]
return (
<div
key={sym}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 11px',
background: 'rgba(255,255,255,0.02)',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.04)',
transition: 'all 0.15s ease',
cursor: 'default',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)'
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.02)'
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.04)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 8,
background: bgColor,
display: 'grid',
placeItems: 'center',
fontSize: 13,
fontWeight: 700,
color: color,
fontFamily: 'system-ui',
}}
>
{icon}
</div>
<div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', letterSpacing: '-0.01em' }}>
{label}
</div>
<div style={{ fontSize: 10, color: '#4c4c62' }}>
Vol {formatVolume(t.volume)}
</div>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<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>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,187 @@
/**
* MessageRenderer — markdown-to-JSX renderer for agent chat messages.
* Supports: headers, bold, italic, inline code, code blocks, lists, links, HR.
*/
// Inline formatting: bold, italic, code, links
export function renderInline(text: string): (string | JSX.Element)[] {
const parts = text.split(/(```[\s\S]*?```|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\(([^)]+)\))/g)
const result: (string | JSX.Element)[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (!part) continue
if (part.startsWith('`') && part.endsWith('`') && !part.startsWith('```')) {
result.push(
<code
key={i}
style={{
background: 'rgba(240,185,11,0.08)',
padding: '2px 6px',
borderRadius: 5,
fontSize: '0.88em',
fontFamily: '"IBM Plex Mono", monospace',
color: '#F0B90B',
border: '1px solid rgba(240,185,11,0.12)',
}}
>
{part.slice(1, -1)}
</code>
)
} else if (part.startsWith('**') && part.endsWith('**')) {
result.push(
<strong key={i} style={{ fontWeight: 600, color: '#f0f0f8' }}>
{part.slice(2, -2)}
</strong>
)
} else if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) {
result.push(
<em key={i} style={{ fontStyle: 'italic', color: '#d0d0e0' }}>
{part.slice(1, -1)}
</em>
)
} else if (part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)) {
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (match) {
const href = match[2]
// Only allow http/https links to prevent javascript: XSS
const safeHref = /^https?:\/\//i.test(href) ? href : '#'
result.push(
<a
key={i}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#F0B90B', textDecoration: 'underline', textUnderlineOffset: 2 }}
>
{match[1]}
</a>
)
}
} else {
result.push(part)
}
}
return result
}
// Enhanced markdown renderer: headers, bold, italic, code, lists, links
export function renderMessageContent(text: string) {
const lines = text.split('\n')
const elements: JSX.Element[] = []
let inCodeBlock = false
let codeContent = ''
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Code block toggle
if (line.startsWith('```')) {
if (inCodeBlock) {
elements.push(
<pre
key={`code-${i}`}
style={{
background: '#0a0a12',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 10,
padding: '12px 14px',
fontSize: 12,
overflowX: 'auto',
margin: '8px 0',
fontFamily: '"IBM Plex Mono", monospace',
color: '#c0c0d0',
lineHeight: 1.6,
}}
>
{codeContent.trim()}
</pre>
)
codeContent = ''
inCodeBlock = false
} else {
inCodeBlock = true
}
continue
}
if (inCodeBlock) {
codeContent += (codeContent ? '\n' : '') + line
continue
}
// Headers
if (line.startsWith('### ')) {
elements.push(
<div key={i} style={{ fontSize: 14, fontWeight: 700, color: '#f0f0f8', margin: '12px 0 6px', letterSpacing: '-0.01em' }}>
{renderInline(line.slice(4))}
</div>
)
continue
}
if (line.startsWith('## ')) {
elements.push(
<div key={i} style={{ fontSize: 15, fontWeight: 700, color: '#f0f0f8', margin: '14px 0 6px', letterSpacing: '-0.01em' }}>
{renderInline(line.slice(3))}
</div>
)
continue
}
if (line.startsWith('# ')) {
elements.push(
<div key={i} style={{ fontSize: 16, fontWeight: 700, color: '#f0f0f8', margin: '16px 0 8px', letterSpacing: '-0.02em' }}>
{renderInline(line.slice(2))}
</div>
)
continue
}
// Bullet lists
if (line.match(/^[-•*]\s/)) {
elements.push(
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
<span style={{ color: '#F0B90B', flexShrink: 0, fontSize: 8, marginTop: 7 }}></span>
<span>{renderInline(line.replace(/^[-•*]\s/, ''))}</span>
</div>
)
continue
}
// Numbered lists
if (line.match(/^\d+\.\s/)) {
const num = line.match(/^(\d+)\./)?.[1]
elements.push(
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
<span style={{ color: '#8a8aa0', flexShrink: 0, fontSize: 12, fontWeight: 600, minWidth: 16, fontFamily: '"IBM Plex Mono", monospace' }}>{num}.</span>
<span>{renderInline(line.replace(/^\d+\.\s/, ''))}</span>
</div>
)
continue
}
// Horizontal rule
if (line.match(/^---+$/)) {
elements.push(
<hr key={i} style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.06)', margin: '12px 0' }} />
)
continue
}
// Empty line → small gap
if (line.trim() === '') {
elements.push(<div key={i} style={{ height: 6 }} />)
continue
}
// Regular paragraph
elements.push(
<div key={i} style={{ lineHeight: 1.7, padding: '1px 0' }}>
{renderInline(line)}
</div>
)
}
return elements
}

View File

@@ -0,0 +1,154 @@
import useSWR from 'swr'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { ArrowUpRight, ArrowDownRight, Wallet } from 'lucide-react'
import type { Position, TraderInfo } from '../../types'
export function PositionsPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
user && token ? 'agent-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
// Get first running trader's positions
const runningTrader = traders?.find((t) => t.is_running)
const traderId = runningTrader?.trader_id
const { data: positions } = useSWR<Position[]>(
traderId ? `agent-positions-${traderId}` : null,
() => api.getPositions(traderId),
{ refreshInterval: 15000, shouldRetryOnError: false }
)
if (!user || !token) {
return (
<div
style={{
padding: '20px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
<Wallet size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<div>Login to view positions</div>
</div>
)
}
const openPositions = positions?.filter((p) => p.quantity !== 0) || []
if (openPositions.length === 0) {
return (
<div
style={{
padding: '16px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
No open positions
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{openPositions.map((pos, i) => {
const pnl = pos.unrealized_pnl
const isProfit = pnl >= 0
const color = isProfit ? '#00e5a0' : '#F6465D'
const side = pos.side?.toUpperCase() || (pos.quantity > 0 ? 'LONG' : 'SHORT')
const rawSymbol = pos.symbol || ''
// Stock symbols are pure letters (1-5 chars), crypto has USDT suffix
const isStock = /^[A-Z]{1,5}$/.test(rawSymbol) && !rawSymbol.endsWith('USDT')
const symbol = isStock ? rawSymbol : rawSymbol.replace('USDT', '')
const currencyPrefix = isStock ? '$' : ''
return (
<div
key={i}
style={{
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: '#eaeaf0',
}}
>
{symbol}
</span>
{isStock && (
<span style={{ fontSize: 10, color: '#8b8ba0' }}>🇺🇸</span>
)}
<span
style={{
fontSize: 10,
fontWeight: 600,
padding: '1px 5px',
borderRadius: 4,
background:
side === 'LONG'
? 'rgba(0,229,160,0.12)'
: 'rgba(246,70,93,0.12)',
color: side === 'LONG' ? '#00e5a0' : '#F6465D',
}}
>
{isStock ? (side === 'LONG' ? 'HOLD' : 'SHORT') : side}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 3,
color,
fontSize: 12,
fontWeight: 600,
}}
>
{isProfit ? (
<ArrowUpRight size={12} />
) : (
<ArrowDownRight size={12} />
)}
{isProfit ? '+' : ''}
{currencyPrefix}{pnl.toFixed(2)}
</div>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: '#5c5c72',
}}
>
<span>{isStock ? 'Shares' : 'Qty'}: {pos.quantity}</span>
<span>Entry: {currencyPrefix}{pos.entry_price.toFixed(2)}</span>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,110 @@
import useSWR from 'swr'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { Activity, CircleOff, Bot } from 'lucide-react'
import type { TraderInfo } from '../../types'
export function TraderStatusPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
user && token ? 'agent-sidebar-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
if (!user || !token) {
return (
<div
style={{
padding: '20px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
<Bot size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<div>Login to view traders</div>
</div>
)
}
if (!traders || traders.length === 0) {
return (
<div
style={{
padding: '16px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
No traders configured
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{traders.map((trader) => (
<div
key={trader.trader_id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 7,
background: trader.is_running
? 'rgba(0,229,160,0.08)'
: 'rgba(92,92,114,0.08)',
display: 'grid',
placeItems: 'center',
}}
>
{trader.is_running ? (
<Activity size={14} color="#00e5a0" />
) : (
<CircleOff size={14} color="#5c5c72" />
)}
</div>
<div>
<div
style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}
>
{trader.trader_name}
</div>
<div style={{ fontSize: 10, color: '#5c5c72' }}>
{trader.trader_id.slice(0, 8)}...
</div>
</div>
</div>
<div
style={{
fontSize: 10,
fontWeight: 600,
padding: '3px 8px',
borderRadius: 6,
background: trader.is_running
? 'rgba(0,229,160,0.12)'
: 'rgba(92,92,114,0.12)',
color: trader.is_running ? '#00e5a0' : '#5c5c72',
}}
>
{trader.is_running ? 'RUNNING' : 'STOPPED'}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,222 @@
import { useEffect, useState } from 'react'
interface Preference {
id: string
text: string
created_at?: string
}
interface Props {
token: string | null
language: string
}
export function UserPreferencesPanel({ token, language }: Props) {
const [preferences, setPreferences] = useState<Preference[]>([])
const [draft, setDraft] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadPreferences = async () => {
if (!token) {
setPreferences([])
setLoading(false)
return
}
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 : [])
setError(null)
} catch {
setError(language === 'zh' ? '加载偏好失败' : 'Failed to load')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!token) {
setPreferences([])
setLoading(false)
return
}
let cancelled = false
void (async () => {
await loadPreferences()
})()
const handleRefresh = () => {
if (!cancelled) void loadPreferences()
}
window.addEventListener('agent-preferences-refresh', handleRefresh)
return () => {
cancelled = true
window.removeEventListener('agent-preferences-refresh', handleRefresh)
}
}, [token, language])
const addPreference = async () => {
const text = draft.trim()
if (!text || !token || saving) return
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 : [])
setDraft('')
setError(null)
} catch {
setError(language === 'zh' ? '保存偏好失败' : 'Failed to save')
} finally {
setSaving(false)
}
}
const removePreference = async (id: string) => {
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 : [])
setError(null)
} catch {
setError(language === 'zh' ? '删除偏好失败' : 'Failed to delete')
} finally {
setSaving(false)
}
}
return (
<div
id="agent-preferences-panel"
style={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.05)',
borderRadius: 12,
padding: 10,
}}
>
<div style={{ marginBottom: 8 }}>
<div style={{ color: '#d7d7e0', fontSize: 12, fontWeight: 600 }}>
{language === 'zh' ? '长期偏好' : 'Persistent Preferences'}
</div>
<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.'}
</div>
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
<input
data-agent-preferences-input="true"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void addPreference()
}}
placeholder={language === 'zh' ? '例如:默认用中文回答,优先关注 BTC、ETH' : 'Example: Answer in Chinese and focus on BTC, ETH'}
style={{
flex: 1,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
color: '#e8e8f0',
borderRadius: 8,
padding: '8px 10px',
fontSize: 12,
outline: 'none',
}}
/>
<button
onClick={() => void addPreference()}
disabled={!draft.trim() || saving}
style={{
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,
padding: '0 10px',
fontSize: 12,
cursor: draft.trim() && !saving ? 'pointer' : 'default',
}}
>
{language === 'zh' ? '添加' : 'Add'}
</button>
</div>
{error && (
<div style={{ color: '#f08a8a', fontSize: 11, marginBottom: 8 }}>{error}</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{loading ? (
<div style={{ color: '#77778d', fontSize: 11 }}>
{language === 'zh' ? '加载中...' : 'Loading...'}
</div>
) : preferences.length === 0 ? (
<div style={{ color: '#77778d', fontSize: 11, lineHeight: 1.5 }}>
{language === 'zh'
? '还没有长期偏好。你可以把关注标的、风险倾向、回答习惯放在这里。'
: 'No persistent preferences yet. Add watchlists, risk preferences, or response habits here.'}
</div>
) : (
preferences.map((pref) => (
<div
key={pref.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: 8,
borderRadius: 10,
background: 'rgba(255,255,255,0.025)',
border: '1px solid rgba(255,255,255,0.04)',
}}
>
<div style={{ flex: 1, color: '#d7d7e0', fontSize: 12, lineHeight: 1.5 }}>
{pref.text}
</div>
<button
onClick={() => void removePreference(pref.id)}
disabled={saving}
style={{
background: 'transparent',
border: 'none',
color: '#8b8ba0',
fontSize: 11,
cursor: saving ? 'default' : 'pointer',
padding: 0,
}}
>
{language === 'zh' ? '删除' : 'Delete'}
</button>
</div>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,138 @@
import { motion } from 'framer-motion'
import {
Zap,
BarChart3,
Lightbulb,
Search,
} from 'lucide-react'
interface SuggestionCard {
icon: JSX.Element
title: string
subtitle: string
cmd: string
}
interface WelcomeScreenProps {
language: string
onSend: (cmd: string) => void
}
export function WelcomeScreen({ language, onSend }: WelcomeScreenProps) {
const suggestions: SuggestionCard[] = language === 'zh'
? [
{ icon: <BarChart3 size={18} />, title: '分析 BTC 走势', subtitle: '技术分析 + 市场情绪', cmd: '分析一下 BTC 的走势' },
{ icon: <Zap size={18} />, title: '做多 ETH', subtitle: 'Agent 帮你自动下单', cmd: '帮我做多 ETH 0.01 手' },
{ icon: <Search size={18} />, title: '搜索股票', subtitle: '输入名称或代码即可', cmd: '搜索一下中远海控' },
{ icon: <Lightbulb size={18} />, title: '策略建议', subtitle: '根据当前市场给出建议', cmd: '当前市场适合什么策略?' },
]
: [
{ icon: <BarChart3 size={18} />, title: 'Analyze BTC', subtitle: 'Technical analysis + sentiment', cmd: 'Analyze BTC price action' },
{ icon: <Zap size={18} />, title: 'Trade ETH', subtitle: 'Agent executes for you', cmd: 'Open a long position on ETH 0.01' },
{ icon: <Search size={18} />, title: 'Search Stocks', subtitle: 'Enter name or ticker', cmd: 'Search for NVIDIA stock' },
{ icon: <Lightbulb size={18} />, title: 'Strategy Ideas', subtitle: 'Market-based suggestions', cmd: 'What strategy fits the current market?' },
]
return (
<div style={{
maxWidth: 640,
margin: '0 auto',
padding: '0 20px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 400,
}}>
{/* Logo / greeting */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
style={{ textAlign: 'center', marginBottom: 40 }}
>
<div style={{
width: 56,
height: 56,
borderRadius: 16,
background: 'linear-gradient(135deg, rgba(240,185,11,0.12), rgba(0,229,160,0.06))',
border: '1px solid rgba(240,185,11,0.15)',
display: 'grid',
placeItems: 'center',
margin: '0 auto 16px',
fontSize: 24,
}}>
</div>
<h1 style={{
fontSize: 22,
fontWeight: 700,
color: '#f0f0f8',
margin: '0 0 8px',
letterSpacing: '-0.02em',
}}>
{language === 'zh' ? '跟 NOFXi 聊点什么' : 'What can I help with?'}
</h1>
<p style={{
fontSize: 13.5,
color: '#5c5c72',
margin: 0,
lineHeight: 1.5,
}}>
{language === 'zh'
? '分析行情、执行交易、搜索股票 — 用自然语言就行'
: 'Analyze markets, execute trades, search stocks — just ask'}
</p>
</motion.div>
{/* Suggestion cards grid */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1, ease: 'easeOut' }}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 10,
width: '100%',
maxWidth: 520,
}}
>
{suggestions.map((s, i) => (
<button
key={i}
onClick={() => onSend(s.cmd)}
className="suggestion-card"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 6,
padding: '16px 14px',
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 14,
cursor: 'pointer',
textAlign: 'left',
fontFamily: 'inherit',
transition: 'all 0.2s ease',
}}
>
<div style={{ color: '#F0B90B', opacity: 0.7 }}>
{s.icon}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#d0d0e0', marginBottom: 2 }}>
{s.title}
</div>
<div style={{ fontSize: 11.5, color: '#5c5c72' }}>
{s.subtitle}
</div>
</div>
</button>
))}
</motion.div>
</div>
)
}

View File

@@ -109,6 +109,12 @@ export default function HeaderBar({
label: string
requiresAuth: boolean
}[] = [
{
page: 'agent',
path: ROUTES.agent,
label: 'Agent',
requiresAuth: false,
},
{
page: 'data',
path: ROUTES.data,
@@ -431,6 +437,12 @@ export default function HeaderBar({
label: string
requiresAuth: boolean
}[] = [
{
page: 'agent',
path: ROUTES.agent,
label: 'Agent',
requiresAuth: false,
},
{
page: 'data',
path: ROUTES.data,

View File

@@ -7,7 +7,6 @@ import type {
CreateTraderRequest,
AIModel,
Exchange,
ExchangeAccountState,
} from '../../types'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
@@ -19,17 +18,13 @@ import { TelegramConfigModal } from './TelegramConfigModal'
import { ModelConfigModal } from './ModelConfigModal'
import { ConfigStatusGrid } from './ConfigStatusGrid'
import { TradersList } from './TradersList'
import { BeginnerGuideCards } from './BeginnerGuideCards'
import { AlertTriangle, Bot, Plus, MessageCircle } from 'lucide-react'
import {
Bot,
Plus,
MessageCircle,
} from 'lucide-react'
import { confirmToast } from '../../lib/notify'
import { toast } from 'sonner'
import {
getBeginnerWalletAddress,
getUserMode,
setBeginnerWalletAddress as persistBeginnerWalletAddress,
} from '../../lib/onboarding'
import type { Strategy } from '../../types'
import { ApiError } from '../../lib/httpClient'
interface AITradersPageProps {
onTraderSelect?: (traderId: string) => void
@@ -50,288 +45,34 @@ 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 [quickSetupLoading, setQuickSetupLoading] = useState(false)
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<
string | null
>(() => getBeginnerWalletAddress())
const isBeginnerMode = getUserMode() === 'beginner'
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message.trim() !== '') {
return error.message
}
return fallback
}
const formatActionableDescriptionByKey = (
errorKey: string,
params: Record<string, string> = {},
fallback: string
) => {
const traderName = params.trader_name || params.traderName || 'this trader'
const modelName = params.model_name || params.modelName || 'selected model'
const exchangeName =
params.exchange_name || params.exchangeName || 'selected exchange account'
const reason = localizeTraderReason(
params.reason_key,
params.reason || fallback
)
const symbol = params.symbol || ''
const zh = language === 'zh'
switch (errorKey) {
case 'trader.create.invalid_request':
return zh
? '提交的信息不完整,或者格式不正确。请检查后重新提交。'
: 'The submitted information is incomplete or invalid. Please review it and try again.'
case 'trader.create.invalid_btc_eth_leverage':
return zh
? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。'
: 'BTC/ETH leverage must be between 1x and 50x.'
case 'trader.create.invalid_altcoin_leverage':
return zh
? '山寨币杠杆倍数需要在 1 到 20 倍之间。'
: 'Altcoin leverage must be between 1x and 20x.'
case 'trader.create.invalid_symbol':
return zh
? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。`
: `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.`
case 'trader.create.model_not_found':
return zh
? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。'
: 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.'
case 'trader.create.model_disabled':
return zh
? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。`
: `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.`
case 'trader.create.model_missing_credentials':
return zh
? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。`
: `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.`
case 'trader.create.strategy_required':
return zh
? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。'
: 'No trading strategy is selected yet. Please choose a strategy before creating a trader.'
case 'trader.create.strategy_not_found':
return zh
? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。'
: 'The selected strategy no longer exists. Please choose another available strategy.'
case 'trader.create.exchange_not_found':
return zh
? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。'
: 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.'
case 'trader.create.exchange_disabled':
return zh
? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。`
: `Exchange account "${exchangeName}" is currently disabled. Please enable it first.`
case 'trader.create.exchange_missing_fields':
return zh
? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。`
: `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.`
case 'trader.create.exchange_unsupported':
return zh
? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。`
: `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.`
case 'trader.create.exchange_probe_failed':
return zh
? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}`
: `Exchange account "${exchangeName}" failed initialization checks: ${reason}`
case 'trader.start.strategy_missing':
return zh
? `机器人「${traderName}」缺少有效的交易策略配置。`
: `Trader "${traderName}" does not have a valid strategy configuration.`
case 'trader.start.model_not_found':
return zh
? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。`
: `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.`
case 'trader.start.model_disabled':
return zh
? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。`
: `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.`
case 'trader.start.exchange_not_found':
return zh
? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。`
: `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.`
case 'trader.start.exchange_disabled':
return zh
? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。`
: `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.`
case 'trader.start.setup_invalid':
case 'trader.start.load_failed':
return zh
? `机器人「${traderName}」暂时还不能启动,原因是:${reason}`
: `Trader "${traderName}" cannot be started yet because ${reason}`
default:
return fallback
}
}
const localizeTraderReason = (reasonKey?: string, fallback?: string) => {
const zh = language === 'zh'
switch (reasonKey) {
case 'trader.reason.strategy_config_invalid':
return zh
? '当前策略配置内容已损坏,系统暂时无法解析'
: 'the current strategy configuration is corrupted and cannot be parsed'
case 'trader.reason.strategy_missing':
return zh
? '当前机器人缺少有效的交易策略配置'
: 'the trader is missing a valid strategy configuration'
case 'trader.reason.private_key_invalid':
return zh
? '私钥格式不正确,系统无法识别'
: 'the private key format is invalid and cannot be recognized'
case 'trader.reason.hyperliquid_init_failed':
return zh
? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确'
: 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration'
case 'trader.reason.aster_init_failed':
return zh
? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确'
: 'Aster account initialization failed. Please verify the Aster User, Signer, and private key'
case 'trader.reason.exchange_meta_unavailable':
return zh
? '系统暂时无法从交易所读取账户元信息'
: 'the system could not read account metadata from the exchange'
case 'trader.reason.hyperliquid_agent_balance_too_high':
return zh
? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求'
: 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements'
case 'trader.reason.exchange_account_init_failed':
return zh
? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配'
: 'exchange account initialization failed. Please verify that the wallet address and API key match'
case 'trader.reason.exchange_unsupported':
return zh
? '当前交易所类型暂不支持机器人初始化'
: 'the selected exchange type is not currently supported for trader initialization'
case 'trader.reason.exchange_balance_unavailable':
return zh
? '系统暂时无法从交易所读取账户余额'
: 'the system could not read the account balance from the exchange'
case 'trader.reason.exchange_service_unreachable':
return zh
? '系统暂时无法连接交易所服务'
: 'the system could not reach the exchange service right now'
default:
return (
fallback ||
(zh
? '系统返回了一个未知错误'
: 'an unknown error was returned by the system')
)
}
}
const normalizeActionableDescription = (
error: unknown,
message: string,
title: string
) => {
if (error instanceof ApiError && error.errorKey) {
return formatActionableDescriptionByKey(
error.errorKey,
error.errorParams,
message
)
}
const prefixes = [
'这次未能创建机器人:',
'机器人创建失败:',
'这次未能更新机器人:',
'机器人更新失败:',
'这次未能启动机器人:',
'Failed to create trader:',
'Failed to update trader:',
'Unable to create trader:',
'Unable to update trader:',
'Unable to start trader:',
]
let description = message.trim()
if (description === title) return ''
for (const prefix of prefixes) {
if (description.startsWith(prefix)) {
description = description.slice(prefix.length).trim()
break
}
}
return description
}
const showActionableError = (title: string, error: unknown) => {
const message = getErrorMessage(error, title)
const description = normalizeActionableDescription(error, message, title)
if (description === '') {
toast.error(title)
const loadConfigs = async () => {
if (!user || !token) {
const models = await api.getSupportedModels()
setSupportedModels(models)
return
}
toast.error(title, {
description,
})
}
const parseBalanceUsdc = (balance?: string) => {
if (!balance) return null
const parsed = Number.parseFloat(balance)
return Number.isFinite(parsed) ? parsed : null
}
const getClaw402BalanceMessage = (balance: number, blocking: boolean) => {
if (language === 'zh') {
return blocking
? `当前 Claw402 钱包余额为 ${balance.toFixed(6)} USDCAI 调用无法执行。请先为这个钱包充值,再重新点击启动。`
: `当前 Claw402 钱包余额仅剩 ${balance.toFixed(6)} USDC虽然还能尝试启动但很快可能因为 AI 调用费用不足而停止。建议先补一点 USDC。`
}
return blocking
? `Your Claw402 wallet balance is ${balance.toFixed(6)} USDC. AI calls cannot run with zero balance. Please top up this wallet before starting again.`
: `Your Claw402 wallet balance is only ${balance.toFixed(6)} USDC. You can still try to start, but AI calls may stop soon due to insufficient funds.`
}
const getClaw402BalanceIssue = (traderId: string) => {
const trader = traders?.find((item) => item.trader_id === traderId)
if (!trader) return null
const model =
allModels.find((item) => item.id === trader.ai_model) ||
allModels.find((item) => item.provider === trader.ai_model)
if (!model || model.provider !== 'claw402') return null
const balance = parseBalanceUsdc(model.balanceUsdc)
if (balance === null) return null
if (balance <= 0) {
return {
blocking: true,
title: language === 'zh' ? '启动失败' : 'Start failed',
description: getClaw402BalanceMessage(balance, true),
}
}
if (balance < 1) {
return {
blocking: false,
title: language === 'zh' ? 'Claw402 余额偏低' : 'Low Claw402 balance',
description: getClaw402BalanceMessage(balance, false),
}
}
return null
}
const navigateInApp = (path: string) => {
navigate(path)
const [
modelConfigs,
exchangeConfigs,
models,
] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getSupportedModels(),
])
setAllModels(modelConfigs)
setAllExchanges(exchangeConfigs)
setSupportedModels(models)
}
// 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)
@@ -344,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)
@@ -366,64 +107,27 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
}
const {
data: traders,
mutate: mutateTraders,
isLoading: isTradersLoading,
} = useSWR<TraderInfo[]>(user && token ? 'traders' : null, api.getTraders, {
refreshInterval: 5000,
})
const {
data: exchangeAccountStateData,
mutate: mutateExchangeAccountStates,
isLoading: isExchangeAccountStatesLoading,
} = useSWR<{ states: Record<string, ExchangeAccountState> }>(
user && token ? 'exchange-account-state' : null,
api.getExchangeAccountState,
{
refreshInterval: 30000,
shouldRetryOnError: false,
}
)
const { data: strategies } = useSWR<Strategy[]>(
user && token ? 'strategies' : null,
api.getStrategies,
{ refreshInterval: 30000 }
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
user && token ? 'traders' : null,
api.getTraders,
{ refreshInterval: 5000 }
)
useEffect(() => {
const loadConfigs = async () => {
if (!user || !token) {
try {
const models = await api.getSupportedModels()
setSupportedModels(models)
} catch (err) {
console.error('Failed to load supported configs:', err)
}
return
}
try {
const [modelConfigs, exchangeConfigs, models] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getSupportedModels(),
])
setAllModels(modelConfigs)
const clawWalletAddress =
modelConfigs.find((model) => model.provider === 'claw402')
?.walletAddress || null
if (clawWalletAddress) {
setBeginnerWalletAddress(clawWalletAddress)
persistBeginnerWalletAddress(clawWalletAddress)
}
setAllExchanges(exchangeConfigs)
setSupportedModels(models)
} catch (error) {
console.error('Failed to load configs:', error)
}
}
loadConfigs()
.catch((error) => {
console.error('Failed to load configs:', error)
})
}, [user, token])
useEffect(() => {
const handleRefresh = () => {
loadConfigs().catch((error) => {
console.error('Failed to refresh configs:', error)
})
}
window.addEventListener('agent-config-refresh', handleRefresh)
return () => window.removeEventListener('agent-config-refresh', handleRefresh)
}, [user, token])
const configuredModels =
@@ -443,31 +147,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}) || []
const enabledModels = allModels?.filter((m) => m.enabled) || []
const enabledClaw402Model =
enabledModels.find((model) => model.provider === 'claw402') || null
const enabledClaw402Balance = parseBalanceUsdc(
enabledClaw402Model?.balanceUsdc
)
const claw402BalanceAlert =
enabledClaw402Model &&
enabledClaw402Balance !== null &&
enabledClaw402Balance < 1
? {
blocking: enabledClaw402Balance <= 0,
title:
language === 'zh'
? enabledClaw402Balance <= 0
? 'Claw402 钱包余额为 0'
: 'Claw402 钱包余额偏低'
: enabledClaw402Balance <= 0
? 'Claw402 wallet balance is zero'
: 'Claw402 wallet balance is low',
description: getClaw402BalanceMessage(
enabledClaw402Balance,
enabledClaw402Balance <= 0
),
}
: null
const enabledExchanges =
allExchanges?.filter((e) => {
if (!e.enabled) return false
@@ -501,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 }
@@ -526,19 +204,26 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const handleCreateTrader = async (data: CreateTraderRequest) => {
try {
const createdTrader = await api.createTrader(data)
if (createdTrader.startup_warning) {
toast.success(t('aiTradersToast.created', language), {
description: createdTrader.startup_warning,
})
} else {
toast.success(t('aiTradersToast.created', language))
const model = allModels?.find((m) => m.id === data.ai_model_id)
const exchange = allExchanges?.find((e) => e.id === data.exchange_id)
if (!model?.enabled) {
toast.error(t('modelNotConfigured', language))
return
}
if (!exchange?.enabled) {
toast.error(t('exchangeNotConfigured', language))
return
}
await api.createTrader(data)
toast.success(t('aiTradersToast.created', language))
setShowCreateModal(false)
await mutateTraders()
} catch (error) {
console.error('Failed to create trader:', error)
showActionableError(t('createTraderFailed', language), error)
toast.error(t('createTraderFailed', language))
}
}
@@ -588,7 +273,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
await mutateTraders()
} catch (error) {
console.error('Failed to update trader:', error)
showActionableError(t('updateTraderFailed', language), error)
toast.error(t('updateTraderFailed', language))
}
}
@@ -613,48 +298,24 @@ 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 {
const claw402Issue = getClaw402BalanceIssue(traderId)
if (claw402Issue?.blocking) {
toast.error(claw402Issue.title, {
description: claw402Issue.description,
})
return
}
if (claw402Issue && !claw402Issue.blocking) {
toast.warning(claw402Issue.title, {
description: claw402Issue.description,
})
}
await api.startTrader(traderId)
toast.success(t('aiTradersToast.started', language))
toast.success(t('aiTradersToast.started', language))
}
await mutateTraders()
} catch (error) {
console.error('Failed to toggle trader:', error)
showActionableError(
running
? t('aiTradersToast.stopFailed', language)
: t('aiTradersToast.startFailed', language),
error
)
toast.error(t('operationFailed', language))
}
}
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) {
@@ -791,12 +452,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 {
@@ -856,7 +517,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const refreshedExchanges = await api.getExchangeConfigs()
setAllExchanges(refreshedExchanges)
await mutateExchangeAccountStates()
setShowExchangeModal(false)
setEditingExchange(null)
@@ -912,7 +572,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,
@@ -933,12 +593,11 @@ 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()
setAllExchanges(refreshedExchanges)
await mutateExchangeAccountStates()
setShowExchangeModal(false)
setEditingExchange(null)
@@ -958,40 +617,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
setShowExchangeModal(true)
}
const handleQuickSetupClaw402 = async () => {
if (quickSetupLoading) return
try {
setQuickSetupLoading(true)
const result = await api.prepareBeginnerOnboarding()
setBeginnerWalletAddress(result.address)
const refreshedModels = await api.getModelConfigs()
setAllModels(refreshedModels)
toast.success(
language === 'zh'
? 'Claw402 已默认配置为 DeepSeek'
: 'Claw402 is configured with DeepSeek by default'
)
} catch (error) {
console.error('Failed to quick setup claw402:', error)
toast.error(
language === 'zh'
? '一键配置 Claw402 失败'
: 'Failed to quick setup Claw402'
)
} finally {
setQuickSetupLoading(false)
}
}
const claw402Configured = configuredModels.some(
(model) => model.provider === 'claw402'
)
const hasStrategies = (strategies?.length || 0) > 0
const hasCreatedTrader = (traders?.length || 0) > 0
const canCreateTrader =
configuredModels.length > 0 && configuredExchanges.length > 0
return (
<DeepVoidBackground className="py-8" disableAnimation>
<div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
@@ -1051,10 +676,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">
@@ -1066,89 +688,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
</div>
</div>
{isBeginnerMode ? (
<BeginnerGuideCards
language={language}
claw402Ready={claw402Configured}
exchangeReady={configuredExchanges.length > 0}
strategyReady={hasStrategies}
traderReady={hasCreatedTrader}
canCreateTrader={canCreateTrader}
walletAddress={beginnerWalletAddress}
onQuickSetupClaw402={handleQuickSetupClaw402}
onOpenExchange={handleAddExchange}
onOpenStrategy={() => navigateInApp('/strategy')}
onCreateTrader={() => setShowCreateModal(true)}
/>
) : null}
{claw402BalanceAlert ? (
<div
className="mb-6 rounded-xl border px-4 py-4 md:px-5 md:py-4 flex flex-col md:flex-row md:items-start md:justify-between gap-3"
style={{
borderColor: claw402BalanceAlert.blocking
? 'rgba(239, 68, 68, 0.55)'
: 'rgba(245, 158, 11, 0.45)',
background: claw402BalanceAlert.blocking
? 'rgba(127, 29, 29, 0.22)'
: 'rgba(120, 53, 15, 0.18)',
}}
>
<div className="flex items-start gap-3">
<div
className="mt-0.5 rounded-full p-2"
style={{
background: claw402BalanceAlert.blocking
? 'rgba(239, 68, 68, 0.16)'
: 'rgba(245, 158, 11, 0.14)',
color: claw402BalanceAlert.blocking ? '#F87171' : '#FBBF24',
}}
>
<AlertTriangle className="w-4 h-4" />
</div>
<div>
<div
className="text-sm font-semibold"
style={{
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
}}
>
{claw402BalanceAlert.title}
</div>
<div
className="text-sm mt-1 leading-6"
style={{ color: '#D4D4D8' }}
>
{claw402BalanceAlert.description}
</div>
</div>
</div>
<button
type="button"
onClick={() =>
enabledClaw402Model && handleModelClick(enabledClaw402Model.id)
}
className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider border whitespace-nowrap self-start"
style={{
borderColor: claw402BalanceAlert.blocking
? 'rgba(248, 113, 113, 0.45)'
: 'rgba(251, 191, 36, 0.35)',
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
background: 'rgba(0, 0, 0, 0.18)',
}}
>
{language === 'zh' ? '查看 AI 钱包' : 'Open AI wallet'}
</button>
</div>
) : null}
{/* Configuration Status Grid */}
<ConfigStatusGrid
configuredModels={configuredModels}
configuredExchanges={configuredExchanges}
exchangeAccountStates={exchangeAccountStateData?.states}
isExchangeAccountStatesLoading={isExchangeAccountStatesLoading}
visibleExchangeAddresses={visibleExchangeAddresses}
copiedId={copiedId}
language={language}
@@ -1173,7 +716,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
copiedId={copiedId}
language={language}
onTraderSelect={onTraderSelect}
onNavigate={navigateInApp}
onNavigate={(path) => navigate(path)}
onEditTrader={handleEditTrader}
onToggleTrader={handleToggleTrader}
onToggleCompetition={handleToggleCompetition}

View File

@@ -28,7 +28,7 @@ export function CompetitionPage() {
const handleTraderClick = async (traderId: string) => {
try {
const traderConfig = await api.getTraderConfig(traderId)
const traderConfig = await api.getPublicTraderConfig(traderId)
setSelectedTrader(traderConfig)
setIsModalOpen(true)
} catch (error) {
@@ -281,14 +281,14 @@ export function CompetitionPage() {
</div>
{/* Stats */}
<div className="flex items-center gap-4 md:gap-6">
<div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
{/* Total Equity */}
<div className="text-right min-w-[60px] md:min-w-[80px]">
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('equity', language)}
</div>
<div
className="text-sm md:text-base font-bold mono"
className="text-xs md:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{trader.total_equity?.toFixed(2) || '0.00'}
@@ -297,11 +297,11 @@ export function CompetitionPage() {
{/* P&L */}
<div className="text-right min-w-[70px] md:min-w-[90px]">
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('pnl', language)}
</div>
<div
className="text-sm md:text-base font-bold mono"
className="text-base md:text-lg font-bold mono"
style={{
color:
(trader.total_pnl ?? 0) >= 0
@@ -313,7 +313,7 @@ export function CompetitionPage() {
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
</div>
<div
className="text-[10px] mono"
className="text-xs mono"
style={{ color: '#848E9C' }}
>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
@@ -322,17 +322,17 @@ export function CompetitionPage() {
</div>
{/* Positions */}
<div className="text-right min-w-[40px] md:min-w-[50px]">
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('pos', language)}
</div>
<div
className="text-sm md:text-base font-bold mono"
className="text-xs md:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{trader.position_count}
</div>
<div className="text-[10px]" style={{ color: '#848E9C' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{trader.margin_used_pct.toFixed(1)}%
</div>
</div>

View File

@@ -539,6 +539,22 @@ export function ExchangeConfigModal({
</div>
)}
{editingExchangeId && selectedExchange && (
<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' }}
>
{' '}
API Key {selectedExchange.has_api_key ? '已配置' : '未配置'}
{' · '}
Secret {selectedExchange.has_secret_key ? '已配置' : '未配置'}
{(currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'kucoin')
? ` · Passphrase ${selectedExchange.has_passphrase ? '已配置' : '未配置'}`
: ''}
</div>
)}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<Key className="w-4 h-4" style={{ color: '#F0B90B' }} />
@@ -548,7 +564,11 @@ export function ExchangeConfigModal({
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t('enterAPIKey', language)}
placeholder={
editingExchangeId && selectedExchange?.has_api_key
? '已保存,如需更换请重新输入'
: t('enterAPIKey', language)
}
className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
required
@@ -564,7 +584,11 @@ export function ExchangeConfigModal({
type="password"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
placeholder={t('enterSecretKey', language)}
placeholder={
editingExchangeId && selectedExchange?.has_secret_key
? '已保存,如需更换请重新输入'
: t('enterSecretKey', language)
}
className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
required
@@ -581,7 +605,11 @@ export function ExchangeConfigModal({
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder={t('enterPassphrase', language)}
placeholder={
editingExchangeId && selectedExchange?.has_passphrase
? '已保存,如需更换请重新输入'
: t('enterPassphrase', language)
}
className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
required

View File

@@ -4,16 +4,15 @@ 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 { getModelIcon } from '../common/ModelIcons'
import { ModelStepIndicator } from './ModelStepIndicator'
import { ModelCard } from './ModelCard'
import {
BLOCKRUN_MODELS,
CLAW402_MODELS,
AI_PROVIDER_CONFIG,
getShortName,
} from './model-constants'
import { getBeginnerWalletAddress, getUserMode } from '../../lib/onboarding'
interface ModelConfigModalProps {
allModels: AIModel[]
@@ -44,22 +43,20 @@ export function ModelConfigModal({
const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [modelName, setModelName] = useState('')
const configuredModel =
configuredModels?.find((model) => model.id === selectedModelId) || null
// Always prefer allModels (supportedModels) for provider/id lookup;
// fall back to configuredModels for edit mode details (apiKey etc.)
const selectedModel =
allModels?.find((m) => m.id === selectedModelId) || configuredModel
allModels?.find((m) => m.id === selectedModelId) ||
configuredModels?.find((m) => m.id === selectedModelId)
useEffect(() => {
const modelDetails = configuredModel || selectedModel
if (editingModelId && modelDetails) {
setApiKey(modelDetails.apiKey || '')
setBaseUrl(modelDetails.customApiUrl || '')
setModelName(modelDetails.customModelName || '')
if (editingModelId && selectedModel) {
setApiKey(selectedModel.apiKey || '')
setBaseUrl(selectedModel.customApiUrl || '')
setModelName(selectedModel.customModelName || '')
}
}, [editingModelId, configuredModel, selectedModel])
}, [editingModelId, selectedModel])
const handleSelectModel = (modelId: string) => {
setSelectedModelId(modelId)
@@ -77,28 +74,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
onSave(selectedModelId, key, baseUrl.trim() || undefined, modelName.trim() || undefined)
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 isBeginnerDefaultModel = isClaw402Selected && getUserMode() === 'beginner'
const stepLabels = [
t('modelConfig.selectModel', language),
t(
!selectedModel
? 'modelConfig.configure'
: isClaw402Selected
? 'modelConfig.configureWallet'
: 'modelConfig.configure',
language
),
]
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">
@@ -121,7 +103,7 @@ export function ModelConfigModal({
</h3>
</div>
<div className="flex items-center gap-2">
{editingModelId && !isBeginnerDefaultModel && (
{editingModelId && (
<button
type="button"
onClick={() => onDelete(editingModelId)}
@@ -162,7 +144,6 @@ export function ModelConfigModal({
<Claw402ConfigForm
apiKey={apiKey}
modelName={modelName}
configuredModel={configuredModel}
editingModelId={editingModelId}
onApiKeyChange={setApiKey}
onModelNameChange={setModelName}
@@ -209,10 +190,6 @@ function ModelSelectionStep({
onSelectModel: (modelId: string) => void
language: Language
}) {
const [showOtherProviders, setShowOtherProviders] = useState(false)
const claw402Model = availableModels.find((m) => m.provider === 'claw402')
const otherProviders = availableModels.filter((m) => m.provider !== 'claw402')
return (
<div className="space-y-4">
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
@@ -220,11 +197,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={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)', border: '1.5px solid rgba(37, 99, 235, 0.4)' }}
@@ -245,7 +223,7 @@ function ModelSelectionStep({
</div>
</div>
<div className="flex items-center gap-2">
{configuredIds.has(claw402Model.id) && (
{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" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
@@ -258,57 +236,45 @@ 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)}
</div>
</div>
)
}
@@ -316,7 +282,6 @@ function ModelSelectionStep({
function Claw402ConfigForm({
apiKey,
modelName,
configuredModel,
editingModelId,
onApiKeyChange,
onModelNameChange,
@@ -326,7 +291,6 @@ function Claw402ConfigForm({
}: {
apiKey: string
modelName: string
configuredModel: AIModel | null
editingModelId: string | null
onApiKeyChange: (value: string) => void
onModelNameChange: (value: string) => void
@@ -337,21 +301,14 @@ function Claw402ConfigForm({
const [walletAddress, setWalletAddress] = useState('')
const [copiedAddr, setCopiedAddr] = useState(false)
const [showDeposit, setShowDeposit] = useState(false)
const [showNewWalletBackup, setShowNewWalletBackup] = useState(false)
const [newWalletKey, setNewWalletKey] = useState('')
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
const [keyError, setKeyError] = useState('')
const [validating, setValidating] = useState(false)
const [claw402Status, setClaw402Status] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ status: string; 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 => {
@@ -364,36 +321,8 @@ function Claw402ConfigForm({
const isKeyValid = apiKey.length === 66 && 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(() => {
@@ -441,23 +370,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' },
@@ -485,7 +397,7 @@ function Claw402ConfigForm({
}
}
const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0
const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0
return (
<form onSubmit={onSubmit} className="space-y-5">
@@ -507,25 +419,6 @@ function Claw402ConfigForm({
</span>
))}
</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 */}
@@ -539,7 +432,7 @@ function Claw402ConfigForm({
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{CLAW402_MODELS.map((m) => {
const isSelected = (modelName || 'glm-5') === m.id
const isSelected = (modelName || 'deepseek') === m.id
return (
<button
key={m.id}
@@ -597,33 +490,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)}
@@ -633,30 +499,72 @@ function Claw402ConfigForm({
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder={
hasExistingWallet
? language === 'zh'
? '如需切换钱包,请手动输入新的私钥'
: 'Enter a new private key only if you want to switch wallets'
: '0x...'
}
placeholder="0x..."
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
style={{
background: '#0B0E11',
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '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 data = await res.json()
if (data.private_key) {
onApiKeyChange(data.private_key)
setShowNewWalletBackup(true)
setNewWalletKey(data.private_key)
}
} 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' }}
>
{language === 'zh' ? '🔑 创建钱包' : '🔑 Create Wallet'}
</button>
)}
</div>
{hasExistingWallet && !apiKey ? (
<div className="text-[11px] leading-5" style={{ color: '#848E9C' }}>
{language === 'zh'
? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。'
: 'This screen keeps using the wallet created and saved the first time. Enter a new private key manually only if you want to switch wallets.'}
{/* 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>
<div className="text-[11px] mb-2" style={{ color: '#F87171' }}>
{language === 'zh'
? '这是你的钱包私钥,丢失后无法恢复,钱包里的资产将永久丢失。请复制并安全保存。'
: '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' }}>
{newWalletKey}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(newWalletKey)
setCopiedAddr(true)
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' }}
>
{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>
</div>
) : null}
)}
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
<span className="mt-px">🔒</span>
@@ -667,7 +575,7 @@ function Claw402ConfigForm({
</div>
{/* Wallet Validation Results */}
{(apiKey || hasExistingWallet) && (
{apiKey && (
<div className="space-y-2 pl-1">
{/* Validating spinner */}
{validating && (
@@ -686,7 +594,7 @@ function Claw402ConfigForm({
)}
{/* Success: address + balance + status */}
{resolvedWalletAddress && !validating && !keyError && (
{walletAddress && !validating && !keyError && (
<>
<div className="p-2.5 rounded-lg" style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.15)' }}>
<div className="flex items-center justify-between mb-1">
@@ -696,7 +604,7 @@ function Claw402ConfigForm({
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(resolvedWalletAddress)
navigator.clipboard.writeText(walletAddress)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
@@ -706,16 +614,16 @@ function Claw402ConfigForm({
{copiedAddr ? '✅' : '📋'}
</button>
</div>
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code>
<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>
{resolvedUsdcBalance !== null && (
{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}
{t('modelConfig.usdcBalance', language)}: ${usdcBalance}
</span>
<button
type="button"
@@ -736,17 +644,17 @@ function Claw402ConfigForm({
</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" />
<QRCodeSVG value={walletAddress} size={80} level="M" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
{language === 'zh' ? '扫码或复制地址转账' : '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={() => {
navigator.clipboard.writeText(resolvedWalletAddress)
navigator.clipboard.writeText(walletAddress)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
@@ -765,13 +673,6 @@ function Claw402ConfigForm({
</div>
</div>
)}
{!apiKey && hasExistingWallet && (
<div className="text-[11px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前正在使用这个钱包充值。若要切换钱包,再输入新的私钥并保存即可。'
: 'This wallet is currently used for funding. Enter a new private key only if you want to switch wallets.'}
</div>
)}
{claw402Status && (
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
@@ -784,11 +685,11 @@ function Claw402ConfigForm({
)}
{/* Test Connection button */}
{(isKeyValid || hasExistingWallet) && !validating && (
{isKeyValid && !validating && (
<button
type="button"
onClick={handleTestConnection}
disabled={testing || (!hasExistingWallet && !isKeyValid)}
disabled={testing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all hover:scale-[1.02] disabled:opacity-50"
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
>
@@ -836,9 +737,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>
@@ -899,7 +800,9 @@ function StandardProviderConfigForm({
>
<ExternalLink className="w-4 h-4" style={{ color: '#A78BFA' }} />
<span className="text-sm font-medium" style={{ color: '#A78BFA' }}>
{t('modelConfig.getApiKey', language)}
{selectedModel.provider?.startsWith('blockrun')
? t('modelConfig.getStarted', language)
: t('modelConfig.getApiKey', language)}
</span>
</a>
)}
@@ -918,66 +821,122 @@ function StandardProviderConfigForm({
)}
{/* API Key / Wallet Private Key */}
{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' }}
>
{selectedModel.has_api_key ? '已配置 API Key' : '未配置 API Key'}
</div>
)}
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{'API Key *'}
{selectedModel.provider?.startsWith('blockrun')
? t('modelConfig.walletPrivateKeyLabel', language)
: 'API Key *'}
</label>
<input
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder={t('enterAPIKey', language)}
placeholder={
editingModelId && selectedModel.has_api_key
? '已保存,如需更换请重新输入'
: selectedModel.provider === 'blockrun-base'
? '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={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
</div>
{/* Custom Base URL */}
<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" />
</svg>
{t('customBaseURL', language)}
</label>
<input
type="url"
value={baseUrl}
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' }}
/>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefault', language)}
{/* 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" />
</svg>
{t('customBaseURL', language)}
</label>
<input
type="url"
value={baseUrl}
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' }}
/>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefault', language)}
</div>
</div>
</div>
)}
{/* Custom Model Name */}
<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" />
</svg>
{t('customModelName', language)}
</label>
<input
type="text"
value={modelName}
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' }}
/>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefaultModel', language)}
{/* 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" />
</svg>
{t('customModelName', language)}
</label>
<input
type="text"
value={modelName}
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' }}
/>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefaultModel', language)}
</div>
</div>
</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" style={{ background: 'rgba(139, 92, 246, 0.1)', border: '1px solid rgba(139, 92, 246, 0.2)' }}>

View File

@@ -15,6 +15,12 @@ export interface AIProviderConfig {
apiName: string
}
export interface BlockrunModel {
id: string
name: string
desc: string
}
// Get friendly AI model display name
export function getModelDisplayName(modelId: string): string {
switch (modelId.toLowerCase()) {
@@ -53,6 +59,29 @@ export const CLAW402_MODELS: Claw402Model[] = [
{ id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI', desc: '$0.50/call', icon: '🧠', price: 0.50 },
]
export const BLOCKRUN_MODELS: BlockrunModel[] = [
{
id: 'gpt-5.2',
name: 'GPT-5.2',
desc: 'Base wallet payment',
},
{
id: 'claude-opus-4-6',
name: 'Claude Opus 4.6',
desc: 'Base wallet payment',
},
{
id: 'gemini-3.1-pro',
name: 'Gemini 3.1 Pro',
desc: 'Base wallet payment',
},
{
id: 'qwen3-max',
name: 'Qwen 3 Max',
desc: 'Base wallet payment',
},
]
// AI Provider configuration - default models and API links
export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = {
deepseek: {

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
LEGACY_AGENT_CHAT_STORAGE_KEY,
chatStorageKey,
clearAgentMessages,
loadAgentMessages,
migrateAgentMessages,
normalizeStorageUserId,
prepareAgentMessagesForPersistence,
} from './agentChatStorage'
function createStorage(): Storage {
const data = new Map<string, string>()
return {
get length() {
return data.size
},
clear() {
data.clear()
},
getItem(key: string) {
return data.has(key) ? data.get(key)! : null
},
key(index: number) {
return Array.from(data.keys())[index] ?? null
},
removeItem(key: string) {
data.delete(key)
},
setItem(key: string, value: string) {
data.set(key, value)
},
}
}
describe('agentChatStorage', () => {
it('normalizes string and numeric user ids', () => {
expect(normalizeStorageUserId(' user-1 ')).toBe('user-1')
expect(normalizeStorageUserId(42)).toBe('42')
expect(normalizeStorageUserId('')).toBeUndefined()
})
it('falls back to guest history for a logged-in user when user history is empty', () => {
const storage = createStorage()
const guestMessages = [{ id: '1', text: 'hello' }]
storage.setItem(chatStorageKey('guest'), JSON.stringify(guestMessages))
expect(loadAgentMessages(storage, 'user-1')).toEqual({
messages: guestMessages,
sourceKey: chatStorageKey('guest'),
})
})
it('migrates guest history into the user-specific key after login', () => {
const storage = createStorage()
const guestMessages = [{ id: '1', text: 'hello' }]
storage.setItem(chatStorageKey('guest'), JSON.stringify(guestMessages))
migrateAgentMessages(storage, 'user-1')
expect(storage.getItem(chatStorageKey('user-1'))).toBe(JSON.stringify(guestMessages))
})
it('clears primary and fallback chat storage keys', () => {
const storage = createStorage()
storage.setItem(chatStorageKey('user-1'), JSON.stringify([{ id: '1' }]))
storage.setItem(chatStorageKey('guest'), JSON.stringify([{ id: '2' }]))
storage.setItem(LEGACY_AGENT_CHAT_STORAGE_KEY, JSON.stringify([{ id: '3' }]))
clearAgentMessages(storage, 'user-1')
expect(storage.getItem(chatStorageKey('user-1'))).toBeNull()
expect(storage.getItem(chatStorageKey('guest'))).toBeNull()
expect(storage.getItem(LEGACY_AGENT_CHAT_STORAGE_KEY)).toBeNull()
})
it('persists streaming messages as non-streaming snapshots', () => {
const messages = [
{ id: '1', text: 'hello', streaming: true, steps: [{ id: 's1' }] },
{ id: '2', text: 'done', streaming: false },
]
expect(prepareAgentMessagesForPersistence(messages)).toEqual([
{ id: '1', text: 'hello', streaming: false, steps: [{ id: 's1' }], time: '' },
{ id: '2', text: 'done', streaming: false },
])
})
})

View File

@@ -0,0 +1,104 @@
export const LEGACY_AGENT_CHAT_STORAGE_KEY = 'nofxi-agent-chat'
export function normalizeStorageUserId(value: unknown): string | undefined {
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed || undefined
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return undefined
}
export function chatStorageKey(userId?: string) {
return `nofxi-agent-chat:${userId || 'guest'}`
}
export function getStoredAuthUserId(storage: Storage = window.localStorage) {
try {
const raw = storage.getItem('auth_user')
if (!raw) return undefined
const parsed = JSON.parse(raw)
return normalizeStorageUserId(parsed?.id)
} catch {
return undefined
}
}
function loadMessagesFromKey<T>(storage: Storage, key: string): T[] {
try {
const raw = storage.getItem(key)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function candidateStorageKeys(userId?: string): string[] {
const keys = [chatStorageKey(userId)]
if (userId) {
keys.push(chatStorageKey('guest'))
}
keys.push(LEGACY_AGENT_CHAT_STORAGE_KEY)
return [...new Set(keys)]
}
export function loadAgentMessages<T>(storage: Storage, userId?: string) {
const keys = candidateStorageKeys(userId)
for (const key of keys) {
const messages = loadMessagesFromKey<T>(storage, key)
if (messages.length > 0) {
return { messages, sourceKey: key }
}
}
return { messages: [] as T[], sourceKey: chatStorageKey(userId) }
}
export function persistAgentMessages<T>(
storage: Storage,
userId: string | undefined,
messages: T[]
) {
storage.setItem(chatStorageKey(userId), JSON.stringify(messages))
}
export function prepareAgentMessagesForPersistence<
T extends { streaming?: boolean; text?: string; steps?: unknown[]; time?: string }
>(messages: T[]): T[] {
return messages.map((message) => {
if (!message.streaming) {
return message
}
return {
...message,
// Persist the latest visible snapshot, but don't restore it as an
// actively streaming message after the user leaves and comes back.
streaming: false,
time: message.time || '',
}
})
}
export function migrateAgentMessages(storage: Storage, userId?: string) {
if (!userId) return
const targetKey = chatStorageKey(userId)
const targetMessages = loadMessagesFromKey(storage, targetKey)
if (targetMessages.length > 0) return
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))
return
}
}
export function clearAgentMessages(storage: Storage, userId?: string) {
for (const key of candidateStorageKeys(userId)) {
storage.removeItem(key)
}
}

View File

@@ -28,7 +28,10 @@ export const dataApi = {
return result.data!
},
async getPositions(traderId?: string, silent?: boolean): Promise<Position[]> {
async getPositions(
traderId?: string,
silent?: boolean
): Promise<Position[]> {
const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions`
@@ -65,7 +68,10 @@ export const dataApi = {
return result.data!
},
async getStatistics(traderId?: string, silent?: boolean): Promise<Statistics> {
async getStatistics(
traderId?: string,
silent?: boolean
): Promise<Statistics> {
const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics`
@@ -74,7 +80,10 @@ export const dataApi = {
return result.data!
},
async getEquityHistory(traderId?: string, silent?: boolean): Promise<any[]> {
async getEquityHistory(
traderId?: string,
silent?: boolean
): Promise<any[]> {
const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history`
@@ -100,7 +109,7 @@ export const dataApi = {
async getPublicTraderConfig(traderId: string): Promise<any> {
const result = await httpClient.get<any>(
`${API_BASE}/trader/${traderId}/config`
`${API_BASE}/traders/${traderId}/public-config`
)
if (!result.success) throw new Error('Failed to fetch public trader config')
return result.data!

View File

@@ -0,0 +1,759 @@
import { useState, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
PanelRightClose,
PanelRightOpen,
TrendingUp,
Wallet,
Bot,
Bookmark,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext'
import { useAuth } from '../contexts/AuthContext'
import { MarketTicker } from '../components/agent/MarketTicker'
import { PositionsPanel } from '../components/agent/PositionsPanel'
import { TraderStatusPanel } from '../components/agent/TraderStatusPanel'
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 {
chatStorageKey,
clearAgentMessages,
getStoredAuthUserId,
loadAgentMessages,
migrateAgentMessages,
prepareAgentMessagesForPersistence,
persistAgentMessages,
} from '../lib/agentChatStorage'
let msgIdCounter = 0
function nextId() {
return `msg-${Date.now()}-${++msgIdCounter}`
}
function appendStep(
existing: AgentStep[] | undefined,
step: AgentStep
): AgentStep[] {
const prev = existing ?? []
const index = prev.findIndex((item) => item.id === step.id)
if (index === -1) return [...prev, step]
return prev.map((item, i) => (i === index ? { ...item, ...step } : item))
}
function parsePlanSteps(data: string): AgentStep[] {
const text = data.replace(/^🗺️\s*(Plan|计划):\s*/i, '').trim()
if (!text) return []
return text.split(/\s*->\s*/).map((part, index) => {
const cleaned = part.replace(/^\d+\./, '').trim()
return {
id: `action-${index + 1}`,
label: cleaned || `Step ${index + 1}`,
status: 'pending',
}
})
}
function parseStepEvent(data: string, fallbackIndex: number): AgentStep {
const match = data.match(/Step\s+(\d+)\/(\d+):\s+(.+)$/i) || data.match(/步骤\s+(\d+)\/(\d+):\s+(.+)$/)
if (match) {
const id = `action-${match[1]}`
return {
id,
label: match[3].trim(),
status: 'running',
detail: data,
}
}
return {
id: `step-${fallbackIndex}`,
label: data,
status: 'running',
detail: data,
}
}
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') {
return prev.map((step, index) =>
index === i ? { ...step, status: 'completed', detail } : step
)
}
}
return prev
}
export function AgentChatPage() {
const { language } = useLanguage()
const { token, user } = useAuth()
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 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 messagesEndRef = useRef<HTMLDivElement>(null)
const chatInputRef = useRef<ChatInputHandle>(null)
const abortRef = useRef<AbortController | null>(null)
// Sidebar section collapse state
const [sections, setSections] = useState({
market: true,
positions: true,
traders: false,
preferences: true,
})
const toggleSection = (key: keyof typeof sections) => {
setSections((prev) => ({ ...prev, [key]: !prev[key] }))
}
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
useEffect(() => {
setStorageUserId(user?.id || getStoredAuthUserId())
}, [user?.id])
useEffect(() => {
if (!user?.id) return
migrateAgentMessages(window.localStorage, user.id)
}, [user?.id])
// Restore chat history for the current user when opening the agent page.
useEffect(() => {
const nextUserId = user?.id || storageUserId
if (activeUserId === nextUserId && historyHydrated) return
resetForUser(
nextUserId,
loadAgentMessages<Message>(window.localStorage, nextUserId).messages
)
}, [activeUserId, historyHydrated, resetForUser, 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)
} 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)
}
}
const patchMessages = (updater: (prev: Message[]) => Message[]) => {
const nextMessages = updater(useAgentChatStore.getState().messages)
updateMessages(() => nextMessages)
if (useAgentChatStore.getState().hydrated) {
persistMessagesSnapshot(nextMessages)
}
}
// Responsive sidebar
useEffect(() => {
const handleResize = () => {
if (window.innerWidth <= 768) setSidebarOpen(false)
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// Escape to close sidebar on mobile
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && window.innerWidth <= 768) {
setSidebarOpen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
const send = async (text: string) => {
if (!text || 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,
},
]
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)
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 sidebarSections = [
{
key: 'market' as const,
icon: <TrendingUp size={14} />,
title: language === 'zh' ? '市场行情' : 'Market',
component: <MarketTicker />,
},
{
key: 'positions' as const,
icon: <Wallet size={14} />,
title: language === 'zh' ? '持仓' : 'Positions',
component: <PositionsPanel />,
},
{
key: 'traders' as const,
icon: <Bot size={14} />,
title: 'Traders',
component: <TraderStatusPanel />,
},
{
key: 'preferences' as const,
icon: <Bookmark size={14} />,
title: language === 'zh' ? '用户偏好' : 'Preferences',
component: <UserPreferencesPanel token={token} language={language} />,
},
]
const isWelcomeState = messages.length === 0
return (
<div
style={{
display: 'flex',
height: 'calc(100dvh - 64px)',
background: '#09090b',
overflow: 'hidden',
}}
>
{/* ==================== MAIN CHAT AREA ==================== */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
minWidth: 0,
position: 'relative',
}}
>
{/* Top bar with quick actions */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 16px',
borderBottom: '1px solid rgba(255,255,255,0.04)',
overflowX: 'auto',
flexShrink: 0,
backdropFilter: 'blur(12px)',
background: 'rgba(9,9,11,0.8)',
}}
className="hide-scrollbar"
>
{quickActions.map((a, i) => (
<button
key={i}
onClick={() => void send(a.cmd)}
className="quick-action-btn"
style={{
padding: '5px 12px',
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 20,
color: '#6c6c82',
fontSize: 12,
cursor: 'pointer',
whiteSpace: 'nowrap',
fontFamily: 'inherit',
transition: 'all 0.2s ease',
}}
>
{a.label}
</button>
))}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
style={{
marginLeft: 'auto',
padding: 6,
background: 'transparent',
border: 'none',
color: '#4c4c62',
cursor: 'pointer',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
flexShrink: 0,
transition: 'color 0.2s',
}}
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
onMouseEnter={(e) => { e.currentTarget.style.color = '#8a8aa0' }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#4c4c62' }}
>
{sidebarOpen ? (
<PanelRightClose size={18} />
) : (
<PanelRightOpen size={18} />
)}
</button>
</div>
{/* Messages area or Welcome state */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '20px 0',
}}
className="custom-scrollbar"
>
{isWelcomeState ? (
<WelcomeScreen language={language} onSend={send} />
) : (
<ChatMessages messages={messages} ref={messagesEndRef} />
)}
</div>
{/* Input area */}
<ChatInput
ref={chatInputRef}
language={language}
loading={loading}
onSend={send}
/>
</div>
{/* ==================== RIGHT SIDEBAR ==================== */}
<AnimatePresence>
{sidebarOpen && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 280, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
style={{
borderLeft: '1px solid rgba(255,255,255,0.04)',
background: 'rgba(11,11,19,0.6)',
backdropFilter: 'blur(12px)',
overflowY: 'auto',
overflowX: 'hidden',
flexShrink: 0,
}}
className="custom-scrollbar"
>
<div style={{ padding: '12px 10px 20px', width: 280 }}>
{/* Sidebar header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
padding: '4px 6px',
}}
>
<span
style={{
fontSize: 10,
fontWeight: 700,
color: '#4c4c62',
textTransform: 'uppercase',
letterSpacing: 1.5,
}}
>
{language === 'zh' ? '交易面板' : 'Trading Panel'}
</span>
</div>
{/* Sidebar sections */}
{sidebarSections.map((section) => (
<div key={section.key} style={{ marginBottom: 8 }}>
<button
onClick={() => toggleSection(section.key)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
width: '100%',
padding: '7px 8px',
background: 'transparent',
border: 'none',
color: '#7a7a90',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
borderRadius: 8,
transition: 'all 0.15s ease',
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.03)'
e.currentTarget.style.color = '#a0a0b0'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = '#7a7a90'
}}
>
{section.icon}
<span>{section.title}</span>
<span style={{ marginLeft: 'auto', transition: 'transform 0.2s' }}>
{sections[section.key] ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</span>
</button>
<AnimatePresence>
{sections[section.key] && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
style={{ overflow: 'hidden', padding: '0 4px' }}
>
<div style={{ paddingTop: 4 }}>
{section.component}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Animations */}
<style>{`
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.3; }
30% { transform: translateY(-4px); opacity: 0.8; }
}
.typing-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #F0B90B;
display: inline-block;
animation: typingBounce 1.2s infinite;
}
.suggestion-card:hover {
background: rgba(240,185,11,0.04) !important;
border-color: rgba(240,185,11,0.15) !important;
transform: translateY(-1px);
}
.quick-action-btn:hover {
border-color: rgba(240,185,11,0.2) !important;
color: #F0B90B !important;
background: rgba(240,185,11,0.04) !important;
}
.chat-input-wrapper:focus-within {
border-color: rgba(240,185,11,0.25) !important;
box-shadow: 0 0 0 1px rgba(240,185,11,0.08);
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.06);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.1);
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
@media (max-width: 640px) {
.suggestion-card {
padding: 12px !important;
}
}
`}</style>
</div>
)
}

View File

@@ -1,26 +1,9 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
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'
import {
getPostAuthPath,
getUserMode,
setUserMode,
type UserMode,
} from '../lib/onboarding'
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
@@ -28,14 +11,24 @@ import type { Exchange, AIModel } from '../types'
type Tab = 'account' | 'models' | 'exchanges' | 'telegram'
function configBadge(label: string, active: boolean) {
return (
<span
className={`text-[11px] px-2 py-0.5 rounded-full ${
active
? 'bg-emerald-500/10 text-emerald-300'
: 'bg-zinc-800 text-zinc-500'
}`}
>
{label}
</span>
)
}
export function SettingsPage() {
const { user } = useAuth()
const { language } = useLanguage()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<Tab>('account')
const [userMode, setUserModeState] = useState<UserMode>(
() => getUserMode() ?? 'advanced'
)
// Account state
const [newPassword, setNewPassword] = useState('')
@@ -56,24 +49,41 @@ export function SettingsPage() {
// Telegram state
const [showTelegramModal, setShowTelegramModal] = useState(false)
const refreshModelConfigs = async () => {
const [configs, supported] = await Promise.all([
api.getModelConfigs(),
api.getSupportedModels(),
])
setConfiguredModels(configs)
setSupportedModels(supported)
}
const refreshExchangeConfigs = async () => {
const refreshed = await api.getExchangeConfigs()
setExchanges(refreshed)
}
// Fetch data when tabs are visited
useEffect(() => {
if (activeTab === 'models') {
Promise.all([api.getModelConfigs(), api.getSupportedModels()])
.then(([configs, supported]) => {
setConfiguredModels(configs)
setSupportedModels(supported)
})
refreshModelConfigs()
.catch(() => toast.error('Failed to load AI models'))
}
if (activeTab === 'exchanges') {
api
.getExchangeConfigs()
.then(setExchanges)
refreshExchangeConfigs()
.catch(() => toast.error('Failed to load exchanges'))
}
}, [activeTab])
useEffect(() => {
const handleRefresh = () => {
refreshModelConfigs().catch(() => {})
refreshExchangeConfigs().catch(() => {})
}
window.addEventListener('agent-config-refresh', handleRefresh)
return () => window.removeEventListener('agent-config-refresh', handleRefresh)
}, [])
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
if (newPassword.length < 8) {
@@ -86,7 +96,7 @@ export function SettingsPage() {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
Authorization: `Bearer ${localStorage.getItem('auth_token') || ''}`,
},
body: JSON.stringify({ new_password: newPassword }),
})
@@ -97,33 +107,12 @@ 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)
}
}
const handleSwitchMode = (nextMode: UserMode) => {
if (nextMode === userMode) {
return
}
setUserMode(nextMode)
setUserModeState(nextMode)
toast.success(
language === 'zh'
? `已切换到${nextMode === 'beginner' ? '新手模式' : '老手模式'}`
: nextMode === 'beginner'
? 'Switched to beginner mode'
: 'Switched to advanced mode'
)
const nextPath = getPostAuthPath(nextMode)
navigate(nextPath)
}
const handleSaveModel = async (
modelId: string,
apiKey: string,
@@ -134,54 +123,38 @@ 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)
toast.success('Model config saved')
const refreshed = await api.getModelConfigs()
setConfiguredModels(refreshed)
await refreshModelConfigs()
setShowModelModal(false)
setEditingModel(null)
} catch {
@@ -192,32 +165,20 @@ 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)
const refreshed = await api.getModelConfigs()
setConfiguredModels(refreshed)
await refreshModelConfigs()
setShowModelModal(false)
setEditingModel(null)
toast.success('Model config removed')
@@ -265,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,
@@ -285,10 +246,9 @@ export function SettingsPage() {
lighter_api_key_index: lighterApiKeyIndex || 0,
}
await api.createExchangeEncrypted(createRequest)
toast.success('Exchange account created')
toast.success('Exchange account created')
}
const refreshed = await api.getExchangeConfigs()
setExchanges(refreshed)
await refreshExchangeConfigs()
setShowExchangeModal(false)
setEditingExchange(null)
} catch {
@@ -300,8 +260,7 @@ export function SettingsPage() {
try {
await api.deleteExchange(exchangeId)
toast.success('Exchange account deleted')
const refreshed = await api.getExchangeConfigs()
setExchanges(refreshed)
await refreshExchangeConfigs()
setShowExchangeModal(false)
setEditingExchange(null)
} catch {
@@ -317,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>
@@ -331,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}
@@ -345,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,78 +310,10 @@ export function SettingsPage() {
</div>
<div className="border-t border-zinc-800 pt-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-white">
{language === 'zh' ? '使用模式' : 'Usage Mode'}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '新手模式会显示钱包引导和 4 步卡片;老手模式保持原来的专业界面。'
: 'Beginner mode shows wallet onboarding and quickstart cards. Advanced mode keeps the original pro workflow.'}
</p>
</div>
<span className="rounded-full border border-nofx-gold/20 bg-nofx-gold/10 px-3 py-1 text-xs font-semibold text-nofx-gold">
{userMode === 'beginner'
? language === 'zh'
? '当前:新手模式'
: 'Current: Beginner'
: language === 'zh'
? '当前:老手模式'
: 'Current: Advanced'}
</span>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => handleSwitchMode('beginner')}
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
userMode === 'beginner'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
}`}
>
<div className="text-sm font-semibold text-white">
{language === 'zh' ? '新手模式' : 'Beginner Mode'}
</div>
<div className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '更简单,优先显示钱包、充值和快速上手引导。'
: 'Simpler flow with wallet, funding, and quickstart guidance first.'}
</div>
</button>
<button
type="button"
onClick={() => handleSwitchMode('advanced')}
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
userMode === 'advanced'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
}`}
>
<div className="text-sm font-semibold text-white">
{language === 'zh' ? '老手模式' : 'Advanced Mode'}
</div>
<div className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '保持原来的配置与交易流程,不展示新手引导。'
: 'Keeps the original configuration and trading workflow without beginner hints.'}
</div>
</button>
</div>
</div>
<div className="border-t border-zinc-800 pt-6">
<h3 className="text-sm font-semibold text-white mb-4">
Change Password
</h3>
<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'}
@@ -440,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>
@@ -465,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} />
@@ -489,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">
@@ -500,24 +377,20 @@ export function SettingsPage() {
<Cpu size={14} className="text-zinc-300" />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">
{model.name}
</p>
<p className="text-xs text-zinc-500">
{model.provider}
</p>
<p className="text-sm font-medium text-white">{model.name}</p>
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<p className="text-xs text-zinc-500">{model.provider}</p>
{configBadge('API Key', !!model.has_api_key)}
{model.customModelName ? configBadge('Custom Model', true) : null}
{model.customApiUrl ? configBadge('Base URL', true) : null}
</div>
</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>
))}
@@ -531,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} />
@@ -555,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">
@@ -566,18 +432,19 @@ export function SettingsPage() {
<Building2 size={14} className="text-zinc-300" />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">
{exchange.account_name || exchange.name}
</p>
<p className="text-xs text-zinc-500 capitalize">
{exchange.exchange_type || exchange.type}
</p>
<p className="text-sm font-medium text-white">{exchange.account_name || exchange.name}</p>
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<p className="text-xs text-zinc-500 capitalize">{exchange.exchange_type || exchange.type}</p>
{configBadge('API Key', !!exchange.has_api_key)}
{configBadge('Secret', !!exchange.has_secret_key)}
{exchange.has_passphrase ? configBadge('Passphrase', true) : null}
{exchange.hyperliquidWalletAddr ? configBadge('Wallet', true) : null}
{exchange.has_aster_private_key ? configBadge('Aster Key', true) : null}
{exchange.has_lighter_private_key || exchange.has_lighter_api_key_private_key ? configBadge('Lighter Key', true) : null}
</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>
@@ -589,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)}
@@ -600,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>
)}
@@ -623,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>
@@ -640,10 +498,7 @@ export function SettingsPage() {
editingExchangeId={editingExchange}
onSave={handleSaveExchange}
onDelete={handleDeleteExchange}
onClose={() => {
setShowExchangeModal(false)
setEditingExchange(null)
}}
onClose={() => { setShowExchangeModal(false); setEditingExchange(null) }}
language={language}
/>
</div>

View File

@@ -22,6 +22,7 @@ import { FAQPage } from '../pages/FAQPage'
import { LandingPage } from '../pages/LandingPage'
import { BeginnerOnboardingPage } from '../pages/BeginnerOnboardingPage'
import { DataPage } from '../pages/DataPage'
import { AgentChatPage } from '../pages/AgentChatPage'
import { SettingsPage } from '../pages/SettingsPage'
import { StrategyMarketPage } from '../pages/StrategyMarketPage'
import { StrategyStudioPage } from '../pages/StrategyStudioPage'
@@ -456,6 +457,14 @@ export function AppRoutes() {
</AppChrome>
}
/>
<Route
path={ROUTES.agent}
element={
<AppChrome currentPage="agent" showFooter={false}>
<AgentChatPage />
</AppChrome>
}
/>
<Route
path={ROUTES.data}
element={

View File

@@ -1,4 +1,5 @@
export type Page =
| 'agent'
| 'competition'
| 'traders'
| 'trader'
@@ -11,6 +12,7 @@ export type Page =
export const ROUTES = {
home: '/',
agent: '/agent',
login: '/login',
register: '/register',
setup: '/setup',
@@ -27,6 +29,7 @@ export const ROUTES = {
} as const
export const PAGE_PATHS: Record<Page, string> = {
agent: ROUTES.agent,
competition: ROUTES.competition,
traders: ROUTES.traders,
trader: ROUTES.dashboard,
@@ -39,6 +42,7 @@ export const PAGE_PATHS: Record<Page, string> = {
}
export const LEGACY_HASH_ROUTES: Record<string, string> = {
agent: ROUTES.agent,
competition: ROUTES.competition,
traders: ROUTES.traders,
trader: ROUTES.dashboard,
@@ -50,6 +54,8 @@ export const LEGACY_HASH_ROUTES: Record<string, string> = {
export function getCurrentPageForPath(pathname: string): Page | undefined {
switch (pathname) {
case ROUTES.agent:
return 'agent'
case ROUTES.welcome:
case ROUTES.traders:
return 'traders'

View File

@@ -0,0 +1,52 @@
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[]
}
interface AgentChatStoreState {
activeUserId?: string
messages: AgentMessage[]
loading: boolean
hydrated: boolean
setActiveUserId: (userId?: string) => void
setMessages: (messages: AgentMessage[]) => void
updateMessages: (
updater: (messages: AgentMessage[]) => AgentMessage[]
) => void
setLoading: (loading: boolean) => void
setHydrated: (hydrated: boolean) => void
resetForUser: (userId?: string, messages?: AgentMessage[]) => void
}
export const useAgentChatStore = create<AgentChatStoreState>((set) => ({
activeUserId: undefined,
messages: [],
loading: false,
hydrated: false,
setActiveUserId: (userId) => set({ activeUserId: userId }),
setMessages: (messages) => set({ messages }),
updateMessages: (updater) =>
set((state) => ({ messages: updater(state.messages) })),
setLoading: (loading) => set({ loading }),
setHydrated: (hydrated) => set({ hydrated }),
resetForUser: (userId, messages = []) =>
set({
activeUserId: userId,
messages,
loading: false,
hydrated: true,
}),
}))

View File

@@ -3,6 +3,7 @@ export interface AIModel {
name: string
provider: string
enabled: boolean
has_api_key?: boolean
apiKey?: string
customApiUrl?: string
customModelName?: string
@@ -24,18 +25,25 @@ export interface Exchange {
name: string // Display name
type: 'cex' | 'dex'
enabled: boolean
has_api_key?: boolean
has_secret_key?: boolean
has_passphrase?: boolean
apiKey?: string
secretKey?: string
passphrase?: string // OKX specific
testnet?: boolean
// Hyperliquid specific
hyperliquidWalletAddr?: string
has_hyperliquid_secret?: boolean
// Aster specific
asterUser?: string
asterSigner?: string
has_aster_private_key?: boolean
asterPrivateKey?: string
// LIGHTER specific
lighterWalletAddr?: string
has_lighter_private_key?: boolean
has_lighter_api_key_private_key?: boolean
lighterPrivateKey?: string
lighterApiKeyPrivateKey?: string
lighterApiKeyIndex?: number