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'),
|
health: () => request<{ status: string }>('/health'),
|
||||||
|
|
||||||
sessions: {
|
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' }),
|
create: () => request<{ session_id: string }>('/sessions', { method: 'POST' }),
|
||||||
delete: (id: string) => request<void>(`/sessions/${id}`, { method: 'DELETE' }),
|
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: {
|
admin: {
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useChatStore } from '../../store/chatStore'
|
import { useChatStore } from '../../store/chatStore'
|
||||||
import { api } from '../../api/client'
|
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() {
|
export default function Sidebar() {
|
||||||
const navigate = useNavigate()
|
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 () => {
|
const handleNewChat = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -12,6 +37,19 @@ export default function Sidebar() {
|
|||||||
setSessionId(session_id)
|
setSessionId(session_id)
|
||||||
clearMessages()
|
clearMessages()
|
||||||
navigate(`/chat/${session_id}`)
|
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 */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,13 +63,32 @@ export default function Sidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto px-2 pb-2">
|
<div className="flex-1 overflow-auto px-2 pb-2 space-y-1">
|
||||||
{currentSessionId ? (
|
{sessions.length === 0 ? (
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<p className="px-3 py-2 text-xs text-[var(--mx-text-dim)] text-center">暂无对话</p>
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
import { useChatStore } from '../store/chatStore'
|
import { useChatStore } from '../store/chatStore'
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { getToken } from '../api/client'
|
import { getToken } from '../api/client'
|
||||||
import type { WSMessage } from '../types/chat'
|
import type { WSMessage } from '../types/chat'
|
||||||
|
|
||||||
@@ -12,7 +13,8 @@ export function useWebSocket() {
|
|||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) return
|
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
|
wsRef.current = ws
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
|||||||
@@ -2,15 +2,40 @@ import { useEffect } from 'react'
|
|||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { useChatStore } from '../../store/chatStore'
|
import { useChatStore } from '../../store/chatStore'
|
||||||
import ChatContainer from '../../components/chat/ChatContainer'
|
import ChatContainer from '../../components/chat/ChatContainer'
|
||||||
|
import Sidebar from '../../components/layout/Sidebar'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
|
import type { Message } from '../../types/chat'
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { sessionId } = useParams()
|
const { sessionId } = useParams()
|
||||||
const { setSessionId } = useChatStore()
|
const { setSessionId, setMessages } = useChatStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionId) setSessionId(sessionId)
|
if (!sessionId) return
|
||||||
}, [sessionId, setSessionId])
|
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
|
setInput: (text: string) => void
|
||||||
setSessionId: (id: string) => void
|
setSessionId: (id: string) => void
|
||||||
|
setMessages: (msgs: Message[]) => void
|
||||||
addMessage: (msg: Message) => void
|
addMessage: (msg: Message) => void
|
||||||
appendToLast: (content: string) => void
|
appendToLast: (content: string) => void
|
||||||
setStreaming: (v: boolean) => void
|
setStreaming: (v: boolean) => void
|
||||||
@@ -26,6 +27,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
setInput: (text) => set({ inputText: text }),
|
setInput: (text) => set({ inputText: text }),
|
||||||
setSessionId: (id) => set({ currentSessionId: id }),
|
setSessionId: (id) => set({ currentSessionId: id }),
|
||||||
|
setMessages: (msgs) => set({ messages: msgs }),
|
||||||
setStreaming: (v) => set({ isStreaming: v }),
|
setStreaming: (v) => set({ isStreaming: v }),
|
||||||
setIotDevices: (d) => set({ iotDevices: d }),
|
setIotDevices: (d) => set({ iotDevices: d }),
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user