Skip to content

Commit b098665

Browse files
mkloveyyma_k
andauthored
feat: 问股页面对话历史持久化 (Fixes #400) (#414)
* fix: 问股页面对话历史持久化 (Fixes #400) - 后端新增会话列表/消息查询/会话删除 3 个 REST API - 前端 ChatPage 支持会话恢复、侧边栏历史列表、切换/新建/删除会话 - session_id 通过 localStorage 持久化,刷新页面保持对话连续 - 基于已有 conversation_messages 表聚合,无需数据库迁移 * fix: 新会话发送后立即展示在历史列表 - 后端: 用户消息在 agent 处理前立即落库,刷新页面不会丢失会话 - 前端: 发送消息时乐观更新 sidebar,无需等待后端响应 * fix: address PR #414 review feedback from massif-01 - Replace deprecated session.query().filter().delete() with delete().where() style in storage.delete_conversation_session - Detect stale session_id on mount by cross-checking with sessions list; auto-reset to new session if deleted externally - Add mobile hamburger menu toggle for chat history sidebar, extract shared sidebarContent, auto-close on session switch Made-with: Cursor --------- Co-authored-by: ma_k <[email protected]>
1 parent 605224b commit b098665

File tree

6 files changed

+387
-20
lines changed

6 files changed

+387
-20
lines changed

api/v1/endpoints/agent.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,45 @@ async def agent_chat(request: ChatRequest):
107107
raise HTTPException(status_code=500, detail=str(e))
108108

109109

110+
class SessionItem(BaseModel):
111+
session_id: str
112+
title: str
113+
message_count: int
114+
created_at: Optional[str] = None
115+
last_active: Optional[str] = None
116+
117+
class SessionsResponse(BaseModel):
118+
sessions: List[SessionItem]
119+
120+
class SessionMessagesResponse(BaseModel):
121+
session_id: str
122+
messages: List[Dict[str, Any]]
123+
124+
125+
@router.get("/chat/sessions", response_model=SessionsResponse)
126+
async def list_chat_sessions(limit: int = 50):
127+
"""获取聊天会话列表"""
128+
from src.storage import get_db
129+
sessions = get_db().get_chat_sessions(limit=limit)
130+
return SessionsResponse(sessions=sessions)
131+
132+
133+
@router.get("/chat/sessions/{session_id}", response_model=SessionMessagesResponse)
134+
async def get_chat_session_messages(session_id: str, limit: int = 100):
135+
"""获取单个会话的完整消息"""
136+
from src.storage import get_db
137+
messages = get_db().get_conversation_messages(session_id, limit=limit)
138+
return SessionMessagesResponse(session_id=session_id, messages=messages)
139+
140+
141+
@router.delete("/chat/sessions/{session_id}")
142+
async def delete_chat_session(session_id: str):
143+
"""删除指定会话"""
144+
from src.storage import get_db
145+
count = get_db().delete_conversation_session(session_id)
146+
return {"deleted": count}
147+
148+
110149
def _build_executor(config, skills: Optional[List[str]] = None):
111150
"""Build and return a configured AgentExecutor (sync helper)."""
112151
from src.agent.factory import build_agent_executor

apps/dsa-web/src/api/agent.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,41 @@ export interface StrategiesResponse {
2222
strategies: StrategyInfo[];
2323
}
2424

25+
export interface ChatSessionItem {
26+
session_id: string;
27+
title: string;
28+
message_count: number;
29+
created_at: string | null;
30+
last_active: string | null;
31+
}
32+
33+
export interface ChatSessionMessage {
34+
id: string;
35+
role: 'user' | 'assistant';
36+
content: string;
37+
created_at: string | null;
38+
}
39+
2540
export const agentApi = {
2641
async chat(payload: ChatRequest): Promise<ChatResponse> {
2742
const response = await apiClient.post<ChatResponse>('/api/v1/agent/chat', payload, {
28-
timeout: 120000, // Agent analysis may take longer
43+
timeout: 120000,
2944
});
3045
return response.data;
3146
},
3247
async getStrategies(): Promise<StrategiesResponse> {
3348
const response = await apiClient.get<StrategiesResponse>('/api/v1/agent/strategies');
3449
return response.data;
3550
},
51+
async getChatSessions(limit = 50): Promise<ChatSessionItem[]> {
52+
const response = await apiClient.get<{ sessions: ChatSessionItem[] }>('/api/v1/agent/chat/sessions', { params: { limit } });
53+
return response.data.sessions;
54+
},
55+
async getChatSessionMessages(sessionId: string): Promise<ChatSessionMessage[]> {
56+
const response = await apiClient.get<{ messages: ChatSessionMessage[] }>(`/api/v1/agent/chat/sessions/${sessionId}`);
57+
return response.data.messages;
58+
},
59+
async deleteChatSession(sessionId: string): Promise<void> {
60+
await apiClient.delete(`/api/v1/agent/chat/sessions/${sessionId}`);
61+
},
3662
};

apps/dsa-web/src/pages/ChatPage.tsx

Lines changed: 222 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import React, { useState, useRef, useEffect } from 'react';
1+
import React, { useState, useRef, useEffect, useCallback } from 'react';
22
import { useSearchParams } from 'react-router-dom';
33
import Markdown from 'react-markdown';
44
import remarkGfm from 'remark-gfm';
55
import { agentApi } from '../api/agent';
66
import { generateUUID } from '../utils/uuid';
7-
import type { StrategyInfo } from '../api/agent';
7+
import type { StrategyInfo, ChatSessionItem } from '../api/agent';
88
import { historyApi } from '../api/history';
99

10+
const STORAGE_KEY_SESSION = 'dsa_chat_session_id';
11+
1012
interface 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
};

docs/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@
3636
- **流水线接入**`AGENT_MODE=true` 时 pipeline 自动路由至 Agent 分析分支,向下兼容
3737
- **配置项**`AGENT_MODE``AGENT_MAX_STEPS``AGENT_STRATEGY_DIR`
3838
- **兼容性**`AGENT_MODE` 默认 false,不影响现有非 Agent 模式;回滚只需将 `AGENT_MODE` 设为 false
39+
- 💬 **聊天历史持久化**(Issue #400
40+
- `/chat` 页面支持会话历史记录,刷新或重新进入页面后可恢复之前的对话
41+
- 侧边栏展示历史会话列表,支持切换、新建和删除会话(含二次确认)
42+
- 后端新增 3 个 REST API:会话列表、会话消息查询、会话删除
43+
- 基于已有 `conversation_messages` 表聚合,无需数据库迁移
44+
- `session_id` 通过 localStorage 持久化,跨页面刷新保持会话连续性
3945
- ⚙️ **Agent 工具链能力增强**
4046
- 扩展 `analysis_tools``data_tools`,优化策略问股的工具调用链路与分析覆盖
4147
- 📡 **LiteLLM Proxy 接入**

0 commit comments

Comments
 (0)