Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions frontend/src/api/wiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,25 @@ export function getKbInventory(kb: string): Promise<KbInventory> {
return apiFetch<KbInventory>("/api/v1/list", { body: { kb } })
}

/** A document's ingested source text (`/api/v1/document/source`). This is the
* READ-ONLY conversion output stored under `wiki/sources/` — short docs are a
* single Markdown string; long docs are per-page text concatenated into one.
* `pages` is the page count for long docs and null for short. */
export interface DocumentSource {
hash: string
name: string
doc_name: string
type: string
format: string
content: string
pages: number | null
}

/** Fetch a document's converted full text by its `hash` (the /list identifier). */
export function getDocumentSource(kb: string, hash: string): Promise<DocumentSource> {
return apiFetch<DocumentSource>("/api/v1/document/source", { body: { kb, hash } })
}

/** Result of `/api/v1/page/delete`. `backlinks` are 'section/stem' refs whose
* inbound [[links]] will be / were demoted to plain text. */
export interface PageDeleteResult {
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/locales/en/kb.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@
"empty": "No documents yet · they compile into the KB automatically after upload",
"pages_one": "{{count}} page",
"pages_other": "{{count}} pages",
"reader": {
"open": "Read document",
"title": "Document",
"readonly": "Read-only",
"close": "Close",
"loading": "Loading document…",
"error": "Couldn't load document: {{error}}",
"retry": "Retry",
"emptyDoc": "This document has no extracted text."
},
"delete": {
"action": "Delete document",
"prompt": "Delete?",
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/locales/zh/kb.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@
"empty": "暂无文档 · 上传后会自动编译进知识库",
"pages_one": "{{count}} 页",
"pages_other": "{{count}} 页",
"reader": {
"open": "查看文档",
"title": "文档",
"readonly": "只读",
"close": "关闭",
"loading": "正在加载文档…",
"error": "加载文档失败:{{error}}",
"retry": "重试",
"emptyDoc": "该文档没有可显示的正文。"
},
"delete": {
"action": "删除文档",
"prompt": "确认删除?",
Expand Down
267 changes: 253 additions & 14 deletions frontend/src/pages/KbDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react'
import { useNavigate, useParams } from 'react-router'
import { useTranslation } from 'react-i18next'
import { motion, useReducedMotion } from 'motion/react'
import { FileText, Link2, Loader2, Pencil, Upload, RefreshCw, Settings2, Trash2, Circle, CheckCircle2, CircleSlash2, XCircle } from 'lucide-react'
import * as Dialog from '@radix-ui/react-dialog'
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { FileText, Link2, Loader2, Pencil, Upload, RefreshCw, Settings2, Trash2, Circle, CheckCircle2, CircleSlash2, XCircle, X, BookOpen } from 'lucide-react'
import { toast } from 'sonner'
import { deletePage, editPage, getKbInventory, getPage, getPageLinks, type KbInventory, type WikiDocument } from '@/api/wiki'
import { deletePage, editPage, getDocumentSource, getKbInventory, getPage, getPageLinks, type DocumentSource, type KbInventory, type WikiDocument } from '@/api/wiki'
import { streamUpload, removeDocument, type AddResult } from '@/api/maintenance'
import { ApiError } from '@/api/client'
import MarkdownView from '@/components/MarkdownView'
Expand Down Expand Up @@ -444,6 +445,7 @@ export default function KbDetail() {
/>
) : section === 'documents' ? (
<DocumentsPane
kb={id}
documents={documents}
invError={invError}
uploading={uploading}
Expand Down Expand Up @@ -844,9 +846,163 @@ function UploadStatusIcon({ status }: { status: UploadStatus }) {
}
}

/** Read-only slide-out reader for a document's converted source text.
*
* Presentational: the parent (DocumentsPane) owns the fetch, per-hash cache,
* and memoized body, so this can unmount on close (Radix Dialog) yet re-open
* instantly with un-reparsed Markdown. Radix Dialog supplies the modal a11y —
* focus trap, initial + return focus, Escape, and background inert — and
* `shown` keeps the last doc rendered through the exit animation (doc goes null
* on close). The body scrolls independently and native find-in-page works (the
* whole document is rendered, not virtualized). Documents are read-only
* ingestion artifacts, so there is no edit affordance. */
function DocumentReaderDrawer({
doc,
body,
loading,
error,
isEmpty,
onRetry,
onClose,
}: {
doc: WikiDocument | null
body: ReactNode
loading: boolean
error: string | null
isEmpty: boolean
onRetry: () => void
onClose: () => void
}) {
const { t } = useTranslation(['kb', 'common'])
const reduce = useReducedMotion()
// The control that opened the drawer, captured before Radix moves focus in,
// so focus returns to it on close (mirrors KbSettingsSheet).
const openerRef = useRef<HTMLElement | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const open = doc !== null
// Keep the last doc visible during the exit animation (doc is null once closed).
const [shown, setShown] = useState<WikiDocument | null>(doc)
useEffect(() => {
if (doc) setShown(doc)
}, [doc])
// Reset scroll to top when switching documents.
useEffect(() => {
if (doc) scrollRef.current?.scrollTo(0, 0)
}, [doc])

return (
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next) onClose()
}}
>
<AnimatePresence>
{open && (
<Dialog.Portal forceMount>
<Dialog.Overlay asChild forceMount>
<motion.div
className="fixed inset-0 z-40 bg-black/30"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduce ? { duration: 0.12 } : { duration: 0.2 }}
onClick={onClose}
/>
</Dialog.Overlay>
<Dialog.Content
asChild
forceMount
aria-modal="true"
aria-describedby={undefined}
onOpenAutoFocus={() => {
openerRef.current = document.activeElement as HTMLElement | null
}}
onCloseAutoFocus={(e) => {
e.preventDefault()
openerRef.current?.focus()
}}
>
<motion.aside
className="fixed inset-y-0 right-0 z-50 flex w-[min(70vw,900px)] max-w-full flex-col glass border-l border-[hsl(var(--glass-border))] shadow-glass-lg rounded-l-apple-lg"
initial={reduce ? { opacity: 0 } : { opacity: 0, x: 24 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, x: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, x: 24 }}
transition={reduce ? { duration: 0.12 } : { type: 'spring', bounce: 0, duration: 0.3 }}
>
<div className="flex shrink-0 items-center gap-3 border-b border-[hsl(var(--glass-border))] px-5 py-3">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-[hsl(var(--glass-border))] bg-muted">
<FileText className="h-4 w-4 text-muted-foreground" />
</span>
<div className="min-w-0">
<Dialog.Title asChild>
<div className="truncate text-[13.5px] font-medium text-foreground">{shown?.name}</div>
</Dialog.Title>
<div className="mt-0.5 flex items-center gap-2 text-[12px] text-muted-foreground">
{shown?.display_type && <span>{shown.display_type}</span>}
{shown?.pages != null && <span>· {t('kb:docs.pages', { count: shown.pages })}</span>}
<span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-px text-[11px]">
<BookOpen className="h-3 w-3" />
{t('kb:docs.reader.readonly')}
</span>
</div>
</div>
<button
onClick={onClose}
title={t('kb:docs.reader.close')}
aria-label={t('kb:docs.reader.close')}
className="ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>

<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div className="mx-auto max-w-[72ch] px-6 py-6">
{loading && (
<div className="flex items-center justify-center gap-2 py-16 text-[13px] text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('kb:docs.reader.loading')}
</div>
)}
{error && (
<div className="py-16 text-center">
<p className="text-[13px] text-red-600 dark:text-red-400">
{t('kb:docs.reader.error', { error })}
</p>
<button
onClick={onRetry}
className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-lg border border-[hsl(var(--glass-border))] px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent"
>
<RefreshCw className="h-3 w-3" />
{t('kb:docs.reader.retry')}
</button>
</div>
)}
{!loading && !error && isEmpty && (
<div className="py-16 text-center text-[13px] text-muted-foreground">
{t('kb:docs.reader.emptyDoc')}
</div>
)}
{!loading && !error && !isEmpty && (
<div className="text-[14px] leading-relaxed text-foreground">{body}</div>
)}
</div>
</div>
</motion.aside>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
)
}

/** Documents section: upload dropzone + uploaded-document list + remote
* connector cards. Moved verbatim from the old Sources tab body. */

function DocumentsPane({
kb,
documents,
invError,
uploading,
Expand All @@ -858,6 +1014,7 @@ function DocumentsPane({
onRefresh,
onDelete,
}: {
kb: string
documents: WikiDocument[]
invError: string | null
uploading: boolean
Expand All @@ -874,6 +1031,64 @@ function DocumentsPane({
// `deletingName` is the row whose remove request is in flight.
const [confirmName, setConfirmName] = useState<string | null>(null)
const [deletingName, setDeletingName] = useState<string | null>(null)
// Document reader drawer. State lives HERE (not in the drawer, which unmounts
// on close via Radix) so the per-hash cache and memoized body survive
// open/close and re-opening is instant without re-parsing Markdown.
const [openDoc, setOpenDoc] = useState<WikiDocument | null>(null)
const [docSource, setDocSource] = useState<DocumentSource | null>(null)
const [docLoading, setDocLoading] = useState(false)
const [docError, setDocError] = useState<string | null>(null)
const [docReloadSeq, setDocReloadSeq] = useState(0)
const sourceCache = useRef<Map<string, DocumentSource>>(new Map())
const closeDrawer = useCallback(() => setOpenDoc(null), [])
const openHash = openDoc?.hash ?? null

// Drop cached content when the inventory changes (a recompile can rewrite a
// document's converted text under the same raw hash), so the next open
// refetches instead of serving stale text.
useEffect(() => {
sourceCache.current.clear()
}, [documents])

// Fetch the open document's source (per-hash cache; retry via docReloadSeq).
useEffect(() => {
if (!openHash) return
const cached = sourceCache.current.get(openHash)
if (cached) {
setDocSource(cached)
setDocError(null)
setDocLoading(false)
return
}
let cancelled = false
setDocLoading(true)
setDocSource(null)
setDocError(null)
getDocumentSource(kb, openHash)
.then((r) => {
if (cancelled) return
sourceCache.current.set(openHash, r)
setDocSource(r)
})
.catch((e) => {
if (!cancelled) setDocError(errMsg(e))
})
.finally(() => {
if (!cancelled) setDocLoading(false)
})
return () => {
cancelled = true
}
}, [kb, openHash, docReloadSeq])

// Parse Markdown once per fetched source (stable cache ref → no re-parse).
const readerBody = useMemo(
() =>
docSource && docSource.content.trim() ? <MarkdownView source={docSource.content} /> : null,
[docSource],
)
const readerEmpty = docSource != null && docSource.content.trim().length === 0

const handleDelete = async (name: string) => {
setDeletingName(name)
try {
Expand All @@ -884,6 +1099,7 @@ function DocumentsPane({
}
}
return (
<>
<div className="h-full overflow-y-auto scroll-edge-top">
<div className="max-w-[1280px] mx-auto px-8 lg:px-12 py-6">
<p className="text-[13px] text-muted-foreground">
Expand Down Expand Up @@ -983,20 +1199,33 @@ function DocumentsPane({
key={d.hash || d.name || i}
className={cn(
'anim-fade-up rounded-2xl border border-[hsl(var(--glass-border))] glass-2 px-4 py-3 flex items-center gap-3',
'transition-colors hover:border-foreground/20',
`anim-d${Math.min(i + 1, 4)}`,
)}
>
<span className="w-9 h-9 rounded-xl bg-muted border border-[hsl(var(--glass-border))] grid place-items-center shrink-0">
<FileText className="w-4 h-4 text-muted-foreground" />
</span>
<div className="min-w-0">
<div className="text-[13.5px] font-medium text-foreground truncate">{d.name}</div>
<div className="text-[12px] text-muted-foreground mt-0.5">
{d.display_type}
{d.pages != null && <> · {t('kb:docs.pages', { count: d.pages })}</>}
{/* The open-reader target is a real <button> covering the icon +
title; the delete control is a SIBLING (not nested), so keyboard
activation of delete never bubbles into opening the reader and
no event-propagation guard is needed. */}
<button
type="button"
onClick={() => setOpenDoc(d)}
title={t('kb:docs.reader.open')}
className="flex min-w-0 flex-1 items-center gap-3 text-left rounded-xl cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-brand/50"
>
<span className="w-9 h-9 rounded-xl bg-muted border border-[hsl(var(--glass-border))] grid place-items-center shrink-0">
<FileText className="w-4 h-4 text-muted-foreground" />
</span>
<div className="min-w-0">
<div className="text-[13.5px] font-medium text-foreground truncate">{d.name}</div>
<div className="text-[12px] text-muted-foreground mt-0.5">
{d.display_type}
{d.pages != null && <> · {t('kb:docs.pages', { count: d.pages })}</>}
</div>
</div>
</div>
<div className="ml-auto flex items-center gap-2 shrink-0">
</button>
{/* Trailing controls: hash chip + delete, siblings of the open button. */}
<div className="flex items-center gap-2 shrink-0">
{d.hash && (
<span className="font-mono2 text-[11px] text-muted-foreground bg-muted rounded px-1.5 py-0.5">
{d.hash.slice(0, 8)}
Expand Down Expand Up @@ -1047,5 +1276,15 @@ function DocumentsPane({
</div>
</div>
</div>
<DocumentReaderDrawer
doc={openDoc}
body={readerBody}
loading={docLoading}
error={docError}
isEmpty={readerEmpty}
onRetry={() => setDocReloadSeq((s) => s + 1)}
onClose={closeDrawer}
/>
</>
)
}
Loading
Loading