Skip to content

Commit f1f4831

Browse files
authored
fix: prevent NaN thinking timers (#11556)
* fix: prevent NaN thinking timers * test: cover thinking timer fallback and cleanup
1 parent 876f59d commit f1f4831

File tree

5 files changed

+114
-10
lines changed

5 files changed

+114
-10
lines changed

src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,12 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
102102
)
103103
}
104104

105+
const normalizeThinkingTime = (value?: number) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
106+
105107
const ThinkingTimeSeconds = memo(
106108
({ blockThinkingTime, isThinking }: { blockThinkingTime: number; isThinking: boolean }) => {
107109
const { t } = useTranslation()
108-
const [displayTime, setDisplayTime] = useState(blockThinkingTime)
110+
const [displayTime, setDisplayTime] = useState(normalizeThinkingTime(blockThinkingTime))
109111

110112
const timer = useRef<NodeJS.Timeout | null>(null)
111113

@@ -121,7 +123,7 @@ const ThinkingTimeSeconds = memo(
121123
clearInterval(timer.current)
122124
timer.current = null
123125
}
124-
setDisplayTime(blockThinkingTime)
126+
setDisplayTime(normalizeThinkingTime(blockThinkingTime))
125127
}
126128

127129
return () => {
@@ -132,10 +134,10 @@ const ThinkingTimeSeconds = memo(
132134
}
133135
}, [isThinking, blockThinkingTime])
134136

135-
const thinkingTimeSeconds = useMemo(
136-
() => ((displayTime < 1000 ? 100 : displayTime) / 1000).toFixed(1),
137-
[displayTime]
138-
)
137+
const thinkingTimeSeconds = useMemo(() => {
138+
const safeTime = normalizeThinkingTime(displayTime)
139+
return ((safeTime < 1000 ? 100 : safeTime) / 1000).toFixed(1)
140+
}, [displayTime])
139141

140142
return isThinking
141143
? t('chat.thinking', {

src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,20 @@ describe('ThinkingBlock', () => {
255255
unmount()
256256
})
257257
})
258+
259+
it('should clamp invalid thinking times to a safe default', () => {
260+
const testCases = [undefined, Number.NaN, Number.POSITIVE_INFINITY]
261+
262+
testCases.forEach((thinking_millsec) => {
263+
const block = createThinkingBlock({
264+
thinking_millsec: thinking_millsec as any,
265+
status: MessageBlockStatus.SUCCESS
266+
})
267+
const { unmount } = renderThinkingBlock(block)
268+
expect(getThinkingTimeText()).toHaveTextContent('0.1s')
269+
unmount()
270+
})
271+
})
258272
})
259273

