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:
@@ -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 />))
|
||||
Reference in New Issue
Block a user