feat(frontend): session list sidebar and persisted history loading
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled

- 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:
Aska committed 2026-08-20 20:34:08 +08:00
1 parent f8196c111c
commit 8190007865
5 files changed
+103 -13

No files matched your search

+5 -1
View File
@@ -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: {
+64 -7
View File
@@ -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>
+3 -1
View File
@@ -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 = () => {
+29 -4
View File
@@ -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>
)
}
+2
View File
@@ -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 }),