diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts index ba3303c..f908b40 100644 --- a/src/main/group-report-service.ts +++ b/src/main/group-report-service.ts @@ -906,6 +906,8 @@ const captureFullPage = async ( const reportSessionPartition = `report-template-${crypto.randomUUID()}` const reportWindow = new BrowserWindow({ show: false, + opacity: 0, + skipTaskbar: true, width: captureWidth, height: 800, frame: false, @@ -948,7 +950,9 @@ const captureFullPage = async ( reportWindow.webContents.on('will-navigate', (event) => event.preventDefault()) try { + const readyToShow = new Promise((resolve) => reportWindow.once('ready-to-show', resolve)) await reportWindow.loadFile(htmlPath) + await readyToShow console.log('[GroupReport] capture loaded html') await reportWindow.webContents.executeJavaScript(`Promise.all([ document.fonts.ready, @@ -964,10 +968,21 @@ const captureFullPage = async ( })`)) as { width: number; height: number } const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width))) const height = Math.max(800, Math.min(maxCaptureHeight, Math.ceil(metrics.height))) - reportWindow.setContentSize(width, height) + const [currentWidth, currentHeight] = reportWindow.getContentSize() + if (currentWidth !== width || currentHeight !== height) { + const resized = new Promise((resolve) => reportWindow.once('resize', resolve)) + reportWindow.setContentSize(width, height) + await resized + } + await reportWindow.webContents.executeJavaScript( + 'new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))' + ) await new Promise((resolve) => setTimeout(resolve, 100)) console.log(`[GroupReport] capture native page width=${width} height=${height}`) - const image = await reportWindow.webContents.capturePage({ x: 0, y: 0, width, height }) + const image = await reportWindow.webContents.capturePage( + { x: 0, y: 0, width, height }, + { stayHidden: true } + ) const png = image.toPNG() if (png.length < 1000) throw new Error('生成的日报图片为空') await fs.writeFile(pngPath, png) diff --git a/src/renderer/src/components/search/hooks/useSearchHistory.ts b/src/renderer/src/components/search/hooks/useSearchHistory.ts index 3c2b4d3..f01c041 100644 --- a/src/renderer/src/components/search/hooks/useSearchHistory.ts +++ b/src/renderer/src/components/search/hooks/useSearchHistory.ts @@ -217,6 +217,33 @@ export function useSearchHistory({ onNotice('已恢复这条历史问题的最近结果') } + const readCachedResult = (cacheKey: string): AISearchCacheRecord | null => { + const exact = readSearchCache(cacheKey) + if (exact) return exact + try { + const requested = parseSearchCacheKey(cacheKey) + if (!requested) return null + const records = JSON.parse(localStorage.getItem(SEARCH_CACHE_KEY) || '[]') as AISearchCacheRecord[] + return records + .filter((record) => { + try { + const raw = JSON.parse(record.key) as unknown[] + const location = parseSearchCacheKey(record.key) + // Only legacy four-part keys may use compatibility lookup, and + // all base dimensions must still match the active request. + return raw.length === 4 && location?.scope === requested.scope && + location.contactMd5 === requested.contactMd5 && location.range === requested.range && + location.query === requested.query + } catch { + return false + } + }) + .sort((a, b) => b.createdAt - a.createdAt)[0] || null + } catch { + return null + } + } + const persistSearchResult = (input: PersistSearchResultInput): AISearchCacheRecord => { const record = createSearchCacheRecord({ ...input, @@ -288,7 +315,7 @@ export function useSearchHistory({ removeHistoryQuery, restoreHistoryQuery, applyCachedResult, - readCachedResult: readSearchCache, + readCachedResult, persistSearchResult, clearActiveResult, skipNextCache, diff --git a/tests/component/ai-search-cache-consent.test.tsx b/tests/component/ai-search-cache-consent.test.tsx index e5f83ae..052c5db 100644 --- a/tests/component/ai-search-cache-consent.test.tsx +++ b/tests/component/ai-search-cache-consent.test.tsx @@ -6,7 +6,7 @@ import { SEARCH_CACHE_KEY, buildSearchCacheKey } from '../../src/renderer/src/components/search/searchUtils' -import { makeSearchResult } from './support/ai-search-fixtures' +import { makePipelineEvidence, makeSearchResult } from './support/ai-search-fixtures' const api = { getSettings: vi.fn(), @@ -224,7 +224,7 @@ describe('AISearchWorkspace cache privacy boundary', () => { value: scrollIntoView }) api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false }) - api.runAiSearch.mockResolvedValue({ + api.runAiSearch.mockResolvedValue(makeSearchResult({ requestId: 'evidence-navigation', status: 'completed', answer: '请查看这条证据 [E7]。', @@ -264,7 +264,7 @@ describe('AISearchWorkspace cache privacy boundary', () => { agent: { mode: 'agent', toolCalls: 1, trace: [] }, timings: {}, elapsedMs: 1 - } as never) + }) as never) render( { it('keeps the submitted result title stable while drafting a new question and clears it from 新问题', async () => { api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false }) - api.runAiSearch.mockResolvedValue({ + api.runAiSearch.mockResolvedValue(makeSearchResult({ requestId: 'new-question', status: 'completed', answer: 'first answer', @@ -319,7 +319,7 @@ describe('AISearchWorkspace cache privacy boundary', () => { agent: { mode: 'agent', toolCalls: 1, trace: [] }, timings: {}, elapsedMs: 1 - } as never) + }) as never) render( { await writeFile(join(storageDir, 'settings.json'), JSON.stringify({ enabled: true })) } +// tick() 为保证调度不阻塞而 fire-and-forget 启动执行,测试只能轮询执行记录来等待结束。 +// 该路径耗时几乎全部是临时目录的 JSON 落盘:本地约 16ms,Windows CI(含杀毒扫描)实测 +// 50~120ms,最慢一次耗尽旧的 200ms 预算后误报“未结束”。窗口按最坏观测值留足余量, +// 但仍在 5s 的 vitest 用例超时之内,保留有界失败而不是无限等待。 +const EXECUTION_WAIT_TIMEOUT_MS = 4_000 +const EXECUTION_WAIT_POLL_INTERVAL_MS = 10 + async function runScheduled( service: ScheduledReportService, taskId: string ): Promise { const task = (await service.listTasks()).find((item) => item.id === taskId) if (!task) throw new Error('scheduled task not found') - await service.tick(new Date(Date.parse(task.nextRunAt) + 1_000)) - for (let attempt = 0; attempt < 20; attempt += 1) { - const execution = (await service.listExecutions(taskId))[0] - if (execution && execution.status !== 'running') return execution - await new Promise((resolve) => setTimeout(resolve, 10)) + const scheduledSlot = task.nextRunAt + await service.tick(new Date(Date.parse(scheduledSlot) + 1_000)) + const deadline = Date.now() + EXECUTION_WAIT_TIMEOUT_MS + let lastStatus = 'no execution recorded' + for (;;) { + // 取最新一次执行,而不是数组首项(executions.json 按追加顺序保存)。 + const latest = (await service.listExecutions(taskId)) + .slice() + .sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt))[0] + if (latest && latest.status !== 'running') return latest + lastStatus = latest?.status ?? 'no execution recorded' + if (Date.now() >= deadline) { + throw new Error( + `scheduled execution did not finish within ${EXECUTION_WAIT_TIMEOUT_MS}ms ` + + `(slot ${scheduledSlot}, last status: ${lastStatus})` + ) + } + await new Promise((resolve) => setTimeout(resolve, EXECUTION_WAIT_POLL_INTERVAL_MS)) } - throw new Error('scheduled execution did not finish') } const onlineAgentHubStatus = (): AgentHubStatus => ({