From ceb275bdb8a6cb075f76e38ad1ae8f304de8a921 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sun, 27 Sep 2026 12:55:50 +0800 Subject: [PATCH] fix: clarify knowledge document actions and task history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 知识库文档列表保留来源网址,统一查看入口和更多操作,并整理解析结果页。任务历史按批次展示,筛选计数与当前对象一致,不同来源的操作互不阻塞。 --- .../features/document-processing/ui-design.md | 4 + docs/features/knowledge-base/progress.md | 44 +++ docs/features/knowledge-base/ui-design.md | 58 +++- src/renderer/src/AnchoredMenu.tsx | 8 +- src/renderer/src/ApplicationMenu.test.tsx | 18 ++ .../src/DocumentResultPreview.test.tsx | 18 ++ src/renderer/src/DocumentResultPreview.tsx | 8 +- src/renderer/src/KnowledgeWorkspace.test.tsx | 259 ++++++++++++++++-- .../src/i18n/locales/en-US/knowledge.ts | 10 +- .../src/i18n/locales/zh-CN/knowledge.ts | 10 +- .../src/knowledge-workspace/DocumentsView.tsx | 161 +++++------ .../KnowledgeActionsMenu.tsx | 36 +++ .../KnowledgeTasksView.tsx | 93 +++---- src/renderer/src/styles.css | 49 ++-- tests/knowledge-layout.electron.test.ts | 119 ++++++++ tests/support/knowledge-layout-regression.tsx | 41 +++ 16 files changed, 730 insertions(+), 206 deletions(-) create mode 100644 src/renderer/src/knowledge-workspace/KnowledgeActionsMenu.tsx create mode 100644 tests/knowledge-layout.electron.test.ts create mode 100644 tests/support/knowledge-layout-regression.tsx diff --git a/docs/features/document-processing/ui-design.md b/docs/features/document-processing/ui-design.md index b8ba0262..a771a7b2 100644 --- a/docs/features/document-processing/ui-design.md +++ b/docs/features/document-processing/ui-design.md @@ -138,6 +138,10 @@ 聊天标明会话,知识库显示库名与 `ScopeBadge`。下一行固定正文、图片、详情三个 PageTabs, 页签及直接包装层 `flex: 0 0 auto`,仅当前面板滚动。查看已存结果不发 OCR 请求。 +知识库将原件与维护操作收进详情头部的共享菜单,并将添加图片工具限制在图片页;聊天与 +设置预览保持原有入口。具体顺序、来源与解析时原件的区别及返回规则见 +[知识库来源操作](../knowledge-base/ui-design.md#6-来源操作)。 + 正文面板按来源页或章节呈现 Markdown、HTML 表格及受管图片。页面导航显示真实页号; PPTX 显示“幻灯片 N”,没有分页的原生文档显示工作表或标题定位,不生成假页码。点击图片 旁的“第 N 页”或“查看正文位置”切到正文并聚焦对应标题;正文图片的“在图片中查看”反向 diff --git a/docs/features/knowledge-base/progress.md b/docs/features/knowledge-base/progress.md index f331c7d1..a77e866c 100644 --- a/docs/features/knowledge-base/progress.md +++ b/docs/features/knowledge-base/progress.md @@ -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 与 diff --git a/docs/features/knowledge-base/ui-design.md b/docs/features/knowledge-base/ui-design.md index 75c2f23c..53edac8e 100644 --- a/docs/features/knowledge-base/ui-design.md +++ b/docs/features/knowledge-base/ui-design.md @@ -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`,提交期间禁用重复 +操作,失败后保留筛选、任务内容及就地错误。 diff --git a/src/renderer/src/AnchoredMenu.tsx b/src/renderer/src/AnchoredMenu.tsx index 7cbd2fa7..c881f033 100644 --- a/src/renderer/src/AnchoredMenu.tsx +++ b/src/renderer/src/AnchoredMenu.tsx @@ -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(() => { diff --git a/src/renderer/src/ApplicationMenu.test.tsx b/src/renderer/src/ApplicationMenu.test.tsx index ce708622..d9e997a2 100644 --- a/src/renderer/src/ApplicationMenu.test.tsx +++ b/src/renderer/src/ApplicationMenu.test.tsx @@ -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() + 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' }) +}) diff --git a/src/renderer/src/DocumentResultPreview.test.tsx b/src/renderer/src/DocumentResultPreview.test.tsx index d659cbcc..c1e33e4d 100644 --- a/src/renderer/src/DocumentResultPreview.test.tsx +++ b/src/renderer/src/DocumentResultPreview.test.tsx @@ -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() + 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() + }) +}) diff --git a/src/renderer/src/DocumentResultPreview.tsx b/src/renderer/src/DocumentResultPreview.tsx index 2e8d4e1a..1ab2c9b6 100644 --- a/src/renderer/src/DocumentResultPreview.tsx +++ b/src/renderer/src/DocumentResultPreview.tsx @@ -63,7 +63,7 @@ function SectionContent({ content, result, onImage }: { } }}>{piece.replace(/<\/?(?:div|p|span|h[1-6])\b[^>]*>/giu, '\n').replace(//giu, '\n\n')})} } -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() const [error, setError] = useState('') @@ -128,10 +128,10 @@ export function DocumentResultPreview({ resultId, allowAddImages = false, conver if (!result) return

正在读取解析结果

return
{({ complete: '解析完成', partial: '部分解析', 'images-only': '仅图片,无可发送正文' })[result.completeness]} -
+ }}>打开原文件}
{actionError &&

{actionError}

} @@ -172,7 +172,7 @@ export function DocumentResultPreview({ resultId, allowAddImages = false, conver } {result.warnings.length > 0 &&
解析警告
    {result.warnings.map((warning, index) =>
  • {warning}
  • )}
} - {allowAddImages &&
+ {allowAddImages && (!imageActionsInImagesTab || tab === 'images') &&
diff --git a/src/renderer/src/KnowledgeWorkspace.test.tsx b/src/renderer/src/KnowledgeWorkspace.test.tsx index a81c5529..61c75d51 100644 --- a/src/renderer/src/KnowledgeWorkspace.test.tsx +++ b/src/renderer/src/KnowledgeWorkspace.test.tsx @@ -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() + 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() + 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() 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() - 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() - 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() + 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((resolve) => { resolveA = resolve }) + const b = new Promise((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() + 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() + 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: '知识图谱' })) diff --git a/src/renderer/src/i18n/locales/en-US/knowledge.ts b/src/renderer/src/i18n/locales/en-US/knowledge.ts index be6468e3..8efd3e56 100644 --- a/src/renderer/src/i18n/locales/en-US/knowledge.ts +++ b/src/renderer/src/i18n/locales/en-US/knowledge.ts @@ -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', diff --git a/src/renderer/src/i18n/locales/zh-CN/knowledge.ts b/src/renderer/src/i18n/locales/zh-CN/knowledge.ts index 702969f0..e847feb7 100644 --- a/src/renderer/src/i18n/locales/zh-CN/knowledge.ts +++ b/src/renderer/src/i18n/locales/zh-CN/knowledge.ts @@ -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}}', diff --git a/src/renderer/src/knowledge-workspace/DocumentsView.tsx b/src/renderer/src/knowledge-workspace/DocumentsView.tsx index 624703e3..d6a84def 100644 --- a/src/renderer/src/knowledge-workspace/DocumentsView.tsx +++ b/src/renderer/src/knowledge-workspace/DocumentsView.tsx @@ -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() + const [pending, setPending] = useState>(() => new Map()) const [error, setError] = useState() const [removingSource, setRemovingSource] = useState() @@ -159,16 +156,23 @@ export function DocumentsView({ id: string, action: () => void | Promise ): Promise => { - 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 => ( @@ -224,34 +232,39 @@ export function DocumentsView({ ) - const sourceActions = (source: KnowledgeSource): React.JSX.Element => ( -
- - -
- ) + 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 (
{viewedDocument?.resultId &&
-
+
- {library.name} · {viewedDocument.name} - +

{viewedDocument.name}

{library.name} + {viewedDocument.path &&
{formatDocumentLocation(viewedDocument.path, t)}
} +
+
- {pending &&

{t('documents.reparsing')}

} - {error &&

{t('documents.reparseFailed', { error })}

} - + {pending.has(`retry:${viewedDocument.id}`) &&

{t('documents.reparsing')}

} + {error &&

{error}

} +
} + {source.kind === 'url' && source.location &&
{formatDocumentLocation(source.location, t)}
} {source.error &&
{source.error}
}
- {tasks?.some((task) => task.sourceId === source.id) && } - {sourceActions(source)} + + task.sourceId === source.id) ? [{ label: t('actions.viewTasks'), onClick: () => onViewTasks({ sourceId: source.id }) }] : []), + ...sourceActions(source) + ]} />
@@ -544,9 +566,9 @@ export function DocumentsView({ {document.name} - {document.path && !source && ( -
- {formatDocumentLocation(document.path, t)} + {(document.path || (source?.kind === 'url' && source.location)) && ( +
+ {formatDocumentLocation(source?.kind === 'url' && source.location ? source.location : document.path!, t)}
)} {source &&
{t('documents.lastSynced', { time: formatTime(source.lastSyncedAt, locale, t) })}
} @@ -621,7 +643,7 @@ export function DocumentsView({ setViewedDocumentId(document.id) requestAnimationFrame(() => { previewBackRef.current?.focus(); if (scroll) scroll.scrollTop = 0 }) }}>{t('documents.viewResult')}} - {relatedTasks.length > 0 && ( + {!document.resultId && document.status === 'failed' ? : !document.resultId && relatedTasks.length > 0 ? ( - )} - - {document.status === 'failed' && ( - - )} - -
- {(source || (document.resultId && document.status !== 'failed')) &&
- {t('documents.moreActions')} -
- {document.resultId && document.status !== 'failed' && } - {source && sourceActions(source)} -
-
} + : null} + +
) @@ -699,6 +684,7 @@ export function DocumentsView({ )} + {removingSource && ( setRemovingSource(undefined)} @@ -707,7 +693,6 @@ export function DocumentsView({ storageMode={library.storageMode} /> )} - ) } diff --git a/src/renderer/src/knowledge-workspace/KnowledgeActionsMenu.tsx b/src/renderer/src/knowledge-workspace/KnowledgeActionsMenu.tsx new file mode 100644 index 00000000..453983a9 --- /dev/null +++ b/src/renderer/src/knowledge-workspace/KnowledgeActionsMenu.tsx @@ -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(null) + const id = useId() + return <> + + {open && setOpen(false)}> + {actions.map((action) =>
+ {action.separator &&
} + +
)} + } + +} diff --git a/src/renderer/src/knowledge-workspace/KnowledgeTasksView.tsx b/src/renderer/src/knowledge-workspace/KnowledgeTasksView.tsx index c0c2fa01..546cde9d 100644 --- a/src/renderer/src/knowledge-workspace/KnowledgeTasksView.tsx +++ b/src/renderer/src/knowledge-workspace/KnowledgeTasksView.tsx @@ -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 >(() => 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])}
-
- {t('tasks.currentStage')} + {containsFailures && task.status !== 'failed' && task.status !== 'interrupted' &&

{t('tasks.containsFailures')}

} + {(active || task.status === 'failed' || task.status === 'interrupted') &&
+ {t(active ? 'tasks.currentStage' : 'tasks.stoppedStage')} {t(taskStageLabelKeys[task.stage])} {task.completedItems !== undefined && task.totalItems !== undefined && ( @@ -303,8 +289,8 @@ export function KnowledgeTasksView({ })} )} -
-
+
} + {active &&
{formatPercent(clampProgress(task.progress) / 100, locale)} -
+
}
- {task.message || t('tasks.waiting')} + {summary} @@ -425,12 +411,13 @@ export function KnowledgeTasksView({ >
-

+

{t('tasks.title')} + {t('tasks.batchHelp')}

{t('tasks.totalCount', { - count: formatNumber(tasks.length, locale) + count: formatNumber(batches.length, locale) })}

@@ -455,7 +442,11 @@ export function KnowledgeTasksView({
{ + 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({ /> ) : (
    - {topLevelTasks.map((task) => renderTask(task))} + {topLevelTasks.map(({ task, failed }) => renderTask(task, false, failed))}
)} diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 3d93ae68..feca35c9 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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 { diff --git a/tests/knowledge-layout.electron.test.ts b/tests/knowledge-layout.electron.test.ts new file mode 100644 index 00000000..7867ee91 --- /dev/null +++ b/tests/knowledge-layout.electron.test.ts @@ -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', + '
')) + }) + } }], 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) diff --git a/tests/support/knowledge-layout-regression.tsx b/tests/support/knowledge-layout-regression.tsx new file mode 100644 index 00000000..eccc9e5e --- /dev/null +++ b/tests/support/knowledge-layout-regression.tsx @@ -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() + return
+ {context ? <> + setContext({})} onCancelTask={noop} onRetryTask={noop} /> + : } +
+} + +void i18n.changeLanguage('zh-CN').then(() => createRoot(document.getElementById('root')!).render())