260274
describe('collapse behavior', () => {

src/renderer/src/windows/mini/home/HomeWindow.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,17 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
254254

255255
let blockId: string | null = null
256256
let thinkingBlockId: string | null = null
257+
let thinkingStartTime: number | null = null
258+
259+
const resolveThinkingDuration = (duration?: number) => {
260+
if (typeof duration === 'number' && Number.isFinite(duration)) {
261+
return duration
262+
}
263+
if (thinkingStartTime !== null) {
264+
return Math.max(0, performance.now() - thinkingStartTime)
265+
}
266+
return 0
267+
}
257268

258269
setIsLoading(true)
259270
setIsOutputted(false)
@@ -291,6 +302,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
291302
case ChunkType.THINKING_START:
292303
{
293304
setIsOutputted(true)
305+
thinkingStartTime = performance.now()
294306
if (thinkingBlockId) {
295307
store.dispatch(
296308
updateOneBlock({ id: thinkingBlockId, changes: { status: MessageBlockStatus.STREAMING } })
@@ -315,24 +327,31 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
315327
{
316328
setIsOutputted(true)
317329
if (thinkingBlockId) {
330+
if (thinkingStartTime === null) {
331+
thinkingStartTime = performance.now()
332+
}
333+
const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec)
318334
throttledBlockUpdate(thinkingBlockId, {
319335
content: chunk.text,
320-
thinking_millsec: chunk.thinking_millsec
336+
thinking_millsec: thinkingDuration
321337
})
322338
}
323339
}
324340
break
325341
case ChunkType.THINKING_COMPLETE:
326342
{
327343
if (thinkingBlockId) {
344+
const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec)
328345
cancelThrottledBlockUpdate(thinkingBlockId)
329346
store.dispatch(
330347
updateOneBlock({
331348
id: thinkingBlockId,
332-
changes: { status: MessageBlockStatus.SUCCESS, thinking_millsec: chunk.thinking_millsec }
349+
changes: { status: MessageBlockStatus.SUCCESS, thinking_millsec: thinkingDuration }
333350
})
334351
)
335352
}
353+
thinkingStartTime = null
354+
thinkingBlockId = null
336355
}
337356
break
338357
case ChunkType.TEXT_START:
@@ -404,6 +423,8 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
404423
if (!isAborted) {
405424
throw new Error(chunk.error.message)
406425
}
426+
thinkingStartTime = null
427+
thinkingBlockId = null
407428
}
408429
//fall through
409430
case ChunkType.BLOCK_COMPLETE:

src/renderer/src/windows/selection/action/components/ActionUtils.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,19 @@ export const processMessages = async (
4141

4242
let textBlockId: string | null = null
4343
let thinkingBlockId: string | null = null
44+
let thinkingStartTime: number | null = null
4445
let textBlockContent: string = ''
4546

47+
const resolveThinkingDuration = (duration?: number) => {
48+
if (typeof duration === 'number' && Number.isFinite(duration)) {
49+
return duration
50+
}
51+
if (thinkingStartTime !== null) {
52+
return Math.max(0, performance.now() - thinkingStartTime)
53+
}
54+
return 0
55+
}
56+
4657
const assistantMessage = getAssistantMessage({
4758
assistant,
4859
topic
@@ -79,6 +90,7 @@ export const processMessages = async (
7990
switch (chunk.type) {
8091
case ChunkType.THINKING_START:
8192
{
93+
thinkingStartTime = performance.now()
8294
if (thinkingBlockId) {
8395
store.dispatch(
8496
updateOneBlock({ id: thinkingBlockId, changes: { status: MessageBlockStatus.STREAMING } })
@@ -102,9 +114,13 @@ export const processMessages = async (
102114
case ChunkType.THINKING_DELTA:
103115
{
104116
if (thinkingBlockId) {
117+
if (thinkingStartTime === null) {
118+
thinkingStartTime = performance.now()
119+
}
120+
const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec)
105121
throttledBlockUpdate(thinkingBlockId, {
106122
content: chunk.text,
107-
thinking_millsec: chunk.thinking_millsec
123+
thinking_millsec: thinkingDuration
108124
})
109125
}
110126
onStream()
@@ -113,19 +129,21 @@ export const processMessages = async (
113129
case ChunkType.THINKING_COMPLETE:
114130
{
115131
if (thinkingBlockId) {
132+
const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec)
116133
cancelThrottledBlockUpdate(thinkingBlockId)
117134
store.dispatch(
118135
updateOneBlock({
119136
id: thinkingBlockId,
120137
changes: {
121138
content: chunk.text,
122139
status: MessageBlockStatus.SUCCESS,
123-
thinking_millsec: chunk.thinking_millsec
140+
thinking_millsec: thinkingDuration
124141
}
125142
})
126143
)
127144
thinkingBlockId = null
128145
}
146+
thinkingStartTime = null
129147
}
130148
break
131149
case ChunkType.TEXT_START:
@@ -190,6 +208,7 @@ export const processMessages = async (
190208
case ChunkType.ERROR:
191209
{
192210
const blockId = textBlockId || thinkingBlockId
211+
thinkingStartTime = null
193212
if (blockId) {
194213
store.dispatch(
195214
updateOneBlock({

src/renderer/src/windows/selection/action/components/__tests__/ActionUtils.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,54 @@ describe('processMessages', () => {
284284
})
285285
})
286286

287+
describe('thinking timer fallback', () => {
288+
it('should use local timer when thinking_millsec is missing', async () => {
289+
const nowValues = [1000, 1500, 2000]
290+
let nowIndex = 0
291+
const performanceSpy = vi.spyOn(performance, 'now').mockImplementation(() => {
292+
const value = nowValues[Math.min(nowIndex, nowValues.length - 1)]
293+
nowIndex += 1
294+
return value
295+
})
296+
297+
const mockChunks = [
298+
{ type: ChunkType.THINKING_START },
299+
{ type: ChunkType.THINKING_DELTA, text: 'Thinking...' },
300+
{ type: ChunkType.THINKING_COMPLETE, text: 'Done thinking' },
301+
{ type: ChunkType.TEXT_START },
302+
{ type: ChunkType.TEXT_COMPLETE, text: 'Final answer' },
303+
{ type: ChunkType.BLOCK_COMPLETE }
304+
]
305+
306+
vi.mocked(fetchChatCompletion).mockImplementation(async ({ onChunkReceived }: any) => {
307+
for (const chunk of mockChunks) {
308+
await onChunkReceived(chunk)
309+
}
310+
})
311+
312+
await processMessages(
313+
mockAssistant,
314+
mockTopic,
315+
'test prompt',
316+
mockSetAskId,
317+
mockOnStream,
318+
mockOnFinish,
319+
mockOnError
320+
)
321+
322+
const thinkingDeltaCall = vi.mocked(throttledBlockUpdate).mock.calls.find(([id]) => id === 'thinking-block-1')
323+
const deltaPayload = thinkingDeltaCall?.[1] as { thinking_millsec?: number } | undefined
324+
expect(deltaPayload?.thinking_millsec).toBe(500)
325+
326+
const thinkingCompleteUpdate = vi
327+
.mocked(updateOneBlock)
328+
.mock.calls.find(([payload]) => (payload as any)?.changes?.thinking_millsec !== undefined)
329+
expect((thinkingCompleteUpdate?.[0] as any)?.changes?.thinking_millsec).toBe(1000)
330+
331+
performanceSpy.mockRestore()
332+
})
333+
})
334+
287335
describe('stream with exceptions', () => {
288336
it('should handle error chunks properly', async () => {
289337
const mockError = new Error('Stream processing error')

0 commit comments

Comments
 (0)