mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50:57 +08:00
feat: integrate NOFXi agent into dev
This commit is contained in:
749
web/src/pages/AgentChatPage.tsx
Normal file
749
web/src/pages/AgentChatPage.tsx
Normal file
@@ -0,0 +1,749 @@
|
||||
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 {
|
||||
chatStorageKey,
|
||||
clearAgentMessages,
|
||||
getStoredAuthUserId,
|
||||
loadAgentMessages,
|
||||
migrateAgentMessages,
|
||||
prepareAgentMessagesForPersistence,
|
||||
persistAgentMessages,
|
||||
} from '../lib/agentChatStorage'
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'bot'
|
||||
text: string
|
||||
time: string
|
||||
streaming?: boolean
|
||||
steps?: AgentStep[]
|
||||
}
|
||||
|
||||
interface AgentStep {
|
||||
id: string
|
||||
label: string
|
||||
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
|
||||
detail?: string
|
||||
}
|
||||
|
||||
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: `plan-${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 = `plan-${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, setMessages] = useState<Message[]>(
|
||||
() => loadAgentMessages<Message>(window.localStorage, user?.id || storageUserId).messages
|
||||
)
|
||||
const [historyHydrated, setHistoryHydrated] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
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(() => {
|
||||
setHistoryHydrated(false)
|
||||
setMessages(loadAgentMessages<Message>(window.localStorage, user?.id || storageUserId).messages)
|
||||
setHistoryHydrated(true)
|
||||
}, [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])
|
||||
|
||||
// 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,
|
||||
},
|
||||
]
|
||||
setMessages((prev) =>
|
||||
text.trim() === '/clear'
|
||||
? nextConversation
|
||||
: [...prev, ...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
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === botId
|
||||
? { ...m, text: data, time: now() }
|
||||
: m
|
||||
)
|
||||
)
|
||||
} else if (eventType === 'plan') {
|
||||
const parsedSteps = parsePlanSteps(data)
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === botId
|
||||
? {
|
||||
...m,
|
||||
steps: parsedSteps.length > 0 ? parsedSteps : m.steps,
|
||||
text: m.text || data,
|
||||
time: now(),
|
||||
}
|
||||
: m
|
||||
)
|
||||
)
|
||||
} else if (eventType === 'step_start') {
|
||||
stepCounter += 1
|
||||
const nextStep = parseStepEvent(data, stepCounter)
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === botId
|
||||
? {
|
||||
...m,
|
||||
steps: appendStep(m.steps, nextStep),
|
||||
text: m.text || data,
|
||||
time: now(),
|
||||
}
|
||||
: m
|
||||
)
|
||||
)
|
||||
} else if (eventType === 'step_complete') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === botId
|
||||
? {
|
||||
...m,
|
||||
steps: markLatestRunningCompleted(m.steps, data),
|
||||
text: m.text || data,
|
||||
time: now(),
|
||||
}
|
||||
: m
|
||||
)
|
||||
)
|
||||
} else if (eventType === 'replan') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === botId
|
||||
? {
|
||||
...m,
|
||||
steps: appendStep(m.steps, {
|
||||
id: `replan-${Date.now()}`,
|
||||
label: data,
|
||||
status: 'replanned',
|
||||
detail: data,
|
||||
}),
|
||||
text: m.text || data,
|
||||
time: now(),
|
||||
}
|
||||
: m
|
||||
)
|
||||
)
|
||||
} else if (
|
||||
eventType === 'tool'
|
||||
) {
|
||||
// Show tool being called as a status indicator
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === botId
|
||||
? {
|
||||
...m,
|
||||
text: m.text || `🔧 _Calling ${data}..._`,
|
||||
steps: appendStep(m.steps, {
|
||||
id: `tool-${Date.now()}`,
|
||||
label: `Tool: ${data}`,
|
||||
status: 'running',
|
||||
detail: data,
|
||||
}),
|
||||
time: now(),
|
||||
}
|
||||
: m
|
||||
)
|
||||
)
|
||||
} else if (eventType === 'done') {
|
||||
finalText = data
|
||||
setMessages((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
|
||||
setMessages((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
|
||||
setMessages((prev) => prev.filter((m) => m.id !== botId))
|
||||
} else {
|
||||
setMessages((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>
|
||||
|
||||
Reference in New Issue
Block a user