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(() => getStoredAuthUserId()) const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024) const storageKey = chatStorageKey(user?.id || storageUserId) const [messages, setMessages] = useState( () => loadAgentMessages(window.localStorage, user?.id || storageUserId).messages ) const [historyHydrated, setHistoryHydrated] = useState(false) const [loading, setLoading] = useState(false) const messagesEndRef = useRef(null) const chatInputRef = useRef(null) const abortRef = useRef(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(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: , title: language === 'zh' ? '市场行情' : 'Market', component: , }, { key: 'positions' as const, icon: , title: language === 'zh' ? '持仓' : 'Positions', component: , }, { key: 'traders' as const, icon: , title: 'Traders', component: , }, { key: 'preferences' as const, icon: , title: language === 'zh' ? '用户偏好' : 'Preferences', component: , }, ] const isWelcomeState = messages.length === 0 return (
{/* ==================== MAIN CHAT AREA ==================== */}
{/* Top bar with quick actions */}
{quickActions.map((a, i) => ( ))}
{/* Messages area or Welcome state */}
{isWelcomeState ? ( ) : ( )}
{/* Input area */}
{/* ==================== RIGHT SIDEBAR ==================== */} {sidebarOpen && (
{/* Sidebar header */}
{language === 'zh' ? '交易面板' : 'Trading Panel'}
{/* Sidebar sections */} {sidebarSections.map((section) => (
{sections[section.key] && (
{section.component}
)}
))}
)}
{/* Animations */}
) }