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
+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 />))