mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
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:
104
web/src/components/agent/AgentStepPanel.tsx
Normal file
104
web/src/components/agent/AgentStepPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
154
web/src/components/agent/ChatInput.tsx
Normal file
154
web/src/components/agent/ChatInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
151
web/src/components/agent/ChatMessages.tsx
Normal file
151
web/src/components/agent/ChatMessages.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
178
web/src/components/agent/MarketTicker.tsx
Normal file
178
web/src/components/agent/MarketTicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
187
web/src/components/agent/MessageRenderer.tsx
Normal file
187
web/src/components/agent/MessageRenderer.tsx
Normal 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
|
||||
}
|
||||
154
web/src/components/agent/PositionsPanel.tsx
Normal file
154
web/src/components/agent/PositionsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
web/src/components/agent/TraderStatusPanel.tsx
Normal file
110
web/src/components/agent/TraderStatusPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
222
web/src/components/agent/UserPreferencesPanel.tsx
Normal file
222
web/src/components/agent/UserPreferencesPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
web/src/components/agent/WelcomeScreen.tsx
Normal file
138
web/src/components/agent/WelcomeScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)} USDC,AI 调用无法执行。请先为这个钱包充值,再重新点击启动。`
|
||||
: `当前 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
88
web/src/lib/agentChatStorage.test.ts
Normal file
88
web/src/lib/agentChatStorage.test.ts
Normal 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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
104
web/src/lib/agentChatStorage.ts
Normal file
104
web/src/lib/agentChatStorage.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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!
|
||||
|
||||
759
web/src/pages/AgentChatPage.tsx
Normal file
759
web/src/pages/AgentChatPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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'
|
||||
|
||||
52
web/src/stores/agentChatStore.ts
Normal file
52
web/src/stores/agentChatStore.ts
Normal 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,
|
||||
}),
|
||||
}))
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user