1- import React , { useState , useRef , useEffect } from 'react' ;
1+ import React , { useState , useRef , useEffect , useCallback } from 'react' ;
22import { useSearchParams } from 'react-router-dom' ;
33import Markdown from 'react-markdown' ;
44import remarkGfm from 'remark-gfm' ;
55import { agentApi } from '../api/agent' ;
66import { generateUUID } from '../utils/uuid' ;
7- import type { StrategyInfo } from '../api/agent' ;
7+ import type { StrategyInfo , ChatSessionItem } from '../api/agent' ;
88import { historyApi } from '../api/history' ;
99
10+ const STORAGE_KEY_SESSION = 'dsa_chat_session_id' ;
11+
1012interface Message {
1113 id : string ;
1214 role : 'user' | 'assistant' ;
@@ -64,8 +66,20 @@ const ChatPage: React.FC = () => {
6466 const [ showStrategyDesc , setShowStrategyDesc ] = useState < string | null > ( null ) ;
6567 const messagesEndRef = useRef < HTMLDivElement > ( null ) ;
6668 const initialFollowUpHandled = useRef ( false ) ;
67- // Stable session ID for multi-turn conversation - persists for the page lifetime
68- const sessionIdRef = useRef ( generateUUID ( ) ) ;
69+
70+ // Session management
71+ const [ sessionId , setSessionId ] = useState < string > ( ( ) => {
72+ return localStorage . getItem ( STORAGE_KEY_SESSION ) || generateUUID ( ) ;
73+ } ) ;
74+ // Keep a ref in sync for use inside streaming callback
75+ const sessionIdRef = useRef ( sessionId ) ;
76+ useEffect ( ( ) => { sessionIdRef . current = sessionId ; } , [ sessionId ] ) ;
77+
78+ // Chat history sidebar
79+ const [ sessions , setSessions ] = useState < ChatSessionItem [ ] > ( [ ] ) ;
80+ const [ sessionsLoading , setSessionsLoading ] = useState ( false ) ;
81+ const [ deleteConfirmId , setDeleteConfirmId ] = useState < string | null > ( null ) ;
82+ const [ sidebarOpen , setSidebarOpen ] = useState ( false ) ;
6983
7084 const scrollToBottom = ( ) => {
7185 messagesEndRef . current ?. scrollIntoView ( { behavior : 'smooth' } ) ;
@@ -83,6 +97,76 @@ const ChatPage: React.FC = () => {
8397 } ) . catch ( ( ) => { } ) ;
8498 } , [ ] ) ;
8599
100+ // Load sessions list
101+ const loadSessions = useCallback ( ( ) => {
102+ setSessionsLoading ( true ) ;
103+ agentApi . getChatSessions ( ) . then ( setSessions ) . catch ( ( ) => { } ) . finally ( ( ) => setSessionsLoading ( false ) ) ;
104+ } , [ ] ) ;
105+
106+ // Load sessions list + restore messages on mount (with stale session detection)
107+ const sessionRestoredRef = useRef ( false ) ;
108+ useEffect ( ( ) => {
109+ if ( sessionRestoredRef . current ) return ;
110+ sessionRestoredRef . current = true ;
111+ const savedId = localStorage . getItem ( STORAGE_KEY_SESSION ) ;
112+ setSessionsLoading ( true ) ;
113+ agentApi . getChatSessions ( ) . then ( ( sessionList ) => {
114+ setSessions ( sessionList ) ;
115+ if ( savedId ) {
116+ const sessionExists = sessionList . some ( ( s ) => s . session_id === savedId ) ;
117+ if ( sessionExists ) {
118+ return agentApi . getChatSessionMessages ( savedId ) . then ( ( msgs ) => {
119+ if ( msgs . length > 0 ) {
120+ setMessages ( msgs . map ( ( m ) => ( { id : m . id , role : m . role , content : m . content } ) ) ) ;
121+ }
122+ } ) ;
123+ }
124+ // Session was deleted externally — reset to a new session
125+ const newId = generateUUID ( ) ;
126+ setSessionId ( newId ) ;
127+ sessionIdRef . current = newId ;
128+ }
129+ } ) . catch ( ( ) => { } ) . finally ( ( ) => setSessionsLoading ( false ) ) ;
130+ } , [ ] ) ;
131+
132+ // Persist session_id to localStorage
133+ useEffect ( ( ) => {
134+ localStorage . setItem ( STORAGE_KEY_SESSION , sessionId ) ;
135+ } , [ sessionId ] ) ;
136+
137+ // Switch to an existing session
138+ const switchSession = useCallback ( ( targetSessionId : string ) => {
139+ if ( targetSessionId === sessionId && messages . length > 0 ) return ;
140+ setMessages ( [ ] ) ;
141+ setSessionId ( targetSessionId ) ;
142+ sessionIdRef . current = targetSessionId ;
143+ setSidebarOpen ( false ) ;
144+ agentApi . getChatSessionMessages ( targetSessionId ) . then ( ( msgs ) => {
145+ setMessages ( msgs . map ( ( m ) => ( { id : m . id , role : m . role , content : m . content } ) ) ) ;
146+ } ) . catch ( ( ) => { } ) ;
147+ } , [ sessionId , messages . length ] ) ;
148+
149+ // Start a new conversation
150+ const startNewChat = useCallback ( ( ) => {
151+ const newId = generateUUID ( ) ;
152+ setSessionId ( newId ) ;
153+ sessionIdRef . current = newId ;
154+ setMessages ( [ ] ) ;
155+ setProgressSteps ( [ ] ) ;
156+ followUpContextRef . current = null ;
157+ setSidebarOpen ( false ) ;
158+ } , [ ] ) ;
159+
160+ // Delete with confirmation
161+ const confirmDelete = useCallback ( ( ) => {
162+ if ( ! deleteConfirmId ) return ;
163+ agentApi . deleteChatSession ( deleteConfirmId ) . then ( ( ) => {
164+ setSessions ( ( prev ) => prev . filter ( ( s ) => s . session_id !== deleteConfirmId ) ) ;
165+ if ( deleteConfirmId === sessionId ) startNewChat ( ) ;
166+ } ) . catch ( ( ) => { } ) ;
167+ setDeleteConfirmId ( null ) ;
168+ } , [ deleteConfirmId , sessionId , startNewChat ] ) ;
169+
86170 // Handle follow-up from report page: ?stock=600519&name=贵州茅台&queryId=xxx
87171 useEffect ( ( ) => {
88172 if ( initialFollowUpHandled . current ) return ;
@@ -130,9 +214,23 @@ const ChatPage: React.FC = () => {
130214 setLoading ( true ) ;
131215 setProgressSteps ( [ ] ) ;
132216
217+ const currentSessionId = sessionIdRef . current ;
218+
219+ // Optimistically add new session to sidebar if not already present
220+ setSessions ( ( prev ) => {
221+ if ( prev . some ( ( s ) => s . session_id === currentSessionId ) ) return prev ;
222+ return [ {
223+ session_id : currentSessionId ,
224+ title : msgText . slice ( 0 , 60 ) ,
225+ message_count : 1 ,
226+ created_at : new Date ( ) . toISOString ( ) ,
227+ last_active : new Date ( ) . toISOString ( ) ,
228+ } , ...prev ] ;
229+ } ) ;
230+
133231 const payload : ChatStreamPayload = {
134232 message : userMessage . content ,
135- session_id : sessionIdRef . current ,
233+ session_id : currentSessionId ,
136234 skills : usedStrategy ? [ usedStrategy ] : undefined ,
137235 } ;
138236 // Attach follow-up context if available (data reuse from report page)
@@ -216,6 +314,7 @@ const ChatPage: React.FC = () => {
216314 } finally {
217315 setLoading ( false ) ;
218316 setProgressSteps ( [ ] ) ;
317+ loadSessions ( ) ; // Refresh sidebar after new message
219318 }
220319 } ;
221320
@@ -315,19 +414,125 @@ const ChatPage: React.FC = () => {
315414 </ div >
316415 ) ;
317416
318- return (
319- < div className = "h-screen flex flex-col max-w-5xl mx-auto w-full p-4 md:p-6" >
320- < header className = "mb-6 flex-shrink-0" >
321- < h1 className = "text-2xl font-bold text-white mb-2 flex items-center gap-2" >
322- < svg className = "w-6 h-6 text-cyan" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
323- < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
417+ const sidebarContent = (
418+ < >
419+ < div className = "p-3 border-b border-white/5 flex items-center justify-between" >
420+ < span className = "text-sm font-medium text-white" > 历史对话</ span >
421+ < button
422+ onClick = { startNewChat }
423+ className = "p-1.5 rounded-lg hover:bg-white/10 transition-colors text-secondary hover:text-white"
424+ title = "新对话"
425+ >
426+ < svg className = "w-4 h-4" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
427+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M12 4v16m8-8H4" />
324428 </ svg >
325- 问股
326- </ h1 >
327- < p className = "text-secondary text-sm" > 向 AI 询问个股分析,获取基于策略的交易建议与实时决策报告。</ p >
328- </ header >
429+ </ button >
430+ </ div >
431+ < div className = "flex-1 overflow-y-auto custom-scrollbar" >
432+ { sessionsLoading ? (
433+ < div className = "p-4 text-center text-xs text-muted" > 加载中...</ div >
434+ ) : sessions . length === 0 ? (
435+ < div className = "p-4 text-center text-xs text-muted" > 暂无历史对话</ div >
436+ ) : (
437+ sessions . map ( ( s ) => (
438+ < button
439+ key = { s . session_id }
440+ onClick = { ( ) => switchSession ( s . session_id ) }
441+ className = { `w-full text-left px-3 py-2.5 border-b border-white/5 hover:bg-white/5 transition-colors group ${
442+ s . session_id === sessionId ? 'bg-white/10' : ''
443+ } `}
444+ >
445+ < div className = "flex items-center justify-between gap-2" >
446+ < span className = "text-sm text-secondary group-hover:text-white truncate flex-1" >
447+ { s . title }
448+ </ span >
449+ < button
450+ onClick = { ( e ) => { e . stopPropagation ( ) ; setDeleteConfirmId ( s . session_id ) ; } }
451+ className = "opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-white/10 text-muted hover:text-red-400 transition-all flex-shrink-0"
452+ title = "删除"
453+ >
454+ < svg className = "w-3.5 h-3.5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
455+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
456+ </ svg >
457+ </ button >
458+ </ div >
459+ < div className = "text-xs text-muted mt-0.5" >
460+ { s . message_count } 条消息
461+ { s . last_active && ` · ${ new Date ( s . last_active ) . toLocaleDateString ( 'zh-CN' , { month : 'short' , day : 'numeric' , hour : '2-digit' , minute : '2-digit' } ) } ` }
462+ </ div >
463+ </ button >
464+ ) )
465+ ) }
466+ </ div >
467+ </ >
468+ ) ;
469+
470+ return (
471+ < div className = "h-screen flex max-w-6xl mx-auto w-full p-4 md:p-6 gap-4" >
472+ { /* Desktop sidebar */ }
473+ < div className = "hidden md:flex flex-col w-64 flex-shrink-0 glass-card overflow-hidden" >
474+ { sidebarContent }
475+ </ div >
329476
330- < div className = "flex-1 flex flex-col glass-card overflow-hidden min-h-0 relative z-10" >
477+ { /* Mobile sidebar overlay */ }
478+ { sidebarOpen && (
479+ < div className = "fixed inset-0 z-40 md:hidden" onClick = { ( ) => setSidebarOpen ( false ) } >
480+ < div className = "absolute inset-0 bg-black/60" />
481+ < div
482+ className = "absolute left-0 top-0 bottom-0 w-72 flex flex-col glass-card overflow-hidden border-r border-white/10 shadow-2xl"
483+ onClick = { ( e ) => e . stopPropagation ( ) }
484+ >
485+ { sidebarContent }
486+ </ div >
487+ </ div >
488+ ) }
489+
490+ { /* Delete confirmation dialog */ }
491+ { deleteConfirmId && (
492+ < div className = "fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick = { ( ) => setDeleteConfirmId ( null ) } >
493+ < div className = "bg-elevated border border-white/10 rounded-xl p-6 max-w-sm mx-4 shadow-2xl" onClick = { ( e ) => e . stopPropagation ( ) } >
494+ < h3 className = "text-white font-medium mb-2" > 删除对话</ h3 >
495+ < p className = "text-sm text-secondary mb-5" > 删除后,该对话将不可恢复,确认删除吗?</ p >
496+ < div className = "flex justify-end gap-3" >
497+ < button
498+ onClick = { ( ) => setDeleteConfirmId ( null ) }
499+ className = "px-4 py-1.5 rounded-lg text-sm text-secondary hover:text-white hover:bg-white/5 border border-white/10 transition-colors"
500+ >
501+ 取消
502+ </ button >
503+ < button
504+ onClick = { confirmDelete }
505+ className = "px-4 py-1.5 rounded-lg text-sm text-white bg-red-500/80 hover:bg-red-500 transition-colors"
506+ >
507+ 删除
508+ </ button >
509+ </ div >
510+ </ div >
511+ </ div >
512+ ) }
513+
514+ { /* Main chat area */ }
515+ < div className = "flex-1 flex flex-col min-w-0" >
516+ < header className = "mb-4 flex-shrink-0" >
517+ < h1 className = "text-2xl font-bold text-white mb-2 flex items-center gap-2" >
518+ < button
519+ onClick = { ( ) => setSidebarOpen ( true ) }
520+ className = "md:hidden p-1.5 -ml-1 rounded-lg hover:bg-white/10 transition-colors text-secondary hover:text-white"
521+ title = "历史对话"
522+ >
523+ < svg className = "w-5 h-5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
524+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M4 6h16M4 12h16M4 18h16" />
525+ </ svg >
526+ </ button >
527+ < svg className = "w-6 h-6 text-cyan" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
528+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
529+ </ svg >
530+ 问股
531+ </ h1 >
532+ < p className = "text-secondary text-sm" > 向 AI 询问个股分析,获取基于策略的交易建议与实时决策报告。</ p >
533+ </ header >
534+
535+ < div className = "flex-1 flex flex-col glass-card overflow-hidden min-h-0 relative z-10" >
331536 { /* Messages */ }
332537 < div className = "flex-1 overflow-y-auto p-4 md:p-6 space-y-6 custom-scrollbar relative z-10" >
333538 { messages . length === 0 && ! loading ? (
@@ -518,6 +723,7 @@ const ChatPage: React.FC = () => {
518723 </ div >
519724 </ div >
520725 </ div >
726+ </ div > { /* end main chat area */ }
521727 </ div >
522728 ) ;
523729} ;
0 commit comments