Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions src/main/pdfjsAssetPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions src/main/smokeTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,22 @@ async function runChecks(): Promise<string[]> {
'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'))
Expand Down
19 changes: 3 additions & 16 deletions src/renderer/src/components/notebook/chat/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,11 @@ function MessageItem({ message }: MessageItemProps): ReactElement {
return (
<div className="flex justify-start group">
<div className="flex w-full max-w-[var(--answer-measure)] flex-col gap-3 min-w-0">
{/* Reasoning process display - only shown when reasoning content exists */}
{reasoningContent && (
<ReasoningContent content={reasoningContent} isStreaming={reasoningLive} />
{(reasoningContent || reasoningLive) && (
<ReasoningContent content={reasoningContent ?? ''} isStreaming={reasoningLive} />
)}

{content ? (
{content && (
<div className="markdown-content text-foreground px-2">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkCitationMarkers]}
Expand Down Expand Up @@ -273,19 +272,7 @@ function MessageItem({ message }: MessageItemProps): ReactElement {
>
{content}
</ReactMarkdown>
{/* Streaming message cursor */}
{isLive && (
<span className="inline-block w-2 h-4 ml-1 bg-muted-foreground animate-pulse" />
)}
</div>
) : (
// Show cursor when message is empty and still streaming
isLive && (
<div className="flex items-center gap-2 px-2">
<span className="text-sm text-muted-foreground">{t('chat:thinking')}</span>
<span className="inline-block w-2 h-4 bg-muted-foreground animate-pulse" />
</div>
)
)}
{/* What the answer was built from. Only after the turn ends: during
streaming there is nothing to show yet, and an empty evidence list
Expand Down
21 changes: 2 additions & 19 deletions src/renderer/src/components/notebook/chat/ReasoningContent.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 <></>
Expand All @@ -43,6 +30,7 @@ export default function ReasoningContent({
{/* 头部:展开/折叠按钮 */}
<Button
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
variant="ghost"
className="w-full flex items-center justify-between px-3 py-1.5 text-xs h-auto hover:bg-surface-hover"
>
Expand Down Expand Up @@ -113,16 +101,11 @@ export default function ReasoningContent({
>
{content}
</ReactMarkdown>
{/* 流式传输光标 */}
{isStreaming && (
<span className="inline-block w-2 h-3 ml-1 bg-muted-foreground/50 animate-pulse" />
)}
</div>
) : (
// 空内容时显示占位符
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{t('thinkingShort')}</span>
<span className="inline-block w-2 h-3 bg-muted-foreground/50 animate-pulse" />
</div>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/store/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ function applyOutcome(event: Extract<ChatTurnEvent, { type: 'outcome' }>): 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,
Expand Down
37 changes: 37 additions & 0 deletions test/chatTurnEvents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
17 changes: 17 additions & 0 deletions test/pdfjsAssets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Loading