feat: add streaming chat and evidence workspace

This commit is contained in:
gqt
2026-07-13 12:00:01 +08:00
parent 52e14986d4
commit 6e3f4b8d04
11 changed files with 370 additions and 3 deletions
+2
View File
@@ -4,6 +4,7 @@ import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { Button } from '../components/ui/button' import { Button } from '../components/ui/button'
import { SessionList } from '../features/chat/SessionList'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
function Navigation({ onNavigate }: { onNavigate?: () => void }) { function Navigation({ onNavigate }: { onNavigate?: () => void }) {
@@ -22,6 +23,7 @@ function Navigation({ onNavigate }: { onNavigate?: () => void }) {
<BookOpenText size={17} /><span></span> <BookOpenText size={17} /><span></span>
</NavLink> </NavLink>
</nav> </nav>
<SessionList onNavigate={onNavigate} />
<div className="sidebar-note"> <div className="sidebar-note">
<span className="status-dot" /> <span className="status-dot" />
<div><strong></strong><small></small></div> <div><strong></strong><small></small></div>
+3 -3
View File
@@ -1,7 +1,7 @@
import { Navigate, createBrowserRouter } from 'react-router-dom' import { Navigate, createBrowserRouter } from 'react-router-dom'
import { AppLayout } from './AppLayout' import { AppLayout } from './AppLayout'
import { PlaceholderPage } from './PlaceholderPage' import { ChatPage } from '../features/chat/ChatPage'
import { DocumentsPage } from '../features/documents/DocumentsPage' import { DocumentsPage } from '../features/documents/DocumentsPage'
export const router = createBrowserRouter([ export const router = createBrowserRouter([
@@ -10,8 +10,8 @@ export const router = createBrowserRouter([
element: <AppLayout />, element: <AppLayout />,
children: [ children: [
{ index: true, element: <Navigate to="/chat" replace /> }, { index: true, element: <Navigate to="/chat" replace /> },
{ path: 'chat', element: <PlaceholderPage /> }, { path: 'chat', element: <ChatPage /> },
{ path: 'chat/:sessionId', element: <PlaceholderPage /> }, { path: 'chat/:sessionId', element: <ChatPage /> },
{ path: 'documents', element: <DocumentsPage /> }, { path: 'documents', element: <DocumentsPage /> },
{ path: 'documents/:documentId', element: <DocumentsPage /> }, { path: 'documents/:documentId', element: <DocumentsPage /> },
], ],
+22
View File
@@ -0,0 +1,22 @@
import { SendHorizontal, Square } from 'lucide-react'
import { useState } from 'react'
import { Button } from '../../components/ui/button'
type Props = { streaming: boolean; onSend: (query: string) => void; onCancel: () => void }
export function ChatComposer({ streaming, onSend, onCancel }: Props) {
const [query, setQuery] = useState('')
function submit() {
const value = query.trim()
if (!value || streaming) return
setQuery(''); onSend(value)
}
return (
<div className="chat-composer">
<textarea value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); submit() } }} placeholder="向知识库提问…" rows={2} disabled={streaming} />
{streaming ? <Button variant="secondary" size="icon" onClick={onCancel} aria-label="停止生成"><Square size={15} fill="currentColor" /></Button> : <Button size="icon" onClick={submit} disabled={!query.trim()} aria-label="发送问题"><SendHorizontal size={17} /></Button>}
<small>Enter · Shift + Enter </small>
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import * as Dialog from '@radix-ui/react-dialog'
import { PanelRightOpen, Radar, Sparkles } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button } from '../../components/ui/button'
import type { Message, Source } from '../../lib/types'
import { announceSessionChange, createSession, listMessages } from './api'
import { ChatComposer } from './ChatComposer'
import { EvidencePanel } from './EvidencePanel'
import { MessageList } from './MessageList'
import { useChatStreamStore } from './store'
import { useChatStream } from './useChatStream'
export function ChatPage() {
const { sessionId } = useParams()
const navigate = useNavigate()
const [messages, setMessages] = useState<Message[]>([])
const [evidenceOpen, setEvidenceOpen] = useState(false)
const stream = useChatStreamStore()
const refreshMessages = useCallback(async () => {
if (!sessionId) { setMessages([]); return }
const page = await listMessages(sessionId)
setMessages(page.items); announceSessionChange()
}, [sessionId])
useEffect(() => {
if (!sessionId) return
void listMessages(sessionId).then((page) => setMessages(page.items))
}, [sessionId])
const { send, cancel } = useChatStream(async () => { await refreshMessages(); useChatStreamStore.getState().reset() })
async function handleSend(query: string) {
let target = sessionId
if (!target) {
const session = await createSession(); target = session.id; navigate(`/chat/${target}`); announceSessionChange()
}
await send(target, query)
}
function chooseSource(source: Source, sources: Source[] = stream.sources) {
stream.setSources(sources)
stream.selectSource(source)
setEvidenceOpen(true)
}
const streaming = stream.phase === 'researching' || stream.phase === 'answering'
return (
<section className="chat-workspace">
<main className="chat-main">
<header className="chat-header"><div><p className="eyebrow">KNOWLEDGE CONVERSATION</p><h1>{sessionId ? '知识库对话' : '开始新研究'}</h1></div><Button className="evidence-trigger" variant="secondary" size="sm" onClick={() => setEvidenceOpen(true)}><PanelRightOpen size={15} /></Button></header>
<div className="conversation-scroll"><MessageList messages={messages} onSource={chooseSource} />{stream.activeQuery && <div className="streaming-turn"><article className="message message--user"><div className="message-body"><p>{stream.activeQuery}</p></div></article><article className="message message--assistant"><div className="message-avatar"><Sparkles size={15} /></div><div className="message-body">{stream.phase === 'researching' && <p className="research-state"><Radar className="pulse" size={16} /> Agent </p>}{stream.text && <p className="answer-copy">{stream.text}<span className="typing-cursor" /></p>}{stream.error && <p className="failed-message">{stream.error}</p>}</div></article></div>}</div>
<ChatComposer streaming={streaming} onSend={(query) => void handleSend(query)} onCancel={cancel} />
</main>
<div className="evidence-desktop"><EvidencePanel sources={stream.sources} selected={stream.selectedSource} onSelect={stream.selectSource} /></div>
<Dialog.Root open={evidenceOpen} onOpenChange={setEvidenceOpen}><Dialog.Portal><Dialog.Overlay className="dialog-overlay" /><Dialog.Content className="evidence-drawer"><Dialog.Title className="sr-only"></Dialog.Title><EvidencePanel sources={stream.sources} selected={stream.selectedSource} onSelect={stream.selectSource} onClose={() => setEvidenceOpen(false)} /></Dialog.Content></Dialog.Portal></Dialog.Root>
</section>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { BookOpenCheck, FileText, X } from 'lucide-react'
import { Button } from '../../components/ui/button'
import type { Source } from '../../lib/types'
import { cn } from '../../lib/utils'
type Props = { sources: Source[]; selected: Source | null; onSelect: (source: Source) => void; onClose?: () => void }
export function EvidencePanel({ sources, selected, onSelect, onClose }: Props) {
return (
<aside className="evidence-panel">
<header className="evidence-header"><div><p className="eyebrow">EVIDENCE</p><h2></h2></div><span>{sources.length} </span>{onClose && <Button variant="ghost" size="icon" onClick={onClose}><X size={17} /></Button>}</header>
{sources.length === 0 ? <div className="evidence-empty"><BookOpenCheck size={23} /><strong></strong><span></span></div> : <><div className="source-list">{sources.map((source) => <button key={source.chunk_id} className={cn('source-card', selected?.chunk_id === source.chunk_id && 'source-card--active')} onClick={() => onSelect(source)}><FileText size={14} /><span><strong>{source.label} · {source.filename}</strong><small> {source.order_index + 1} · {(source.score * 100).toFixed(0)}%</small></span></button>)}</div>{selected && <article className="source-detail"><header><strong>{selected.label} · </strong><span>{selected.page_number ? `${selected.page_number}` : `${selected.order_index + 1}`}</span></header><p>{selected.content}</p></article>}</>}
</aside>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { Bot, CircleAlert, UserRound } from 'lucide-react'
import type { Message, Source } from '../../lib/types'
function AnswerContent({ content, sources, onSource }: { content: string; sources: Source[]; onSource: (source: Source, sources: Source[]) => void }) {
const lookup = new Map(sources.map((source) => [source.label, source]))
return <p className="answer-copy">{content.split(/(\[S\d+])/g).map((part, index) => {
const label = part.match(/^\[(S\d+)]$/)?.[1]
const source = label ? lookup.get(label) : undefined
if (!label) return <span key={index}>{part}</span>
if (!source) return <span key={index} className="citation citation--deleted"></span>
return <button key={index} className="citation" onClick={() => onSource(source, sources)}>{label}</button>
})}</p>
}
type Props = { messages: Message[]; onSource: (source: Source, sources: Source[]) => void }
export function MessageList({ messages, onSource }: Props) {
if (messages.length === 0) return <div className="chat-empty"><div><Bot size={28} /></div><p className="eyebrow">STRICT KNOWLEDGE MODE</p><h2></h2><span> Agent Agent </span></div>
return (
<div className="message-list">
{messages.map((message) => <article key={message.id} className={`message message--${message.role}`}>
<div className="message-avatar">{message.role === 'user' ? <UserRound size={15} /> : <Bot size={15} />}</div>
<div className="message-body"><header><strong>{message.role === 'user' ? '你' : '知识库助手'}</strong><time>{new Date(message.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</time></header>{message.status === 'failed' ? <p className="failed-message"><CircleAlert size={14} /></p> : message.role === 'assistant' ? <AnswerContent content={message.content} sources={message.sources} onSource={onSource} /> : <p>{message.content}</p>}</div>
</article>)}
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { MessageSquarePlus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Button } from '../../components/ui/button'
import type { Session } from '../../lib/types'
import { cn } from '../../lib/utils'
import { createSession, deleteSession, listSessions } from './api'
export function SessionList({ onNavigate }: { onNavigate?: () => void }) {
const [sessions, setSessions] = useState<Session[]>([])
const navigate = useNavigate()
const { sessionId } = useParams()
const isChat = useLocation().pathname.startsWith('/chat')
const refresh = useCallback(async () => {
if (!isChat) return
const page = await listSessions()
setSessions(page.items)
}, [isChat])
useEffect(() => {
if (!isChat) return
void listSessions().then((page) => setSessions(page.items))
const listener = () => void refresh()
window.addEventListener('kbqa:sessions-changed', listener)
return () => window.removeEventListener('kbqa:sessions-changed', listener)
}, [isChat, refresh])
if (!isChat) return null
async function addSession() {
const session = await createSession()
await refresh(); navigate(`/chat/${session.id}`); onNavigate?.()
}
async function removeSession(id: string) {
if (!window.confirm('确定删除这个会话及全部历史吗?')) return
await deleteSession(id); await refresh()
if (sessionId === id) navigate('/chat')
}
return (
<section className="session-rail">
<Button className="new-session" size="sm" onClick={() => void addSession()}><MessageSquarePlus size={15} /></Button>
<p className="nav-label"></p>
<div className="session-list">
{sessions.map((session) => <div key={session.id} className={cn('session-item', session.id === sessionId && 'session-item--active')}><button onClick={() => { navigate(`/chat/${session.id}`); onNavigate?.() }}><strong>{session.title || '新会话'}</strong><span>{new Date(session.updated_at).toLocaleDateString('zh-CN')}</span></button><Button variant="ghost" size="icon" title="删除会话" onClick={() => void removeSession(session.id)}><Trash2 size={13} /></Button></div>)}
{sessions.length === 0 && <p className="session-empty"></p>}
</div>
</section>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { apiRequest } from '../../lib/api'
import type { Message, Page, Session } from '../../lib/types'
export function createSession(title?: string) {
return apiRequest<Session>('/api/v1/sessions', {
method: 'POST',
body: JSON.stringify({ title: title || null }),
})
}
export function listSessions() {
return apiRequest<Page<Session>>('/api/v1/sessions?limit=100')
}
export function listMessages(sessionId: string) {
return apiRequest<Page<Message>>(`/api/v1/sessions/${sessionId}/messages?limit=100`)
}
export function deleteSession(sessionId: string) {
return apiRequest<void>(`/api/v1/sessions/${sessionId}`, { method: 'DELETE' })
}
export function announceSessionChange() {
window.dispatchEvent(new Event('kbqa:sessions-changed'))
}
+41
View File
@@ -0,0 +1,41 @@
import { create } from 'zustand'
import type { Source } from '../../lib/types'
type Phase = 'idle' | 'researching' | 'answering' | 'error'
type ChatStreamState = {
phase: Phase
activeQuery: string
text: string
sources: Source[]
selectedSource: Source | null
error: string | null
start: (query: string) => void
setPhase: (phase: Phase) => void
appendText: (content: string) => void
setSources: (sources: Source[]) => void
selectSource: (source: Source | null) => void
fail: (message: string) => void
reset: () => void
}
const initial = {
phase: 'idle' as Phase,
activeQuery: '',
text: '',
sources: [] as Source[],
selectedSource: null as Source | null,
error: null as string | null,
}
export const useChatStreamStore = create<ChatStreamState>((set) => ({
...initial,
start: (query) => set({ ...initial, phase: 'researching', activeQuery: query }),
setPhase: (phase) => set({ phase }),
appendText: (content) => set((state) => ({ text: state.text + content })),
setSources: (sources) => set({ sources, selectedSource: sources[0] ?? null }),
selectSource: (selectedSource) => set({ selectedSource }),
fail: (error) => set({ phase: 'error', error }),
reset: () => set(initial),
}))
+39
View File
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useRef } from 'react'
import { streamSSE } from '../../lib/sse'
import { useChatStreamStore } from './store'
export function useChatStream(onDone: () => Promise<void>) {
const controllerRef = useRef<AbortController | null>(null)
const store = useChatStreamStore()
const abort = useCallback(() => controllerRef.current?.abort(), [])
const cancel = useCallback(() => {
controllerRef.current?.abort()
useChatStreamStore.getState().fail('已停止生成,可重新发送问题。')
}, [])
const send = useCallback(async (sessionId: string, query: string) => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
store.start(query)
try {
await streamSSE('/api/v1/chat/stream', { session_id: sessionId, query }, (event) => {
if (event.event === 'status') store.setPhase(event.data.phase)
if (event.event === 'sources') store.setSources(event.data.sources)
if (event.event === 'token') store.appendText(event.data.content)
if (event.event === 'error') store.fail(event.data.message)
}, controller.signal)
if (!controller.signal.aborted && useChatStreamStore.getState().phase !== 'error') await onDone()
} catch (reason) {
if (controller.signal.aborted) return
store.fail(reason instanceof Error ? reason.message : '流式回答失败')
} finally {
if (controllerRef.current === controller) controllerRef.current = null
}
}, [onDone, store])
useEffect(() => abort, [abort])
return { send, cancel }
}
+81
View File
@@ -139,6 +139,79 @@ a { color: inherit; text-decoration: none; }
.detail-pagination { padding-top: 9px; display: flex; align-items: center; justify-content: center; gap: 10px; color: #84908b; border-top: 1px solid var(--line); font-size: .65rem; } .detail-pagination { padding-top: 9px; display: flex; align-items: center; justify-content: center; gap: 10px; color: #84908b; border-top: 1px solid var(--line); font-size: .65rem; }
.evidence-drawer { position: fixed; inset: 0 0 0 auto; z-index: 51; width: min(390px, 92vw); background: #f0f3f1; box-shadow: -24px 0 80px rgba(0,0,0,.2); animation: drawer-in .25s ease; } .evidence-drawer { position: fixed; inset: 0 0 0 auto; z-index: 51; width: min(390px, 92vw); background: #f0f3f1; box-shadow: -24px 0 80px rgba(0,0,0,.2); animation: drawer-in .25s ease; }
.session-rail { min-height: 0; margin-top: 14px; display: flex; flex: 1; flex-direction: column; }
.new-session { width: 100%; justify-content: flex-start; }
.session-rail .nav-label { margin-top: 18px; }
.session-list { min-height: 0; display: grid; align-content: start; gap: 4px; overflow-y: auto; scrollbar-width: thin; }
.session-item { display: grid; grid-template-columns: minmax(0, 1fr) 30px; align-items: center; border-radius: 7px; color: #aebdb7; }
.session-item > button:first-child { min-width: 0; padding: 8px 7px; color: inherit; border: 0; background: transparent; text-align: left; cursor: pointer; }
.session-item strong, .session-item span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-item strong { font-size: .68rem; font-weight: 650; }
.session-item span { margin-top: 3px; color: #748b82; font-size: .56rem; }
.session-item .button { width: 28px; height: 28px; opacity: 0; color: #91a49d; }
.session-item:hover { background: rgba(255,255,255,.05); }
.session-item:hover .button { opacity: 1; }
.session-item--active { color: white; background: var(--forest-2); box-shadow: inset 3px 0 var(--ember); }
.session-empty { margin: 8px; color: #70867d; font-size: .62rem; }
.chat-workspace { display: grid; grid-template-columns: minmax(500px, 1fr) 330px; min-height: 100vh; }
.chat-main { min-width: 0; height: 100vh; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; background: rgba(250,250,247,.82); }
.chat-header { min-height: 78px; padding: 20px clamp(20px, 3vw, 38px); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.62); backdrop-filter: blur(12px); }
.chat-header h1 { margin: 5px 0 0; font-family: "Songti SC", Georgia, serif; font-size: 1.35rem; font-weight: 600; }
.evidence-trigger { display: none; }
.conversation-scroll { min-height: 0; padding: 26px clamp(20px, 4vw, 54px); overflow-y: auto; scroll-behavior: smooth; }
.chat-empty { min-height: 100%; display: grid; place-content: center; justify-items: center; text-align: center; }
.chat-empty > div { width: 57px; height: 57px; margin-bottom: 19px; display: grid; place-items: center; color: var(--ember); border: 1px solid #ddc9bd; border-radius: 50%; background: white; box-shadow: 0 16px 45px rgba(39,55,49,.08); }
.chat-empty h2 { margin: 8px 0 7px; font-family: "Songti SC", Georgia, serif; font-size: 1.45rem; font-weight: 600; }
.chat-empty > span { max-width: 360px; color: #7e8a85; font-size: .75rem; line-height: 1.7; }
.message-list, .streaming-turn { display: grid; gap: 21px; }
.streaming-turn { margin-top: 21px; }
.message { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 10px; align-items: start; animation: page-enter .25s ease both; }
.message--user { margin-left: clamp(20px, 15%, 110px); grid-template-columns: minmax(0, 1fr); }
.message--user .message-body { justify-self: end; max-width: 82%; padding: 10px 13px; border-radius: 12px 12px 3px 12px; background: #e2ebe7; }
.message--user .message-avatar { display: none; }
.message-avatar { width: 28px; height: 28px; display: grid; place-items: center; color: white; border-radius: 50%; background: var(--ember); box-shadow: 0 5px 14px rgba(172,86,53,.18); }
.message-body { min-width: 0; color: #374943; }
.message-body header { margin: 2px 0 7px; display: flex; align-items: baseline; gap: 8px; }
.message-body header strong { font-size: .72rem; }
.message-body header time { color: #929c98; font-size: .58rem; }
.message-body p { margin: 0; white-space: pre-wrap; font-size: .82rem; line-height: 1.82; }
.answer-copy { font-family: "Songti SC", Georgia, serif; }
.citation { display: inline-flex; margin: 0 2px; padding: 1px 5px; color: #a75435; border: 1px solid #dfc1af; border-radius: 4px; background: #fff5ef; cursor: pointer; font-family: "Avenir Next", sans-serif; font-size: .62rem; font-weight: 800; vertical-align: .08em; }
.citation--deleted { color: #8d9692; border-color: #d9dddb; background: #f0f1ee; cursor: default; }
.failed-message { display: flex; align-items: center; gap: 7px; color: var(--danger); }
.research-state { display: flex; align-items: center; gap: 8px; color: #73827c; font-family: "Avenir Next", sans-serif; font-size: .72rem !important; }
.typing-cursor { display: inline-block; width: 2px; height: 1em; margin-left: 3px; background: var(--ember); animation: blink .8s step-end infinite; vertical-align: -.12em; }
.pulse { color: var(--ember); animation: pulse 1.2s ease-in-out infinite; }
.chat-composer { position: relative; margin: 0 clamp(20px, 4vw, 54px) 20px; padding: 10px 10px 24px 13px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 9px; border: 1px solid #cdd7d2; border-radius: 11px; background: white; box-shadow: 0 10px 35px rgba(37,54,47,.08); }
.chat-composer textarea { width: 100%; min-height: 45px; max-height: 140px; padding: 5px 0; resize: none; color: var(--ink); border: 0; outline: 0; background: transparent; font-size: .8rem; line-height: 1.55; }
.chat-composer textarea::placeholder { color: #9aa4a0; }
.chat-composer small { position: absolute; bottom: 7px; left: 13px; color: #a0aaa6; font-size: .54rem; }
.evidence-desktop { min-width: 0; border-left: 1px solid var(--line); background: #f0f3f1; }
.evidence-panel { height: 100vh; padding: 22px 17px 16px; display: flex; flex-direction: column; overflow: hidden; }
.evidence-header { min-height: 47px; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: start; gap: 7px; }
.evidence-header h2 { margin: 5px 0 0; font-family: "Songti SC", Georgia, serif; font-size: 1.15rem; font-weight: 600; }
.evidence-header > span { margin-top: 7px; color: #82908a; font-size: .62rem; }
.evidence-empty { flex: 1; display: grid; place-content: center; justify-items: center; gap: 7px; color: #85918c; text-align: center; }
.evidence-empty svg { margin-bottom: 4px; color: var(--ember); }
.evidence-empty strong { color: var(--ink); font-family: "Songti SC", Georgia, serif; }
.evidence-empty span { max-width: 220px; font-size: .68rem; line-height: 1.6; }
.source-list { max-height: 42%; margin: 16px 0 11px; display: grid; align-content: start; gap: 7px; overflow-y: auto; }
.source-card { width: 100%; padding: 10px; display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 7px; color: #52625c; border: 1px solid #d8dfdb; border-radius: 8px; background: rgba(255,255,255,.64); cursor: pointer; text-align: left; }
.source-card > svg { margin-top: 1px; color: var(--ember); }
.source-card strong, .source-card small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.source-card strong { color: #354a42; font-size: .68rem; }
.source-card small { margin-top: 4px; color: #89948f; font-size: .58rem; }
.source-card--active { border-color: #d2a78e; background: white; box-shadow: inset 3px 0 var(--ember), 0 6px 20px rgba(41,58,51,.05); }
.source-detail { min-height: 0; flex: 1; padding: 14px; overflow-y: auto; color: #e7efeb; border-radius: 10px; background: var(--forest); }
.source-detail header { display: flex; justify-content: space-between; gap: 10px; }
.source-detail header strong { font-size: .68rem; }
.source-detail header span { color: #9fb1aa; font-size: .57rem; }
.source-detail p { margin: 12px 0 0; white-space: pre-wrap; color: #d8e2de; font-family: "Songti SC", Georgia, serif; font-size: .72rem; line-height: 1.85; }
@keyframes blink { 50% { opacity: 0; } }
@keyframes pulse { 50% { opacity: .45; transform: scale(.92); } }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@keyframes drawer-in { from { transform: translateX(100%); } } @keyframes drawer-in { from { transform: translateX(100%); } }
@@ -160,6 +233,9 @@ a { color: inherit; text-decoration: none; }
.documents-workspace { display: block; } .documents-workspace { display: block; }
.documents-detail-desktop { display: none; } .documents-detail-desktop { display: none; }
.detail-trigger { display: inline-flex; } .detail-trigger { display: inline-flex; }
.chat-workspace { display: block; }
.evidence-desktop { display: none; }
.evidence-trigger { display: inline-flex; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -172,6 +248,11 @@ a { color: inherit; text-decoration: none; }
.document-row { grid-template-columns: 40px minmax(0, 1fr); padding-right: 38px; } .document-row { grid-template-columns: 40px minmax(0, 1fr); padding-right: 38px; }
.document-row .status { grid-column: 2; justify-self: start; } .document-row .status { grid-column: 2; justify-self: start; }
.row-actions { grid-column: 2; } .row-actions { grid-column: 2; }
.chat-main { height: calc(100vh - 58px); }
.chat-header { padding: 14px 16px; }
.conversation-scroll { padding: 20px 16px; }
.chat-composer { margin: 0 12px 12px; }
.message--user { margin-left: 16px; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {