diff --git a/frontend/web/src/api/client.ts b/frontend/web/src/api/client.ts index 1499f74..44acf2f 100644 --- a/frontend/web/src/api/client.ts +++ b/frontend/web/src/api/client.ts @@ -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(path: string, options: RequestInit = {}): Promise { const headers: Record = { 'Content-Type': 'application/json', @@ -38,8 +54,12 @@ export const api = { delete: (id: string) => request(`/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: { diff --git a/frontend/web/src/components/chat/SearchModal.tsx b/frontend/web/src/components/chat/SearchModal.tsx new file mode 100644 index 0000000..9416102 --- /dev/null +++ b/frontend/web/src/components/chat/SearchModal.tsx @@ -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([]) + 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 ( +
+
e.stopPropagation()}> +
+ 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)' }} + /> + +
+ +
+ {searched && results.length === 0 && ( +

没有找到相关消息

+ )} + {results.map((m) => ( +
jump(m.session_id)} + className="px-3 py-2 rounded-[14px] cursor-pointer transition-colors hover:opacity-80" + style={{ background: 'var(--mx-bg)' }}> +

{snippet(m.content, q)}

+

+ {m.role === 'user' ? '你' : '昔涟'} · {new Date(m.created_at).toLocaleString('zh-CN')} +

+
+ ))} +
+
+
+ ) +} diff --git a/frontend/web/src/components/layout/Sidebar.tsx b/frontend/web/src/components/layout/Sidebar.tsx index 126b1a9..92ec90e 100644 --- a/frontend/web/src/components/layout/Sidebar.tsx +++ b/frontend/web/src/components/layout/Sidebar.tsx @@ -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([]) @@ -55,12 +55,20 @@ export default function Sidebar() { return (