255 lines
9.0 KiB
TypeScript
255 lines
9.0 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { ArrowRight, BookOpen, Check, FileText, Layers, Plus, Search, type LucideIcon } from 'lucide-react'
|
|
import { Link, useParams } from 'react-router-dom'
|
|
import { Button } from '../../components/Button'
|
|
import { PageShell } from '../../components/ui/PageShell'
|
|
import { PageHeader } from '../../components/ui/PageHeader'
|
|
import { SegmentedTabs } from '../../components/ui/SegmentedTabs'
|
|
import { TableSkeleton } from '../../components/ui/Skeleton'
|
|
import { StatusBadge } from '../../components/ui/StatusBadge'
|
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
|
import { t } from '../../i18n'
|
|
import {
|
|
addKnowledgeDocuments,
|
|
deleteKnowledgeDocument,
|
|
getKnowledgeSpace,
|
|
listKnowledgeDocuments,
|
|
syncKnowledgeDocument,
|
|
} from './api'
|
|
import { CreateSpaceModal } from './CreateSpaceModal'
|
|
import { DocumentTable } from './DocumentTable'
|
|
import { ChunkBrowser } from './ChunkBrowser'
|
|
import { RecallTestPanel } from './RecallTestPanel'
|
|
import { isLiveDocumentStatus, spaceStatusLabel } from './status'
|
|
import type { KnowledgeDocument, KnowledgeSpace } from '../../contracts/knowledge'
|
|
|
|
type Tab = 'documents' | 'chunks' | 'retrieve'
|
|
|
|
export function KnowledgeDetail() {
|
|
const { id = '' } = useParams()
|
|
const [space, setSpace] = useState<KnowledgeSpace | null>(null)
|
|
const [documents, setDocuments] = useState<KnowledgeDocument[]>([])
|
|
const [tab, setTab] = useState<Tab>('documents')
|
|
const [selectedDoc, setSelectedDoc] = useState<string | null>(null)
|
|
const [adding, setAdding] = useState(false)
|
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
async function load() {
|
|
if (!id) return
|
|
setError(null)
|
|
try {
|
|
const [nextSpace, nextDocs] = await Promise.all([getKnowledgeSpace(id), listKnowledgeDocuments(id)])
|
|
setSpace(nextSpace)
|
|
setDocuments(nextDocs)
|
|
setSelectedDoc((current) => current || nextDocs[0]?.id || null)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t('knowledge.loadFailed'))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void load()
|
|
}, [id])
|
|
|
|
const live = useMemo(() => documents.some((doc) => isLiveDocumentStatus(doc.status)), [documents])
|
|
|
|
useEffect(() => {
|
|
if (!live) return
|
|
const timer = window.setInterval(() => {
|
|
void load()
|
|
}, 2500)
|
|
return () => window.clearInterval(timer)
|
|
}, [live, id])
|
|
|
|
const readyDocs = documents.filter((doc) => String(doc.status).toUpperCase() === 'FINISHED').length
|
|
|
|
return (
|
|
<PageShell>
|
|
<Link
|
|
to="/knowledge"
|
|
className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1.5 text-sm text-ink-muted shadow-card transition hover:text-accent"
|
|
>
|
|
<ArrowRight className="h-4 w-4" />
|
|
{t('knowledge.back')}
|
|
</Link>
|
|
<PageHeader
|
|
icon={BookOpen}
|
|
title={space?.name || t('knowledge.title')}
|
|
subtitle={space?.description || t('knowledge.detailSubtitle')}
|
|
meta={space ? <StatusBadge status={space.status} label={spaceStatusLabel(space.status)} /> : null}
|
|
action={
|
|
<Button onClick={() => setAdding(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
{t('knowledge.addDocument')}
|
|
</Button>
|
|
}
|
|
/>
|
|
{error ? (
|
|
<p className="mb-4 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
{loading ? (
|
|
<TableSkeleton rows={4} />
|
|
) : (
|
|
<>
|
|
<div className="mb-5 grid gap-3 sm:grid-cols-3">
|
|
<DetailMetric
|
|
label={t('knowledge.statDocuments')}
|
|
value={space?.document_count ?? documents.length}
|
|
icon={FileText}
|
|
wrap="bg-emerald-50 text-emerald-700"
|
|
active={tab === 'documents'}
|
|
onClick={() => setTab('documents')}
|
|
/>
|
|
<DetailMetric
|
|
label={t('knowledge.statChunks')}
|
|
value={space?.chunk_count ?? 0}
|
|
icon={Layers}
|
|
wrap="bg-sky-50 text-sky-800"
|
|
active={tab === 'chunks'}
|
|
onClick={() => setTab('chunks')}
|
|
/>
|
|
<DetailMetric
|
|
label={t('knowledge.statReady')}
|
|
value={readyDocs}
|
|
icon={Check}
|
|
wrap="bg-violet-50 text-violet-700"
|
|
/>
|
|
</div>
|
|
<div className="mb-5">
|
|
<SegmentedTabs
|
|
items={[
|
|
{ id: 'documents', label: t('knowledge.documentsTab'), icon: FileText },
|
|
{ id: 'chunks', label: t('knowledge.chunksTab'), icon: Layers },
|
|
{ id: 'retrieve', label: t('knowledge.retrieveTab'), icon: Search },
|
|
]}
|
|
value={tab}
|
|
onChange={setTab}
|
|
/>
|
|
</div>
|
|
{tab === 'documents' ? (
|
|
<DocumentTable
|
|
documents={documents}
|
|
onAdd={() => setAdding(true)}
|
|
onSync={(docId) => {
|
|
void syncKnowledgeDocument(id, docId)
|
|
.then(() => load())
|
|
.catch((err) => setError(err instanceof Error ? err.message : t('knowledge.syncFailed')))
|
|
}}
|
|
onDelete={setDeletingId}
|
|
onOpenChunks={(docId) => {
|
|
setSelectedDoc(docId)
|
|
setTab('chunks')
|
|
}}
|
|
/>
|
|
) : null}
|
|
{tab === 'chunks' ? (
|
|
documents.length === 0 ? (
|
|
<p className="rounded-2xl border border-dashed border-line bg-panel px-6 py-12 text-center text-sm text-ink-muted shadow-card">
|
|
{t('knowledge.chunksEmpty')}
|
|
</p>
|
|
) : (
|
|
<>
|
|
{documents.length > 1 ? (
|
|
<div className="mb-4 flex gap-2 overflow-x-auto pb-1">
|
|
{documents.map((doc) => {
|
|
const active = doc.id === selectedDoc
|
|
return (
|
|
<button
|
|
key={doc.id}
|
|
type="button"
|
|
onClick={() => setSelectedDoc(doc.id)}
|
|
className={`max-w-[220px] shrink-0 truncate rounded-full px-3 py-1.5 text-[13px] transition ${
|
|
active
|
|
? 'bg-accent-soft font-medium text-accent'
|
|
: 'bg-panel text-ink-muted ring-1 ring-line hover:text-ink'
|
|
}`}
|
|
>
|
|
{doc.name}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
) : null}
|
|
{selectedDoc ? (
|
|
<ChunkBrowser spaceId={id} documentId={selectedDoc} />
|
|
) : (
|
|
<p className="text-sm text-ink-muted">{t('knowledge.chunksEmpty')}</p>
|
|
)}
|
|
</>
|
|
)
|
|
) : null}
|
|
{tab === 'retrieve' ? <RecallTestPanel spaceId={id} /> : null}
|
|
</>
|
|
)}
|
|
<CreateSpaceModal
|
|
open={adding}
|
|
variant="document"
|
|
title={t('knowledge.addDocument')}
|
|
submitLabel={t('knowledge.addDocument')}
|
|
onClose={() => setAdding(false)}
|
|
onSubmit={async (payload) => {
|
|
await addKnowledgeDocuments(id, { sources: payload.sources, chunk: payload.chunk })
|
|
await load()
|
|
}}
|
|
/>
|
|
<ConfirmDialog
|
|
open={Boolean(deletingId)}
|
|
tone="danger"
|
|
title={t('knowledge.deleteDocument')}
|
|
message={t('knowledge.deleteDocumentConfirm')}
|
|
confirmLabel={t('knowledge.delete')}
|
|
onCancel={() => setDeletingId(null)}
|
|
onConfirm={() => {
|
|
const target = deletingId
|
|
setDeletingId(null)
|
|
if (!target) return
|
|
void deleteKnowledgeDocument(id, target)
|
|
.then(() => load())
|
|
.catch((err) => setError(err instanceof Error ? err.message : t('knowledge.deleteFailed')))
|
|
}}
|
|
/>
|
|
</PageShell>
|
|
)
|
|
}
|
|
|
|
function DetailMetric({
|
|
label,
|
|
value,
|
|
icon: Icon,
|
|
wrap,
|
|
active = false,
|
|
onClick,
|
|
}: {
|
|
label: string
|
|
value: number
|
|
icon: LucideIcon
|
|
wrap: string
|
|
active?: boolean
|
|
onClick?: () => void
|
|
}) {
|
|
const className = `rounded-2xl border bg-panel p-4 text-right shadow-card transition ${
|
|
active ? 'border-accent/40' : 'border-line'
|
|
} ${onClick ? 'hover:-translate-y-0.5 hover:border-line-strong' : ''}`
|
|
const body = (
|
|
<>
|
|
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl ${wrap}`}>
|
|
<Icon className="h-4 w-4" />
|
|
</span>
|
|
<p className="mt-3 text-2xl font-semibold tabular-nums tracking-tight text-ink">{value}</p>
|
|
<p className="mt-0.5 text-sm text-ink-muted">{label}</p>
|
|
</>
|
|
)
|
|
if (!onClick) return <div className={className}>{body}</div>
|
|
return (
|
|
<button type="button" onClick={onClick} className={className}>
|
|
{body}
|
|
</button>
|
|
)
|
|
}
|