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
@@ -138,6 +138,10 @@
聊天标明会话,知识库显示库名与 `ScopeBadge`。下一行固定正文、图片、详情三个 PageTabs,
页签及直接包装层 `flex: 0 0 auto`,仅当前面板滚动。查看已存结果不发 OCR 请求。
知识库将原件与维护操作收进详情头部的共享菜单,并将添加图片工具限制在图片页;聊天与
设置预览保持原有入口。具体顺序、来源与解析时原件的区别及返回规则见
[知识库来源操作](../knowledge-base/ui-design.md#6-来源操作)。
正文面板按来源页或章节呈现 Markdown、HTML 表格及受管图片。页面导航显示真实页号;
PPTX 显示“幻灯片 N”,没有分页的原生文档显示工作表或标题定位,不生成假页码。点击图片
旁的“第 N 页”或“查看正文位置”切到正文并聚焦对应标题;正文图片的“在图片中查看”反向
+44
View File
@@ -16,6 +16,50 @@
## 验证证据
### 2026-09-27 独立评审 P2 修复
- `DocumentsView` 将任务来源 ID 与维护用来源分开传递。目录和一对多 URL 的文档预览
只筛选当前文档任务,父批次作为上下文保留,同组 B 的任务不再因来源 ID 被纳入。
真正单文档合并行在列表和预览中均保留来源、文档双 ID;判定使用完整文档集合。
- 在飞状态由全局单 ID 改为按操作 ID 计数,完成或失败时只清理本次操作。来源暂停、
同步及文档操作使用各自 ID 判断禁用,A 同步或重解析期间仍可暂停 B。
- 新增 9 项行为回归:目录及一对多 URL 各覆盖列表、预览入口,并在搜索只剩 A 时确认
不纳入 B 或其他来源级任务;单文档合并预览覆盖双 ID。四组 deferred 用例验证
A 同步/重解析与 B 暂停的调用 ID、重复提交禁用、A 先完成及 B 先失败后的独立状态。
预览测试同时确认所属来源的同步和移除入口仍可用。
- `npx vitest run src/renderer/src/KnowledgeWorkspace.test.tsx src/renderer/src/DocumentResultPreview.test.tsx`:
2 个文件、66 项通过;`npm run typecheck`、`npm run lint`、`git diff --check` 通过。
- 本轮仅修改上述组件、工作区测试及本地知识库 UI/进度文档。未重跑全量或 Electron
渲染验收,未提交;此前全量结果及渲染验证边界见下方记录。
### 2026-09-27 文档入口与任务历史整理
对应 US-D1、US-D2、US-D4,完成文档行、解析预览与知识任务中心的界面修改。当前交互
以 [来源操作](./ui-design.md#6-来源操作) 和 [任务历史](./ui-design.md#8-任务批次与历史)
为准,替代下方同日早期记录中的原生折叠操作区。
- 合并 URL 行恢复地址,保留查询参数与片段;同标题、不同查询参数的两条 URL 已验证
可以区分、分别搜索并打开正确来源。
- 文档与来源行使用一个主要按钮和共享菜单,分块、任务、重解析、同步、暂停、重试及
移除继续可达。预览按返回、标题、维护操作分层,保留搜索、焦点与滚动恢复;解析时
原件与当前来源使用各自原有 API。共享菜单补充向下翻转及缩窗后的视口高度限制。
- 任务中心按真实父子记录计批次,对象筛选同步影响计数。历史包含已结束失败批次,
运行批次中的成功子步骤不会单独混入历史。取消、重试继续服从服务契约。
- 最终定向命令 `npx vitest run src/renderer/src/ApplicationMenu.test.tsx src/renderer/src/KnowledgeWorkspace.test.tsx src/renderer/src/DocumentResultPreview.test.tsx src/renderer/src/KnowledgeChunkManager.test.tsx src/renderer/src/WorkspacePrimitives.test.tsx tests/knowledge-layout.electron.test.ts tests/attachment-layout.electron.test.ts tests/overlay-layering.electron.test.ts`:8 个文件、106 项通过。
- `tests/knowledge-layout.electron.test.ts` 使用真实 DocumentsView、KnowledgeTasksView、
DocumentResultPreview、菜单与 CSS,覆盖浅深主题及 1100×720、720×720、420×480。
验证页面无横向溢出、表格内部滚动、菜单展开不改变行高、顶部菜单翻转、原生 End/Escape、
预览焦点恢复及历史默认折叠。截图已检查宽屏列表、窄屏预览与菜单、任务历史。
截图复核发现文档 Grid 原先会被表格最小宽度撑开;列宽改用 `minmax(0, 1fr)`,
并增加内部页面容器无横向溢出的断言,确保导入工具栏不随表格横向滚动。
隐藏窗口无截图时曾停在帧等待;改用可见窗口并确认真实焦点后,无截图运行通过。
- 附件布局和 Modal 浮层的真实 Electron 回归通过,验证共享菜单修改没有破坏这两个入口。
- 完整 `npm test` 最后一次完成:448 个文件通过、1 个失败、11 个跳过;5291 项通过、
2 项失败、83 项跳过。失败仅为下方已记录的 HeartbeatCenter 中英文 tablist 数量断言。
此后补回解析时原件菜单入口、补测锚点滚出视口,并修正窄屏 Grid,均通过上述最终定向回归。
- `npm run typecheck`、`npm run lint`、`git diff --check` 通过。布局 fixture 使用合成数据与桥接替身,
本轮未从完整 App 重做 UI → Main 的文件导入及真实解析服务验收;没有新增外部模型调用。
### 2026-09-27 文档与来源统一列表
在现有 `knowledge-workspace` 拆分基础上修改 `DocumentsView.tsx`,单文件、单 URL 与
+50 -8
View File
@@ -51,8 +51,8 @@ Conversation 的检索范围,返回对话并聚焦输入框。
- 向量状态。
- 启用图谱时的图谱状态。
失败文档保留错误信息和“重新处理”。只要仍有可用分块,就允许打开分块管理,避免隐藏
上一版可用索引。
失败文档保留错误信息和“重新处理”,入口位置见第 6 节。只要仍有可用分块,就允许
打开分块管理,避免隐藏上一版可用索引。
## 5. 问答效果与高级参数
@@ -75,13 +75,28 @@ Conversation 的检索范围,返回对话并聚焦输入框。
单文档行。移除确认使用来源的完整文档数量,不使用搜索结果数。
导入文件、目录、URL 及拖放入口保持可用,拖放区使用紧凑的图标与文字提示。
单文档行的同步、移除和重新解析收进原生“更多操作”折叠区,解析预览、打开来源、
分块和任务保留直接入口。失败文档的重新处理入口仍直接显示。合并行的任务入口包含
该来源及该文档的相关任务,保留没有来源 ID 的文档任务。大小与分块数不拆行,操作组
按可用宽度换行,窄窗口在表格内部横向滚动。
合并行继续显示 URL,包含路径、查询参数和片段,使同标题的不同页面可以区分;
长地址允许换行。本地文件沿用文件名摘要。来源组中的 URL 同样可见。
解析预览复用现有结果组件;返回列表时恢复搜索、原触发按钮焦点及页面滚动位置。
重新解析继续调用文档重建,使用当前设置并保留上次结果,失败时显示可恢复错误。
每行只直接显示一个主要动作及一个“更多操作”按钮。有解析结果时主要动作是
“查看解析结果”;没有结果时,失败文档显示“重新处理”,其余文档有任务时显示
“查看任务”,否则显示“打开来源”。来源组优先提供失败重试或查看任务,没有任务时
提供同步或暂停。状态与错误始终在行内可见。
更多操作复用 `AnchoredMenu`,依次放置打开来源、打开解析时原件、文档分块、查看任务,
再用分隔线区分重新处理、来源同步和移除来源。仅在有对应资源时显示原件或任务入口;
没有分块时禁用分块入口。来源组的菜单提供任务及来源维护。菜单支持方向键、Home、End、
Escape 和焦点返回,展开不改变行高;空间不足时翻转方向或在菜单内滚动。
大小与分块数保持单行,窄窗口在表格内部横向滚动。
解析预览头部按返回、文档标题与库名及全局范围、维护菜单排列,地址仍可辨认。
正文、图片和详情使用共享结果组件;知识库的会话选择及添加图片工具仅在图片页显示,
切换页签保留选择。返回列表恢复搜索、原触发按钮焦点及页面滚动位置。重新解析继续
调用文档重建,使用当前设置并保留上次结果,失败时显示可恢复错误。维护菜单同时保留
当前来源与解析时原件的打开操作,两者分别读取知识来源和已保存的解析资产。
在飞操作只禁用对应的操作入口。同步或重解析 A 期间,其他来源 B 的暂停仍可用;
并发操作分别恢复可用状态,A 结束不能提前解除仍在执行的 B。
文档行提供“打开来源”,Main 根据知识库和文档归属重新读取保存的文件路径或 URL。
移除来源使用确认对话框,说明文档数、索引、图谱证据和托管副本影响。初始焦点位于
@@ -96,3 +111,30 @@ Conversation 的检索范围,返回对话并聚焦输入框。
均切换一次选择;选中背景和整行焦点环保持可见。Tab 聚焦、Space 切换,Escape 关闭并
返回触发按钮。点击行内非焦点元素时产生的空目标失焦不关闭弹层;点击外部或焦点明确
移到外部时才关闭,避免原生标签完成勾选前被卸载。
## 8. 任务批次与历史
任务中心按服务返回的 `parentTaskId` 组织一次操作及其文档、阶段子任务。顶层操作按
创建时间倒序排列,一个操作计为一个批次;缺失父记录的旧任务仍独立显示。重试产生的
新操作保留真实历史,使用已有 `attempt` 显示尝试次数,不按标题猜测合并关系。
文档或来源进入任务中心后,列表与计数使用同一对象范围,并保留命中任务的父级上下文。
合并文档行同时匹配来源 ID 和文档 ID,包括没有来源 ID 的文档任务。“清除对象筛选”
恢复当前库的任务集合。
列表与解析预览使用相同的文档任务范围。目录及一对多来源下的文档只按文档 ID 筛选,
即使搜索后只剩一篇也不扩大到整个来源;只有真正的单文档合并行同时包含来源 ID。
预览菜单中的来源同步、暂停和移除仍作用于所属来源,来源维护范围独立于任务筛选。
- 进行中:批次内有排队或运行记录。
- 失败:批次内有失败或中断记录;运行批次也可能同时存在失败子任务。
- 历史:批次内没有排队或运行记录,包含完成、失败、中断、取消和跳过。
计数均按批次计算,失败与历史可以重叠。标题旁的共享帮助解释口径。运行批次中已成功的
阶段不会单独进入历史。切换到进行中或失败时展开相关批次;全部和历史默认收起子任务,
用户仍可逐项展开。批次状态保留服务原值,子任务失败以额外文字提示,不改写父状态。
只有进行中的记录显示进度条;失败和中断保留停止阶段、错误及处理建议。已结束记录
不再显示“当前阶段”、百分比或默认“等待处理”。摘要保留时间、服务消息、子任务数及
重试次数。取消和重试严格使用服务返回的 `canCancel`、`canRetry`,提交期间禁用重复
操作,失败后保留筛选、任务内容及就地错误。
+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 {
+119
View File
@@ -0,0 +1,119 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('renders knowledge rows, menus, preview and task history in wide and narrow Electron windows', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-knowledge-layout-'))
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'knowledge-layout', configureServer(server) {
server.middlewares.use('/knowledge.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/knowledge.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/knowledge-layout-regression.tsx"></script></body></html>'))
})
} }], server: { host: '127.0.0.1', port: 0, watch: null } })
try {
await server.listen()
const driver = join(directory, 'driver.cjs')
await writeFile(driver, `
const { app, BrowserWindow } = require('electron');
const assert = require('node:assert/strict');
const { writeFile } = require('node:fs/promises');
const { join } = require('node:path');
app.commandLine.appendSwitch('force-device-scale-factor', '1');
app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))});
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1100, height: 720, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
const js = code => win.webContents.executeJavaScript(code);
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
const wait = async code => { for (let i = 0; i < 400; i++) {
if (await js(code)) return; await new Promise(resolve => setTimeout(resolve, 25));
} throw new Error('Timeout: ' + code); };
const click = async text => { await js('Array.from(document.querySelectorAll("button")).find(b => b.textContent === ' + JSON.stringify(text) + ').click()'); await settle(); };
const capture = async name => { if (process.env.GOODBUDDY_LAYOUT_ARTIFACTS) await writeFile(join(process.env.GOODBUDDY_LAYOUT_ARTIFACTS, name + '.png'), (await win.webContents.capturePage()).toPNG()); };
console.log('Loading knowledge fixture');
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'knowledge.html')});
await wait('document.querySelectorAll("tbody tr").length === 2');
console.log('Knowledge fixture ready');
for (const theme of ['light', 'dark']) {
await js('document.documentElement.dataset.theme = ' + JSON.stringify(theme));
for (const width of [1100, 720, 420]) {
win.setContentSize(width, width === 420 ? 480 : 720); await settle();
console.log('Checking ' + theme + ' ' + width);
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
assert(await js('document.querySelector(".workspace-panel-scroll").scrollWidth <= document.querySelector(".workspace-panel-scroll").clientWidth'));
assert(await js('Array.from(document.querySelectorAll("tbody tr")).every(r => r.querySelectorAll("button").length === 2)'));
assert(await js('Array.from(document.querySelectorAll(".knowledge-document-path")).map(e => e.textContent).join(" ").includes("edition=2")'));
await capture('knowledge-list-' + theme + '-' + width);
console.log('Opening list menu');
const height = await js('document.querySelector("tbody tr").getBoundingClientRect().height');
await js('document.querySelector("tbody [aria-haspopup=menu]").scrollIntoView({block:"center",inline:"end"}); document.querySelector("tbody [aria-haspopup=menu]").click()');
await wait('!!document.querySelector("[role=menuitem]")'); await settle();
assert.equal(await js('document.querySelector("tbody tr").getBoundingClientRect().height'), height);
const menu = await js('document.querySelector("[role=menu]").getBoundingClientRect().toJSON()');
assert(menu.left >= 15 && menu.right <= width - 15 && menu.top >= 15 && menu.height >= 32, JSON.stringify(menu));
await capture('knowledge-menu-' + theme + '-' + width);
console.log('Closing list menu');
win.focus(); win.webContents.focus();
await wait('document.hasFocus()');
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }); await settle();
assert(await js('!document.querySelector("[role=menu]") && document.activeElement.matches("[aria-haspopup=menu]")'));
await click('查看解析结果');
console.log('Opening preview');
await wait('!!document.querySelector(".document-result-preview")');
assert(await js('document.activeElement.textContent === "返回文档列表"'));
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
assert(await js('document.querySelector(".workspace-panel-scroll").scrollWidth <= document.querySelector(".workspace-panel-scroll").clientWidth'));
assert(await js('!Array.from(document.querySelectorAll("button")).some(b => b.textContent === "添加所选图片")'));
await capture('knowledge-preview-' + theme + '-' + width);
console.log('Opening preview menu');
await js('document.querySelector(".knowledge-document-preview__header [aria-haspopup=menu]").click()'); await settle();
const previewMenu = await js('document.querySelector("[role=menu]").getBoundingClientRect().toJSON()');
assert(previewMenu.height >= 100 && previewMenu.top >= 16 && previewMenu.bottom <= (width === 420 ? 480 : 720) - 15, JSON.stringify(previewMenu));
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'End' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'End' }); await settle();
assert(await js('document.activeElement.textContent.includes("移除来源")'));
await capture('knowledge-preview-menu-' + theme + '-' + width);
console.log('Closing preview menu');
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }); await settle();
await click('返回文档列表');
console.log('Returned to list');
assert(await js('document.activeElement.textContent === "查看解析结果"'));
await js('document.querySelector("tbody [aria-haspopup=menu]").click()'); await settle();
console.log('Opened task navigation menu');
await click('查看任务');
console.log('Navigated to tasks');
await click('历史');
console.log('Opening history');
assert(await js('document.querySelectorAll(".knowledge-task-list > li").length === 1 && !document.querySelector("progress") && !document.querySelector(".knowledge-task__children")'));
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
await capture('knowledge-history-' + theme + '-' + width);
await click('Documents');
}
}
console.log('Knowledge layout passed: light/dark, 1100/720/420px, stable row height, bounded menus, native Escape, preview focus, compact history');
app.exit(0);
}).catch(error => { console.error(error); app.exit(1); });
`)
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 60000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
expect(code, output).toBe(0)
} finally { clearTimeout(timeout) }
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
}, 90000)
@@ -0,0 +1,41 @@
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { DocumentsView } from '../../src/renderer/src/knowledge-workspace/DocumentsView'
import { KnowledgeTasksView, type KnowledgeTaskContext } from '../../src/renderer/src/knowledge-workspace/KnowledgeTasksView'
import type { KnowledgeLibrary, KnowledgeTaskItem } from '../../src/renderer/src/knowledge-workspace/types'
import { defaultKnowledgeOntologySettings } from '../../src/shared/knowledge-ontology'
import i18n from '../../src/renderer/src/i18n'
import '../../src/renderer/src/styles.css'
const noop = (): void => undefined
const library: KnowledgeLibrary = { id: 'library', name: 'Knowledge library', storageMode: 'managed',
graphEnabled: false, graphStrategy: 'hybrid', sourceCount: 2, documentCount: 2, indexedDocumentCount: 2,
ontologySettings: defaultKnowledgeOntologySettings, updatedAt: '2026-09-27T00:00:00Z' }
const sources = [1, 2].map((index) => ({ id: `source-${index}`, libraryId: library.id, name: 'Guide', kind: 'url' as const,
location: `https://example.com/guide?edition=${index}`, status: 'ready' as const, documentCount: 1 }))
const documents = sources.map((source, index) => ({ id: `document-${index}`, sourceId: source.id, libraryId: library.id,
name: 'Guide', path: source.location, resultId: 'saved-result', status: 'ready' as const, indexProgress: 100, chunkCount: 12, size: 2048 }))
const task: KnowledgeTaskItem = { id: 'batch', libraryId: library.id, sourceId: sources[0]!.id, documentName: 'Guide import',
scope: 'source', kind: 'source-sync', stage: 'finalizing', status: 'succeeded', progress: 100,
attempt: 1, canCancel: false, canRetry: false, createdAt: '2026-09-27T00:00:00Z', updatedAt: '2026-09-27T00:00:00Z' }
const tasks = [task, { ...task, id: 'stage', parentTaskId: task.id, documentName: 'Parsing stage', scope: 'document' as const, kind: 'parsing' as const }]
Object.defineProperty(window, 'goodbuddy', { value: {
documentParsing: { getResult: async () => ({ id: 'saved-result', fileName: 'Guide', completeness: 'complete', images: [], missingImages: [], warnings: [],
sections: [{ locator: 'Page 1', content: 'Saved readable content.' }], settings: {}, parsedAt: '2026-09-27T00:00:00Z', durationMs: 100 }) },
conversations: { listSummaries: async () => [] }, projects: { list: async () => [] }
} })
function Fixture(): React.JSX.Element {
const [context, setContext] = useState<KnowledgeTaskContext>()
return <div className="knowledge-page workspace-panel-scroll" style={{ height: '100vh', padding: 24 }}>
{context ? <><button className="secondary-button" onClick={() => setContext(undefined)}>Documents</button>
<KnowledgeTasksView context={context} tasks={tasks} onClearContext={() => setContext({})} onCancelTask={noop} onRetryTask={noop} /></>
: <DocumentsView library={library} documents={documents} sources={sources} tasks={tasks}
onViewTasks={setContext} onImportDirectory={noop} onImportFiles={noop} onImportUrl={noop}
onOpenDocumentSource={noop} onPauseSource={noop} onManageChunks={noop} onRemoveSource={noop}
onRebuildDocument={noop} onRetrySource={noop} onSyncSource={noop} />}
</div>
}
void i18n.changeLanguage('zh-CN').then(() => createRoot(document.getElementById('root')!).render(<Fixture />))