Pause scroll writes while the jump-to-bottom smooth scroll runs; keep rows with pending questions, approvals, recent interactions and selection ends mounted; cache Mermaid results; copy text spanning unmounted rows; anchor the sidebar on id changes; disable native overflow-anchor for windowed lists; write terminal output on the leading edge and before clear; forward remote events every 16 ms; flush streaming text before copy/export; add performance principle P10 (no UX regressions).
728 lines
31 KiB
TypeScript
728 lines
31 KiB
TypeScript
import type {
|
|
KnowledgeSourceStatus,
|
|
KnowledgeDocumentStatus,
|
|
KnowledgeWorkspaceProps,
|
|
KnowledgeLibrary,
|
|
KnowledgeDocumentItem,
|
|
KnowledgeSource,
|
|
KnowledgeGraphStrategy
|
|
} from './types'
|
|
import type { TFunction } from 'i18next'
|
|
import { getLocaleFormatters, resolvedLocale, formatPercent, formatNumber } from './formatting'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useRef, useState, useMemo } from 'react'
|
|
import { toErrorMessage, clampProgress, taskStageLabelKeys } from './helpers'
|
|
import { ScopeBadge } from '../WorkspacePrimitives'
|
|
import { DocumentResultPreview } from '../DocumentResultPreview'
|
|
import {
|
|
FilePlus2,
|
|
FolderOpen,
|
|
Link2,
|
|
X,
|
|
UploadCloud,
|
|
FileText,
|
|
ListChecks,
|
|
Search
|
|
} from 'lucide-react'
|
|
import { RemoveSourceDialog } from './RemoveSourceDialog'
|
|
import { KnowledgeActionsMenu, type KnowledgeAction } from './KnowledgeActionsMenu'
|
|
import { useListWindow } from '../use-list-window'
|
|
import { ListWindowTableSpacer } from '../ListWindowSpacer'
|
|
|
|
const sourceStatusLabelKeys = {
|
|
queued: 'sourceStatuses.queued',
|
|
syncing: 'sourceStatuses.syncing',
|
|
paused: 'sourceStatuses.paused',
|
|
ready: 'sourceStatuses.ready',
|
|
failed: 'sourceStatuses.failed'
|
|
} as const satisfies Record<KnowledgeSourceStatus, string>
|
|
|
|
const documentStatusLabelKeys = {
|
|
queued: 'documentStatuses.queued',
|
|
parsing: 'documentStatuses.parsing',
|
|
indexing: 'documentStatuses.indexing',
|
|
ready: 'documentStatuses.ready',
|
|
failed: 'documentStatuses.failed'
|
|
} as const satisfies Record<KnowledgeDocumentStatus, string>
|
|
|
|
function formatTime(
|
|
value: string | undefined,
|
|
locale: string,
|
|
t: TFunction<'knowledge'>
|
|
): string {
|
|
if (!value) {
|
|
return t('format.neverSynced')
|
|
}
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) {
|
|
return t('format.unknownTime')
|
|
}
|
|
return getLocaleFormatters(locale).compactDateTime.format(date)
|
|
}
|
|
|
|
function formatSize(
|
|
size: number | undefined,
|
|
locale: string,
|
|
t: TFunction<'knowledge'>
|
|
): string {
|
|
if (!Number.isFinite(size) || (size ?? 0) < 0) {
|
|
return t('format.unknownSize')
|
|
}
|
|
const value = size ?? 0
|
|
const formatters = getLocaleFormatters(locale)
|
|
if (value < 1024) {
|
|
return `${formatters.integer.format(value)} B`
|
|
}
|
|
if (value < 1024 * 1024) {
|
|
return `${formatters.decimal.format(value / 1024)} KB`
|
|
}
|
|
return `${formatters.decimal.format(value / 1024 / 1024)} MB`
|
|
}
|
|
|
|
function formatDocumentLocation(
|
|
value: string,
|
|
t: TFunction<'knowledge'>
|
|
): string {
|
|
try {
|
|
const url = new URL(value)
|
|
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
|
return `${url.origin}${url.pathname}${url.search}${url.hash}`
|
|
}
|
|
} catch {
|
|
// Local paths are intentionally reduced below.
|
|
}
|
|
const filename = value.split(/[\\/]/u).filter(Boolean).at(-1)
|
|
return filename
|
|
? t('format.localFileNamed', { filename })
|
|
: t('format.localFile')
|
|
}
|
|
|
|
export function DocumentsView({
|
|
documents,
|
|
library,
|
|
onImportDirectory,
|
|
onImportFiles,
|
|
onImportUrl,
|
|
onOpenDocumentSource,
|
|
onPauseSource,
|
|
onManageChunks,
|
|
onRemoveSource,
|
|
onRebuildDocument,
|
|
onRetrySource,
|
|
onSyncSource,
|
|
onViewTasks,
|
|
sources,
|
|
tasks
|
|
}: Pick<
|
|
KnowledgeWorkspaceProps,
|
|
| 'documents'
|
|
| 'onImportDirectory'
|
|
| 'onImportFiles'
|
|
| 'onImportUrl'
|
|
| 'onOpenDocumentSource'
|
|
| 'onPauseSource'
|
|
| 'onRemoveSource'
|
|
| 'onRebuildDocument'
|
|
| 'onRetrySource'
|
|
| 'onSyncSource'
|
|
| 'sources'
|
|
| 'tasks'
|
|
> & {
|
|
library: KnowledgeLibrary
|
|
onManageChunks: (document: KnowledgeDocumentItem) => void
|
|
onViewTasks: (context: {
|
|
documentId?: string
|
|
sourceId?: string
|
|
}) => void
|
|
}): React.JSX.Element {
|
|
const { i18n, t } = useTranslation('knowledge')
|
|
const locale = resolvedLocale(i18n.resolvedLanguage ?? i18n.language)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const [viewedDocumentId, setViewedDocumentId] = useState<string>()
|
|
const previewBackRef = useRef<HTMLButtonElement>(null)
|
|
const tableBodyRef = useRef<HTMLTableSectionElement>(null)
|
|
const [previewReturnKey, setPreviewReturnKey] = useState<string>()
|
|
const previewOriginRef = useRef<{ trigger: HTMLElement; scroll?: HTMLElement; top: number } | undefined>(undefined)
|
|
const [urlOpen, setUrlOpen] = useState(false)
|
|
const [url, setUrl] = useState('')
|
|
const [query, setQuery] = useState('')
|
|
const [dragging, setDragging] = useState(false)
|
|
const [pending, setPending] = useState<ReadonlyMap<string, number>>(() => new Map())
|
|
const [error, setError] = useState<string>()
|
|
const [removingSource, setRemovingSource] =
|
|
useState<KnowledgeSource>()
|
|
const [askStrategy, setAskStrategy] =
|
|
useState<Exclude<KnowledgeGraphStrategy, 'ask'>>('hybrid')
|
|
const importGraphStrategy =
|
|
library.graphStrategy === 'ask' ? askStrategy : undefined
|
|
|
|
const run = async (
|
|
id: string,
|
|
action: () => void | Promise<void>
|
|
): Promise<boolean> => {
|
|
setPending((current) => new Map(current).set(id, (current.get(id) ?? 0) + 1))
|
|
setError(undefined)
|
|
try {
|
|
await action()
|
|
return true
|
|
} catch (reason) {
|
|
const message = toErrorMessage(reason, t)
|
|
setError(viewedDocumentId && id === `retry:${viewedDocumentId}` ? t('documents.reparseFailed', { error: message }) : message)
|
|
return false
|
|
} finally {
|
|
setPending((current) => {
|
|
const next = new Map(current)
|
|
const remaining = (next.get(id) ?? 1) - 1
|
|
if (remaining > 0) next.set(id, remaining)
|
|
else next.delete(id)
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
const importFiles = (files: File[]): void => {
|
|
if (files.length === 0) {
|
|
return
|
|
}
|
|
void run('files', () =>
|
|
onImportFiles(library.id, files, importGraphStrategy)
|
|
)
|
|
}
|
|
|
|
const rows = useMemo(() => {
|
|
const normalized = query.trim().toLocaleLowerCase(locale)
|
|
const matches = (value: string): boolean => value.toLocaleLowerCase(locale).includes(normalized)
|
|
const bySource = new Map<string, KnowledgeDocumentItem[]>()
|
|
const sourceIds = new Set(sources.map((source) => source.id))
|
|
const result: { key: string; source?: KnowledgeSource; document?: KnowledgeDocumentItem; grouped?: boolean }[] = []
|
|
for (const document of documents) {
|
|
if (document.sourceId && sourceIds.has(document.sourceId)) {
|
|
const group = bySource.get(document.sourceId) ?? []
|
|
group.push(document)
|
|
bySource.set(document.sourceId, group)
|
|
}
|
|
}
|
|
for (const source of sources) {
|
|
const related = bySource.get(source.id) ?? []
|
|
const sourceMatches = matches(`${source.name} ${source.location}`)
|
|
const visible = sourceMatches ? related : related.filter((document) => matches(`${document.name} ${document.path ?? ''}`))
|
|
if (!sourceMatches && visible.length === 0) continue
|
|
// Decide from the full group so filtering never changes source ownership.
|
|
if (source.kind !== 'directory' && related.length === 1) {
|
|
result.push({ key: `source:${source.id}`, source, document: related[0] })
|
|
} else {
|
|
result.push({ key: `source:${source.id}`, source })
|
|
for (const document of visible) result.push({ key: `document:${document.id}`, document, grouped: true })
|
|
}
|
|
}
|
|
for (const document of documents) {
|
|
if ((!document.sourceId || !sourceIds.has(document.sourceId)) && matches(`${document.name} ${document.path ?? ''}`)) {
|
|
result.push({ key: `document:${document.id}`, document })
|
|
}
|
|
}
|
|
return result
|
|
}, [documents, sources, locale, query])
|
|
const rowKeysKey = rows.map((row) => row.key).join('\n')
|
|
const rowKeys = useMemo(() => (rowKeysKey ? rowKeysKey.split('\n') : []), [rowKeysKey])
|
|
// P5: large libraries mount only the table rows near the page viewport. The
|
|
// page (`.workspace-panel-scroll`) scrolls, not the table.
|
|
const documentWindow = useListWindow({
|
|
ids: rowKeys,
|
|
scope: `${library.id}\n${query}`,
|
|
listRef: tableBodyRef,
|
|
scrollParent: (body) => body.closest<HTMLElement>('.workspace-panel-scroll'),
|
|
// The previewed document's row is where focus returns after the preview.
|
|
keepIds: [previewReturnKey],
|
|
enabled: true,
|
|
active: !viewedDocumentId,
|
|
estimatedRowHeight: 96,
|
|
rowAttribute: 'data-knowledge-document-row'
|
|
})
|
|
const viewedDocument = useMemo(() => documents.find((document) => document.id === viewedDocumentId), [documents, viewedDocumentId])
|
|
const viewedSource = sources.find((source) => source.id === viewedDocument?.sourceId)
|
|
const viewedTaskSourceId = viewedSource && viewedSource.kind !== 'directory' &&
|
|
!documents.some((document) => document.sourceId === viewedSource.id && document.id !== viewedDocumentId)
|
|
? viewedSource.id : undefined
|
|
|
|
const sourceStatus = (source: KnowledgeSource): React.JSX.Element => (
|
|
<span className={`knowledge-source-row__status knowledge-source-row__status--${source.status}`}>
|
|
{t(sourceStatusLabelKeys[source.status])}
|
|
{source.status === 'syncing' && source.progress !== undefined
|
|
? ` · ${formatPercent(clampProgress(source.progress) / 100, locale)}` : ''}
|
|
</span>
|
|
)
|
|
|
|
const sourceActions = (source: KnowledgeSource): KnowledgeAction[] => [
|
|
{
|
|
label: t(source.status === 'syncing' ? 'actions.pause' : source.status === 'failed' ? 'actions.retry' : 'actions.sync'),
|
|
disabled: pending.has(source.id),
|
|
onClick: () => void run(source.id, () => source.status === 'syncing' ? onPauseSource(source.id) : source.status === 'failed' ? onRetrySource(source.id) : onSyncSource(source.id))
|
|
},
|
|
{
|
|
label: t('actions.removeSource'),
|
|
disabled: pending.has(source.id), danger: true, separator: true,
|
|
onClick: () => setRemovingSource(source)
|
|
}
|
|
]
|
|
const documentActions = (document: KnowledgeDocumentItem, source: KnowledgeSource | undefined, taskSourceId?: string): KnowledgeAction[] => [
|
|
{ label: t('actions.openSource'), disabled: pending.has(`open:${document.id}`),
|
|
onClick: () => void run(`open:${document.id}`, () => onOpenDocumentSource(library.id, document.id)) },
|
|
...(document.resultId ? [{
|
|
label: t('documents.openParsedOriginal'), disabled: pending.has(`original:${document.id}`),
|
|
onClick: () => void run(`original:${document.id}`, () => window.goodbuddy.documentParsing!.openResultOriginal(document.resultId!))
|
|
}] : []),
|
|
{ label: t('chunks.title'), disabled: (document.chunkCount ?? 0) === 0, onClick: () => onManageChunks(document) },
|
|
...(tasks?.some((task) => task.documentId === document.id || (taskSourceId && task.sourceId === taskSourceId)) ? [{
|
|
label: t('actions.viewTasks'), onClick: () => onViewTasks({ documentId: document.id, ...(taskSourceId ? { sourceId: taskSourceId } : {}) })
|
|
}] : []),
|
|
{ label: t(document.status === 'failed' ? 'actions.retryDocument' : 'documents.reparse'), separator: true,
|
|
disabled: pending.has(`retry:${document.id}`) || tasks?.some((task) => task.documentId === document.id && (task.status === 'queued' || task.status === 'running')),
|
|
onClick: () => void run(`retry:${document.id}`, () => onRebuildDocument(library.id, document.id)) },
|
|
...(source ? sourceActions(source) : [])
|
|
]
|
|
|
|
return (
|
|
<div className="knowledge-documents">
|
|
{viewedDocument?.resultId && <section aria-label={t('documents.previewLabel', { name: viewedDocument.name })}>
|
|
<div className="knowledge-document-preview__header">
|
|
<button ref={previewBackRef} type="button" className="secondary-button" onClick={() => {
|
|
setViewedDocumentId(undefined)
|
|
requestAnimationFrame(() => {
|
|
previewOriginRef.current?.trigger.focus()
|
|
if (previewOriginRef.current?.scroll) previewOriginRef.current.scroll.scrollTop = previewOriginRef.current.top
|
|
})
|
|
}}>{t('documents.backToList')}</button>
|
|
<div className="knowledge-document-preview__title"><h3>{viewedDocument.name}</h3><span>{library.name}</span><ScopeBadge scope={{ kind: 'global' }} />
|
|
{viewedDocument.path && <div className="knowledge-document-path">{formatDocumentLocation(viewedDocument.path, t)}</div>}
|
|
</div>
|
|
<KnowledgeActionsMenu label={t('documents.moreActionsFor', { name: viewedDocument.name })} actions={documentActions(viewedDocument, viewedSource, viewedTaskSourceId)} />
|
|
</div>
|
|
{pending.has(`retry:${viewedDocument.id}`) && <p role="status">{t('documents.reparsing')}</p>}
|
|
{error && <p className="knowledge-inline-error" role="alert">{error}</p>}
|
|
<DocumentResultPreview key={viewedDocument.resultId} resultId={viewedDocument.resultId} allowAddImages showOpenOriginal={false} imageActionsInImagesTab />
|
|
</section>}
|
|
<div hidden={Boolean(viewedDocument?.resultId)}>
|
|
<section aria-labelledby="documents-title">
|
|
<div
|
|
className="knowledge-documents__section-heading"
|
|
>
|
|
<div>
|
|
<h3 id="documents-title">
|
|
{t('documents.table.title')}
|
|
</h3>
|
|
<p className="knowledge-section-description">
|
|
{t(
|
|
library.graphEnabled
|
|
? 'documents.sources.descriptionWithGraph'
|
|
: 'documents.sources.description'
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="knowledge-documents__import-actions">
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
type="button"
|
|
>
|
|
<FilePlus2 aria-hidden="true" size={15} />
|
|
{t('actions.importFiles')}
|
|
</button>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() =>
|
|
void run('directory', () =>
|
|
onImportDirectory(
|
|
library.id,
|
|
[],
|
|
importGraphStrategy
|
|
)
|
|
)
|
|
}
|
|
type="button"
|
|
>
|
|
<FolderOpen aria-hidden="true" size={15} />
|
|
{t('actions.importDirectory')}
|
|
</button>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => setUrlOpen((current) => !current)}
|
|
type="button"
|
|
>
|
|
<Link2 aria-hidden="true" size={15} />
|
|
{t('actions.importUrl')}
|
|
</button>
|
|
<input
|
|
hidden
|
|
multiple
|
|
onChange={(event) => {
|
|
importFiles(Array.from(event.currentTarget.files ?? []))
|
|
event.currentTarget.value = ''
|
|
}}
|
|
ref={fileInputRef}
|
|
type="file"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{library.graphEnabled && library.graphStrategy === 'ask' && (
|
|
<label className="knowledge-documents__import-strategy knowledge-field">
|
|
{t('documents.importStrategy')}
|
|
<select
|
|
onChange={(event) =>
|
|
setAskStrategy(
|
|
event.currentTarget.value as Exclude<
|
|
KnowledgeGraphStrategy,
|
|
'ask'
|
|
>
|
|
)
|
|
}
|
|
value={askStrategy}
|
|
>
|
|
<option value="rules">
|
|
{t('documents.importStrategies.rules')}
|
|
</option>
|
|
<option value="model">
|
|
{t('documents.importStrategies.model')}
|
|
</option>
|
|
<option value="hybrid">
|
|
{t('documents.importStrategies.hybrid')}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
)}
|
|
|
|
{urlOpen && (
|
|
<form
|
|
aria-label={t('documents.urlImport.ariaLabel')}
|
|
className="knowledge-documents__url-form"
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
const value = url.trim()
|
|
if (!value) {
|
|
setError(t('validation.urlRequired'))
|
|
return
|
|
}
|
|
let parsed: URL
|
|
try {
|
|
parsed = new URL(value)
|
|
} catch {
|
|
setError(t('validation.urlInvalid'))
|
|
return
|
|
}
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
setError(t('validation.urlProtocol'))
|
|
return
|
|
}
|
|
void run(
|
|
'url',
|
|
() =>
|
|
onImportUrl(
|
|
library.id,
|
|
parsed.href,
|
|
importGraphStrategy
|
|
)
|
|
).then((succeeded) => {
|
|
if (succeeded) {
|
|
setUrl('')
|
|
setUrlOpen(false)
|
|
}
|
|
})
|
|
}}
|
|
>
|
|
<input
|
|
aria-label={t('documents.urlImport.addressAriaLabel')}
|
|
onChange={(event) => setUrl(event.currentTarget.value)}
|
|
placeholder="https://"
|
|
type="url"
|
|
value={url}
|
|
/>
|
|
<button
|
|
className="primary-button"
|
|
disabled={pending.has('url')}
|
|
type="submit"
|
|
>
|
|
{t('actions.import')}
|
|
</button>
|
|
<button
|
|
aria-label={t('documents.urlImport.closeAriaLabel')}
|
|
className="secondary-button"
|
|
onClick={() => setUrlOpen(false)}
|
|
type="button"
|
|
>
|
|
<X aria-hidden="true" size={15} />
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
<div
|
|
className={`knowledge-documents__drop-zone${
|
|
dragging ? ' knowledge-documents__drop-zone--dragging' : ''
|
|
}`}
|
|
onDragEnter={(event) => {
|
|
event.preventDefault()
|
|
setDragging(true)
|
|
}}
|
|
onDragLeave={(event) => {
|
|
if (event.currentTarget === event.target) {
|
|
setDragging(false)
|
|
}
|
|
}}
|
|
onDragOver={(event) => event.preventDefault()}
|
|
onDrop={(event) => {
|
|
event.preventDefault()
|
|
setDragging(false)
|
|
importFiles(Array.from(event.dataTransfer.files))
|
|
}}
|
|
>
|
|
<UploadCloud aria-hidden="true" size={16} />
|
|
<div>
|
|
{t('documents.dropFiles', { name: library.name })}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<p
|
|
aria-live="polite"
|
|
className="knowledge-inline-error"
|
|
role="alert"
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div
|
|
className="knowledge-documents__toolbar"
|
|
>
|
|
<label
|
|
className="knowledge-documents__search"
|
|
>
|
|
<Search
|
|
aria-hidden="true"
|
|
size={15}
|
|
/>
|
|
<span className="sr-only">
|
|
{t('documents.search.label')}
|
|
</span>
|
|
<input
|
|
aria-label={t('documents.search.label')}
|
|
onChange={(event) => setQuery(event.currentTarget.value)}
|
|
placeholder={t('documents.search.placeholder')}
|
|
type="search"
|
|
value={query}
|
|
/>
|
|
</label>
|
|
</div>
|
|
{rows.length === 0 ? (
|
|
<div className="knowledge-documents__empty">
|
|
{documents.length === 0 && sources.length === 0
|
|
? t('documents.table.empty')
|
|
: t('documents.table.noResults')}
|
|
</div>
|
|
) : (
|
|
<div className={documentWindow.windowed ? 'knowledge-documents__table-scroll knowledge-documents__table-scroll--windowed' : 'knowledge-documents__table-scroll'}>
|
|
<table aria-label={t('documents.table.title')} aria-rowcount={documentWindow.windowed ? rows.length + 1 : undefined}>
|
|
<thead>
|
|
<tr aria-rowindex={documentWindow.windowed ? 1 : undefined}>
|
|
<th>
|
|
{t('documents.table.columns.document')}
|
|
</th>
|
|
<th>
|
|
{t('documents.table.columns.processingStatus')}
|
|
</th>
|
|
<th className="knowledge-document-metric">
|
|
{t('documents.table.columns.chunks')}
|
|
</th>
|
|
<th className="knowledge-document-metric">
|
|
{t('documents.table.columns.size')}
|
|
</th>
|
|
<th>
|
|
{t('documents.table.columns.actions')}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody ref={tableBodyRef} onBlur={documentWindow.onBlur} onFocus={documentWindow.onFocus}>
|
|
{documentWindow.segments.map((segment) => {
|
|
if (segment.kind === 'spacer') return (
|
|
<ListWindowTableSpacer className="knowledge-documents__spacer" columns={5} height={segment.height} key={`spacer:${segment.key}`} />
|
|
)
|
|
const { key, source, document, grouped } = rows[segment.index]!
|
|
// The header row is row 1.
|
|
const rowProps = documentWindow.windowed
|
|
? { 'aria-rowindex': segment.index + 2, 'data-knowledge-document-row': key, ref: documentWindow.rowRef(key) }
|
|
: {}
|
|
if (!document && source) return (
|
|
<tr key={key} className="knowledge-source-group" {...rowProps}>
|
|
<td colSpan={5}>
|
|
<div className="knowledge-source-row">
|
|
<div className="knowledge-source-row__main">
|
|
<div className="knowledge-source-row__identity">
|
|
{source.kind === 'directory' ? <FolderOpen aria-hidden="true" size={16} /> : source.kind === 'url' ? <Link2 aria-hidden="true" size={16} /> : <FileText aria-hidden="true" size={16} />}
|
|
<strong title={source.name}>{source.name}</strong>
|
|
{sourceStatus(source)}
|
|
</div>
|
|
<div className="knowledge-source-row__meta">{t('documents.sourceMeta', {
|
|
count: formatNumber(source.documentCount, locale),
|
|
time: formatTime(source.lastSyncedAt, locale, t)
|
|
})}</div>
|
|
{source.kind === 'url' && source.location && <div className="knowledge-document-path">{formatDocumentLocation(source.location, t)}</div>}
|
|
{source.error && <div className="knowledge-source-row__error">{source.error}</div>}
|
|
</div>
|
|
<div className="knowledge-document-actions">
|
|
<button type="button" className="secondary-button" disabled={pending.has(source.id)}
|
|
onClick={source.status === 'failed' || !tasks?.some((task) => task.sourceId === source.id) ? sourceActions(source)[0]!.onClick : () => onViewTasks({ sourceId: source.id })}>
|
|
{source.status === 'failed' || !tasks?.some((task) => task.sourceId === source.id) ? sourceActions(source)[0]!.label : t('actions.viewTasks')}
|
|
</button>
|
|
<KnowledgeActionsMenu label={t('documents.moreActionsFor', { name: source.name })} actions={[
|
|
...(tasks?.some((task) => task.sourceId === source.id) ? [{ label: t('actions.viewTasks'), onClick: () => onViewTasks({ sourceId: source.id }) }] : []),
|
|
...sourceActions(source)
|
|
]} />
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
if (!document) return null
|
|
const relatedTasks = tasks?.filter(
|
|
(task) => task.documentId === document.id || (source && task.sourceId === source.id)
|
|
) ?? []
|
|
const activeTask = relatedTasks.find(
|
|
(task) =>
|
|
task.documentId === document.id &&
|
|
(task.status === 'queued' || task.status === 'running')
|
|
)
|
|
return (
|
|
<tr key={key} className={grouped ? 'knowledge-document-row--grouped' : undefined} {...rowProps}>
|
|
<td>
|
|
<strong>{document.name}</strong>
|
|
{(document.path || (source?.kind === 'url' && source.location)) && (
|
|
<div className="knowledge-document-path">
|
|
{formatDocumentLocation(source?.kind === 'url' && source.location ? source.location : document.path!, t)}
|
|
</div>
|
|
)}
|
|
{source && <div className="knowledge-source-row__meta">{t('documents.lastSynced', { time: formatTime(source.lastSyncedAt, locale, t) })}</div>}
|
|
</td>
|
|
<td>
|
|
<div className="knowledge-document-status">
|
|
{source && sourceStatus(source)}
|
|
<span
|
|
className={`knowledge-document-status__durable knowledge-document-status__durable--${document.status}`}
|
|
>
|
|
{t(documentStatusLabelKeys[document.status])}
|
|
</span>
|
|
<span className="knowledge-document-status__channels">
|
|
<span>
|
|
{t(
|
|
`indexStatuses.text.${
|
|
document.textIndexStatus ??
|
|
(document.status === 'ready'
|
|
? 'ready'
|
|
: document.status === 'failed'
|
|
? 'failed'
|
|
: 'waiting')
|
|
}`
|
|
)}
|
|
</span>
|
|
<span>
|
|
{t(
|
|
`indexStatuses.vector.${
|
|
document.vectorIndexStatus ?? 'disabled'
|
|
}`
|
|
)}
|
|
</span>
|
|
{document.graphIndexStatus !== undefined && (
|
|
<span>
|
|
{t(
|
|
`indexStatuses.graph.${document.graphIndexStatus}`
|
|
)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{activeTask && (
|
|
<span className="knowledge-document-status__active">
|
|
{t(taskStageLabelKeys[activeTask.stage])}
|
|
{' · '}
|
|
{formatPercent(
|
|
clampProgress(activeTask.progress) / 100,
|
|
locale
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{document.error && (
|
|
<div className="knowledge-document-error">
|
|
{document.error}
|
|
</div>
|
|
)}
|
|
{source?.error && source.error !== document.error && <div className="knowledge-source-row__error">{source.error}</div>}
|
|
</td>
|
|
<td className="knowledge-document-metric">
|
|
{document.chunkCount === undefined
|
|
? '—'
|
|
: formatNumber(document.chunkCount, locale)}
|
|
</td>
|
|
<td className="knowledge-document-metric">
|
|
{formatSize(document.size, locale, t)}
|
|
</td>
|
|
<td>
|
|
<div className="knowledge-document-actions">
|
|
{document.resultId && <button type="button" className="secondary-button" onClick={(event) => {
|
|
const scroll = event.currentTarget.closest<HTMLElement>('.workspace-panel-scroll') ?? undefined
|
|
previewOriginRef.current = { trigger: event.currentTarget, scroll, top: scroll?.scrollTop ?? 0 }
|
|
setPreviewReturnKey(key)
|
|
setViewedDocumentId(document.id)
|
|
requestAnimationFrame(() => { previewBackRef.current?.focus(); if (scroll) scroll.scrollTop = 0 })
|
|
}}>{t('documents.viewResult')}</button>}
|
|
{!document.resultId && document.status === 'failed' ? <button type="button" className="secondary-button" aria-label={t('documents.actions.retryDocument', { name: document.name })} disabled={pending.has(`retry:${document.id}`) || Boolean(activeTask)} onClick={() => void run(`retry:${document.id}`, () => onRebuildDocument(library.id, document.id))}>{t('actions.retryDocument')}</button> : !document.resultId && relatedTasks.length > 0 ? (
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() =>
|
|
onViewTasks({ documentId: document.id, ...(source ? { sourceId: source.id } : {}) })
|
|
}
|
|
type="button"
|
|
>
|
|
<ListChecks aria-hidden="true" size={14} />
|
|
{t('actions.viewTasks')}
|
|
</button>
|
|
) : !document.resultId ? <button
|
|
aria-label={t(
|
|
'documents.actions.openDocumentSource',
|
|
{ name: document.name }
|
|
)}
|
|
className="secondary-button"
|
|
disabled={pending.has(`open:${document.id}`)}
|
|
onClick={() =>
|
|
void run(`open:${document.id}`, () =>
|
|
onOpenDocumentSource(
|
|
library.id,
|
|
document.id
|
|
)
|
|
)
|
|
}
|
|
type="button"
|
|
>
|
|
{t('actions.openSource')}
|
|
</button> : null}
|
|
<KnowledgeActionsMenu label={t('documents.moreActionsFor', { name: document.name })} actions={documentActions(document, source, source?.id)} />
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
{removingSource && (
|
|
<RemoveSourceDialog
|
|
onCancel={() => setRemovingSource(undefined)}
|
|
onConfirm={() => onRemoveSource(removingSource.id)}
|
|
source={removingSource}
|
|
storageMode={library.storageMode}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|