Files
goodbuddy/src/renderer/src/knowledge-workspace/DocumentsView.tsx
T
mesalogo 67dedc2ed2 fix: simplify knowledge action menus
Remove repeated document names from action labels, shorten the reparse label, and narrow knowledge menus to 220px while preserving other menu widths and viewport bounds. Keep object context on menu triggers and removal confirmations.

Validation: 72 focused and Electron layout tests, typecheck, lint, and diff checks passed.

Release note: 知识库操作菜单去掉重复文件名,将操作简写为重新解析,并收窄菜单宽度,减少换行。
2026-09-27 13:11:09 +08:00

699 lines
29 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'
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 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 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="knowledge-documents__table-scroll">
<table aria-label={t('documents.table.title')}>
<thead>
<tr>
<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>
{rows.map(({ key, source, document, grouped }) => {
if (!document && source) return (
<tr key={key} className="knowledge-source-group">
<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}>
<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 }
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>
)
}