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' const sourceStatusLabelKeys = { queued: 'sourceStatuses.queued', syncing: 'sourceStatuses.syncing', paused: 'sourceStatuses.paused', ready: 'sourceStatuses.ready', failed: 'sourceStatuses.failed' } as const satisfies Record const documentStatusLabelKeys = { queued: 'documentStatuses.queued', parsing: 'documentStatuses.parsing', indexing: 'documentStatuses.indexing', ready: 'documentStatuses.ready', failed: 'documentStatuses.failed' } as const satisfies Record 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(null) const [viewedDocumentId, setViewedDocumentId] = useState() const previewBackRef = useRef(null) 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>(() => new Map()) const [error, setError] = useState() const [removingSource, setRemovingSource] = useState() const [askStrategy, setAskStrategy] = useState>('hybrid') const importGraphStrategy = library.graphStrategy === 'ask' ? askStrategy : undefined const run = async ( id: string, action: () => void | Promise ): Promise => { 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() 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 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 => ( {t(sourceStatusLabelKeys[source.status])} {source.status === 'syncing' && source.progress !== undefined ? ` · ${formatPercent(clampProgress(source.progress) / 100, locale)}` : ''} ) 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 (
{viewedDocument?.resultId &&

{viewedDocument.name}

{library.name} {viewedDocument.path &&
{formatDocumentLocation(viewedDocument.path, t)}
}
{pending.has(`retry:${viewedDocument.id}`) &&

{t('documents.reparsing')}

} {error &&

{error}

}
} {removingSource && ( setRemovingSource(undefined)} onConfirm={() => onRemoveSource(removingSource.id)} source={removingSource} storageMode={library.storageMode} /> )}
) }