Files
kamidso-front/src/features/knowledge/KnowledgeDetail.tsx
T

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>
)
}