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:
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user