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

+22 -2
View File
@@ -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>
)
}
+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"
+18 -2
View File
@@ -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>
)
}