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>
)
}
+17 -3
View File
@@ -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"