feat: add streaming chat and evidence workspace
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'))
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}))
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user