diff --git a/src/main/pdfjsAssetPaths.ts b/src/main/pdfjsAssetPaths.ts index a7409aa..1e81470 100644 --- a/src/main/pdfjsAssetPaths.ts +++ b/src/main/pdfjsAssetPaths.ts @@ -9,21 +9,17 @@ * 开发时直接读 `node_modules/pdfjs-dist`,省掉"先打包再调试"。 */ -import * as electron from 'electron' +import { createRequire } from 'node:module' import { join } from 'path' import type { PdfjsAssetDir } from '../shared/utils/pdfjsAssets' type ElectronApp = { isPackaged?: boolean; getAppPath?: () => string } -/** - * `electron` 在纯 Node 下解析成一个可执行文件路径字符串,`app` 因此是 `undefined`; - * `test/parsers.test.ts`、`test/blocks.test.ts` 等会在不启动 Electron 的情况下跑 - * `PdfLoader`,所以这里不能假设自己运行在 Electron 里。 - */ -// SAFETY: `electron` outside a running Electron process resolves to a path string -// with no `app` property, so the runtime value is genuinely `undefined` there. The -// cast narrows that one case away instead of pretending `app` is always present. -const electronApp = (electron as unknown as { app?: ElectronApp }).app +// Pure Node parser tests must not load the Electron package: resolving its +// executable can trigger a binary download even though these tests need no app. +const electronApp = process.versions.electron + ? (createRequire(import.meta.url)('electron') as { app?: ElectronApp }).app + : undefined /** 三类资源的共同根目录。 */ export function pdfjsAssetRoot(): string { diff --git a/src/main/smokeTest.ts b/src/main/smokeTest.ts index e30090a..4c77068 100644 --- a/src/main/smokeTest.ts +++ b/src/main/smokeTest.ts @@ -776,6 +776,22 @@ async function runChecks(): Promise { 'reconcile did not import a file added to a watched folder' ) + // Reconciliation enqueues imports; it does not await indexing. Let both + // imports finish before deleting a source, otherwise an in-flight parse can + // still mark that source available after the deletion check. + const importDeadline = Date.now() + 30_000 + while ( + ['watched.md', 'added.md'].some( + (name) => + knowledge + .getDocuments(reindexNotebook) + .find((doc) => doc.sourceUri === join(watchedFolder, name))?.status !== 'indexed' + ) + ) { + assert(Date.now() < importDeadline, 'watched folder imports did not finish indexing') + await new Promise((resolve) => setTimeout(resolve, 25)) + } + // A file disappears: the source is marked missing; the row, its chunks and its // citations survive, because a citation is a snapshot. rmSync(join(watchedFolder, 'watched.md')) diff --git a/src/renderer/src/components/notebook/chat/MessageItem.tsx b/src/renderer/src/components/notebook/chat/MessageItem.tsx index 1262788..edd0e09 100644 --- a/src/renderer/src/components/notebook/chat/MessageItem.tsx +++ b/src/renderer/src/components/notebook/chat/MessageItem.tsx @@ -226,12 +226,11 @@ function MessageItem({ message }: MessageItemProps): ReactElement { return (
- {/* Reasoning process display - only shown when reasoning content exists */} - {reasoningContent && ( - + {(reasoningContent || reasoningLive) && ( + )} - {content ? ( + {content && (
{content} - {/* Streaming message cursor */} - {isLive && ( - - )}
- ) : ( - // Show cursor when message is empty and still streaming - isLive && ( -
- {t('chat:thinking')} - -
- ) )} {/* What the answer was built from. Only after the turn ends: during streaming there is nothing to show yet, and an empty evidence list diff --git a/src/renderer/src/components/notebook/chat/ReasoningContent.tsx b/src/renderer/src/components/notebook/chat/ReasoningContent.tsx index fb8d484..eb08225 100644 --- a/src/renderer/src/components/notebook/chat/ReasoningContent.tsx +++ b/src/renderer/src/components/notebook/chat/ReasoningContent.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useState, useEffect } from 'react' +import { ReactElement, useState } from 'react' import { useTranslation } from 'react-i18next' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -20,19 +20,6 @@ export default function ReasoningContent({ const { t } = useTranslation('chat') const [isExpanded, setIsExpanded] = useState(false) - // 监听思考状态变化,自动控制展开/折叠 - // 思考开始 → 自动展开 - // 思考结束 → 自动折叠 - /* eslint-disable */ - useEffect(() => { - if (isStreaming) { - setIsExpanded(true) - } else { - setIsExpanded(false) - } - }, [isStreaming]) - /* eslint-enable */ - // 如果没有内容且不在流式传输,不显示 if (!content && !isStreaming) { return <> @@ -43,6 +30,7 @@ export default function ReasoningContent({ {/* 头部:展开/折叠按钮 */}
) : ( // 空内容时显示占位符
{t('thinkingShort')} -
)}
diff --git a/src/renderer/src/store/chatStore.ts b/src/renderer/src/store/chatStore.ts index ddf17e3..f9c4551 100644 --- a/src/renderer/src/store/chatStore.ts +++ b/src/renderer/src/store/chatStore.ts @@ -267,6 +267,7 @@ function applyOutcome(event: Extract): void // turn is what the reader was left with, and the reason is a line // beside it, not a replacement for it (#142). content: messageText(turn?.message) || message.content, + reasoningContent: messageReasoning(turn?.message) || message.reasoningContent, status: event.outcome.status, finishReason: event.finishReason ?? null, error: event.outcome.status === 'failed' ? event.outcome.error : null, diff --git a/test/chatTurnEvents.test.ts b/test/chatTurnEvents.test.ts index 7333a9a..84e3631 100644 --- a/test/chatTurnEvents.test.ts +++ b/test/chatTurnEvents.test.ts @@ -135,6 +135,43 @@ test('the events assemble into the answer on screen', async () => { close() }) +test('reasoning survives the final snapshot when the turn ends', async () => { + installApi() + const close = setupChatListeners() + seedTurn('msg_reasoning') + + try { + const events: UIMessageChunk[] = [ + { type: 'start' }, + { type: 'start-step' }, + { type: 'reasoning-start', id: 'r1' }, + { type: 'reasoning-delta', id: 'r1', delta: 'the reasoning' }, + { type: 'reasoning-end', id: 'r1' }, + { type: 'text-start', id: 't1' }, + { type: 'text-delta', id: 't1', delta: 'the answer' }, + { type: 'text-end', id: 't1' }, + { type: 'finish-step' } + ] + events.forEach((event, index) => deliver?.(chunk('msg_reasoning', index + 1, event))) + // Deliver the outcome immediately: the last snapshot may still be assembling. + deliver?.({ + type: 'outcome', + executionId: 'exec_1', + messageId: 'msg_reasoning', + seq: events.length + 1, + outcome: { status: 'completed' }, + messageMetadata: {} + }) + + await waitFor(() => messageOf('msg_reasoning')?.status === 'completed') + assert.equal(messageOf('msg_reasoning')?.content, 'the answer') + assert.equal(messageOf('msg_reasoning')?.reasoningContent, 'the reasoning') + assert.equal(useChatStore.getState().turns['msg_reasoning'], undefined) + } finally { + close() + } +}) + test('a gap in the sequence is recorded instead of shown as a short answer', async () => { installApi() const close = setupChatListeners() diff --git a/test/pdfjsAssets.test.ts b/test/pdfjsAssets.test.ts index 385fb39..3ebd219 100644 --- a/test/pdfjsAssets.test.ts +++ b/test/pdfjsAssets.test.ts @@ -2,6 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' +import { registerHooks } from 'node:module' import { PDFJS_ASSET_SCHEME, PDFJS_ASSET_DIRS, @@ -19,6 +20,22 @@ import { const read = (path: string): string => readFileSync(join(process.cwd(), path), 'utf8') +test('Node asset resolution does not load or download Electron', async () => { + const hook = registerHooks({ + resolve(specifier, context, nextResolve) { + assert.notEqual(specifier, 'electron', 'pure Node asset resolution loaded Electron') + return nextResolve(specifier, context) + } + }) + try { + const { pdfjsAssetRoot, pdfjsNodeAssetUrls } = await import('../src/main/pdfjsAssetPaths.ts') + assert.equal(pdfjsAssetRoot(), join(process.cwd(), 'node_modules', 'pdfjs-dist')) + for (const url of Object.values(pdfjsNodeAssetUrls())) assert.ok(url.endsWith('/')) + } finally { + hook.deregister() + } +}) + test('every asset URL ends with the slash PDF.js requires', () => { for (const dir of PDFJS_ASSET_DIRS) { const url = pdfjsAssetUrl(dir)