feat(frontend): session list sidebar and persisted history loading
- Sidebar lists sessions with title/relative time, click to switch, hover-to-delete; refreshes after each round so new titles appear - ChatPage loads message history from GET /sessions/:id/messages when entering a session - WS connect now passes user_id so persisted messages have an owner
This commit is contained in:
1 parent
f8196c111c
commit
8190007865
5 files changed
+103
-13
No files matched your search
@@ -33,9 +33,13 @@ export const api = {
|
||||
health: () => request<{ status: string }>('/health'),
|
||||
|
||||
sessions: {
|
||||
list: () => request<{ sessions: { id: string; title: string }[] }>('/sessions'),
|
||||
list: () => request<{ sessions: { id: string; title: string; updated_at: string }[] }>('/sessions'),
|
||||
create: () => request<{ session_id: string }>('/sessions', { method: 'POST' }),
|
||||
delete: (id: string) => request<void>(`/sessions/${id}`, { method: 'DELETE' }),
|
||||
messages: (id: string) =>
|
||||
request<{ messages: { id: number; session_id: string; role: string; content: string; created_at: string }[] }>(`/sessions/${id}/messages`),
|
||||
exportUrl: (id: string, format: 'markdown' | 'txt' | 'json' = 'markdown') =>
|
||||
`${BASE}/sessions/${id}/export?format=${format}`,
|
||||
},
|
||||
|
||||
admin: {
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useChatStore } from '../../store/chatStore'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface SessionItem { id: string; title: string; updated_at: string }
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const min = Math.floor(diff / 60000)
|
||||
if (min < 1) return '刚刚'
|
||||
if (min < 60) return `${min} 分钟前`
|
||||
const hour = Math.floor(min / 60)
|
||||
if (hour < 24) return `${hour} 小时前`
|
||||
return `${Math.floor(hour / 24)} 天前`
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const navigate = useNavigate()
|
||||
const { currentSessionId, setSessionId, clearMessages } = useChatStore()
|
||||
const { currentSessionId, setSessionId, clearMessages, messages } = useChatStore()
|
||||
const [sessions, setSessions] = useState<SessionItem[]>([])
|
||||
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.sessions.list()
|
||||
setSessions(data.sessions || [])
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchSessions() }, [fetchSessions])
|
||||
// Refresh the list when a round finishes so new titles/ordering appear
|
||||
useEffect(() => { fetchSessions() }, [messages.length, fetchSessions])
|
||||
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
@@ -12,6 +37,19 @@ export default function Sidebar() {
|
||||
setSessionId(session_id)
|
||||
clearMessages()
|
||||
navigate(`/chat/${session_id}`)
|
||||
fetchSessions()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('删除这个会话及其全部消息?')) return
|
||||
try {
|
||||
await api.sessions.delete(id)
|
||||
if (id === currentSessionId) {
|
||||
clearMessages()
|
||||
navigate('/chat')
|
||||
}
|
||||
fetchSessions()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -25,13 +63,32 @@ export default function Sidebar() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-2 pb-2">
|
||||
{currentSessionId ? (
|
||||
<div className="px-3 py-2 bg-[var(--mx-bg)] rounded-[14px]">
|
||||
<p className="text-xs text-[var(--mx-text-dim)] truncate">{currentSessionId.slice(0, 12)}...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto px-2 pb-2 space-y-1">
|
||||
{sessions.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-[var(--mx-text-dim)] text-center">暂无对话</p>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<div key={s.id}
|
||||
onClick={() => navigate(`/chat/${s.id}`)}
|
||||
className="group px-3 py-2 rounded-[14px] cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: s.id === currentSessionId ? 'color-mix(in srgb, var(--mx-primary) 12%, transparent)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<p className="text-sm truncate flex-1" style={{ color: 'var(--mx-text)' }}>
|
||||
{s.title || '未命名会话'}
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(s.id) }}
|
||||
className="opacity-0 group-hover:opacity-100 text-xs px-1 transition-opacity"
|
||||
style={{ color: 'var(--mx-error)' }}
|
||||
title="删除会话"
|
||||
>✕</button>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--mx-text-dim)' }}>{relativeTime(s.updated_at)}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useChatStore } from '../store/chatStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { getToken } from '../api/client'
|
||||
import type { WSMessage } from '../types/chat'
|
||||
|
||||
@@ -12,7 +13,8 @@ export function useWebSocket() {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
|
||||
const ws = new WebSocket(`ws://localhost:8080/ws/chat?token=${encodeURIComponent(token)}`)
|
||||
const uid = useAuthStore.getState().username || 'web'
|
||||
const ws = new WebSocket(`ws://localhost:8080/ws/chat?token=${encodeURIComponent(token)}&user_id=${encodeURIComponent(uid)}`)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
|
||||
@@ -2,15 +2,40 @@ import { useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useChatStore } from '../../store/chatStore'
|
||||
import ChatContainer from '../../components/chat/ChatContainer'
|
||||
import Sidebar from '../../components/layout/Sidebar'
|
||||
import { api } from '../../api/client'
|
||||
import type { Message } from '../../types/chat'
|
||||
|
||||
export default function ChatPage() {
|
||||
const { sessionId } = useParams()
|
||||
const { setSessionId } = useChatStore()
|
||||
const { setSessionId, setMessages } = useChatStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId) setSessionId(sessionId)
|
||||
}, [sessionId, setSessionId])
|
||||
if (!sessionId) return
|
||||
setSessionId(sessionId)
|
||||
|
||||
return <ChatContainer />
|
||||
// Load persisted history for the session
|
||||
let cancelled = false
|
||||
api.sessions.messages(sessionId).then((data) => {
|
||||
if (cancelled) return
|
||||
const msgs: Message[] = (data.messages || []).map((m) => ({
|
||||
id: `db-${m.id}`,
|
||||
session_id: m.session_id,
|
||||
role: m.role === 'user' ? 'user' : 'assistant',
|
||||
content: m.content,
|
||||
created_at: m.created_at,
|
||||
}))
|
||||
setMessages(msgs)
|
||||
}).catch(() => { /* history unavailable */ })
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, setSessionId, setMessages])
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0">
|
||||
<ChatContainer />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface ChatState {
|
||||
|
||||
setInput: (text: string) => void
|
||||
setSessionId: (id: string) => void
|
||||
setMessages: (msgs: Message[]) => void
|
||||
addMessage: (msg: Message) => void
|
||||
appendToLast: (content: string) => void
|
||||
setStreaming: (v: boolean) => void
|
||||
@@ -26,6 +27,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
setInput: (text) => set({ inputText: text }),
|
||||
setSessionId: (id) => set({ currentSessionId: id }),
|
||||
setMessages: (msgs) => set({ messages: msgs }),
|
||||
setStreaming: (v) => set({ isStreaming: v }),
|
||||
setIotDevices: (d) => set({ iotDevices: d }),
|
||||
|
||||
|
||||
Reference in new issue
Block a user