feat(frontend): message search and session export entry points
- SearchModal queries GET /messages/search and jumps to the matching session; opened from the sidebar button or Ctrl+K - session items in the sidebar gain a hover export button that downloads the markdown export via an authenticated fetch + blob (plain links cannot send the JWT)
This commit is contained in:
1 parent
9d040fde0b
commit
26d65dd6bb
4 files changed
+143
-7
No files matched your search
@@ -11,6 +11,22 @@ export function setToken(t: string | null) {
|
||||
|
||||
export function getToken() { return token }
|
||||
|
||||
// downloadSession fetches a session export with auth and saves it as a file.
|
||||
export async function downloadSession(id: string, format: 'markdown' | 'txt' | 'json' = 'markdown') {
|
||||
const res = await fetch(`${BASE}/sessions/${id}/export?format=${format}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error(`export failed: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const ext = format === 'markdown' ? 'md' : format
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `session-${id.slice(0, 8)}.${ext}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -38,8 +54,12 @@ export const api = {
|
||||
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}`,
|
||||
},
|
||||
|
||||
messages: {
|
||||
search: (q: string) =>
|
||||
request<{ messages: { id: number; session_id: string; role: string; content: string; created_at: string }[]; total: number }>(
|
||||
`/messages/search?q=${encodeURIComponent(q)}`),
|
||||
},
|
||||
|
||||
admin: {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface SearchResult {
|
||||
id: number
|
||||
session_id: string
|
||||
role: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function snippet(content: string, q: string): string {
|
||||
const idx = content.indexOf(q)
|
||||
if (idx < 0) return content.slice(0, 80)
|
||||
const start = Math.max(0, idx - 20)
|
||||
return (start > 0 ? '…' : '') + content.slice(start, start + 80) + (start + 80 < content.length ? '…' : '')
|
||||
}
|
||||
|
||||
export default function SearchModal({ onClose }: { onClose: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const [q, setQ] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const search = async () => {
|
||||
const query = q.trim()
|
||||
if (!query) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.messages.search(query)
|
||||
setResults(data.messages || [])
|
||||
setSearched(true)
|
||||
} catch { /* ignore */ } finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const jump = (sessionId: string) => {
|
||||
onClose()
|
||||
navigate(`/chat/${sessionId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24" style={{ background: 'rgba(0,0,0,0.4)' }}
|
||||
onClick={onClose}>
|
||||
<div className="w-full max-w-lg rounded-[18px] border p-4 space-y-3"
|
||||
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && search()}
|
||||
placeholder="搜索所有会话的消息…"
|
||||
className="flex-1 rounded-[14px] border px-3 py-2 text-sm outline-none"
|
||||
style={{ background: 'var(--mx-bg)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }}
|
||||
/>
|
||||
<button onClick={search} disabled={loading}
|
||||
className="px-4 py-2 rounded-full text-sm font-medium text-white"
|
||||
style={{ background: 'var(--mx-primary)' }}>
|
||||
{loading ? '…' : '搜索'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-auto space-y-1">
|
||||
{searched && results.length === 0 && (
|
||||
<p className="text-center text-sm py-6" style={{ color: 'var(--mx-text-dim)' }}>没有找到相关消息</p>
|
||||
)}
|
||||
{results.map((m) => (
|
||||
<div key={m.id} onClick={() => jump(m.session_id)}
|
||||
className="px-3 py-2 rounded-[14px] cursor-pointer transition-colors hover:opacity-80"
|
||||
style={{ background: 'var(--mx-bg)' }}>
|
||||
<p className="text-sm" style={{ color: 'var(--mx-text)' }}>{snippet(m.content, q)}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--mx-text-dim)' }}>
|
||||
{m.role === 'user' ? '你' : '昔涟'} · {new Date(m.created_at).toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useChatStore } from '../../store/chatStore'
|
||||
import { api } from '../../api/client'
|
||||
import { api, downloadSession } from '../../api/client'
|
||||
|
||||
interface SessionItem { id: string; title: string; updated_at: string }
|
||||
|
||||
@@ -15,7 +15,7 @@ function relativeTime(iso: string): string {
|
||||
return `${Math.floor(hour / 24)} 天前`
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
export default function Sidebar({ onSearch }: { onSearch?: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const { currentSessionId, setSessionId, clearMessages, messages } = useChatStore()
|
||||
const [sessions, setSessions] = useState<SessionItem[]>([])
|
||||
@@ -55,12 +55,20 @@ export default function Sidebar() {
|
||||
|
||||
return (
|
||||
<aside className="w-60 bg-[var(--mx-surface)] border-r border-[var(--mx-divider)] shrink-0 flex flex-col">
|
||||
<div className="p-3">
|
||||
<div className="p-3 space-y-2">
|
||||
<button onClick={handleNewChat}
|
||||
className="w-full py-2.5 bg-[var(--mx-primary)] hover:bg-[var(--mx-primary-hover)] active:scale-[0.98] text-white font-medium text-sm rounded-full transition-all duration-200"
|
||||
>
|
||||
+ 新对话
|
||||
</button>
|
||||
{onSearch && (
|
||||
<button onClick={onSearch}
|
||||
className="w-full py-2 text-sm rounded-full border transition-colors hover:opacity-80"
|
||||
style={{ borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}
|
||||
>
|
||||
🔍 搜索消息
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-2 pb-2 space-y-1">
|
||||
@@ -79,6 +87,12 @@ export default function Sidebar() {
|
||||
<p className="text-sm truncate flex-1" style={{ color: 'var(--mx-text)' }}>
|
||||
{s.title || '未命名会话'}
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); downloadSession(s.id, 'markdown').catch(() => {}) }}
|
||||
className="opacity-0 group-hover:opacity-100 text-xs px-1 transition-opacity"
|
||||
style={{ color: 'var(--mx-text-dim)' }}
|
||||
title="导出为 Markdown"
|
||||
>⤓</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(s.id) }}
|
||||
className="opacity-0 group-hover:opacity-100 text-xs px-1 transition-opacity"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useChatStore } from '../../store/chatStore'
|
||||
import ChatContainer from '../../components/chat/ChatContainer'
|
||||
import SearchModal from '../../components/chat/SearchModal'
|
||||
import Sidebar from '../../components/layout/Sidebar'
|
||||
import { api } from '../../api/client'
|
||||
import type { Message } from '../../types/chat'
|
||||
@@ -9,6 +10,7 @@ import type { Message } from '../../types/chat'
|
||||
export default function ChatPage() {
|
||||
const { sessionId } = useParams()
|
||||
const { setSessionId, setMessages } = useChatStore()
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
@@ -30,12 +32,26 @@ export default function ChatPage() {
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, setSessionId, setMessages])
|
||||
|
||||
// Ctrl+K opens message search
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setShowSearch(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<Sidebar />
|
||||
<Sidebar onSearch={() => setShowSearch(true)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<ChatContainer />
|
||||
</div>
|
||||
{showSearch && <SearchModal onClose={() => setShowSearch(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user