Files
goodbuddy/src/renderer/src/knowledge-workspace/KnowledgeTasksView.tsx
T
mesalogo ceb275bdb8 fix: clarify knowledge document actions and task history
Restore URL identity in merged rows and replace crowded document actions with a primary entry and accessible menu. Organize preview navigation and maintenance actions, and show task history as batches with matching counts and filters.

Keep preview task scope consistent for multi-document sources and track pending operations independently so one source does not block another source's pause action. Include responsive Electron layout and behavioral regressions, bilingual copy, and design documentation.

Validation: focused regressions, typecheck, lint, and diff checks passed. Electron fixtures cover light/dark and wide/narrow layouts. Full tests reported two previously known HeartbeatCenter assertions; real import and parsing integration was not rerun.

Release note: 知识库文档列表保留来源网址,统一查看入口和更多操作,并整理解析结果页。任务历史按批次展示,筛选计数与当前对象一致,不同来源的操作互不阻塞。
2026-09-27 12:55:50 +08:00

481 lines
16 KiB
TypeScript

import type { KnowledgeTaskItem, KnowledgeWorkspaceProps } from './types'
import type { TFunction } from 'i18next'
import { getLocaleFormatters, resolvedLocale, formatNumber, formatPercent } from './formatting'
import { useTranslation } from 'react-i18next'
import { useState, useMemo } from 'react'
import { toErrorMessage, taskStageLabelKeys, clampProgress } from './helpers'
import { ChevronDown, ChevronRight, LoaderCircle, X, RotateCcw, ListChecks } from 'lucide-react'
import { EmptyState, SegmentedControl } from '../WorkspacePrimitives'
import { InlineHelp } from '../InlineHelp'
const taskKindLabelKeys = {
'source-sync': 'taskKinds.sourceSync',
'document-process': 'taskKinds.documentProcess',
'document-rebuild': 'taskKinds.documentRebuild',
'library-rebuild': 'taskKinds.libraryRebuild',
'embedding-rebuild': 'taskKinds.embeddingRebuild',
'graph-rebuild': 'taskKinds.graphRebuild',
parsing: 'taskKinds.parsing',
embedding: 'taskKinds.embedding',
graph: 'taskKinds.graph'
} as const satisfies Record<KnowledgeTaskItem['kind'], string>
const taskStatusLabelKeys = {
queued: 'taskStatuses.queued',
running: 'taskStatuses.running',
succeeded: 'taskStatuses.succeeded',
failed: 'taskStatuses.failed',
cancelled: 'taskStatuses.cancelled',
skipped: 'taskStatuses.skipped',
interrupted: 'taskStatuses.interrupted'
} as const satisfies Record<KnowledgeTaskItem['status'], string>
const taskScopeLabelKeys = {
library: 'taskScopes.library',
source: 'taskScopes.source',
document: 'taskScopes.document'
} as const satisfies Record<KnowledgeTaskItem['scope'], string>
function formatDateTime(
value: string,
locale: string,
t: TFunction<'knowledge'>
): string {
const date = new Date(value)
return Number.isNaN(date.getTime())
? t('format.unknownTime')
: getLocaleFormatters(locale).dateTime.format(date)
}
type KnowledgeTaskFilter = 'all' | 'active' | 'failed' | 'history'
export type KnowledgeTaskContext = {
documentId?: string
sourceId?: string
}
type KnowledgeTaskAction = 'cancel' | 'retry'
type KnowledgeTaskActionError = {
action: KnowledgeTaskAction
message: string
}
export function KnowledgeTasksView({
context,
onCancelTask,
onClearContext,
onRetryTask,
tasks
}: {
context?: KnowledgeTaskContext
onCancelTask: KnowledgeWorkspaceProps['onCancelTask']
onClearContext: () => void
onRetryTask: KnowledgeWorkspaceProps['onRetryTask']
tasks: readonly KnowledgeTaskItem[]
}): React.JSX.Element {
const { i18n, t } = useTranslation('knowledge')
const locale = resolvedLocale(i18n.resolvedLanguage ?? i18n.language)
const [filter, setFilter] = useState<KnowledgeTaskFilter>('all')
const [expanded, setExpanded] = useState<ReadonlySet<string>>(
() =>
new Set(
tasks
.filter(
(task) =>
task.status === 'queued' || task.status === 'running'
)
.map((task) => task.parentTaskId)
.filter((id): id is string => Boolean(id))
)
)
const [pendingActions, setPendingActions] = useState<
ReadonlyMap<string, KnowledgeTaskAction>
>(() => new Map())
const [actionErrors, setActionErrors] = useState<
ReadonlyMap<string, KnowledgeTaskActionError>
>(() => new Map())
const taskById = useMemo(
() => new Map(tasks.map((task) => [task.id, task])),
[tasks]
)
const childrenByParent = useMemo(() => {
const result = new Map<string, KnowledgeTaskItem[]>()
for (const task of tasks) {
if (!task.parentTaskId || !taskById.has(task.parentTaskId)) {
continue
}
const children = result.get(task.parentTaskId) ?? []
children.push(task)
result.set(task.parentTaskId, children)
}
return result
}, [taskById, tasks])
const contextTasks = useMemo(
() =>
tasks.filter(
(task) =>
(!context?.documentId && !context?.sourceId) ||
(Boolean(context?.documentId) && task.documentId === context?.documentId) ||
(Boolean(context?.sourceId) && task.sourceId === context?.sourceId)
),
[context, tasks]
)
const visibleIds = useMemo(() => {
const result = new Set(contextTasks.map((task) => task.id))
for (const task of contextTasks) {
let parentId = task.parentTaskId
while (parentId && taskById.has(parentId)) {
result.add(parentId)
parentId = taskById.get(parentId)?.parentTaskId
}
}
return result
}, [contextTasks, taskById])
const batches = useMemo(() => {
const collect = (task: KnowledgeTaskItem): KnowledgeTaskItem[] => [task,
...(childrenByParent.get(task.id) ?? []).filter((child) => visibleIds.has(child.id)).flatMap(collect)]
return tasks.filter((task) => visibleIds.has(task.id) && (!task.parentTaskId || !taskById.has(task.parentTaskId)))
.map((task) => {
const members = collect(task)
return { task, active: members.some((item) => item.status === 'queued' || item.status === 'running'),
failed: members.some((item) => item.status === 'failed' || item.status === 'interrupted') }
}).sort((a, b) => b.task.createdAt.localeCompare(a.task.createdAt))
}, [tasks, taskById, childrenByParent, visibleIds])
const activeCount = batches.filter((batch) => batch.active).length
const failedCount = batches.filter((batch) => batch.failed).length
const historyCount = batches.filter((batch) => !batch.active).length
const topLevelTasks = useMemo(() => batches.filter((batch) => filter === 'all' || (filter === 'active' ? batch.active : filter === 'failed' ? batch.failed : !batch.active)), [batches, filter])
const filterOptions = [
{ value: 'all', label: t('tasks.filters.all') },
{ value: 'active', label: t('tasks.filters.active') },
{ value: 'failed', label: t('tasks.filters.failed') },
{ value: 'history', label: t('tasks.filters.history') }
] as const
const runTaskAction = async (
taskId: string,
action: KnowledgeTaskAction,
invoke: (taskId: string) => void | Promise<void>
): Promise<void> => {
setPendingActions((current) => {
const next = new Map(current)
next.set(taskId, action)
return next
})
setActionErrors((current) => {
if (!current.has(taskId)) {
return current
}
const next = new Map(current)
next.delete(taskId)
return next
})
try {
await invoke(taskId)
} catch (reason) {
setActionErrors((current) => {
const next = new Map(current)
next.set(taskId, {
action,
message: toErrorMessage(reason, t)
})
return next
})
} finally {
setPendingActions((current) => {
const next = new Map(current)
next.delete(taskId)
return next
})
}
}
const renderTask = (
task: KnowledgeTaskItem,
nested = false,
containsFailures = false
): React.JSX.Element => {
const children = (childrenByParent.get(task.id) ?? []).filter(
(child) => visibleIds.has(child.id)
)
const hasChildren = children.length > 0
const isExpanded = expanded.has(task.id)
const detailsId = `knowledge-task-${task.id}-details`
const actionErrorId = `knowledge-task-${task.id}-action-error`
const pendingAction = pendingActions.get(task.id)
const actionError = actionErrors.get(task.id)
const active = task.status === 'queued' || task.status === 'running'
const summary = [
task.message || (active ? t('tasks.waiting') : ''),
hasChildren ? t('tasks.childCount', { count: children.length }) : '',
task.attempt > 1 ? t('tasks.attempt', { count: task.attempt }) : ''
].filter(Boolean).join(' · ')
const time =
task.completedAt ??
task.updatedAt ??
task.startedAt ??
task.createdAt
return (
<li
className={`knowledge-task${
nested ? ' knowledge-task--child' : ''
}`}
data-status={task.status}
key={task.id}
>
<div className="knowledge-task__heading">
<div className="knowledge-task__identity">
{hasChildren ? (
<button
aria-controls={detailsId}
aria-expanded={isExpanded}
aria-label={t(
isExpanded
? 'tasks.actions.collapse'
: 'tasks.actions.expand',
{ name: task.documentName }
)}
className="knowledge-task__disclosure"
onClick={() =>
setExpanded((current) => {
const next = new Set(current)
if (next.has(task.id)) {
next.delete(task.id)
} else {
next.add(task.id)
}
return next
})
}
type="button"
>
{isExpanded ? (
<ChevronDown aria-hidden="true" size={16} />
) : (
<ChevronRight aria-hidden="true" size={16} />
)}
</button>
) : (
<span
aria-hidden="true"
className="knowledge-task__disclosure-placeholder"
/>
)}
<div>
<strong>{task.documentName}</strong>
<span>
<span>{t(taskKindLabelKeys[task.kind])}</span>
{' · '}
<span>{t(taskScopeLabelKeys[task.scope])}</span>
</span>
</div>
</div>
<span
className={`knowledge-task__status knowledge-task__status--${task.status}`}
>
{t(taskStatusLabelKeys[task.status])}
</span>
</div>
{containsFailures && task.status !== 'failed' && task.status !== 'interrupted' && <p className="knowledge-inline-error">{t('tasks.containsFailures')}</p>}
{(active || task.status === 'failed' || task.status === 'interrupted') && <div className="knowledge-task__stage">
<strong>{t(active ? 'tasks.currentStage' : 'tasks.stoppedStage')}</strong>
<span>{t(taskStageLabelKeys[task.stage])}</span>
{task.completedItems !== undefined &&
task.totalItems !== undefined && (
<span>
{t('tasks.itemProgress', {
completed: formatNumber(task.completedItems, locale),
total: formatNumber(task.totalItems, locale)
})}
</span>
)}
</div>}
{active && <div className="knowledge-task__progress">
<progress
aria-label={t('tasks.progressAriaLabel', {
name: task.documentName,
kind: t(taskKindLabelKeys[task.kind])
})}
max={100}
value={clampProgress(task.progress)}
/>
<span>
{formatPercent(clampProgress(task.progress) / 100, locale)}
</span>
</div>}
<div className="knowledge-task__meta">
<span>{summary}</span>
<time dateTime={time}>
{formatDateTime(time, locale, t)}
</time>
</div>
{task.error && (
<div className="knowledge-task__error">
<strong>{t('tasks.errorTitle')}</strong>
<span>{task.error.message}</span>
<span>
{task.error.remedy ?? t('tasks.defaultRemedy')}
</span>
</div>
)}
{(task.canCancel || task.canRetry) && (
<div
aria-describedby={actionError ? actionErrorId : undefined}
className="knowledge-task__actions"
>
{task.canCancel && (
<button
className="secondary-button"
disabled={pendingAction !== undefined}
onClick={() =>
void runTaskAction(
task.id,
'cancel',
onCancelTask
)
}
type="button"
>
{pendingAction === 'cancel' ? (
<LoaderCircle aria-hidden="true" size={14} />
) : (
<X aria-hidden="true" size={14} />
)}
{pendingAction === 'cancel'
? t('tasks.actions.cancelling')
: t('tasks.actions.cancel')}
</button>
)}
{task.canRetry && (
<button
className="secondary-button"
disabled={pendingAction !== undefined}
onClick={() =>
void runTaskAction(task.id, 'retry', onRetryTask)
}
type="button"
>
{pendingAction === 'retry' ? (
<LoaderCircle aria-hidden="true" size={14} />
) : (
<RotateCcw aria-hidden="true" size={14} />
)}
{pendingAction === 'retry'
? t('tasks.actions.retrying')
: t('tasks.actions.retry')}
</button>
)}
</div>
)}
{actionError && (
<div
className="knowledge-task__action-error"
id={actionErrorId}
role="alert"
>
<strong>
{t(
actionError.action === 'cancel'
? 'tasks.actionErrors.cancelTitle'
: 'tasks.actionErrors.retryTitle'
)}
</strong>
<span>{actionError.message}</span>
<span>{t('tasks.actionErrors.recovery')}</span>
</div>
)}
{hasChildren && isExpanded && (
<ol className="knowledge-task__children" id={detailsId}>
{children.map((child) => renderTask(child, true))}
</ol>
)}
</li>
)
}
if (tasks.length === 0) {
return (
<EmptyState
description={t('tasks.emptyDescription')}
icon={<ListChecks size={30} />}
level="section"
title={t('tasks.emptyTitle')}
/>
)
}
return (
<section
aria-labelledby="knowledge-tasks-title"
className="knowledge-tasks"
>
<div className="knowledge-tasks__summary">
<div>
<h3 id="knowledge-tasks-title" className="inline-help-label">
{t('tasks.title')}
<InlineHelp label={t('tasks.title')}>{t('tasks.batchHelp')}</InlineHelp>
</h3>
<p className="knowledge-section-description">
{t('tasks.totalCount', {
count: formatNumber(batches.length, locale)
})}
</p>
</div>
<div className="knowledge-tasks__metrics">
<span>
{t('tasks.activeCount', {
count: formatNumber(activeCount, locale)
})}
</span>
<span>
{t('tasks.failedCount', {
count: formatNumber(failedCount, locale)
})}
</span>
<span>
{t('tasks.historyCount', {
count: formatNumber(historyCount, locale)
})}
</span>
</div>
</div>
<div className="knowledge-tasks__toolbar">
<SegmentedControl
ariaLabel={t('tasks.filters.ariaLabel')}
onChange={(value) => {
setFilter(value)
if (value === 'active' || value === 'failed') setExpanded(new Set(childrenByParent.keys()))
else setExpanded(new Set())
}}
options={filterOptions}
value={filter}
/>
{context && (
<div className="knowledge-tasks__context">
<span>{t('tasks.context.active')}</span>
<button
className="secondary-button"
onClick={onClearContext}
type="button"
>
{t('tasks.context.clear')}
</button>
</div>
)}
</div>
{topLevelTasks.length === 0 ? (
<EmptyState
description={t('tasks.noResultsDescription')}
icon={<ListChecks size={28} />}
level="section"
title={t('tasks.noResultsTitle')}
/>
) : (
<ol className="knowledge-task-list">
{topLevelTasks.map(({ task, failed }) => renderTask(task, false, failed))}
</ol>
)}
</section>
)
}