feat(frontend): message search and session export entry points
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled

- 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:
Aska committed 2026-08-21 12:59:30 +08:00
1 parent 9d040fde0b
commit 26d65dd6bb
4 files changed
+143 -7

No files matched your search

@@ -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>
)
}