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: 知识库文档列表保留来源网址,统一查看入口和更多操作,并整理解析结果页。任务历史按批次展示,筛选计数与当前对象一致,不同来源的操作互不阻塞。
This commit is contained in:
mesalogo
2026-09-27 12:55:50 +08:00
parent 44cd944601
commit ceb275bdb8
16 changed files with 730 additions and 206 deletions
+5 -3
View File
@@ -23,11 +23,13 @@ export function AnchoredMenu({ anchorRef, id, label, onClose, children }: {
const height = viewport?.height ?? window.innerHeight
const rect = anchor.getBoundingClientRect()
const menuWidth = Math.max(0, Math.min(280, width - 32))
const bottom = Math.max(top + 16, Math.min(rect.top - 8, top + height - 16))
menu.style.width = `${menuWidth}px`
menu.style.maxHeight = `${Math.max(0, bottom - top - 16)}px`
const above = Math.max(0, Math.min(height - 32, rect.top - top - 24))
const below = Math.max(0, Math.min(height - 32, top + height - rect.bottom - 24))
const openBelow = menu.scrollHeight > above && below > above
menu.style.maxHeight = `${openBelow ? below : above}px`
menu.style.left = `${Math.max(left + 16, Math.min(rect.left, left + width - menuWidth - 16))}px`
menu.style.top = `${Math.max(top + 16, bottom - menu.getBoundingClientRect().height)}px`
menu.style.top = `${Math.max(top + 16, openBelow ? rect.bottom + 8 : Math.min(rect.top - 8, top + height - 16) - menu.getBoundingClientRect().height)}px`
})
useLayoutEffect(() => { position() })
useEffect(() => {
+18
View File
@@ -103,3 +103,21 @@ it('anchors upward, clamps to a narrow viewport, repositions on resize and uses
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(isBrowserViewportOccluded(host, new DOMRect(200, 100, 100, 100))).toBe(false)
})
it('flips a top anchor downward and keeps the menu in view when its anchor scrolls offscreen', () => {
vi.stubGlobal('innerWidth', 420)
vi.stubGlobal('innerHeight', 480)
let anchorTop = 20
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return this.getAttribute('role') === 'menu' ? new DOMRect(124, 62, 280, 200) : new DOMRect(360, anchorTop, 34, 34)
})
render(<Harness />)
fireEvent.click(screen.getByText('Launcher'))
const menu = screen.getByRole('menu')
Object.defineProperty(menu, 'scrollHeight', { value: 200, configurable: true })
fireEvent(window, new Event('resize'))
expect(menu).toHaveStyle({ top: '62px', left: '124px', maxHeight: '402px' })
anchorTop = -100
fireEvent(window, new Event('scroll'))
expect(menu).toHaveStyle({ top: '16px', maxHeight: '448px' })
})
@@ -51,3 +51,21 @@ it('starts with zero selected images and retains selections across tabs until ex
await waitFor(() => expect(add).toHaveBeenCalledWith('conversation', result.id, [result.images[0]!.id]))
expect(screen.getByRole('checkbox')).not.toBeChecked()
})
it('keeps knowledge reading separate from image actions without changing the default preview', async () => {
render(<DocumentResultPreview resultId={result.id} allowAddImages conversationId="conversation" showOpenOriginal={false} imageActionsInImagesTab />)
expect(await screen.findByText('Invoice')).toBeVisible()
expect(screen.queryByRole('button', { name: '打开原文件' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '添加所选图片' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '图片(1)' }))
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(screen.getByRole('tab', { name: '正文' }))
expect(screen.queryByRole('button', { name: '添加所选图片' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '图片(1)' }))
expect(screen.getByRole('checkbox')).toBeChecked()
fireEvent.click(screen.getByRole('button', { name: '添加所选图片' }))
await waitFor(() => {
expect(add).toHaveBeenCalledWith('conversation', result.id, [result.images[0]!.id])
expect(screen.getByRole('checkbox')).not.toBeChecked()
})
})
+4 -4
View File
@@ -63,7 +63,7 @@ function SectionContent({ content, result, onImage }: {
} }}>{piece.replace(/<\/?(?:div|p|span|h[1-6])\b[^>]*>/giu, '\n').replace(/<br\s*\/?\s*>/giu, '\n\n')}</ReactMarkdown>)}</div>
}
export function DocumentResultPreview({ resultId, allowAddImages = false, conversationId }: { resultId: string; allowAddImages?: boolean; conversationId?: string }): React.JSX.Element {
export function DocumentResultPreview({ resultId, allowAddImages = false, conversationId, showOpenOriginal = true, imageActionsInImagesTab = false }: { resultId: string; allowAddImages?: boolean; conversationId?: string; showOpenOriginal?: boolean; imageActionsInImagesTab?: boolean }): React.JSX.Element {
const conversationContext = useContext(DocumentConversationContext)
const [result, setResult] = useState<DocumentResult>()
const [error, setError] = useState('')
@@ -128,10 +128,10 @@ export function DocumentResultPreview({ resultId, allowAddImages = false, conver
if (!result) return <p role="status">正在读取解析结果</p>
return <div className="document-result-preview">
<div className="document-result-actions"><strong>{({ complete: '解析完成', partial: '部分解析', 'images-only': '仅图片,无可发送正文' })[result.completeness]}</strong>
<button type="button" className="secondary-button" onClick={() => {
{showOpenOriginal && <button type="button" className="secondary-button" onClick={() => {
setActionError('')
void window.goodbuddy.documentParsing!.openResultOriginal(result.id).catch((reason: unknown) => setActionError(reason instanceof Error ? reason.message : '原文件不可用'))
}}>打开原文件</button></div>
}}>打开原文件</button>}</div>
{actionError && <p role="alert">{actionError}</p>}
<PageTabs ariaLabel="解析结果" idPrefix={prefix} variant="segmented" value={tab} onChange={setTab}
tabs={[{ id: 'text', label: '正文' }, { id: 'images', label: `图片(${result.images.length})` }, { id: 'details', label: '详情' }]} />
@@ -172,7 +172,7 @@ export function DocumentResultPreview({ resultId, allowAddImages = false, conver
</>}
{result.warnings.length > 0 && <div className="settings-warning"><strong>解析警告</strong><ul>{result.warnings.map((warning, index) => <li key={index}>{warning}</li>)}</ul></div>}
</section>
{allowAddImages && <div className="document-result-actions">
{allowAddImages && (!imageActionsInImagesTab || tab === 'images') && <div className="document-result-actions">
<label className="field"><span>添加到会话草稿</span><select value={target} disabled={adding} onChange={(event) => { setTarget(event.target.value); setDraft([]) }}>
<option value="">选择会话后添加</option>{conversations.map((conversation) => <option key={conversation.id} value={conversation.id}>{conversation.projectId ? projectNames[conversation.projectId] ?? '项目不可用' : '未绑定项目'} · {conversation.title}</option>)}
</select></label>
+229 -30
View File
@@ -263,6 +263,74 @@ function createProps(
}
describe('KnowledgeWorkspace', () => {
it('distinguishes merged URLs with the same title and keeps secondary actions in a keyboard menu', async () => {
const base = createProps()
const urls = ['https://example.com/guide?edition=one', 'https://example.com/guide?edition=two']
const props = createProps({
sources: urls.map((location, index) => ({ ...base.sources[0]!, id: `url-${index}`, kind: 'url', name: 'Guide', location })),
documents: urls.map((path, index) => ({ ...base.documents[0]!, id: `doc-${index}`, sourceId: `url-${index}`, name: 'Guide', path, resultId: `result-${index}` }))
})
render(<KnowledgeWorkspace {...props} />)
const table = screen.getByRole('table', { name: '文档与来源' })
const rows = within(table).getAllByRole('row').slice(1)
for (const [index, row] of rows.entries()) {
expect(within(row).getByText(urls[index]!)).toBeVisible()
expect(within(row).getAllByRole('button')).toHaveLength(2)
expect(within(row).getByRole('button', { name: '查看解析结果' })).toBeVisible()
expect(row.querySelector('details')).toBeNull()
}
const trigger = within(rows[1]!).getByRole('button', { name: 'Guide 的更多操作' })
fireEvent.click(trigger)
expect(screen.getByRole('menuitem', { name: '打开 Guide 的原始来源' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'End' })
expect(screen.getByRole('menuitem', { name: '移除来源 Guide' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(trigger).toHaveFocus()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: '打开 Guide 的原始来源' }))
await waitFor(() => expect(props.onOpenDocumentSource).toHaveBeenCalledWith(library.id, 'doc-1'))
fireEvent.change(screen.getByRole('searchbox', { name: '搜索文档与来源' }), { target: { value: 'edition=two' } })
expect(within(table).getAllByRole('row')).toHaveLength(2)
expect(screen.queryByText(urls[0]!)).not.toBeInTheDocument()
})
it('counts contextual batches once and keeps finished failures in history without active batch fragments', () => {
const base = createProps()
const task = {
libraryId: library.id, scope: 'document' as const, kind: 'document-rebuild' as const,
stage: 'finalizing' as const, status: 'succeeded' as const, progress: 100,
attempt: 1, canCancel: false, canRetry: false,
createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z'
}
render(<KnowledgeWorkspace {...createProps({ sources: [{ ...base.sources[0]!, kind: 'file' }], tasks: [
{ ...task, id: 'old', documentName: 'Finished batch', documentId: 'document-1', status: 'failed', canRetry: true, error: { message: 'Index failed' } },
{ ...task, id: 'old-stage', parentTaskId: 'old', documentName: 'Finished stage', documentId: 'document-1', kind: 'parsing' },
{ ...task, id: 'active', documentName: 'Current batch', documentId: 'document-1', status: 'running', canCancel: true, attempt: 2, createdAt: '2026-09-27T00:00:00Z' },
{ ...task, id: 'active-stage', parentTaskId: 'active', documentName: 'Successful stage in active batch', documentId: 'document-1', kind: 'parsing' },
{ ...task, id: 'elsewhere', documentName: 'Other document', documentId: 'other' }
] })} />)
fireEvent.click(screen.getByRole('button', { name: '查看任务' }))
expect(screen.getByText('共 2 个任务批次')).toBeVisible()
expect(screen.getByText('进行中 1')).toBeVisible()
expect(screen.getByText('失败 1')).toBeVisible()
expect(screen.getByText('历史 1')).toBeVisible()
expect(document.querySelector('.knowledge-task-list > li strong')).toHaveTextContent('Current batch')
fireEvent.click(screen.getByRole('button', { name: '历史' }))
expect(screen.getByText('Finished batch')).toBeVisible()
expect(screen.queryByText('Current batch')).not.toBeInTheDocument()
expect(screen.queryByText('Successful stage in active batch')).not.toBeInTheDocument()
expect(screen.queryByText('Finished stage')).not.toBeInTheDocument()
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText('等待处理')).not.toBeInTheDocument()
expect(screen.getByText('Index failed')).toBeVisible()
expect(screen.getByRole('button', { name: '重试任务' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '展开 Finished batch 的阶段任务' }))
expect(screen.getByText('Finished stage')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '清除对象筛选' }))
expect(screen.getByText('共 3 个任务批次')).toBeVisible()
})
it.each(['file', 'url'] as const)('merges a single %s source and document, and searches both identities', async (kind) => {
const base = createProps()
const source = { ...base.sources[0]!, kind, name: 'source-alias', location: 'https://example.com/source-location' }
@@ -284,10 +352,12 @@ describe('KnowledgeWorkspace', () => {
fireEvent.change(search, { target: { value: '' } })
view.rerender(<KnowledgeWorkspace {...props} sources={[{ ...source, name: document.name }]} />)
expect(screen.getAllByText('manual.pdf')).toHaveLength(1)
fireEvent.click(screen.getByText('更多操作'))
fireEvent.click(screen.getByRole('button', { name: '同步 manual.pdf' }))
const openMenu = (): void => { fireEvent.click(screen.getByRole('button', { name: 'manual.pdf 的更多操作' })) }
openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: '同步 manual.pdf' }))
await waitFor(() => expect(props.onSyncSource).toHaveBeenCalledWith(source.id))
fireEvent.click(screen.getByRole('button', { name: '使用当前设置重新解析' }))
openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: '使用当前设置重新解析' }))
await waitFor(() => expect(props.onRebuildDocument).toHaveBeenCalledWith(library.id, document.id))
for (const [status, name, callback] of [
['syncing', '暂停', props.onPauseSource],
@@ -295,10 +365,11 @@ describe('KnowledgeWorkspace', () => {
['paused', '同步', props.onSyncSource]
] as const) {
view.rerender(<KnowledgeWorkspace {...props} sources={[{ ...source, name: document.name, status }]} />)
fireEvent.click(screen.getByRole('button', { name: `${name} manual.pdf` }))
openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: `${name} manual.pdf` }))
await waitFor(() => {
expect(callback).toHaveBeenCalledWith(source.id)
expect(screen.getByRole('button', { name: `${name} manual.pdf` })).toBeEnabled()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
}
})
@@ -347,7 +418,8 @@ describe('KnowledgeWorkspace', () => {
fireEvent.change(search, { target: { value: 'second.md' } })
expect(within(table).getAllByRole('row')).toHaveLength(3)
expect(within(table).getByText('产品手册')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '移除来源 产品手册' }))
fireEvent.click(screen.getByRole('button', { name: '产品手册 的更多操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '移除来源 产品手册' }))
expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription(
'将删除此来源的 2 篇文档、检索索引、图谱证据和应用托管副本。磁盘上的原始文件不会改变。'
)
@@ -385,7 +457,7 @@ describe('KnowledgeWorkspace', () => {
await waitFor(() => expect(props.onImportFiles).toHaveBeenCalledWith(library.id, [file], undefined))
})
it('opens both source and document tasks from one merged task action', () => {
it.each(['list', 'preview'] as const)('opens both source and document tasks from a merged %s action', async (entry) => {
const base = createProps()
const task = {
libraryId: library.id, kind: 'document-process' as const, stage: 'parsing' as const,
@@ -394,19 +466,151 @@ describe('KnowledgeWorkspace', () => {
}
render(<KnowledgeWorkspace {...createProps({
sources: [{ ...base.sources[0]!, kind: 'file' }],
documents: [{ ...base.documents[0]!, resultId: 'saved-result' }],
tasks: [
{ ...task, id: 'source-task', sourceId: 'source-1', scope: 'source', documentName: 'Source task' },
{ ...task, id: 'document-task', documentId: 'document-1', scope: 'document', documentName: 'Document task' },
{ ...task, id: 'unrelated-task', documentId: 'other', scope: 'document', documentName: 'Unrelated task' }
]
})} />)
expect(screen.getAllByRole('button', { name: '查看任务' })).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: '查看任务' }))
if (entry === 'preview') {
fireEvent.click(screen.getByRole('button', { name: '查看解析结果' }))
await waitFor(() => expect(screen.getByRole('button', { name: '返回文档列表' })).toHaveFocus())
}
fireEvent.click(screen.getByRole('button', { name: '架构说明.md 的更多操作' }))
expect(screen.getAllByRole('menuitem', { name: '查看任务' })).toHaveLength(1)
fireEvent.click(screen.getByRole('menuitem', { name: '查看任务' }))
expect(screen.getByText('Source task')).toBeVisible()
expect(screen.getByText('Document task')).toBeVisible()
expect(screen.queryByText('Unrelated task')).not.toBeInTheDocument()
})
it.each([
['directory', 'list'], ['directory', 'preview'], ['url', 'list'], ['url', 'preview']
] as const)('keeps document task scope in a filtered %s group through the %s entry', async (kind, entry) => {
const base = createProps()
const task = {
libraryId: library.id, sourceId: 'source-1', kind: 'document-process' as const,
stage: 'finalizing' as const, status: 'succeeded' as const, progress: 100,
attempt: 1, canCancel: false, canRetry: false,
createdAt: '2026-09-27T00:00:00Z', updatedAt: '2026-09-27T00:00:00Z'
}
render(<KnowledgeWorkspace {...createProps({
sources: [{ ...base.sources[0]!, kind, documentCount: 2 }],
documents: [
{ ...base.documents[0]!, name: 'A.md', resultId: 'result-a' },
{ ...base.documents[0]!, id: 'document-2', name: 'B.md', resultId: 'result-b' }
],
tasks: [
{ ...task, id: 'batch', scope: 'source', kind: 'source-sync', documentName: 'Import batch' },
{ ...task, id: 'task-a', parentTaskId: 'batch', scope: 'document', documentId: 'document-1', documentName: 'Task A' },
{ ...task, id: 'task-b', parentTaskId: 'batch', scope: 'document', documentId: 'document-2', documentName: 'Task B' },
{ ...task, id: 'other-source-task', scope: 'source', documentName: 'Other source operation' }
]
})} />)
fireEvent.change(screen.getByRole('searchbox', { name: '搜索文档与来源' }), { target: { value: 'A.md' } })
if (entry === 'preview') {
fireEvent.click(screen.getByRole('button', { name: '查看解析结果' }))
await waitFor(() => expect(screen.getByRole('button', { name: '返回文档列表' })).toHaveFocus())
}
fireEvent.click(screen.getByRole('button', { name: 'A.md 的更多操作' }))
if (entry === 'preview') {
expect(screen.getByRole('menuitem', { name: '同步 产品手册' })).toBeEnabled()
expect(screen.getByRole('menuitem', { name: '移除来源 产品手册' })).toBeEnabled()
}
fireEvent.click(screen.getByRole('menuitem', { name: '查看任务' }))
expect(screen.getByText('共 1 个任务批次')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '展开 Import batch 的阶段任务' }))
expect(screen.getByText('Task A')).toBeVisible()
expect(screen.queryByText('Task B')).not.toBeInTheDocument()
expect(screen.queryByText('Other source operation')).not.toBeInTheDocument()
})
it.each([
['sync', 'a-first'], ['sync', 'b-fails-first'], ['reparse', 'a-first'], ['reparse', 'b-fails-first']
] as const)('allows pausing B during A %s and retains pending state when %s', async (operation, completion) => {
const base = createProps()
let resolveA!: () => void
let resolveB!: () => void
let rejectB!: (error: Error) => void
const a = new Promise<void>((resolve) => { resolveA = resolve })
const b = new Promise<void>((resolve, reject) => { resolveB = resolve; rejectB = reject })
const props = createProps({
sources: [
{ ...base.sources[0]!, name: 'Source A' },
{ ...base.sources[0]!, id: 'source-b', name: 'Source B', status: 'syncing', documentCount: 0 }
],
documents: [{ ...base.documents[0]!, name: 'A.md', resultId: 'result-a' }],
onSyncSource: vi.fn(() => a), onRebuildDocument: vi.fn(() => a), onPauseSource: vi.fn(() => b)
})
render(<KnowledgeWorkspace {...props} />)
const aMenuName = operation === 'sync' ? 'Source A 的更多操作' : 'A.md 的更多操作'
const aActionName = operation === 'sync' ? '同步 Source A' : '使用当前设置重新解析'
const checkPending = (aPending: boolean, bPending: boolean): void => {
fireEvent.click(screen.getByRole('button', { name: aMenuName }))
expect(screen.getByRole('menuitem', { name: aActionName })).toHaveProperty('disabled', aPending)
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.getByRole('button', { name: '暂停 Source B' })).toHaveProperty('disabled', bPending)
fireEvent.click(screen.getByRole('button', { name: 'Source B 的更多操作' }))
expect(screen.getByRole('menuitem', { name: '暂停 Source B' })).toHaveProperty('disabled', bPending)
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
}
fireEvent.click(screen.getByRole('button', { name: aMenuName }))
fireEvent.click(screen.getByRole('menuitem', { name: aActionName }))
if (operation === 'sync') expect(props.onSyncSource).toHaveBeenCalledWith('source-1')
else expect(props.onRebuildDocument).toHaveBeenCalledWith(library.id, 'document-1')
checkPending(true, false)
fireEvent.click(screen.getByRole('button', { name: 'Source B 的更多操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '暂停 Source B' }))
expect(props.onPauseSource).toHaveBeenCalledExactlyOnceWith('source-b')
checkPending(true, true)
fireEvent.click(screen.getByRole('button', { name: '暂停 Source B' }))
expect(props.onPauseSource).toHaveBeenCalledTimes(1)
if (completion === 'a-first') {
await act(async () => { resolveA(); await a })
checkPending(false, true)
await act(async () => { resolveB(); await b })
} else {
await act(async () => { rejectB(new Error('Pause failed')); await b.catch(() => undefined) })
expect(screen.getByRole('alert')).toHaveTextContent('Pause failed')
checkPending(true, false)
await act(async () => { resolveA(); await a })
}
checkPending(false, false)
})
it('keeps the saved preview after a failed reparse and exposes source removal from its menu', async () => {
const base = createProps()
const openResultOriginal = vi.fn(async () => { throw new Error('Saved original missing') })
vi.stubGlobal('goodbuddy', { documentParsing: { openResultOriginal } })
const onRebuildDocument = vi.fn(async () => { throw new Error('Parser unavailable') })
const props = createProps({ sources: [{ ...base.sources[0]!, kind: 'file' }],
documents: [{ ...base.documents[0]!, resultId: 'saved-result' }], onRebuildDocument })
render(<KnowledgeWorkspace {...props} />)
fireEvent.click(screen.getByRole('button', { name: '查看解析结果' }))
await waitFor(() => expect(screen.getByRole('button', { name: '返回文档列表' })).toHaveFocus())
fireEvent.click(screen.getByRole('button', { name: '架构说明.md 的更多操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '打开解析时原件' }))
await waitFor(() => {
expect(openResultOriginal).toHaveBeenCalledWith('saved-result')
expect(screen.getByRole('alert')).toHaveTextContent('Saved original missing')
expect(screen.getByText('Preview saved-result')).toBeVisible()
})
fireEvent.click(screen.getByRole('button', { name: '架构说明.md 的更多操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '使用当前设置重新解析' }))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Parser unavailable')
expect(screen.getByText('Preview saved-result')).toBeVisible()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: '架构说明.md 的更多操作' }))
expect(screen.getByRole('menuitem', { name: '使用当前设置重新解析' })).toBeEnabled()
fireEvent.click(screen.getByRole('menuitem', { name: '移除来源 产品手册' }))
expect(screen.getByRole('alertdialog')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.getByText('Preview saved-result')).toBeVisible()
})
it('searches external names and instances and filters the unified library list', async () => {
await i18n.changeLanguage('en-US')
const external = { ...library, id: 'external-1', name: 'Company handbook', external: { knowledgeBaseId: 'external-1', instanceId: 'instance-1', provider: 'dify' as const, remoteKnowledgeBaseId: 'remote-1', remoteName: 'Remote policies', commonConfig: { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }, providerConfig: { provider: 'dify' as const, useDatasetDefaults: true as const }, lastVerifiedAt: '2026-09-12' } }
@@ -428,6 +632,7 @@ describe('KnowledgeWorkspace', () => {
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.clearAllMocks()
g6Mock.handlers.clear()
g6Mock.graph.getZoom.mockReturnValue(1)
@@ -529,9 +734,8 @@ describe('KnowledgeWorkspace', () => {
/>
)
fireEvent.click(
screen.getByRole('button', { name: '移除来源 产品手册' })
)
fireEvent.click(screen.getByRole('button', { name: '产品手册 的更多操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '移除来源 产品手册' }))
expect(
screen.getByRole('alertdialog', {
name: '移除来源“产品手册”?'
@@ -607,9 +811,8 @@ describe('KnowledgeWorkspace', () => {
'library-1',
'document-1'
)
expect(
screen.getByRole('button', { name: '文档分块' })
).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '架构说明.md 的更多操作' }))
expect(screen.getByRole('menuitem', { name: '文档分块' })).toBeEnabled()
})
it('opens the retrieval workbench and runs an isolated test query', async () => {
@@ -654,9 +857,8 @@ describe('KnowledgeWorkspace', () => {
{...createProps({ onListChunks })}
/>
)
fireEvent.click(
screen.getByRole('button', { name: '文档分块' })
)
fireEvent.click(screen.getByRole('button', { name: '架构说明.md 的更多操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '文档分块' }))
expect(
screen.getByRole('dialog', { name: '文档分块' })
).toBeInTheDocument()
@@ -718,10 +920,11 @@ describe('KnowledgeWorkspace', () => {
/>
)
const chunkButtons = screen.getAllByRole('button', { name: '文档分块' })
fireEvent.click(chunkButtons[0]!)
fireEvent.click(screen.getByRole('button', { name: '架构说明.md 的更多操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '文档分块' }))
fireEvent.click(screen.getByRole('button', { name: '关闭文档分块' }))
fireEvent.click(chunkButtons[1]!)
fireEvent.click(screen.getByRole('button', { name: '第二份文档.md 的更多操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '文档分块' }))
expect(await screen.findByText('第二份文档内容')).toBeInTheDocument()
await act(async () => {
@@ -1386,10 +1589,7 @@ describe('KnowledgeWorkspace', () => {
fireEvent.click(screen.getByRole('tab', { name: '任务中心' }))
fireEvent.click(screen.getByRole('button', { name: '失败' }))
const progress = screen.getByRole('progressbar', {
name: '失败文档.md 文档处理进度'
})
expect(progress.closest('[aria-live]')).toBeNull()
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重试任务' }))
const alert = await screen.findByRole('alert')
@@ -1644,13 +1844,12 @@ describe('KnowledgeWorkspace', () => {
const syncSource = screen.getByRole('button', {
name: '同步 产品手册'
})
const removeSource = screen.getByRole('button', {
fireEvent.click(screen.getByRole('button', { name: '产品手册 的更多操作' }))
const removeSource = screen.getByRole('menuitem', {
name: '移除来源 产品手册'
})
expect(syncSource.parentElement).toBe(removeSource.parentElement)
expect(syncSource.parentElement).toHaveClass(
'knowledge-source-row__actions'
)
expect(syncSource.parentElement).toHaveClass('knowledge-document-actions')
expect(removeSource.closest('[role="menu"]')).toBeInTheDocument()
expect(removeSource).not.toHaveStyle({ padding: '8px' })
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
@@ -599,7 +599,8 @@ export const knowledge = {
dropFiles: 'Drop files here to add them to “{{name}}”',
sourceMeta: '{{count}} documents · {{time}}',
lastSynced: 'Last synced: {{time}}',
moreActions: 'More actions',
moreActionsFor: 'More actions for {{name}}',
openParsedOriginal: 'Open original used for parsing',
reparse: 'Reparse with current settings',
viewResult: 'View parsed result',
previewLabel: 'Parsed result: {{name}}',
@@ -772,7 +773,12 @@ export const knowledge = {
emptyTitle: 'No knowledge tasks yet',
title: 'Task center',
recentCount: '{{count}} recent tasks',
totalCount: '{{count}} tasks',
totalCount: '{{count}} task batches',
childCount: '{{count}} subtasks',
containsFailures: 'A subtask failed. Expand to inspect the error.',
stoppedStage: 'Stopped at',
attempt: 'Attempt {{count}}',
batchHelp: 'Counts represent operations. Expand a batch to inspect documents and processing stages. Failed includes batches with failed subtasks; history includes all finished batches.',
activeCount: '{{count}} in progress',
failedCount: '{{count}} failed',
historyCount: '{{count}} in history',
@@ -563,7 +563,8 @@ export const knowledge = {
dropFiles: '将文件拖到这里,加入“{{name}}”',
sourceMeta: '{{count}} 个文档 · {{time}}',
lastSynced: '最近同步:{{time}}',
moreActions: '更多操作',
moreActionsFor: '{{name}} 的更多操作',
openParsedOriginal: '打开解析时原件',
reparse: '使用当前设置重新解析',
viewResult: '查看解析结果',
previewLabel: '解析结果:{{name}}',
@@ -726,7 +727,12 @@ export const knowledge = {
emptyTitle: '还没有知识任务',
title: '任务中心',
recentCount: '最近 {{count}} 个任务',
totalCount: '共 {{count}} 个任务',
totalCount: '共 {{count}} 个任务批次',
childCount: '{{count}} 个子任务',
containsFailures: '有子任务失败,展开查看原因',
stoppedStage: '停止阶段',
attempt: '第 {{count}} 次尝试',
batchHelp: '按操作批次计数,展开可查看文档和处理阶段。失败包含有失败子任务的批次;历史包含所有已结束批次。',
activeCount: '进行中 {{count}}',
failedCount: '失败 {{count}}',
historyCount: '历史 {{count}}',
@@ -22,13 +22,10 @@ import {
UploadCloud,
FileText,
ListChecks,
CirclePause,
RotateCcw,
RefreshCw,
Trash2,
Search
} from 'lucide-react'
import { RemoveSourceDialog } from './RemoveSourceDialog'
import { KnowledgeActionsMenu, type KnowledgeAction } from './KnowledgeActionsMenu'
const sourceStatusLabelKeys = {
queued: 'sourceStatuses.queued',
@@ -87,7 +84,7 @@ function formatDocumentLocation(
try {
const url = new URL(value)
if (url.protocol === 'http:' || url.protocol === 'https:') {
return `${url.origin}${url.pathname}`
return `${url.origin}${url.pathname}${url.search}${url.hash}`
}
} catch {
// Local paths are intentionally reduced below.
@@ -146,7 +143,7 @@ export function DocumentsView({
const [url, setUrl] = useState('')
const [query, setQuery] = useState('')
const [dragging, setDragging] = useState(false)
const [pending, setPending] = useState<string>()
const [pending, setPending] = useState<ReadonlyMap<string, number>>(() => new Map())
const [error, setError] = useState<string>()
const [removingSource, setRemovingSource] =
useState<KnowledgeSource>()
@@ -159,16 +156,23 @@ export function DocumentsView({
id: string,
action: () => void | Promise<void>
): Promise<boolean> => {
setPending(id)
setPending((current) => new Map(current).set(id, (current.get(id) ?? 0) + 1))
setError(undefined)
try {
await action()
return true
} catch (reason) {
setError(toErrorMessage(reason, t))
const message = toErrorMessage(reason, t)
setError(viewedDocumentId && id === `retry:${viewedDocumentId}` ? t('documents.reparseFailed', { error: message }) : message)
return false
} finally {
setPending(undefined)
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
})
}
}
@@ -215,6 +219,10 @@ export function DocumentsView({
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}`}>
@@ -224,34 +232,39 @@ export function DocumentsView({
</span>
)
const sourceActions = (source: KnowledgeSource): React.JSX.Element => (
<div className="knowledge-source-row__actions">
<button
aria-label={t(source.status === 'syncing' ? 'documents.actions.pauseSource' : source.status === 'failed' ? 'documents.actions.retrySource' : 'documents.actions.syncSource', { name: source.name })}
className="secondary-button"
disabled={pending === source.id}
onClick={() => void run(source.id, () => source.status === 'syncing' ? onPauseSource(source.id) : source.status === 'failed' ? onRetrySource(source.id) : onSyncSource(source.id))}
type="button"
>
{source.status === 'syncing' ? <CirclePause aria-hidden="true" size={14} /> : source.status === 'failed' ? <RotateCcw aria-hidden="true" size={14} /> : <RefreshCw aria-hidden="true" size={14} />}
{t(source.status === 'syncing' ? 'actions.pause' : source.status === 'failed' ? 'actions.retry' : 'actions.sync')}
</button>
<button
aria-label={t('documents.actions.removeSource', { name: source.name })}
className="danger-button danger-button--quiet"
disabled={pending === source.id}
onClick={() => setRemovingSource(source)}
type="button"
>
<Trash2 aria-hidden="true" size={14} />{t('actions.remove')}
</button>
</div>
)
const sourceActions = (source: KnowledgeSource): KnowledgeAction[] => [
{
label: t(source.status === 'syncing' ? 'documents.actions.pauseSource' : source.status === 'failed' ? 'documents.actions.retrySource' : 'documents.actions.syncSource', { name: source.name }),
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('documents.actions.removeSource', { name: source.name }),
disabled: pending.has(source.id), danger: true, separator: true,
onClick: () => setRemovingSource(source)
}
]
const documentActions = (document: KnowledgeDocumentItem, source: KnowledgeSource | undefined, taskSourceId?: string): KnowledgeAction[] => [
{ label: t('documents.actions.openDocumentSource', { name: document.name }), 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="document-result-actions">
<div className="knowledge-document-preview__header">
<button ref={previewBackRef} type="button" className="secondary-button" onClick={() => {
setViewedDocumentId(undefined)
requestAnimationFrame(() => {
@@ -259,12 +272,14 @@ export function DocumentsView({
if (previewOriginRef.current?.scroll) previewOriginRef.current.scroll.scrollTop = previewOriginRef.current.top
})
}}>{t('documents.backToList')}</button>
<strong>{library.name} · {viewedDocument.name}</strong><ScopeBadge scope={{ kind: 'global' }} />
<button type="button" className="secondary-button" disabled={Boolean(pending)} onClick={() => void run(`retry:${viewedDocument.id}`, () => onRebuildDocument(library.id, viewedDocument.id))}>{t('documents.reparse')}</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 && <p role="status">{t('documents.reparsing')}</p>}
{error && <p role="alert">{t('documents.reparseFailed', { error })}</p>}
<DocumentResultPreview key={viewedDocument.resultId} resultId={viewedDocument.resultId} allowAddImages />
{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">
@@ -403,7 +418,7 @@ export function DocumentsView({
/>
<button
className="primary-button"
disabled={pending === 'url'}
disabled={pending.has('url')}
type="submit"
>
{t('actions.import')}
@@ -521,11 +536,18 @@ export function DocumentsView({
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">
{tasks?.some((task) => task.sourceId === source.id) && <button type="button" className="secondary-button" onClick={() => onViewTasks({ sourceId: source.id })}><ListChecks aria-hidden="true" size={14} />{t('actions.viewTasks')}</button>}
{sourceActions(source)}
<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>
@@ -544,9 +566,9 @@ export function DocumentsView({
<tr key={key} className={grouped ? 'knowledge-document-row--grouped' : undefined}>
<td>
<strong>{document.name}</strong>
{document.path && !source && (
<div className="knowledge-document-path">
{formatDocumentLocation(document.path, t)}
{(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>}
@@ -621,7 +643,7 @@ export function DocumentsView({
setViewedDocumentId(document.id)
requestAnimationFrame(() => { previewBackRef.current?.focus(); if (scroll) scroll.scrollTop = 0 })
}}>{t('documents.viewResult')}</button>}
{relatedTasks.length > 0 && (
{!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={() =>
@@ -632,14 +654,13 @@ export function DocumentsView({
<ListChecks aria-hidden="true" size={14} />
{t('actions.viewTasks')}
</button>
)}
<button
) : !document.resultId ? <button
aria-label={t(
'documents.actions.openDocumentSource',
{ name: document.name }
)}
className="secondary-button"
disabled={pending === `open:${document.id}`}
disabled={pending.has(`open:${document.id}`)}
onClick={() =>
void run(`open:${document.id}`, () =>
onOpenDocumentSource(
@@ -651,45 +672,9 @@ export function DocumentsView({
type="button"
>
{t('actions.openSource')}
</button>
{document.status === 'failed' && (
<button
aria-label={t(
'documents.actions.retryDocument',
{ name: document.name }
)}
className="secondary-button"
disabled={pending === `retry:${document.id}`}
onClick={() =>
void run(`retry:${document.id}`, () =>
onRebuildDocument(
library.id,
document.id
)
)
}
type="button"
>
<RotateCcw aria-hidden="true" size={14} />
{t('actions.retryDocument')}
</button>
)}
<button
className="secondary-button"
disabled={(document.chunkCount ?? 0) === 0}
onClick={() => onManageChunks(document)}
type="button"
>
{t('chunks.title')}
</button>
</div>
{(source || (document.resultId && document.status !== 'failed')) && <details className="knowledge-document-more">
<summary>{t('documents.moreActions')}</summary>
<div className="knowledge-document-actions">
{document.resultId && document.status !== 'failed' && <button type="button" className="secondary-button" disabled={Boolean(activeTask) || pending === `retry:${document.id}`} onClick={() => void run(`retry:${document.id}`, () => onRebuildDocument(library.id, document.id))}>{t('documents.reparse')}</button>}
{source && sourceActions(source)}
</div>
</details>}
</button> : null}
<KnowledgeActionsMenu label={t('documents.moreActionsFor', { name: document.name })} actions={documentActions(document, source, source?.id)} />
</div>
</td>
</tr>
)
@@ -699,6 +684,7 @@ export function DocumentsView({
</div>
)}
</section>
</div>
{removingSource && (
<RemoveSourceDialog
onCancel={() => setRemovingSource(undefined)}
@@ -707,7 +693,6 @@ export function DocumentsView({
storageMode={library.storageMode}
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,36 @@
import { useId, useRef, useState } from 'react'
import { Ellipsis } from 'lucide-react'
import { AnchoredMenu } from '../AnchoredMenu'
export type KnowledgeAction = {
label: string
onClick: () => void
disabled?: boolean
danger?: boolean
separator?: boolean
}
export function KnowledgeActionsMenu({ label, actions }: {
label: string
actions: KnowledgeAction[]
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const anchorRef = useRef<HTMLButtonElement>(null)
const id = useId()
return <>
<button ref={anchorRef} type="button" className="icon-button" aria-label={label} title={label}
aria-haspopup="menu" aria-expanded={open} aria-controls={open ? id : undefined}
onClick={() => setOpen(!open)}><Ellipsis aria-hidden="true" size={16} /></button>
{open && <AnchoredMenu anchorRef={anchorRef} id={id} label={label} onClose={() => setOpen(false)}>
{actions.map((action) => <div key={action.label}>
{action.separator && <div role="separator" />}
<button type="button" role="menuitem" className={`knowledge-action${action.danger ? ' knowledge-action--danger' : ''}`}
disabled={action.disabled} onClick={() => {
setOpen(false)
anchorRef.current?.focus()
action.onClick()
}}>{action.label}</button>
</div>)}
</AnchoredMenu>}
</>
}
@@ -6,6 +6,7 @@ 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',
@@ -94,14 +95,6 @@ export function KnowledgeTasksView({
const [actionErrors, setActionErrors] = useState<
ReadonlyMap<string, KnowledgeTaskActionError>
>(() => new Map())
const activeCount = tasks.filter(
(task) => task.status === 'queued' || task.status === 'running'
).length
const failedCount = tasks.filter(
(task) =>
task.status === 'failed' || task.status === 'interrupted'
).length
const historyCount = tasks.length - activeCount - failedCount
const taskById = useMemo(
() => new Map(tasks.map((task) => [task.id, task])),
[tasks]
@@ -128,33 +121,9 @@ export function KnowledgeTasksView({
),
[context, tasks]
)
const directMatches = useMemo(
() =>
contextTasks.filter((task) => {
if (filter === 'active') {
return task.status === 'queued' || task.status === 'running'
}
if (filter === 'failed') {
return (
task.status === 'failed' ||
task.status === 'interrupted'
)
}
if (filter === 'history') {
return ![
'queued',
'running',
'failed',
'interrupted'
].includes(task.status)
}
return true
}),
[contextTasks, filter]
)
const visibleIds = useMemo(() => {
const result = new Set(directMatches.map((task) => task.id))
for (const task of directMatches) {
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)
@@ -162,12 +131,21 @@ export function KnowledgeTasksView({
}
}
return result
}, [directMatches, taskById])
const topLevelTasks = tasks.filter(
(task) =>
visibleIds.has(task.id) &&
(!task.parentTaskId || !taskById.has(task.parentTaskId))
)
}, [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') },
@@ -214,7 +192,8 @@ export function KnowledgeTasksView({
const renderTask = (
task: KnowledgeTaskItem,
nested = false
nested = false,
containsFailures = false
): React.JSX.Element => {
const children = (childrenByParent.get(task.id) ?? []).filter(
(child) => visibleIds.has(child.id)
@@ -225,6 +204,12 @@ export function KnowledgeTasksView({
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 ??
@@ -291,8 +276,9 @@ export function KnowledgeTasksView({
{t(taskStatusLabelKeys[task.status])}
</span>
</div>
<div className="knowledge-task__stage">
<strong>{t('tasks.currentStage')}</strong>
{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 && (
@@ -303,8 +289,8 @@ export function KnowledgeTasksView({
})}
</span>
)}
</div>
<div className="knowledge-task__progress">
</div>}
{active && <div className="knowledge-task__progress">
<progress
aria-label={t('tasks.progressAriaLabel', {
name: task.documentName,
@@ -316,9 +302,9 @@ export function KnowledgeTasksView({
<span>
{formatPercent(clampProgress(task.progress) / 100, locale)}
</span>
</div>
</div>}
<div className="knowledge-task__meta">
<span>{task.message || t('tasks.waiting')}</span>
<span>{summary}</span>
<time dateTime={time}>
{formatDateTime(time, locale, t)}
</time>
@@ -425,12 +411,13 @@ export function KnowledgeTasksView({
>
<div className="knowledge-tasks__summary">
<div>
<h3 id="knowledge-tasks-title">
<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(tasks.length, locale)
count: formatNumber(batches.length, locale)
})}
</p>
</div>
@@ -455,7 +442,11 @@ export function KnowledgeTasksView({
<div className="knowledge-tasks__toolbar">
<SegmentedControl
ariaLabel={t('tasks.filters.ariaLabel')}
onChange={setFilter}
onChange={(value) => {
setFilter(value)
if (value === 'active' || value === 'failed') setExpanded(new Set(childrenByParent.keys()))
else setExpanded(new Set())
}}
options={filterOptions}
value={filter}
/>
@@ -481,7 +472,7 @@ export function KnowledgeTasksView({
/>
) : (
<ol className="knowledge-task-list">
{topLevelTasks.map((task) => renderTask(task))}
{topLevelTasks.map(({ task, failed }) => renderTask(task, false, failed))}
</ol>
)}
</section>
+31 -18
View File
@@ -13983,6 +13983,7 @@ details.settings-section > :not(summary) + :not(summary) {
.knowledge-documents {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
}
@@ -14073,26 +14074,36 @@ details.settings-section > :not(summary) + :not(summary) {
margin-top: var(--space-3);
}
.knowledge-document-more {
margin-top: var(--space-2);
.knowledge-document-preview__header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.knowledge-document-more > summary {
width: fit-content;
min-height: var(--control-height);
align-content: center;
border-radius: var(--radius-control);
.knowledge-document-preview__title {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.knowledge-document-preview__title h3 {
margin: 0 0 var(--space-1);
}
.knowledge-document-preview__title > span {
margin-right: var(--space-2);
color: var(--text-secondary);
cursor: pointer;
}
.knowledge-document-more > summary:hover {
color: var(--accent);
.anchored-menu .knowledge-action--danger {
color: var(--danger);
}
.knowledge-document-more > summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
.anchored-menu .knowledge-action:disabled {
opacity: 0.5;
cursor: default;
}
.knowledge-source-row__main,
@@ -14211,7 +14222,11 @@ details.settings-section > :not(summary) + :not(summary) {
}
.knowledge-documents__table-scroll td:last-child:not([colspan]) {
width: 28%;
width: 1%;
}
.knowledge-document-actions {
flex-wrap: nowrap;
}
.knowledge-documents__table-scroll button {
@@ -14219,13 +14234,11 @@ details.settings-section > :not(summary) + :not(summary) {
}
.knowledge-document-path {
max-width: 360px;
overflow: hidden;
max-width: 280px;
color: var(--text-muted);
font-size: var(--font-body);
line-height: 1.55;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
.knowledge-document-status {