Skip to content
Closed
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
1 change: 1 addition & 0 deletions routes/history/history_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ async def get_session_history(
"model": session.model,
"endpoint_url": session.endpoint_url,
"name": session.name,
"total": len(history_dict),
}

@router.post("/api/session/{session_id}/truncate")
Expand Down
1 change: 1 addition & 0 deletions routes/history_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
from routes.history import history_routes as _canonical # noqa: F401

_sys.modules[__name__] = _canonical

7 changes: 7 additions & 0 deletions routes/session_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ def _verify_session_owner(request: Request, session_id: str, session_manager=Non
rows created while auth was previously enabled.
"""
user = effective_user(request)
# API tokens must carry the 'chat' scope (or wildcard '*') to access
# session history and mutations. Ownership alone is not sufficient — a
# token scoped for e.g. 'documents:read' must not reach chat endpoints.
if getattr(request.state, "api_token", False):
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if "chat" not in scopes and "*" not in scopes:
raise HTTPException(403, "API token does not have chat scope")
if not user and not _auth_disabled():
raise HTTPException(401, "Authentication required")
db = SessionLocal()
Expand Down
136 changes: 124 additions & 12 deletions static/js/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,60 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
const DEFAULT_TIMEOUT_MS = 120000;
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';

// ── DOM memory guard ──
// Long agent runs can create thousands of DOM nodes (one per tool call +
// text bubble). Without a cap the browser tab bloats to multiple GB and
// freezes. Instead of deleting old messages (which erases the visual
// timeline), we collapse them into lightweight placeholders that preserve
// the conversation shape while freeing 95%+ of the DOM memory. The full
// history lives on the server and is re-fetched on session switch.
var MAX_CHAT_DOM_NODES = 150;
(function _initMaxDomNodes() {
var box = document.getElementById('chat-history');
if (box) {
var attr = parseInt(box.getAttribute('data-max-dom-nodes'), 10);
if (attr > 0) MAX_CHAT_DOM_NODES = attr;
}
})();

function _trimChatHistoryDOM() {
var box = document.getElementById('chat-history');
if (!box) return;
var children = box.children;
if (children.length <= MAX_CHAT_DOM_NODES) return;
var keepFloor = Math.min(20, Math.floor(MAX_CHAT_DOM_NODES / 4));

// Remove the oldest children until we're under the cap. The scroll-based
// pager in sessions.js handles re-fetching older messages on demand.
var maxIdx = Math.max(0, children.length - keepFloor);
for (var i = 0; i < maxIdx && children.length > MAX_CHAT_DOM_NODES; i++) {
var el = children[i];
if (!el) break;

// Tear down any per-node intervals
if (el._waveInterval) { clearInterval(el._waveInterval); el._waveInterval = null; }
if (el._elapsedTicker) { clearInterval(el._elapsedTicker); el._elapsedTicker = null; }
if (el._spinner) { try { el._spinner.destroy(); } catch (_) {} }
el.querySelectorAll('.agent-thread-node').forEach(function(n) {
if (n._waveInterval) { clearInterval(n._waveInterval); n._waveInterval = null; }
if (n._elapsedTicker) { clearInterval(n._elapsedTicker); n._elapsedTicker = null; }
});
el.querySelectorAll('img[src^="data:"]').forEach(function(img) {
img.src = '';
});
// Also clear data-URI background images in inline styles
var bg = el.style && el.style.backgroundImage;
if (bg && bg.indexOf('url("data:') === 0) {
el.style.backgroundImage = '';
} else if (bg && bg.indexOf("url('data:") === 0) {
el.style.backgroundImage = '';
}

el.remove();
i--; // children shifted, re-check this index
}
}

let API_BASE = '';
let currentAbort = null;
let isStreaming = false;
Expand Down Expand Up @@ -263,6 +317,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
let currentSpinner = null; // Track current spinner for stop cleanup

// Background streaming support
const MAX_BACKGROUND_STREAMS = 10;
const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
const _resumingStreams = new Set(); // sessionId -> a resumeStream() reader is live (re-attach lock)
let _streamSessionId = null; // Session ID for the currently active reader loop
Expand All @@ -277,6 +332,36 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
_resumingStreams.has(sessionId);
}

/** Purge completed/error background stream entries that are no longer needed. */
function _purgeStaleBackgroundStreams() {
_backgroundStreams.forEach(function(entry, sid) {
if (entry.status === 'completed' || entry.status === 'error') {
// Release any held resources before deleting
if (entry.abortCtrl) { entry.abortCtrl = null; }
entry.accumulated = '';
entry.sourcesHtml = '';
entry.findingsData = null;
entry.metrics = null;
_backgroundStreams.delete(sid);
}
});
// Evict oldest entries when the map exceeds the cardinality cap.
// This bounds memory for abandoned streams that never completed.
while (_backgroundStreams.size > MAX_BACKGROUND_STREAMS) {
var oldestKey = _backgroundStreams.keys().next().value;
if (oldestKey === undefined) break;
var oldest = _backgroundStreams.get(oldestKey);
if (oldest) {
if (oldest.abortCtrl) { try { oldest.abortCtrl.abort(); } catch (_) {} oldest.abortCtrl = null; }
oldest.accumulated = '';
oldest.sourcesHtml = '';
oldest.findingsData = null;
oldest.metrics = null;
}
_backgroundStreams.delete(oldestKey);
}
}

// Sources box builder and toggleSources are now in chatRenderer.js
var _buildSourcesBox = chatRenderer.buildSourcesBox;

Expand Down Expand Up @@ -579,6 +664,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
*/
export async function handleChatSubmit(e) {
e.preventDefault();
// Purge stale background stream entries to free accumulated text
_purgeStaleBackgroundStreams();
// Cancel research clarification timeout if active
if (window._researchTimeoutTimer) {
clearTimeout(window._researchTimeoutTimer);
Expand Down Expand Up @@ -1327,6 +1414,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
researchBtn.classList.remove('active');
}
box.appendChild(holder);
_trimChatHistoryDOM();
uiModule.scrollHistory();

const enableResearchBtn = () => {
Expand Down Expand Up @@ -1537,6 +1625,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
_thinkMsg._spinner = _ts;
_thinkMsg.appendChild(_thinkBody);
document.getElementById('chat-history').appendChild(_thinkMsg);
_trimChatHistoryDOM();
uiModule.scrollHistory();
}

Expand Down Expand Up @@ -1569,6 +1658,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
let _liveThinkSpinnerSlot = null;
let _liveThinkTimerEl = null;
let _liveThinkTokenCount = 0;
let _thinkRenderTimer = null;
let _liveThinkToggle = null;
let _liveThinkDomId = null;

Expand Down Expand Up @@ -1755,6 +1845,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
} catch (toastErr) {
console.warn('[bg-stream] Toast/notification error:', toastErr);
}
// Free the large accumulated text — only the status and query
// are needed now; checkBackgroundStream reloads from the DB.
bgDone.accumulated = '';
bgDone.sourcesHtml = '';
bgDone.abortCtrl = null;
_purgeStaleBackgroundStreams();
}
// CRITICAL: always mark stream complete for the sidebar dot
try {
Expand Down Expand Up @@ -2003,18 +2099,22 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
} 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(/<channel\|>/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);
// Debounce expensive regex processing — O(n) per-char on
// growing text. A single rich render happens when the
// thinking block closes. Between ticks, raw text
// accumulates without triggering the regex chain.
if (!_thinkRenderTimer) {
_thinkRenderTimer = setTimeout(function() {
_thinkRenderTimer = null;
if (!_liveThinkInner || !isThinking) return;
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(/<channel\|>/gi, '');
thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
_liveThinkInner.textContent = thinkText;
}, 200);
}
// Keep thinking box scrolled to bottom, but let user scroll up
var _followThinking = true;
Expand All @@ -2028,6 +2128,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (_followThinking) uiModule.scrollHistory();
continue;
} else if (!hasUnclosedThink && isThinking) {
if (_thinkRenderTimer) { clearTimeout(_thinkRenderTimer); _thinkRenderTimer = null; }
isThinking = false;
var _thinkTextLen = _liveThinkInner ? _liveThinkInner.textContent.trim().length : 0;

Expand All @@ -2054,6 +2155,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Thinking ended — smooth transition: update header, pause, then collapse
// Stop live timer and spinner
cancelAnimationFrame(_thinkTimerRAF);
// Replace plain-text with a single rich markdown render now that
// the thinking block is complete.
if (_liveThinkInner) {
var _finalThinkText = _liveThinkInner.textContent;
_liveThinkInner.style.whiteSpace = '';
_liveThinkInner.style.fontFamily = '';
_liveThinkInner.innerHTML = markdownModule.mdToHtml(_finalThinkText);
}
var elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
// Embed thinking time in the <think> tag for persistence on reload
if (elapsed) {
Expand Down Expand Up @@ -2538,6 +2647,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
threadWrap.classList.add('has-top');
}
chatBox.appendChild(threadWrap);
_trimChatHistoryDOM();
}
threadWrap.classList.add('streaming');
lastToolThread = threadWrap;
Expand Down Expand Up @@ -2830,6 +2940,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
newBody.className = 'body';
newWrap.appendChild(newBody);
box.appendChild(newWrap);
_trimChatHistoryDOM();
roundHolder = newWrap;
roundText = '';
// Destroy any previous spinner before creating new one
Expand Down Expand Up @@ -5426,6 +5537,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
continueFrom,
_appendViewReportLink,
hasActiveStream,
trimChatHistoryDOM: _trimChatHistoryDOM,
};

// Single delegated handler for tool-call fold/expand. One listener on
Expand Down
88 changes: 63 additions & 25 deletions static/js/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -10314,58 +10314,96 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';

/** Append streaming content to the currently-streaming doc */
let _streamHlDebounce = null;
let _streamDeltaPending = null; // throttled content for next rAF
export function streamDocDelta(content) {
if (!_streamDocId) return;
const doc = docs.get(_streamDocId);
const capturedId = _streamDocId;
const doc = docs.get(capturedId);
if (doc) doc.content = content;

if (_streamDocId === activeDocId) {
if (capturedId === activeDocId) {
if ((doc?.language || '').toLowerCase() === 'email') {
_syncStreamingEmailFields(doc);
return;
}
const textarea = document.getElementById('doc-editor-textarea');
if (textarea) {
textarea.value = content;
// Auto-scroll to bottom as content streams in
textarea.scrollTop = textarea.scrollHeight;
}
// Update text and line numbers immediately, debounce expensive highlighting
const codeEl = document.getElementById('doc-editor-code');
if (codeEl) codeEl.textContent = content + '\n';
updateLineNumbers(content);
// Show blinking cursor at end of content
let cursor = document.getElementById('doc-stream-cursor');
if (!cursor) {
cursor = document.createElement('span');
cursor.id = 'doc-stream-cursor';
cursor.className = 'doc-stream-cursor';
cursor.textContent = '\u258F';
// Throttle DOM updates to once per animation frame. The full content
// is always stored in doc.content; we just avoid thrashing the textarea
// and code element (and their line-number layouts) on every SSE delta.
if (!_streamDeltaPending) {
_streamDeltaPending = requestAnimationFrame(function() {
_streamDeltaPending = null;
// Re-check: streamDocOpen may have switched to a different document
if (_streamDocId !== capturedId) return;
var latest = doc.content;
var textarea = document.getElementById('doc-editor-textarea');
if (textarea) {
textarea.value = latest;
textarea.scrollTop = textarea.scrollHeight;
}
var codeEl = document.getElementById('doc-editor-code');
if (codeEl) {
codeEl.textContent = latest + '\n';
var pre = document.getElementById('doc-editor-highlight');
if (pre) pre.scrollTop = textarea ? textarea.scrollHeight : pre.scrollHeight;
}
// Show blinking cursor at end of content
var cursor = document.getElementById('doc-stream-cursor');
if (!cursor) {
cursor = document.createElement('span');
cursor.id = 'doc-stream-cursor';
cursor.className = 'doc-stream-cursor';
cursor.textContent = '\u258F';
}
if (codeEl && codeEl.parentElement && cursor) {
codeEl.parentElement.appendChild(cursor);
}
});
}
if (codeEl && codeEl.parentElement) codeEl.parentElement.appendChild(cursor);
clearTimeout(_streamHlDebounce);
_streamHlDebounce = setTimeout(syncHighlighting, 150);
}
}

/** Finalize streaming — called when doc_update arrives with the real ID.
* Returns the old _streamDocId so handleDocUpdate can migrate temp→real. */
export function streamDocFinalize() {
const oldId = _streamDocId;
const capturedId = _streamDocId;
const finishingDoc = oldId ? docs.get(oldId) : null;
if (oldId === activeDocId && (finishingDoc?.language || '').toLowerCase() === 'email') {
const fields = _parseEmailHeader(finishingDoc.content || '');
_renderStreamingEmailBody(fields.body || '', { immediate: true });
}
// Cancel any pending throttled update and flush final content
if (_streamDeltaPending) {
cancelAnimationFrame(_streamDeltaPending);
_streamDeltaPending = null;
}
// Bail if streamDocOpen switched documents while we were finalizing
if (_streamDocId !== capturedId) return oldId;
_streamDocId = null;
// Flush final content to textarea and code element
var textarea = document.getElementById('doc-editor-textarea');
var codeEl = document.getElementById('doc-editor-code');
if (textarea && codeEl) {
var finalContent = docs.get(oldId)?.content || '';
textarea.value = finalContent;
codeEl.textContent = finalContent + '\n';
}
// Hide streaming indicator + cursor
const indicator = document.getElementById('doc-stream-indicator');
var indicator = document.getElementById('doc-stream-indicator');
if (indicator) indicator.style.display = 'none';
const cursor = document.getElementById('doc-stream-cursor');
var cursor = document.getElementById('doc-stream-cursor');
if (cursor) cursor.remove();
// Final highlighting pass + auto-detect language
// Reset placeholder to default
if (textarea) textarea.placeholder = 'Document content…';
// Final highlighting pass + line numbers + auto-detect language
clearTimeout(_streamHlDebounce);
syncHighlighting();
// Scroll to bottom
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
var pre = document.getElementById('doc-editor-highlight');
if (pre) pre.scrollTop = textarea.scrollHeight;
}
attemptAutoDetect();
return oldId;
}
Expand Down
Loading