diff --git a/static/js/chat.js b/static/js/chat.js index 3c8bbe850..cbdd1e9bf 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -24,6 +24,49 @@ import createResearchSynapse from './researchSynapse.js'; import { createStreamRenderer } from './streamingRenderer.js'; import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArrowUpRecall.js?v=20260714promptrecall'; +/* LIVE_THINKING_THROTTLE_START */ +function _createLiveThinkingThrottle(commit, { + delay = 100, + schedule = (callback, ms) => setTimeout(callback, ms), + cancel = (timer) => clearTimeout(timer), +} = {}) { + let timer = null; + let latest = ''; + let dirty = false; + + const commitLatest = () => { + timer = null; + if (!dirty) return false; + dirty = false; + commit(latest); + return true; + }; + + return { + update(value) { + latest = String(value ?? ''); + dirty = true; + if (timer === null) timer = schedule(commitLatest, delay); + }, + flush() { + if (timer !== null) { + cancel(timer); + timer = null; + } + return commitLatest(); + }, + cancel() { + if (timer !== null) cancel(timer); + timer = null; + dirty = false; + }, + latest() { + return latest; + }, + }; +} +/* LIVE_THINKING_THROTTLE_END */ + const RESEARCH_TIMEOUT_MS = 360000; const DEFAULT_TIMEOUT_MS = 120000; const RESEARCH_SVG = ''; @@ -1370,6 +1413,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr let _renderStream = () => {}; let _cancelThinkingTimer = () => {}; let _removeThinkingSpinner = () => {}; + let _flushLiveThinking = () => ''; + let _cancelLiveThinkingWork = () => {}; let timeoutId = null; let responseTimeoutCleared = false; let clearResponseTimeout = () => {}; @@ -2070,6 +2115,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr let _liveThinkTokenCount = 0; let _liveThinkToggle = null; let _liveThinkDomId = null; + let _liveThinkRenderThrottle = null; + let _liveThinkLatestText = ''; + let _liveThinkTimerId = null; + let _liveThinkReducedMotion = false; function _estimateThinkingTokens(text) { const clean = (text || '').trim(); @@ -2083,6 +2132,102 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr return time && tokens ? time + ' · ' + tokens : (time || tokens); } + function _extractLiveThinkingText(text, preferComplete = false) { + const normalized = markdownModule.normalizeThinkingMarkup(_streamDisplayText(text || '')); + if (preferComplete && markdownModule.extractThinkingBlocks) { + const extracted = markdownModule.extractThinkingBlocks(normalized); + if (extracted?.thinkingBlocks?.length) { + return extracted.thinkingBlocks[extracted.thinkingBlocks.length - 1]; + } + } + let raw = normalized; + const open = /<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/i.exec(raw); + if (open) raw = raw.slice(open.index + open[0].length); + const closeAt = raw.search(/<\/(?:think(?:ing)?|thought)>/i); + if (closeAt >= 0) raw = raw.slice(0, closeAt); + return raw + .replace(/<\|channel>thought\s*\n?/gi, '') + .replace(/<\|channel>response\s*\n?/gi, '') + .replace(//gi, '') + .replace(/^\s*Thinking(?:\s+Process)?:\s*/i, ''); + } + + function _commitLiveThinkingText(text) { + _liveThinkLatestText = String(text ?? ''); + _liveThinkTokenCount = _estimateThinkingTokens(_liveThinkLatestText); + const target = _liveThinkInner; + if (!target || !target.isConnected) return; + const thinkBox = target.closest('.thinking-content'); + const nearBottom = !thinkBox || thinkBox.scrollHeight - thinkBox.clientHeight - thinkBox.scrollTop < 80; + target.style.whiteSpace = 'pre-wrap'; + target.textContent = _liveThinkLatestText; + if (thinkBox && nearBottom) thinkBox.scrollTop = thinkBox.scrollHeight; + if (nearBottom) uiModule.scrollHistory(); + } + + function _ensureLiveThinkingThrottle() { + if (!_liveThinkRenderThrottle) { + _liveThinkRenderThrottle = _createLiveThinkingThrottle(_commitLiveThinkingText, { delay: 100 }); + } + return _liveThinkRenderThrottle; + } + + function _stopLiveThinkTimer() { + if (_liveThinkTimerId !== null) clearInterval(_liveThinkTimerId); + _liveThinkTimerId = null; + } + + function _startLiveThinkTimer() { + if (_liveThinkTimerId !== null || !_liveThinkTimerEl) return; + _liveThinkReducedMotion = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); + const cadence = _liveThinkReducedMotion ? 1000 : 250; + _liveThinkTimerId = setInterval(() => { + if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) { + _stopLiveThinkTimer(); + return; + } + const elapsed = (Date.now() - thinkingStartTime) / 1000; + const seconds = elapsed.toFixed(_liveThinkReducedMotion ? 0 : 1); + _liveThinkTimerEl.textContent = _formatThinkStats(seconds, _liveThinkTokenCount); + }, cadence); + } + + function _queueLiveThinking(text) { + _liveThinkLatestText = String(text ?? ''); + _ensureLiveThinkingThrottle().update(_liveThinkLatestText); + _startLiveThinkTimer(); + } + + _flushLiveThinking = ({ text = null, rich = false } = {}) => { + if (text !== null) _queueLiveThinking(text); + if (_liveThinkRenderThrottle) _liveThinkRenderThrottle.flush(); + if (rich && _liveThinkInner && _liveThinkInner.isConnected) { + _liveThinkInner.style.whiteSpace = ''; + _liveThinkInner.innerHTML = markdownModule.mdToHtml(_liveThinkLatestText); + } + return _liveThinkLatestText; + }; + + _cancelLiveThinkingWork = () => { + if (_liveThinkRenderThrottle) _liveThinkRenderThrottle.cancel(); + _liveThinkRenderThrottle = null; + _stopLiveThinkTimer(); + }; + + function _finalizeLiveThinking(text, rich = true) { + const finalText = _flushLiveThinking({ text, rich }); + _cancelLiveThinkingWork(); + return finalText; + } + + function _closeOpenThinkingMarkup() { + if (!_thinkOpen) return; + accumulated += ''; + roundText += ''; + currentAccumulated = accumulated; + _thinkOpen = false; + } + function _replyAfterClosedThinking(text) { text = markdownModule.normalizeThinkingMarkup(text || ''); const closeRe = /<\/(?:think(?:ing)?|thought)>|/gi; @@ -2224,6 +2369,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr // On first transition to background, store state in map if (_isBg && !_backgroundStreams.has(streamSessionId)) { + _flushLiveThinking({ rich: false }); + _cancelLiveThinkingWork(); _backgroundStreams.set(streamSessionId, { status: 'running', accumulated: accumulated, @@ -2240,6 +2387,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr if (data === '[DONE]') { _streamSawDone = true; + _closeOpenThinkingMarkup(); // Always update background map if entry exists (even if user switched back) var bgDone = _backgroundStreams.get(streamSessionId); if (bgDone && !_isBg) { @@ -2270,7 +2418,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr // Force-close thinking if still open (model never output boundary) if (isThinking) { isThinking = false; - cancelAnimationFrame(_thinkTimerRAF); + _finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true); var _elapsedDone = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null; if (_elapsedDone) { accumulated = accumulated.replace(//i, ''); @@ -2486,16 +2634,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr _liveThinkSpinnerSlot = thinkContent.querySelector('.live-think-spinner-slot'); _liveThinkTimerEl = thinkContent.querySelector('.live-think-timer'); _liveThinkToggle = thinkContent.querySelector('.live-think-toggle'); - // Live timer - var _thinkTimerStart = Date.now(); - var _thinkTimerRAF = 0; - function _tickThinkTimer() { - if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) return; - var s = ((Date.now() - _thinkTimerStart) / 1000).toFixed(1); - _liveThinkTimerEl.textContent = _formatThinkStats(s, _liveThinkTokenCount); - _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer); - } - _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer); + _liveThinkLatestText = ''; + _cancelLiveThinkingWork(); + _queueLiveThinking(_extractLiveThinkingText(roundText)); // Whirlpool spinner if (_liveThinkSpinnerSlot) { var _wp = spinnerModule.createWhirlpool(12); @@ -2506,34 +2647,13 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr _liveThinkSpinnerSlot.appendChild(_wp.element); } } else if (hasUnclosedThink && isThinking) { - if (_liveThinkInner) { - // Extract raw thinking text (strip known thinking wrappers and prefixes) - var thinkText = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText)) - .replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '') - .replace(/<\|channel>thought\s*\n?/gi, '') - .replace(/<\|channel>response\s*\n?/gi, '') - .replace(//gi, ''); - thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, ''); - _liveThinkTokenCount = _estimateThinkingTokens(thinkText); - _liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText); - if (_liveThinkTimerEl) { - var _elapsedLive = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : ''; - _liveThinkTimerEl.textContent = _formatThinkStats(_elapsedLive, _liveThinkTokenCount); - } - // Keep thinking box scrolled to bottom, but let user scroll up - var _followThinking = true; - var thinkBox = _liveThinkInner.closest('.thinking-content'); - if (thinkBox) { - var nearBottom = thinkBox.scrollHeight - thinkBox.clientHeight - thinkBox.scrollTop < 80; - if (nearBottom) thinkBox.scrollTop = thinkBox.scrollHeight; - _followThinking = nearBottom; - } - } - if (_followThinking) uiModule.scrollHistory(); + _queueLiveThinking(_extractLiveThinkingText(roundText)); continue; } else if (!hasUnclosedThink && isThinking) { isThinking = false; - var _thinkTextLen = _liveThinkInner ? _liveThinkInner.textContent.trim().length : 0; + const _closedThinkText = _extractLiveThinkingText(roundText, true); + var _thinkTextLen = _closedThinkText.trim().length; + _finalizeLiveThinking(_closedThinkText, _thinkTextLen >= 20); // If thinking was trivially short (< 20 chars), remove the section entirely // Models sometimes emit The or similar noise @@ -2557,7 +2677,6 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr // Thinking ended — smooth transition: update header, pause, then collapse // Stop live timer and spinner - cancelAnimationFrame(_thinkTimerRAF); var elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null; // Embed thinking time in the tag for persistence on reload if (elapsed) { @@ -2973,13 +3092,14 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr if (holder && json.id) holder.dataset.dbId = json.id; } else if (json.type === 'tool_start') { + _closeOpenThinkingMarkup(); if (_isBg) continue; _cancelThinkingTimer(); _removeThinkingSpinner(); // Force-close thinking if still open — tools are real content, not thinking if (isThinking) { isThinking = false; - cancelAnimationFrame(_thinkTimerRAF); + _finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true); var _elapsed2 = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null; if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process'; if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _formatThinkStats(_elapsed2, _liveThinkTokenCount) : ''; @@ -3324,9 +3444,20 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr if (_pu) _setStoredPlan(_pu); } else if (json.type === 'agent_step') { + _closeOpenThinkingMarkup(); if (_isBg) continue; _cancelThinkingTimer(); _removeThinkingSpinner(); + if (isThinking) { + isThinking = false; + _finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true); + var _elapsedStep = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null; + if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process'; + if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsedStep ? _formatThinkStats(_elapsedStep, _liveThinkTokenCount) : ''; + if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove(); + } else { + _cancelLiveThinkingWork(); + } _renderStream(); // Mark thread as connected to bubble below const _activeThread = document.querySelector('.agent-thread.streaming'); @@ -3739,6 +3870,13 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr } // end if (!_isBgFinal) } catch (err) { + _closeOpenThinkingMarkup(); + if (isThinking) { + isThinking = false; + _finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true); + } else { + _cancelLiveThinkingWork(); + } _renderStream(); // Clean up any active spinner (e.g. "Generating response" during tool calls) if (spinner && spinner.element) spinner.destroy(); @@ -3926,6 +4064,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr } } } finally { + _cancelLiveThinkingWork(); clearResponseTimeout(); clearProcessingProbe(); clearFirstTokenWaitTimers(); diff --git a/tests/live_thinking_scheduler.test.mjs b/tests/live_thinking_scheduler.test.mjs new file mode 100644 index 000000000..77e205c2e --- /dev/null +++ b/tests/live_thinking_scheduler.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +const source = fs.readFileSync(new URL('../static/js/chat.js', import.meta.url), 'utf8'); +const start = source.indexOf('/* LIVE_THINKING_THROTTLE_START */'); +const end = source.indexOf('/* LIVE_THINKING_THROTTLE_END */'); +assert.ok(start >= 0 && end > start, 'live-thinking throttle markers must exist'); +const helperSource = source.slice(start, end) + '\nglobalThis.createThrottle = _createLiveThinkingThrottle;'; +const sandbox = {}; +vm.runInNewContext(helperSource, sandbox); +const createThrottle = sandbox.createThrottle; + +function fakeTimers() { + let nextId = 1; + const callbacks = new Map(); + const delays = []; + return { + schedule(callback, delay) { + const id = nextId++; + callbacks.set(id, callback); + delays.push(delay); + return id; + }, + cancel(id) { + callbacks.delete(id); + }, + run(id) { + const callback = callbacks.get(id); + assert.ok(callback, `missing timer ${id}`); + callbacks.delete(id); + callback(); + }, + pendingIds() { + return [...callbacks.keys()]; + }, + delays, + }; +} + +test('coalesces a burst and commits only the latest text after 100 ms', () => { + const timers = fakeTimers(); + const commits = []; + const throttle = createThrottle((value) => commits.push(value), timers); + + throttle.update('a'); + throttle.update('ab'); + throttle.update('abc'); + + assert.deepEqual(commits, []); + assert.deepEqual(timers.delays, [100]); + const [timer] = timers.pendingIds(); + timers.run(timer); + assert.deepEqual(commits, ['abc']); +}); + +test('flush synchronously preserves trailing text and cancels the pending callback', () => { + const timers = fakeTimers(); + const commits = []; + const throttle = createThrottle((value) => commits.push(value), timers); + + throttle.update('trailing text'); + assert.equal(throttle.flush(), true); + assert.deepEqual(commits, ['trailing text']); + assert.deepEqual(timers.pendingIds(), []); + assert.equal(throttle.flush(), false, 'clean flush must not duplicate the commit'); +}); + +test('cancel discards pending work without a late DOM commit', () => { + const timers = fakeTimers(); + const commits = []; + const throttle = createThrottle((value) => commits.push(value), timers); + + throttle.update('stale session text'); + throttle.cancel(); + assert.deepEqual(timers.pendingIds(), []); + assert.deepEqual(commits, []); +}); diff --git a/tests/test_live_thinking_scheduler_js.py b/tests/test_live_thinking_scheduler_js.py new file mode 100644 index 000000000..fd0d56f66 --- /dev/null +++ b/tests/test_live_thinking_scheduler_js.py @@ -0,0 +1,68 @@ +"""Regression coverage for bounded live-thinking DOM work in chat.js.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_CHAT = _REPO / "static/js/chat.js" +_HAS_NODE = shutil.which("node") is not None + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_live_thinking_scheduler_behavior(): + result = subprocess.run( + ["node", "--test", "tests/live_thinking_scheduler.test.mjs"], + cwd=_REPO, + capture_output=True, + timeout=30, + text=True, + ) + if result.returncode != 0: + raise AssertionError( + f"node --test failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + +def test_live_thinking_hot_path_has_no_full_markdown_reparse_or_raf_timer(): + source = _CHAT.read_text(encoding="utf-8") + hot_start = source.index("} else if (hasUnclosedThink && isThinking) {") + hot_end = source.index("} else if (!hasUnclosedThink && isThinking) {", hot_start) + hot_path = source[hot_start:hot_end] + + assert "_queueLiveThinking(_extractLiveThinkingText(roundText));" in hot_path + assert "mdToHtml" not in hot_path + assert "innerHTML" not in hot_path + assert "requestAnimationFrame(_tickThinkTimer)" not in source + assert "_liveThinkReducedMotion ? 1000 : 250" in source + assert "target.style.whiteSpace = 'pre-wrap';" in source + assert "_liveThinkInner.style.whiteSpace = '';" in source + + +def test_terminal_paths_close_protocol_markup_flush_and_cancel_pending_work(): + source = _CHAT.read_text(encoding="utf-8") + + helper_start = source.index("function _closeOpenThinkingMarkup()") + helper = source[helper_start:source.index("function _replyAfterClosedThinking", helper_start)] + assert "accumulated += '';" in helper + assert "roundText += '';" in helper + assert "currentAccumulated = accumulated;" in helper + assert "_thinkOpen = false;" in helper + + assert source.count("_finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true);") >= 3 + done_start = source.index("if (data === '[DONE]')") + assert "_closeOpenThinkingMarkup();" in source[done_start:done_start + 220] + for marker in ("} else if (json.type === 'tool_start') {", "} else if (json.type === 'agent_step') {"): + start = source.index(marker) + block_head = source[start:start + 220] + assert block_head.index("_closeOpenThinkingMarkup();") < block_head.index("if (_isBg) continue;") + + catch_start = source.index(" } catch (err) {") + catch_block = source[catch_start:source.index(" } finally {", catch_start)] + assert "_closeOpenThinkingMarkup();" in catch_block + assert "_finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true);" in catch_block + assert catch_block.index("_finalizeLiveThinking") < catch_block.index("_renderStream();") + finally_block = source[source.index(" } finally {", catch_start):] + assert "_cancelLiveThinkingWork();" in finally_block