2026-05-31 23:58:26 +09:00
// static/js/chat.js
/ * *
* Main chat functionality - message handling and streaming
* /
// ES6 module — IIFE removed
import Storage from './storage.js' ;
import uiModule from './ui.js' ;
import sessionModule from './sessions.js' ;
import chatRenderer from './chatRenderer.js' ;
import chatStream from './chatStream.js' ;
import { addAITTSButton } from './tts-ai.js' ;
import markdownModule from './markdown.js' ;
import spinnerModule from './spinner.js' ;
import presetsModule from './presets.js' ;
import fileHandlerModule from './fileHandler.js' ;
import searchModule from './search.js' ;
import documentModule from './document.js' ;
import * as emailInbox from './emailInbox.js' ;
import codeRunnerModule from './codeRunner.js' ;
import slashCommands , { initSlashCommands , isCommand , handleSlashCommand , handleSetupInput , handleSetupWizard , typewriterInto } from './slashCommands.js' ;
import createResearchSynapse from './researchSynapse.js' ;
const RESEARCH _TIMEOUT _MS = 360000 ;
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>' ;
let API _BASE = '' ;
let currentAbort = null ;
let isStreaming = false ;
// Continuous stall watchdog: while streaming, if the SSE stream produces
// NOTHING for STALL_THRESHOLD_MS (no deltas, no tool heartbeat — tools beat
// every 2s, so a full minute of silence means it's genuinely stuck or the
// model quietly stopped), surface a non-destructive "still working?" prompt
// instead of silently hanging. Replaces relying only on the tab-refocus
// recovery (which fired only on visibilitychange and silently reloaded).
let _stallWatchdog = null ;
let _stallBannerShown = false ;
const STALL _THRESHOLD _MS = 60000 ;
let _sendInFlight = false ; // covers the window from click → streaming start
let _displayOverride = null ; // Override visible user bubble text (hides injected prompts)
let _hideUserBubble = false ; // Skip user bubble entirely (e.g. continue after stop)
let _pendingContinue = null ; // Stores the stopped AI element to merge with new response
// ── Auto-recovery: when a turn's stream silently dies (connection drop) or
// goes quiet while the connection is alive, re-engage the model with a
// completion handshake instead of leaving it hung. Capped so it can't loop.
let _autoNudges = 0 ; // handshakes fired for the CURRENT user turn
let _autoContinuePending = false ; // marks the next submit as an auto-continue (don't reset the counter)
const _AUTO _NUDGE _CAP = 3 ;
// shortModel and modelColor are now in chatRenderer.js
var _shortModel = chatRenderer . shortModel ;
var _applyModelColor = chatRenderer . applyModelColor ;
// Per-session research tracking (supports concurrent research across sessions)
const _researchingStreamIds = new Set ( ) ;
let _researchTimerEl = null , _researchTimerInterval = null ;
let _researchStartTime = 0 , _researchAvgDuration = null ;
let _researchSynapse = null ;
function _clearResearchTimer ( ) {
if ( _researchTimerInterval ) { clearInterval ( _researchTimerInterval ) ; _researchTimerInterval = null ; }
if ( _researchTimerEl ) { _researchTimerEl . remove ( ) ; _researchTimerEl = null ; }
if ( _researchSynapse ) {
// Mark complete first so the user briefly sees the "done" state,
// then tear it down on next tick.
try { _researchSynapse . complete ( ) ; } catch { }
const s = _researchSynapse ;
_researchSynapse = null ;
setTimeout ( ( ) => { try { s . destroy ( ) ; } catch { } } , 800 ) ;
}
_researchStartTime = 0 ;
_researchAvgDuration = null ;
}
/** Append a "Generate Visual Report" button — delegates to chatRenderer. */
function _appendViewReportLink ( msgEl , sessionId ) {
const body = msgEl . querySelector ( '.body' ) ;
if ( body ) chatRenderer . appendReportButton ( body , sessionId ) ;
}
let currentAccumulated = '' ; // Track accumulated text across function scope
let currentHolder = null ; // Track current message holder
let currentSpinner = null ; // Track current spinner for stop cleanup
// Background streaming support
const _backgroundStreams = new Map ( ) ; // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
let _streamSessionId = null ; // Session ID for the currently active reader loop
let _lastReaderActivity = 0 ; // Timestamp of last reader.read() success — used to detect frozen streams
let _webLockRelease = null ; // Function to release the Web Lock held during streaming
/** Check if an SSE reader is still actively connected for a session. */
function hasActiveStream ( sessionId ) {
return _streamSessionId === sessionId || _backgroundStreams . has ( sessionId ) ;
}
// Sources box builder and toggleSources are now in chatRenderer.js
var _buildSourcesBox = chatRenderer . buildSourcesBox ;
// Browser notifications now in chatStream.js
var _notifyResearchComplete = chatStream . notifyResearchComplete ;
// Model/image pricing, _buildImageBubble now in chatRenderer.js
var _buildImageBubble = chatRenderer . buildImageBubble ;
var getModelCost = chatRenderer . getModelCost ;
var getImageCost = chatRenderer . getImageCost ;
// stripToolBlocks and roleTimestamp now in chatRenderer.js
var stripToolBlocks = chatRenderer . stripToolBlocks ;
function _normalizeEndpointForCompare ( url ) {
if ( ! url ) return '' ;
try {
const u = new URL ( String ( url ) , window . location . origin ) ;
let path = u . pathname . replace ( /\/+$/ , '' ) ;
const suffixes = [
'/v1/chat/completions' , '/chat/completions' ,
'/v1/completions' , '/completions' ,
'/v1/messages' , '/messages' ,
'/v1/models' , '/models' ,
] ;
for ( const suffix of suffixes ) {
if ( path . toLowerCase ( ) . endsWith ( suffix ) ) {
path = path . slice ( 0 , - suffix . length ) . replace ( /\/+$/ , '' ) ;
break ;
}
}
return ( u . origin + path ) . toLowerCase ( ) ;
} catch ( _ ) {
return String ( url ) . trim ( ) . replace ( /\/+$/ , '' ) . toLowerCase ( ) ;
}
}
async function _probeCurrentEndpointStatus ( endpointUrl , signal ) {
const target = _normalizeEndpointForCompare ( endpointUrl ) ;
if ( ! target ) return null ;
const modelsRes = await fetch ( ` ${ API _BASE } /api/models ` , { credentials : 'same-origin' , signal } ) ;
if ( ! modelsRes . ok ) return null ;
const modelsData = await modelsRes . json ( ) . catch ( ( ) => ( { } ) ) ;
const item = ( modelsData . items || [ ] ) . find ( ep =>
_normalizeEndpointForCompare ( ep . url || ep . endpoint _url || ep . base _url ) === target
) ;
if ( ! item || ! item . endpoint _id ) return null ;
const probesRes = await fetch ( ` ${ API _BASE } /api/model-endpoints/probe-local ` , {
credentials : 'same-origin' ,
signal ,
} ) ;
if ( ! probesRes . ok ) return null ;
const probes = await probesRes . json ( ) . catch ( ( ) => ( { } ) ) ;
return probes [ item . endpoint _id ] || null ;
}
/ * *
* Initialize with dependencies
* /
export function init ( apiBase ) {
API _BASE = apiBase ;
initSlashCommands ( { apiBase , isStreaming : ( ) => isStreaming } ) ;
// Initialize email inbox
emailInbox . init ( documentModule ) ;
}
// addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen
// are now in chatRenderer.js — referenced via the public API delegation above.
var addMessage = chatRenderer . addMessage ;
var createMsgFooter = chatRenderer . createMsgFooter ;
var displayMetrics = chatRenderer . displayMetrics ;
var hideWelcomeScreen = chatRenderer . hideWelcomeScreen ;
var showWelcomeScreen = chatRenderer . showWelcomeScreen ;
/ * *
* Update submit button state
* /
function updateSubmitButton ( state , submitBtn ) {
if ( ! submitBtn ) return ;
if ( state === 'streaming' ) {
// Clear any pending transitions from + → arrow swap
submitBtn . classList . remove ( 'anim-spin' , 'anim-spin-swap' , 'anim-land' , 'mic-mode' , 'newchat-mode' , 'newchat-expanded' , 'recording' ) ;
// Ensure arrow icon is showing before launch
var icons = window . _odysseusBtnIcons ;
if ( icons ) submitBtn . innerHTML = icons . send ;
void submitBtn . offsetWidth ;
// Arrow launches up, then stop icon lands in
submitBtn . classList . add ( 'anim-launch' ) ;
const _stopSvg = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>' ;
// Wait for the launch keyframe to finish (0.3s) before swapping the
// arrow out for the stop icon — otherwise the swap happens mid-flight
// and the user sees nothing fly out.
setTimeout ( ( ) => {
submitBtn . innerHTML = _stopSvg ;
submitBtn . classList . remove ( 'anim-launch' ) ;
void submitBtn . offsetWidth ;
submitBtn . classList . add ( 'anim-land' ) ;
submitBtn . addEventListener ( 'animationend' , ( ) => submitBtn . classList . remove ( 'anim-land' ) , { once : true } ) ;
} , 300 ) ;
submitBtn . title = 'Stop generation' ;
submitBtn . dataset . mode = 'streaming' ;
submitBtn . dataset . phase = 'processing' ;
isStreaming = true ;
_startStallWatchdog ( ) ;
} else if ( state === 'idle' ) {
submitBtn . dataset . mode = '' ;
delete submitBtn . dataset . phase ;
submitBtn . classList . remove ( 'recording' ) ;
isStreaming = false ;
_stopStallWatchdog ( ) ;
// Defer to global updater which handles mic/newchat/send modes
if ( window . _updateSendBtnIcon ) {
setTimeout ( window . _updateSendBtnIcon , 50 ) ;
} else {
var icons = window . _odysseusBtnIcons ;
submitBtn . innerHTML = icons ? icons . send : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>' ;
submitBtn . title = 'Send message' ;
submitBtn . classList . remove ( 'mic-mode' , 'newchat-mode' ) ;
}
}
}
// -----------------------------------------------------------------------
// Slash commands — now in slashCommands.js
// -----------------------------------------------------------------------
// API key pattern for the guard in handleChatSubmit
const API _KEY _RE = /^(sk-[a-zA-Z0-9_\-]{20,}|gsk_[a-zA-Z0-9]{20,}|AIza[a-zA-Z0-9_\-]{30,}|xai-[a-zA-Z0-9]{20,})$/ ;
/ * *
* Handle chat form submission
* /
export async function handleChatSubmit ( e ) {
e . preventDefault ( ) ;
// Cancel research clarification timeout if active
if ( window . _researchTimeoutTimer ) {
clearTimeout ( window . _researchTimeoutTimer ) ;
window . _researchTimeoutTimer = null ;
}
// Get current session
const sessionId = sessionModule . getCurrentSessionId ( ) ;
const session = sessionModule . getSessions ( ) . find ( s => s . id === sessionId ) ;
const submitBtn = document . querySelector ( '.send-btn' ) ;
// If compare is active, stop all compare streams
if ( window . compareModule && window . compareModule . isActive ( ) ) {
window . compareModule . handleCompareSubmit ( ) ;
return ;
}
// If currently streaming, stop it
if ( isStreaming ) {
// Cancel server-side research if in progress
const _cancelSid = sessionModule . getCurrentSessionId ( ) ;
if ( _cancelSid && _researchingStreamIds . has ( _cancelSid ) ) {
fetch ( ` ${ API _BASE } /api/research/cancel/ ${ _cancelSid } ` , { method : 'POST' } ) . catch ( e => console . warn ( 'Research cancel failed:' , e ) ) ;
_researchingStreamIds . delete ( _cancelSid ) ;
_clearResearchTimer ( ) ;
}
abortCurrentRequest ( true ) ; // explicit user Stop → also cancel the detached server run
// Clean up any running agent thread nodes (stop wave animation, remove "running" state)
document . querySelectorAll ( '.agent-thread-node.running' ) . forEach ( node => {
if ( node . _waveInterval ) { clearInterval ( node . _waveInterval ) ; node . _waveInterval = null ; }
if ( node . _elapsedTicker ) { clearInterval ( node . _elapsedTicker ) ; node . _elapsedTicker = null ; }
node . classList . remove ( 'running' ) ;
const wave = node . querySelector ( '.agent-thread-wave' ) ;
if ( wave ) wave . textContent = '' ;
const icon = node . querySelector ( '.agent-thread-icon' ) ;
if ( icon ) icon . textContent = '\u25A0' ; // stop square
const statusEl = node . querySelector ( '.agent-thread-status' ) ;
if ( ! statusEl ) {
const header = node . querySelector ( '.agent-thread-header' ) ;
if ( header ) {
const s = document . createElement ( 'span' ) ;
s . className = 'agent-thread-status' ;
s . textContent = 'stopped' ;
header . appendChild ( s ) ;
}
}
} ) ;
document . querySelectorAll ( '.agent-thread.streaming' ) . forEach ( t => t . classList . remove ( 'streaming' ) ) ;
// Clean up any thinking spinners
document . querySelectorAll ( '.agent-thinking-dots' ) . forEach ( el => {
if ( el . _spinner ) el . _spinner . destroy ( ) ;
el . remove ( ) ;
} ) ;
// No text accumulated — remove the empty holder with spinner
if ( currentHolder && ! currentAccumulated ) {
if ( currentSpinner ) { currentSpinner . destroy ( ) ; currentSpinner = null ; }
// Empty cancel — keep the assistant bubble around with a "Cancelled
// by user" indicator and persist a placeholder server-side so the
// turn survives a refresh instead of vanishing without a trace.
_renderCancelledBubble ( currentHolder ) ;
currentHolder = null ;
updateSubmitButton ( 'idle' , submitBtn ) ;
const messageInput = uiModule . el ( 'message' ) ;
if ( messageInput ) messageInput . disabled = false ;
currentAccumulated = '' ;
return ;
}
// Render whatever was accumulated so far
if ( currentHolder && currentAccumulated ) {
// Store accumulated in a closure variable before it gets cleared
const stoppedContent = currentAccumulated ;
// Store raw content in dataset for consistency with other messages
currentHolder . dataset . raw = stoppedContent ;
currentHolder . querySelector ( '.body' ) . innerHTML = markdownModule . processWithThinking (
markdownModule . squashOutsideCode ( stoppedContent )
) ;
// Highlight code blocks
if ( window . hljs ) {
currentHolder . querySelectorAll ( 'pre code' ) . forEach ( ( block ) => {
window . hljs . highlightElement ( block ) ;
} ) ;
}
// Add the stopped indicator with continue button
const stoppedIndicator = document . createElement ( 'div' ) ;
stoppedIndicator . className = 'stopped-indicator' ;
const stoppedLabel = document . createElement ( 'span' ) ;
stoppedLabel . textContent = '[Message interrupted]' ;
stoppedIndicator . appendChild ( stoppedLabel ) ;
const continueBtn = document . createElement ( 'button' ) ;
continueBtn . className = 'continue-btn' ;
continueBtn . title = 'Continue' ;
continueBtn . textContent = '\u25B8' ;
const _stoppedHolder = currentHolder ; // capture before it gets cleared
continueBtn . addEventListener ( 'click' , ( ) => {
stoppedIndicator . remove ( ) ;
_hideUserBubble = true ;
_pendingContinue = _stoppedHolder ;
const cutoff = stoppedContent ;
const msgInput = uiModule . el ( 'message' ) ;
if ( msgInput ) {
msgInput . value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff . slice ( - 500 ) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.' ;
const sb = document . querySelector ( '.send-btn' ) ;
if ( sb ) sb . click ( ) ;
}
} ) ;
stoppedIndicator . appendChild ( continueBtn ) ;
currentHolder . querySelector ( '.body' ) . appendChild ( stoppedIndicator ) ;
// Tell server to mark this message as stopped
const _sid = sessionModule . getCurrentSessionId ( ) ;
if ( _sid ) fetch ( ` ${ API _BASE } /api/session/ ${ _sid } /mark-stopped ` , { method : 'POST' } ) . catch ( e => console . warn ( 'mark-stopped failed:' , e ) ) ;
// Add footer with copy/regen if not already present
if ( ! currentHolder . querySelector ( '.msg-footer' ) ) {
currentHolder . dataset . raw = stoppedContent ;
currentHolder . appendChild ( createMsgFooter ( currentHolder ) ) ;
}
uiModule . scrollHistory ( ) ;
}
// Reset button state
updateSubmitButton ( 'idle' , submitBtn ) ;
// Re-enable message input
const messageInput = uiModule . el ( 'message' ) ;
if ( messageInput ) messageInput . disabled = false ;
// Clear tracking variables
currentAccumulated = '' ;
currentHolder = null ;
return ;
}
// --- Send-path entry: block re-clicks between submit and stream start ---
if ( _sendInFlight ) return ;
_sendInFlight = true ;
// Instant visual feedback so the user sees their click was accepted
// even before the streaming button state kicks in below.
const _earlyMessageInput = uiModule . el ( 'message' ) ;
if ( _earlyMessageInput ) _earlyMessageInput . disabled = true ;
if ( submitBtn ) submitBtn . classList . add ( 'send-pending' ) ;
const _releaseSendFlag = ( ) => {
_sendInFlight = false ;
if ( _earlyMessageInput ) _earlyMessageInput . disabled = false ;
if ( submitBtn ) submitBtn . classList . remove ( 'send-pending' ) ;
} ;
// --- Setup mode: intercept next message (but let slash commands through) ---
{
const el = uiModule . el ;
const rawMsg = ( el ( 'message' ) . value || '' ) . trim ( ) ;
const currentSetupMode = slashCommands . getSetupMode ( ) ;
if ( currentSetupMode && rawMsg && ! isCommand ( rawMsg ) ) {
const mode = currentSetupMode ;
slashCommands . clearSetupMode ( mode === 'endpoint-provider' || mode === 'endpoint-key-for-provider' ) ;
el ( 'message' ) . value = '' ;
if ( window . _syncModelPickerAutohide ) window . _syncModelPickerAutohide ( ) ;
if ( uiModule . autoResize ) uiModule . autoResize ( el ( 'message' ) ) ;
if ( mode === true || mode === 'endpoint' ) {
handleSetupInput ( rawMsg ) ;
} else {
handleSetupWizard ( mode , rawMsg ) ;
}
_releaseSendFlag ( ) ;
return ;
}
if ( currentSetupMode && rawMsg && isCommand ( rawMsg ) ) {
slashCommands . clearSetupMode ( ) ; // Clear setup mode, fall through to slash handler
}
}
const el = uiModule . el ;
const msg = el ( 'message' ) . value ;
// Allow empty text when a regen carries over the original message's
// attachment ids — a photo-only message still has something to send.
if ( ! msg . trim ( ) && ! fileHandlerModule . getPendingCount ( ) && ! ( _pendingRegenAttachments && _pendingRegenAttachments . length ) ) { _releaseSendFlag ( ) ; return ; }
// --- Slash commands: execute directly without AI (no session needed) ---
if ( isCommand ( msg . trim ( ) ) ) {
const handled = await handleSlashCommand ( msg . trim ( ) ) ;
if ( handled ) {
el ( 'message' ) . value = '' ;
if ( window . _syncModelPickerAutohide ) window . _syncModelPickerAutohide ( ) ;
if ( uiModule . autoResize ) uiModule . autoResize ( el ( 'message' ) ) ;
_releaseSendFlag ( ) ;
return ;
}
}
// Materialize pending session (deferred from model click) on first message
if ( sessionModule . hasPendingChat && sessionModule . hasPendingChat ( ) ) {
const ok = await sessionModule . materializePendingSession ( ) ;
if ( ! ok || ! sessionModule . getCurrentSessionId ( ) ) { _releaseSendFlag ( ) ; return ; }
}
if ( ! sessionModule . getCurrentSessionId ( ) ) {
// Auto-create a session using default chat config. Always fetch fresh
// so that a recent Settings change takes effect without a page reload.
try {
let dc = null ;
try {
const dcRes = await fetch ( '/api/default-chat' ) ;
dc = await dcRes . json ( ) ;
if ( dc && dc . endpoint _url && dc . model ) {
try { window . _ _odysseusDefaultChat = dc ; } catch ( _ ) { }
}
} catch ( _ ) {
dc = ( typeof window !== 'undefined' && window . _ _odysseusDefaultChat ) || null ;
}
if ( dc . endpoint _url && dc . model ) {
await sessionModule . createDirectChat ( dc . endpoint _url , dc . model , dc . endpoint _id ) ;
const ok = await sessionModule . materializePendingSession ( ) ;
if ( ! ok || ! sessionModule . getCurrentSessionId ( ) ) { _releaseSendFlag ( ) ; return ; }
} else {
addMessage ( 'assistant' ,
'No chat session active. You can:\n\n' +
2026-06-01 10:00:15 +09:00
'- Open the model picker in the chat box and pick a model\n' +
'- Use the `+` button in the model picker to add a model endpoint\n' +
2026-05-31 23:58:26 +09:00
'- Use `/help` to see all available commands' ) ;
_releaseSendFlag ( ) ;
return ;
}
} catch ( e ) {
addMessage ( 'assistant' ,
'No chat session active. You can:\n\n' +
2026-06-01 10:00:15 +09:00
'- Open the model picker in the chat box and pick a model\n' +
'- Use the `+` button in the model picker to add a model endpoint\n' +
2026-05-31 23:58:26 +09:00
'- Use `/help` to see all available commands' ) ;
_releaseSendFlag ( ) ;
return ;
}
}
// --- API key guard: warn if message looks like an API key ---
if ( API _KEY _RE . test ( msg . trim ( ) ) ) {
if ( ! await window . styledConfirm ( 'This looks like an API key. Sending it to the AI could expose it.\n\nDid you mean to use /setup instead?' , { confirmText : 'Send anyway' , danger : true } ) ) {
_releaseSendFlag ( ) ;
return ;
}
}
const messageInput = el ( 'message' ) ;
const originalBtnText = submitBtn ? submitBtn . innerHTML : '' ;
// Re-enable the textarea now that we've handed off to the stream: the
// user wants to compose the next message while the AI is still talking.
// The `isStreaming` flag is the re-click guard for the send button.
if ( messageInput ) messageInput . disabled = false ;
updateSubmitButton ( 'streaming' , submitBtn ) ;
if ( submitBtn ) submitBtn . classList . remove ( 'send-pending' ) ;
_sendInFlight = false ;
// Capture session ID for background stream detection
const streamSessionId = sessionModule . getCurrentSessionId ( ) ;
_streamSessionId = streamSessionId ;
const streamQuery = msg ;
_lastReaderActivity = Date . now ( ) ;
// Acquire Web Lock to hint browser not to discard this tab while streaming
if ( navigator . locks ) {
navigator . locks . request ( 'odysseus-stream-' + streamSessionId , { mode : 'exclusive' , ifAvailable : true } , lock => {
if ( ! lock ) return ; // Another stream already holds a lock — fine
return new Promise ( resolve => { _webLockRelease = resolve ; } ) ;
} ) . catch ( e => console . warn ( 'web lock acquire failed:' , e ) ) ; // Ignore lock errors — best-effort
}
// Declare accumulated outside try block so it's accessible in catch
let accumulated = '' ;
let holder = null ;
let finalMeta = null ;
let finalModelName = null ;
let spinner = null ;
let timedOut = false ;
let processingProbeTimer = null ;
let processingProbeAbort = null ;
2026-06-01 16:33:35 +03:00
let _renderStream = ( ) => { } ;
let _cancelThinkingTimer = ( ) => { } ;
let _removeThinkingSpinner = ( ) => { } ;
2026-05-31 23:58:26 +09:00
const clearProcessingProbe = ( ) => {
if ( processingProbeTimer ) {
clearTimeout ( processingProbeTimer ) ;
processingProbeTimer = null ;
}
if ( processingProbeAbort ) {
try { processingProbeAbort . abort ( ) ; } catch ( _ ) { }
processingProbeAbort = null ;
}
} ;
// Reset tracking variables at start
currentAccumulated = '' ;
currentHolder = null ;
try {
// Re-enable auto-scroll when user sends a message
uiModule . setAutoScroll ( true ) ;
uiModule . scrollHistoryInstant ( ) ;
// Clear completed dot now that user is interacting
if ( sessionModule . clearStreamComplete ) sessionModule . clearStreamComplete ( sessionModule . getCurrentSessionId ( ) ) ;
// Check for document selection context before consuming display override
const docSel = documentModule && documentModule . getSelectionContext ( ) ;
if ( docSel ) {
const sels = Array . isArray ( docSel ) ? docSel : [ docSel ] ;
const lineRefs = sels . map ( s =>
s . startLine === s . endLine ? ` L ${ s . startLine } ` : ` L ${ s . startLine } - ${ s . endLine } `
) ;
_displayOverride = ` [Doc edit: ${ lineRefs . join ( ', ' ) } ] ${ msg } ` ;
}
const userDisplay = _displayOverride || msg ;
_displayOverride = null ;
const skipBubble = _hideUserBubble ;
_hideUserBubble = false ;
// Auto-recovery counter: carries across a turn's auto-continues, but resets
// when the user genuinely sends a new message (so each task gets a fresh cap).
// A real user turn (visible bubble) ALWAYS resets the budget — even if a
// prior auto-continue's deferred click never cleared the pending flag — so a
// stuck flag can't silently eat the next turn's recovery budget.
if ( ! skipBubble ) { _autoNudges = 0 ; _autoContinuePending = false ; }
else if ( _autoContinuePending ) { _autoContinuePending = false ; }
const _pendingAttachInfo = fileHandlerModule . getPendingCount ( ) ? fileHandlerModule . getPendingInfo ( ) : null ;
// Pre-read importable file contents before upload clears pending files
const IMPORTABLE _EXT = /\.(txt|py|js|ts|html|htm|css|md|json|csv|yml|yaml|sh|sql|rs|go|java|c|cpp|h|rb|php|xml|jsx|tsx|log|toml|ini|conf|env|vue|svelte|scss|sass|less)$/i ;
const _importableFiles = [ ] ;
if ( _pendingAttachInfo && documentModule ) {
const rawFiles = fileHandlerModule . getPendingRaw ? fileHandlerModule . getPendingRaw ( ) : [ ] ;
for ( let i = 0 ; i < _pendingAttachInfo . length ; i ++ ) {
const att = _pendingAttachInfo [ i ] ;
if ( IMPORTABLE _EXT . test ( att . name ) && rawFiles [ i ] ) {
_importableFiles . push ( { info : att , file : rawFiles [ i ] } ) ;
}
}
}
let _userMsgEl = null ;
if ( ! skipBubble ) {
_userMsgEl = addMessage ( 'user' , userDisplay , null , _pendingAttachInfo ? { attachments : _pendingAttachInfo } : null ) ;
}
messageInput . value = '' ;
messageInput . style . height = '' ;
messageInput . dispatchEvent ( new Event ( 'input' ) ) ;
// Mobile: dismiss the on-screen keyboard after sending. iOS in
// particular ignores a bare blur() in some cases (or some other
// listener refocuses straight after), so we temporarily mark the
// input readonly which forces the keyboard to retract, then blur,
// then drop the readonly attribute after the keyboard is gone so
// typing still works for the next message.
if ( window . innerWidth <= 768 ) {
try {
messageInput . setAttribute ( 'readonly' , 'readonly' ) ;
messageInput . blur ( ) ;
const _dropReadonly = ( ) => { try { messageInput . removeAttribute ( 'readonly' ) ; } catch { } } ;
setTimeout ( ( ) => {
// If the blur stuck, the input is no longer the active element —
// safe to drop readonly now so the next message can be typed.
// If it did NOT stick (some mobile browsers keep the textarea
// focused after a programmatic blur), removing readonly here would
// re-summon the keyboard mid-stream — the "bounce up" that then
// lingers until the end-of-stream blur. In that case keep readonly
// on (keyboard stays down) and drop it the moment the user taps to
// type again, so typing still works without the bounce.
if ( document . activeElement === messageInput ) {
messageInput . addEventListener ( 'pointerdown' , _dropReadonly , { once : true } ) ;
messageInput . addEventListener ( 'focus' , _dropReadonly , { once : true } ) ;
} else {
_dropReadonly ( ) ;
}
} , 120 ) ;
} catch { }
}
let ids = [ ] ;
try {
ids = await fileHandlerModule . uploadPending ( ) ;
} catch ( e ) {
console . error ( 'upload failed' , e ) ;
}
// Carry over the original message's file-ids on a regenerate so the new
// send still references the same photos / docs (and picks up the user's
// edited OCR text via the server-side .vision cache). Always CONSUME the
// slot — even when empty / errored — so the regen ids can't bleed into
// an unrelated next message if uploadPending() above had thrown.
if ( _pendingRegenAttachments && _pendingRegenAttachments . length ) {
ids = ids . concat ( _pendingRegenAttachments ) ;
}
_pendingRegenAttachments = null ;
// The optimistic user bubble was rendered before the upload assigned ids,
// so image previews couldn't show (the renderer needs att.id). Now that
// the upload resolved, stamp the ids — plus width/height for images so
// the skeleton can size itself to the photo's aspect ratio — and
// re-render so the thumbnail appears live, no refresh needed.
if ( _userMsgEl && _pendingAttachInfo && ids . length ) {
const _meta = fileHandlerModule . getLastUploadedMeta ? . ( ) || [ ] ;
for ( let i = 0 ; i < _pendingAttachInfo . length && i < ids . length ; i ++ ) {
_pendingAttachInfo [ i ] . id = ids [ i ] ;
const _m = _meta [ i ] ;
if ( _m ) {
if ( _m . width ) _pendingAttachInfo [ i ] . width = _m . width ;
if ( _m . height ) _pendingAttachInfo [ i ] . height = _m . height ;
}
}
chatRenderer . updateMessageAttachments ( _userMsgEl , _pendingAttachInfo ) ;
}
// Offer to import text files to document library
if ( _importableFiles . length > 0 ) {
const existing = document . getElementById ( 'import-prompt-banner' ) ;
if ( existing ) existing . remove ( ) ;
const banner = document . createElement ( 'div' ) ;
banner . id = 'import-prompt-banner' ;
banner . className = 'import-prompt-banner' ;
const label = _importableFiles . length === 1
? ` Import " ${ _importableFiles [ 0 ] . info . name } " to document library? `
: ` Import ${ _importableFiles . length } files to document library? ` ;
const textEl = document . createElement ( 'span' ) ;
textEl . textContent = label ;
banner . appendChild ( textEl ) ;
const importBtn = document . createElement ( 'button' ) ;
importBtn . textContent = 'Import' ;
importBtn . addEventListener ( 'click' , async ( ) => {
importBtn . disabled = true ;
importBtn . textContent = 'Importing…' ;
const EXT _LANG = { '.py' : 'python' , '.js' : 'javascript' , '.ts' : 'typescript' , '.html' : 'html' , '.css' : 'css' , '.md' : 'markdown' , '.json' : 'json' , '.yml' : 'yaml' , '.yaml' : 'yaml' , '.sh' : 'bash' , '.sql' : 'sql' , '.rs' : 'rust' , '.go' : 'go' , '.java' : 'java' , '.c' : 'c' , '.cpp' : 'cpp' , '.rb' : 'ruby' , '.php' : 'php' , '.xml' : 'xml' , '.jsx' : 'javascript' , '.tsx' : 'typescript' } ;
let imported = 0 ;
for ( const { info , file } of _importableFiles ) {
try {
const content = await file . text ( ) ;
const dotIdx = info . name . lastIndexOf ( '.' ) ;
const title = dotIdx > 0 ? info . name . slice ( 0 , dotIdx ) : info . name ;
const ext = dotIdx >= 0 ? info . name . slice ( dotIdx ) . toLowerCase ( ) : '' ;
await fetch ( ` ${ API _BASE } /api/document ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { title , language : EXT _LANG [ ext ] || '' , content } ) ,
} ) ;
imported ++ ;
} catch ( e ) { console . error ( 'Import failed:' , info . name , e ) ; }
}
banner . textContent = ` Imported ${ imported } file ${ imported !== 1 ? 's' : '' } ` ;
setTimeout ( ( ) => banner . remove ( ) , 2000 ) ;
} ) ;
banner . appendChild ( importBtn ) ;
const dismissBtn = document . createElement ( 'button' ) ;
dismissBtn . textContent = '\u00d7' ;
dismissBtn . className = 'import-prompt-dismiss' ;
dismissBtn . addEventListener ( 'click' , ( ) => banner . remove ( ) ) ;
banner . appendChild ( dismissBtn ) ;
const chatBar = document . getElementById ( 'chat-bar' ) ;
if ( chatBar ) chatBar . parentNode . insertBefore ( banner , chatBar ) ;
// Auto-dismiss after 15 seconds
setTimeout ( ( ) => { if ( banner . parentNode ) banner . remove ( ) ; } , 15000 ) ;
}
// Auto-save document editor content before sending so the AI sees latest text
if ( documentModule && documentModule . isPanelOpen ( ) && documentModule . getCurrentDocId ( ) ) {
try { await documentModule . saveDocument ( ) ; } catch ( e ) { console . warn ( 'doc auto-save failed' , e ) ; }
}
// Inject document selection context if present
let finalMsg = msg ;
if ( docSel ) {
const sels = Array . isArray ( docSel ) ? docSel : [ docSel ] ;
if ( sels . length === 1 ) {
const s = sels [ 0 ] ;
const lineRef = s . startLine === s . endLine ? ` line ${ s . startLine } ` : ` lines ${ s . startLine } - ${ s . endLine } ` ;
finalMsg = ` In the document, edit this specific text ( ${ lineRef } ): \n \` \` \` \n ${ s . text } \n \` \` \` \n \n Instruction: ${ msg } ` ;
} else {
const parts = sels . map ( ( s , i ) => {
const lineRef = s . startLine === s . endLine ? ` line ${ s . startLine } ` : ` lines ${ s . startLine } - ${ s . endLine } ` ;
return ` Selection ${ i + 1 } ( ${ lineRef } ): \n \` \` \` \n ${ s . text } \n \` \` \` ` ;
} ) ;
finalMsg = ` In the document, edit these specific sections: \n \n ${ parts . join ( '\n\n' ) } \n \n Instruction: ${ msg } ` ;
}
}
// Apply inject prefix/suffix
const _inject = presetsModule . getInject ? presetsModule . getInject ( ) : { prefix : '' , suffix : '' } ;
let _finalMsgWithInject = finalMsg ;
if ( _inject . prefix ) _finalMsgWithInject = _inject . prefix + ' ' + _finalMsgWithInject ;
if ( _inject . suffix ) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject . suffix ;
const fd = new FormData ( ) ;
fd . append ( 'message' , _finalMsgWithInject ) ;
fd . append ( 'session' , streamSessionId ) ;
if ( ids . length ) fd . append ( 'attachments' , JSON . stringify ( ids ) ) ;
// Auto-save & send active doc ID so the backend sees latest content
if ( documentModule && documentModule . isPanelOpen ( ) && documentModule . getCurrentDocId ( ) ) {
try { await documentModule . saveDocument ( { silent : true } ) ; } catch ( _e ) { /* best-effort */ }
fd . append ( 'active_doc_id' , documentModule . getCurrentDocId ( ) ) ;
}
// Web toggle: pre-search in Chat mode, tool permission in Agent mode
const toggleState = Storage . loadToggleState ( ) ;
let isAgentMode = ( toggleState . mode || 'chat' ) === 'agent' ;
// Auto-escalate to agent mode when a document is open — the user expects
// the AI to see the document and have tools to edit it
if ( ! isAgentMode && documentModule && documentModule . isPanelOpen ( ) && documentModule . getCurrentDocId ( ) ) {
isAgentMode = true ;
}
fd . append ( 'mode' , isAgentMode ? 'agent' : 'chat' ) ;
if ( el ( 'web-toggle' ) . checked ) {
if ( isAgentMode ) {
fd . append ( 'allow_web_search' , 'true' ) ;
} else {
fd . append ( 'use_web' , 'true' ) ;
}
}
if ( el ( 'research-toggle' ) . checked ) {
fd . append ( 'use_research' , 'true' ) ;
// Research always runs in chat mode — override agent if set
fd . set ( 'mode' , 'chat' ) ;
}
if ( el ( 'bash-toggle' ) . checked ) {
fd . append ( 'allow_bash' , 'true' ) ;
}
const ragChk = el ( 'rag-toggle' ) ;
if ( ragChk && ! ragChk . checked ) {
fd . append ( 'use_rag' , 'false' ) ;
}
const incognitoChk = el ( 'incognito-toggle' ) ;
if ( incognitoChk && incognitoChk . checked ) {
fd . append ( 'incognito' , 'true' ) ;
}
if ( presetsModule . getSelectedPreset ( ) ) {
fd . append ( 'preset_id' , presetsModule . getSelectedPreset ( ) ) ;
}
const abortCtrl = new AbortController ( ) ;
abortCtrl . _reason = '' ;
currentAbort = abortCtrl ;
const _tState = Storage . loadToggleState ( ) ;
const _isAgent = ( _tState . mode || 'chat' ) === 'agent' ;
// Timeout: 6 min for research and agent mode, 3 min otherwise
const timeoutMs = el ( 'research-toggle' ) . checked || _isAgent ? RESEARCH _TIMEOUT _MS : DEFAULT _TIMEOUT _MS ;
const timeoutId = setTimeout ( ( ) => {
if ( ! abortCtrl . signal . aborted ) {
timedOut = true ;
abortCtrl . _reason = 'timeout' ;
abortCtrl . abort ( ) ;
}
} , timeoutMs ) ;
const box = el ( 'chat-history' ) ;
holder = document . createElement ( 'div' ) ;
holder . className = 'msg msg-ai streaming' ;
// Track holder globally so stop button can access it
currentHolder = holder ;
holder . _researchQuery = msg ; // Store query for notification text
const modelName = sessionModule . getCurrentModel ( ) || null ;
let loadingText = 'Initializing...' ;
if ( el ( 'web-toggle' ) . checked && ! _isAgent ) {
const _searchLabel = searchModule ? searchModule . getProviderLabel ( ) : 'web' ;
loadingText = ` Searching via ${ _searchLabel } ...<br>
< span style = "font-size: 0.9em; opacity: 0.8;" >
Query : "${msg.substring(0, 50)}${msg.length > 50 ? '...' : ''}" < br >
Fetching top results ... < / s p a n > ` ;
} else if ( el ( 'research-toggle' ) . checked ) {
loadingText = 'Deep research mode active...' ;
} else {
loadingText = 'Processing request...' ;
}
var roleLabel = _shortModel ( modelName ) ;
var _charNameInit = presetsModule . getCharacterName ? presetsModule . getCharacterName ( ) : '' ;
if ( _charNameInit ) roleLabel = _charNameInit ;
const roleTs = new Date ( ) . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } ) ;
holder . innerHTML = ` <div class="role"> ${ roleLabel } <span class="role-timestamp"> ${ roleTs } </span></div><div class="body"></div> ` ;
_applyModelColor ( holder . querySelector ( '.role' ) , modelName ) ;
holder . style . position = 'relative' ;
// Create spinner
spinner = spinnerModule . create ( 'Initializing' , 'right' , 'wave' ) ;
currentSpinner = spinner ;
const bodyDiv = holder . querySelector ( '.body' ) ;
bodyDiv . appendChild ( spinner . createElement ( ) ) ;
spinner . start ( ) ;
// Update spinner message based on mode
if ( el ( 'web-toggle' ) . checked && ! _isAgent ) {
spinner . updateMessage ( 'Searching web with ' + ( searchModule ? searchModule . getProviderLabel ( ) : 'SearXNG' ) ) ;
setTimeout ( ( ) => spinner . updateMessage ( 'Processing results' ) , 1500 ) ;
} else if ( el ( 'research-toggle' ) . checked ) {
spinner . updateMessage ( 'Researching' ) ;
setTimeout ( ( ) => spinner . updateMessage ( 'Analyzing sources' ) , 1500 ) ;
} else {
spinner . updateMessage ( 'Processing request' ) ;
const endpointUrlForProbe = sessionModule . getCurrentEndpointUrl ? sessionModule . getCurrentEndpointUrl ( ) : null ;
if ( endpointUrlForProbe && modelName ) {
processingProbeTimer = setTimeout ( async ( ) => {
processingProbeTimer = null ;
if ( accumulated || ! spinner || ! spinner . element || ( currentAbort && currentAbort . signal . aborted ) ) return ;
processingProbeAbort = new AbortController ( ) ;
try {
spinner . updateMessage ( 'Checking model endpoint' ) ;
const status = await _probeCurrentEndpointStatus ( endpointUrlForProbe , processingProbeAbort . signal ) ;
if ( accumulated || ! spinner || ! spinner . element || ( currentAbort && currentAbort . signal . aborted ) ) return ;
if ( ! status ) {
spinner . updateMessage ( 'Still waiting for model' ) ;
} else if ( status . alive ) {
const latency = status . latency _ms ? ` ( ${ status . latency _ms } ms) ` : '' ;
spinner . updateMessage ( ` Endpoint online ${ latency } ; waiting for first token ` ) ;
} else {
// Probe confirms the endpoint isn't responding. Don't
// sit on a hung fetch — give the user 5s to read the
// status, then auto-abort with reason='offline' so the
// catch handler shows a clean "switch model" message
// instead of leaving the spinner spinning forever.
if ( status . error ) console . warn ( 'Model endpoint probe failed:' , status . error ) ;
let _countdown = 5 ;
spinner . updateMessage ( ` Endpoint offline — cancelling in ${ _countdown } s ` ) ;
const _tick = setInterval ( ( ) => {
_countdown -- ;
if ( ! spinner || ! spinner . element || ( currentAbort && currentAbort . signal . aborted ) || accumulated ) {
clearInterval ( _tick ) ;
return ;
}
if ( _countdown > 0 ) {
spinner . updateMessage ( ` Endpoint offline — cancelling in ${ _countdown } s ` ) ;
} else {
clearInterval ( _tick ) ;
if ( currentAbort && ! currentAbort . signal . aborted ) {
currentAbort . _reason = 'offline' ;
currentAbort . abort ( ) ;
}
}
} , 1000 ) ;
}
} catch ( e ) {
if ( e && e . name !== 'AbortError' && spinner && spinner . element && ! accumulated ) {
spinner . updateMessage ( 'Still waiting for model' ) ;
}
} finally {
processingProbeAbort = null ;
}
} , 10000 ) ;
}
}
const researchBtn = el ( 'research-toggle-btn' ) ;
if ( el ( 'research-toggle' ) . checked && researchBtn ) {
researchBtn . disabled = true ;
researchBtn . classList . remove ( 'active' ) ;
}
box . appendChild ( holder ) ;
uiModule . scrollHistory ( ) ;
const enableResearchBtn = ( ) => {
if ( ! researchBtn ) return ;
researchBtn . disabled = false ;
researchBtn . classList . toggle ( 'active' , el ( 'research-toggle' ) . checked ) ;
} ;
if ( el ( 'research-toggle' ) . checked && researchBtn ) {
researchBtn . style . display = 'none' ;
// Uncheck research toggle so follow-up messages don't trigger another research
el ( 'research-toggle' ) . checked = false ;
}
// User's current UTC offset in minutes (east of UTC). Threaded into
// the agent so natural-language times like "today at 9pm" are
// interpreted in YOUR timezone, not the server's.
const _tzOffsetMin = - new Date ( ) . getTimezoneOffset ( ) ;
const res = await fetch ( ` ${ API _BASE } /api/chat_stream ` , {
method : 'POST' ,
body : fd ,
headers : { 'X-Tz-Offset' : String ( _tzOffsetMin ) } ,
signal : abortCtrl . signal
} ) ;
clearTimeout ( timeoutId ) ;
if ( ! res . ok ) {
if ( res . status === 404 ) {
// Session was deleted (e.g. by AI) — reload and go to welcome
holder . remove ( ) ;
if ( sessionModule ) await sessionModule . loadSessions ( ) ;
return ;
}
let errText = ` Error ${ res . status } ` ;
try {
const errBody = await res . text ( ) ;
// Parse nested JSON error if present
const m = errBody . match ( /"message"\s*:\s*"([^"]+)"/ ) ;
if ( m ) errText = m [ 1 ] . replace ( /\\"/g , '"' ) ;
else if ( errBody . length < 200 ) errText = errBody ;
} catch { }
// Auto-switch to chat mode for tool-related errors
if ( errText . includes ( 'tool' ) || errText . includes ( 'auto' ) ) {
errText = 'This model doesn\'t support agent tools — switched to Chat mode. Try again.' ;
const _ab = document . getElementById ( 'mode-agent-btn' ) ;
const _cb = document . getElementById ( 'mode-chat-btn' ) ;
if ( _ab && _cb ) {
_ab . classList . remove ( 'active' ) ;
_cb . classList . add ( 'active' ) ;
const _toggle = _ab . closest ( '.mode-toggle' ) ;
if ( _toggle ) _toggle . classList . add ( 'mode-chat' ) ;
}
if ( typeof Storage !== 'undefined' && Storage . KEYS ) {
const _st = Storage . getJSON ( Storage . KEYS . TOGGLES , { } ) ;
_st . mode = 'chat' ;
Storage . setJSON ( Storage . KEYS . TOGGLES , _st ) ;
}
}
typewriterInto ( holder . querySelector ( '.body' ) , errText ) ;
enableResearchBtn ( ) ;
return ;
}
const reader = res . body . getReader ( ) ;
const decoder = new TextDecoder ( ) ;
let buffer = '' ;
let metrics = null ;
let isThinking = false ;
let thinkingStartTime = null ;
// Streaming TTS: synthesize sentence-by-sentence during streaming
const streamingTTS = ! ! ( window . aiTTSManager && window . aiTTSManager . autoPlay && window . aiTTSManager . available ) ;
if ( streamingTTS ) window . aiTTSManager . streamingStart ( ) ;
// Multi-bubble agent tracking
let roundHolder = holder ; // Current AI text bubble (changes per round)
let roundText = '' ; // Text accumulated for current round
let currentToolBubble = null ; // Current tool execution bubble
let roundFinalized = false ; // Whether current round's text is finalized
let _sourcesHtml = '' ; // Sources box HTML to prepend to body
let _sourcesExpanded = false ; // Track if user expanded sources during stream
let _sourcesData = null ; // Raw sources data for rebuilding
let _sourcesType = '' ; // 'web' or 'research'
let _findingsData = null ; // Raw findings data for collapsible box
// _keepResearchOn removed — clarification state now persisted server-side via DB mode
// Insert sources box as a stable DOM node that won't be replaced during streaming.
// Returns the content container to use for innerHTML updates.
function _ensureStreamLayout ( body ) {
if ( ! body ) return body ;
// Sources are deferred to final render — don't insert during streaming
// Ensure a stable content div exists for text content
var contentDiv = body . querySelector ( '.stream-content' ) ;
if ( ! contentDiv ) {
contentDiv = document . createElement ( 'div' ) ;
contentDiv . className = 'stream-content' ;
body . appendChild ( contentDiv ) ;
}
return contentDiv ;
}
const esc = uiModule . esc ;
// Remove thinking spinner helper
2026-06-01 16:33:35 +03:00
_removeThinkingSpinner = ( ) => {
2026-05-31 23:58:26 +09:00
const el = document . querySelector ( '.agent-thinking-dots' ) ;
if ( el ) {
if ( el . _spinner ) el . _spinner . destroy ( ) ;
el . remove ( ) ;
}
2026-06-01 16:33:35 +03:00
} ;
2026-05-31 23:58:26 +09:00
// Tool-aware thinking spinner
let _lastToolName = '' ;
const _searchIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' ;
const _toolLabels = {
'web_search' : _searchIcon + 'Searching' ,
'bash' : 'Running' ,
'python' : 'Running' ,
'create_document' : 'Writing' ,
'update_document' : 'Writing' ,
'read_document' : 'Reading' ,
'edit_file' : 'Editing' ,
'read_file' : 'Reading' ,
'write_file' : 'Writing' ,
'list_files' : 'Browsing' ,
'image_gen' : 'Generating' ,
'generate_image' : 'Generating' ,
'manage_memory' : 'Remembering' ,
'save_memory' : 'Remembering' ,
'search_memory' : 'Recalling' ,
'manage_session' : 'Organizing' ,
'deep_research' : 'Researching' ,
'list_models' : 'Browsing' ,
'ui_control' : 'Adjusting' ,
} ;
function _thinkingLabel ( ) {
if ( ! _lastToolName ) {
return 'Thinking' ;
}
// Check exact match first, then prefix match
const lower = _lastToolName . toLowerCase ( ) ;
if ( _toolLabels [ lower ] ) return _toolLabels [ lower ] ;
for ( const [ key , label ] of Object . entries ( _toolLabels ) ) {
if ( lower . includes ( key ) || key . includes ( lower ) ) return label ;
}
return 'Thinking' ;
}
function _showThinkingSpinner ( label ) {
if ( document . querySelector ( '.agent-thinking-dots' ) ) return ;
const _thinkMsg = document . createElement ( 'div' ) ;
_thinkMsg . className = 'msg msg-ai agent-thinking-dots' ;
const _thinkBody = document . createElement ( 'div' ) ;
_thinkBody . className = 'body' ;
const _ts = spinnerModule . create ( label || 'Thinking' , 'right' , 'wave' ) ;
_thinkBody . appendChild ( _ts . createElement ( ) ) ;
_ts . start ( 120 ) ;
_thinkMsg . _spinner = _ts ;
_thinkMsg . appendChild ( _thinkBody ) ;
document . getElementById ( 'chat-history' ) . appendChild ( _thinkMsg ) ;
uiModule . scrollHistory ( ) ;
}
// Auto-show thinking spinner after text stops streaming
let _textPauseTimer = null ;
function _scheduleThinkingSpinner ( ) {
if ( _textPauseTimer ) clearTimeout ( _textPauseTimer ) ;
_textPauseTimer = setTimeout ( ( ) => {
if ( ! document . querySelector ( '.agent-thinking-dots' ) && isStreaming ) {
_showThinkingSpinner ( _thinkingLabel ( ) ) ;
}
} , 400 ) ;
}
2026-06-01 16:33:35 +03:00
_cancelThinkingTimer = ( ) => {
2026-05-31 23:58:26 +09:00
if ( _textPauseTimer ) { clearTimeout ( _textPauseTimer ) ; _textPauseTimer = null ; }
2026-06-01 16:33:35 +03:00
} ;
2026-05-31 23:58:26 +09:00
// Document streaming state (text-fence detection)
let _docFenceOpened = false ;
let _docFenceContentStart = - 1 ;
let _liveThinkSection = null ;
let _liveThinkContent = null ;
let _liveThinkInner = null ;
let _liveThinkHeader = null ;
let _liveThinkSpinnerSlot = null ;
let _liveThinkTimerEl = null ;
let _liveThinkToggle = null ;
let _liveThinkDomId = null ;
// Offscreen measurement div — reused across renders
let _measureDiv = null ;
function _replyAfterClosedThinking ( text ) {
const closeRe = /<\/think(?:ing)?>/gi ;
let match = null ;
let last = null ;
while ( ( match = closeRe . exec ( text || '' ) ) !== null ) last = match ;
if ( ! last ) return '' ;
return ( text || '' ) . slice ( last . index + last [ 0 ] . length ) . trimStart ( ) ;
}
// Direct render helper for streaming text
2026-06-01 16:33:35 +03:00
_renderStream = ( ) => {
2026-05-31 23:58:26 +09:00
let dt = stripToolBlocks ( roundText ) ;
const bodyEl = roundHolder . querySelector ( '.body' ) ;
const contentEl = _ensureStreamLayout ( bodyEl ) ;
// If thinking was already collapsed in-place, only render the reply portion
let liveReply = contentEl . querySelector ( '.live-reply-content' ) ;
if ( liveReply ) {
// Extract reply text — handle native <think> tags and non-tag patterns
const closedThinkReply = _replyAfterClosedThinking ( dt ) ;
const { thinkingBlocks , content : replyText } = closedThinkReply
? { thinkingBlocks : [ '' ] , content : closedThinkReply }
: markdownModule . extractThinkingBlocks ( dt ) ;
let replyTrimmed = '' ;
if ( thinkingBlocks . length ) {
replyTrimmed = ( replyText || '' ) . trim ( ) ;
} else {
// Non-tag: check for garbled <think> (reasoning\n<think>reply)
const _gm = dt . match ( /^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i ) ;
if ( _gm && _gm [ 1 ] . trim ( ) ) {
replyTrimmed = _gm [ 1 ] . trim ( ) ;
} else {
// Pure non-tag: find reply boundary
const _rPrefixes = markdownModule . startsWithReasoningPrefix ;
const _rpStarts = [ 'Hey' , 'Hi ' , 'Hi!' , 'Hello' , 'Sure' , 'Yes' , 'No ' , 'No,' , 'Yo' , 'OK' , 'Here' , 'Absolutely' , 'Of course' , 'Great' , 'Alright' , 'Thanks' , 'Welcome' , 'Good ' , "I'm happy" , "I'd be" ] ;
const _rt = ( replyText || '' ) . trimStart ( ) ;
if ( _rPrefixes ( _rt ) ) {
const _rLines = _rt . split ( '\n' ) ;
for ( let _ri = 1 ; _ri < _rLines . length ; _ri ++ ) {
const _rl = _rLines [ _ri ] . trim ( ) ;
if ( ! _rl ) continue ;
if ( _rpStarts . some ( rp => _rl . startsWith ( rp ) ) ) { replyTrimmed = _rLines . slice ( _ri ) . join ( '\n' ) ; break ; }
}
if ( ! replyTrimmed ) {
for ( const rp of _rpStarts ) {
const rx = new RegExp ( '[.!?]\\s*(' + rp . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) + ')' ) ;
const m = rx . exec ( _rt ) ;
if ( m && m . index > 20 ) { replyTrimmed = _rt . slice ( m . index + 1 ) . trim ( ) ; break ; }
}
}
}
}
}
if ( replyTrimmed ) {
const replyHtml = markdownModule . mdToHtml ( markdownModule . squashOutsideCode ( replyTrimmed ) ) ;
const prevLen = liveReply . _prevTextLen || 0 ;
liveReply . innerHTML = replyHtml ;
_fadeNewTokens ( liveReply , prevLen ) ;
liveReply . _prevTextLen = liveReply . textContent . length ;
if ( window . hljs ) liveReply . querySelectorAll ( 'pre code' ) . forEach ( ( b ) => window . hljs . highlightElement ( b ) ) ;
}
// Reply empty or not — preserve thinking bar, don't fall through to full re-render
uiModule . scrollHistory ( ) ;
return ;
}
const prevLen = contentEl . _prevTextLen || 0 ;
// If thinking is still streaming (unclosed <think>), show indicator instead of raw text
if ( markdownModule . hasUnclosedThinkTag && markdownModule . hasUnclosedThinkTag ( dt ) ) {
const thinkStart = dt . search ( /<think(?:ing)?>/i ) ;
const thinkContent = dt . substring ( thinkStart ) . replace ( /<think(?:ing)?>/i , '' ) . trim ( ) ;
const lines = thinkContent . split ( '\n' ) . length ;
// Don't show beforeThink text during streaming — it'll appear in the final render
// This prevents the "split into two" duplication
contentEl . innerHTML =
'<div class="thinking-section"><div class="thinking-header"><div class="thinking-header-left">Thinking' +
( lines > 1 ? ` ( ${ lines } lines) ` : '' ) + '</div></div></div>' ;
contentEl . _prevTextLen = 0 ;
uiModule . scrollHistory ( ) ;
return ;
}
const html = markdownModule . processWithThinking ( markdownModule . squashOutsideCode ( dt ) ) ;
// Smooth expand only for regular chat text (not thinking/agent blocks)
const _hasThinking = html . includes ( 'thinking-section' ) ;
const _isAgentRound = roundHolder !== holder ;
if ( ! _hasThinking && ! _isAgentRound ) {
// Render into offscreen clone to measure new height before swapping
if ( ! _measureDiv ) {
_measureDiv = document . createElement ( 'div' ) ;
_measureDiv . style . cssText = 'position:absolute;visibility:hidden;pointer-events:none;z-index:-1;' ;
}
_measureDiv . style . width = contentEl . offsetWidth + 'px' ;
_measureDiv . className = contentEl . className ;
_measureDiv . innerHTML = html ;
contentEl . parentNode . appendChild ( _measureDiv ) ;
const measuredH = _measureDiv . offsetHeight ;
_measureDiv . remove ( ) ;
const curMin = parseFloat ( contentEl . style . minHeight ) || 0 ;
contentEl . style . minHeight = Math . max ( curMin , measuredH ) + 'px' ;
} else {
contentEl . style . minHeight = '' ;
}
contentEl . innerHTML = html ;
_fadeNewTokens ( contentEl , prevLen ) ;
contentEl . _prevTextLen = contentEl . textContent . length ;
if ( window . hljs ) contentEl . querySelectorAll ( 'pre code' ) . forEach ( ( b ) => window . hljs . highlightElement ( b ) ) ;
uiModule . scrollHistory ( ) ;
2026-06-01 16:33:35 +03:00
} ;
2026-05-31 23:58:26 +09:00
// Walk text nodes, skip past `prevLen` characters of old text,
// wrap everything after that in <span class="token-new"> for fade-in
function _fadeNewTokens ( container , prevLen ) {
if ( ! prevLen ) return ; // First chunk — skip, whole msg already has entrance anim
const walker = document . createTreeWalker ( container , NodeFilter . SHOW _TEXT ) ;
let charCount = 0 ;
const toWrap = [ ] ;
while ( walker . nextNode ( ) ) {
const node = walker . currentNode ;
const len = node . textContent . length ;
if ( charCount + len <= prevLen ) { charCount += len ; continue ; }
const splitAt = charCount < prevLen ? prevLen - charCount : 0 ;
toWrap . push ( { node , splitAt } ) ;
charCount += len ;
}
for ( const { node , splitAt } of toWrap ) {
const parent = node . parentNode ;
if ( ! parent || parent . closest ( 'pre, .think-content' ) ) continue ;
const target = splitAt > 0 ? node . splitText ( splitAt ) : node ;
const span = document . createElement ( 'span' ) ;
span . className = 'token-new' ;
parent . replaceChild ( span , target ) ;
span . appendChild ( target ) ;
}
}
let _nextIsError = false ;
2026-06-01 16:54:11 +09:00
let _streamSawDone = false ;
2026-05-31 23:58:26 +09:00
while ( true ) {
const { done , value } = await reader . read ( ) ;
_lastReaderActivity = Date . now ( ) ;
if ( done ) break ;
buffer += decoder . decode ( value , { stream : true } ) ;
const lines = buffer . split ( '\n' ) ;
buffer = lines . pop ( ) || '' ;
for ( const line of lines ) {
// Log SSE event types (e.g. "event: error") for debugging
if ( line . startsWith ( 'event: ' ) ) {
const evtType = line . slice ( 7 ) . trim ( ) ;
if ( evtType === 'error' ) _nextIsError = true ;
continue ;
}
if ( line . startsWith ( 'data: ' ) ) {
const data = line . slice ( 6 ) ;
// (thinking spinner removal is handled in agent_step / tool_start / content handlers)
// Background detection: are we on a different session?
const _isBg = ( sessionModule . getCurrentSessionId ( ) !== streamSessionId ) ;
// On first transition to background, store state in map
if ( _isBg && ! _backgroundStreams . has ( streamSessionId ) ) {
_backgroundStreams . set ( streamSessionId , {
status : 'running' ,
accumulated : accumulated ,
sourcesHtml : _sourcesHtml ,
findingsData : null ,
abortCtrl : currentAbort ,
query : streamQuery ,
metrics : null ,
} ) ;
if ( sessionModule && sessionModule . markStreaming ) {
sessionModule . markStreaming ( streamSessionId ) ;
}
}
if ( data === '[DONE]' ) {
2026-06-01 16:54:11 +09:00
_streamSawDone = true ;
2026-05-31 23:58:26 +09:00
// Always update background map if entry exists (even if user switched back)
var bgDone = _backgroundStreams . get ( streamSessionId ) ;
if ( bgDone ) {
bgDone . status = 'completed' ;
bgDone . accumulated = accumulated ;
if ( _isBg ) {
try {
_notifyStreamComplete ( streamSessionId , streamQuery ) ;
_insertStreamDoneToast ( streamSessionId , streamQuery ) ;
} catch ( toastErr ) {
console . warn ( '[bg-stream] Toast/notification error:' , toastErr ) ;
}
}
// CRITICAL: always mark stream complete for the sidebar dot
try {
if ( sessionModule && sessionModule . markStreamComplete ) {
sessionModule . markStreamComplete ( streamSessionId ) ;
}
} catch ( dotErr ) {
console . warn ( '[bg-stream] markStreamComplete error:' , dotErr ) ;
}
// Don't do foreground final render — the checkBackgroundStream poll
// will detect 'completed' and reload history cleanly
break ;
}
// Force-close thinking if still open (model never output boundary)
if ( isThinking ) {
isThinking = false ;
cancelAnimationFrame ( _thinkTimerRAF ) ;
var _elapsedDone = thinkingStartTime ? ( ( Date . now ( ) - thinkingStartTime ) / 1000 ) . toFixed ( 1 ) : null ;
if ( _elapsedDone ) {
accumulated = accumulated . replace ( /<think>/i , '<think time="' + _elapsedDone + '">' ) ;
roundText = roundText . replace ( /<think>/i , '<think time="' + _elapsedDone + '">' ) ;
}
if ( _liveThinkHeader ) _liveThinkHeader . textContent = 'View thinking process' ;
if ( _liveThinkSpinnerSlot ) _liveThinkSpinnerSlot . remove ( ) ;
if ( _liveThinkTimerEl && _elapsedDone ) {
_liveThinkTimerEl . textContent = _elapsedDone + 's' ;
_liveThinkTimerEl . style . marginLeft = 'auto' ;
_liveThinkTimerEl . style . marginRight = '5px' ;
var _hdrDone = _liveThinkTimerEl . closest ( '.thinking-header' ) ;
// Keep the chevron furthest right with the timer to its left
// (match the live + final-render layout) — insert before the
// toggle rather than appending (which would land after it).
if ( _hdrDone ) {
if ( _liveThinkToggle && _liveThinkToggle . parentElement === _hdrDone )
_hdrDone . insertBefore ( _liveThinkTimerEl , _liveThinkToggle ) ;
else _hdrDone . appendChild ( _liveThinkTimerEl ) ;
}
}
// Assign stable IDs
var _thinkIdDone = 'think-' + Date . now ( ) ;
var _liveHdrDone = _liveThinkSection && _liveThinkSection . querySelector ( '.thinking-header' ) ;
if ( _liveHdrDone ) _liveHdrDone . dataset . thinkingId = _thinkIdDone ;
if ( _liveThinkContent ) _liveThinkContent . id = _thinkIdDone ;
if ( _liveThinkToggle ) _liveThinkToggle . id = _thinkIdDone + '-toggle' ;
// Create live-reply container so final render preserves thinking bar
var _streamElDone = _liveThinkSection ? _liveThinkSection . parentElement : roundHolder . querySelector ( '.stream-content' ) ;
if ( ! _streamElDone ) _streamElDone = roundHolder . querySelector ( '.body' ) ;
if ( _streamElDone && ! _streamElDone . querySelector ( '.live-reply-content' ) ) {
var _replyElDone = document . createElement ( 'div' ) ;
_replyElDone . className = 'live-reply-content' ;
_streamElDone . appendChild ( _replyElDone ) ;
}
}
// Normal foreground completion — metrics will be displayed in the final render block below
break ;
}
try {
const json = JSON . parse ( data ) ;
// Handle SSE error events (e.g. HTTP 404 from provider)
if ( _nextIsError || json . status >= 400 ) {
_nextIsError = false ;
const errMsg = json . text || json . error ? . message || ` Error ${ json . status || 'unknown' } ` ;
console . error ( 'Stream error:' , errMsg ) ;
if ( spinner && spinner . element ) spinner . destroy ( ) ;
typewriterInto ( roundHolder . querySelector ( '.body' ) , errMsg ) ;
break ;
}
if ( json . delta || json . type === 'tool_start' || json . type === 'agent_step' || json . type === 'doc_stream_delta' ) {
clearProcessingProbe ( ) ;
}
if ( json . delta ) {
_cancelThinkingTimer ( ) ;
_removeThinkingSpinner ( ) ;
// Text arrived after tools — connect thread line to this bubble
const _threadAbove = roundHolder ? . previousElementSibling ;
if ( _threadAbove && _threadAbove . classList . contains ( 'agent-thread' ) && ! _threadAbove . classList . contains ( 'has-bottom' ) ) {
_threadAbove . classList . add ( 'has-bottom' ) ;
}
// VLLM reasoning tokens: wrap in <think> tags for the thinking UI
let _delta = json . delta ;
if ( json . thinking ) {
if ( ! accumulated . includes ( '<think>' ) ) _delta = '<think>' + _delta ;
} else if ( accumulated . includes ( '<think>' ) && ! accumulated . includes ( '</think>' ) ) {
_delta = '</think>' + _delta ;
}
const wasEmpty = ! accumulated ;
accumulated += _delta ;
roundText += _delta ;
currentAccumulated = accumulated ; // Update global tracker
// First token arrived — switch stop button from processing to streaming
if ( wasEmpty && submitBtn && ! _isBg ) {
submitBtn . dataset . phase = 'receiving' ;
}
// Update background map if running in background
if ( _isBg ) {
var bgEntry = _backgroundStreams . get ( streamSessionId ) ;
if ( bgEntry ) bgEntry . accumulated = accumulated ;
continue ; // Skip all DOM writes
}
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
if ( ! _docFenceOpened && documentModule && roundText . includes ( '```create_document\n' ) ) {
const fenceIdx = roundText . indexOf ( '```create_document\n' ) ;
const afterFence = roundText . slice ( fenceIdx + '```create_document\n' . length ) ;
const fenceLines = afterFence . split ( '\n' ) ;
if ( fenceLines . length >= 1 && fenceLines [ 0 ] . trim ( ) ) {
_docFenceOpened = true ;
const title = fenceLines [ 0 ] . trim ( ) ;
// Keep in sync with backend _KNOWN_LANGS in src/tool_implementations.py
const knownLangs = [ 'python' , 'py' , 'javascript' , 'js' , 'typescript' , 'ts' , 'html' , 'css' , 'json' , 'yaml' , 'bash' , 'sql' , 'rust' , 'go' , 'java' , 'c' , 'cpp' , 'markdown' , 'text' , 'plain' , 'ruby' , 'swift' , 'kotlin' , 'php' , 'email' , 'csv' , 'xml' , 'toml' , 'ini' ] ;
const isLang = fenceLines . length >= 2 && knownLangs . includes ( fenceLines [ 1 ] . trim ( ) . toLowerCase ( ) ) ;
const lang = isLang ? fenceLines [ 1 ] . trim ( ) : '' ;
_docFenceContentStart = fenceIdx + '```create_document\n' . length + title . length + 1 + ( isLang ? fenceLines [ 1 ] . length + 1 : 0 ) ;
documentModule . streamDocOpen ( title , lang ) ;
}
}
if ( _docFenceOpened && _docFenceContentStart > 0 && documentModule ) {
let raw = roundText . slice ( _docFenceContentStart ) ;
const closeIdx = raw . indexOf ( '\n```' ) ;
if ( closeIdx >= 0 ) raw = raw . slice ( 0 , closeIdx ) ;
documentModule . streamDocDelta ( raw ) ;
}
// Detect thinking-in-progress:
// 1. Normal: <think>...no closing tag yet
// 2. Malformed: <think></think>\n...text but no second </think> yet
// 3. Qwen3.5: "Thinking Process:" without <think> tags
let hasUnclosedThink = markdownModule . hasUnclosedThinkTag ( roundText ) ;
// Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning
// These patterns don't use <think> tags, so we simulate unclosed thinking during streaming
const _replyPrefixes = [ 'Hey' , 'Hi ' , 'Hi!' , 'Hello' , 'Sure' , 'Yes' , 'No ' , 'No,' , 'Yo' , 'OK' , 'Here' , 'Absolutely' , 'Of course' , 'Great' , 'Alright' , 'Thanks' , 'Welcome' , 'Good ' , "I'm happy" , "I'd be" ] ;
if ( ! hasUnclosedThink && ! roundText . includes ( '<think' ) ) {
const _trimmedRT = roundText . trimStart ( ) ;
const _isReasoning = markdownModule . startsWithReasoningPrefix ( _trimmedRT ) ;
if ( _isReasoning ) {
// Check if we can see a reply boundary yet (newline then reply pattern)
const _lines = _trimmedRT . split ( '\n' ) ;
let _replyFound = false ;
for ( let li = 1 ; li < _lines . length ; li ++ ) {
const _l = _lines [ li ] . trim ( ) ;
if ( ! _l ) continue ;
if ( _replyPrefixes . some ( rp => _l . startsWith ( rp ) ) ) {
_replyFound = true ;
break ;
}
}
if ( ! _replyFound ) {
// Also check within-line: "reasoning text.Reply text"
const _inlineReply = _replyPrefixes . some ( rp => {
const rx = new RegExp ( '[.!?]\\s*' + rp . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) ) ;
const m = rx . exec ( _trimmedRT ) ;
return m && m . index > 20 ;
} ) ;
if ( ! _inlineReply ) hasUnclosedThink = true ;
}
}
}
if ( ! hasUnclosedThink && /^<think(?:ing)?>\s*<\/think(?:ing)?>/i . test ( roundText ) ) {
// Empty <think></think> — the model likely put thinking outside the tags
const afterEmpty = roundText . replace ( /^<think(?:ing)?>\s*<\/think(?:ing)?>/i , '' ) . trim ( ) ;
const closeTags = ( afterEmpty . match ( /<\/think(?:ing)?>/gi ) || [ ] ) . length ;
if ( closeTags === 0 && afterEmpty . length > 0 ) {
hasUnclosedThink = true ; // still waiting for real closing tag
}
}
// Detect false close: <think>short</think> where real thinking follows untagged
// Only applies when there's a second </think> later (model leaked thinking outside tags)
// Do NOT trigger if the text after </think> contains tool calls (that's real content)
if ( ! hasUnclosedThink && isThinking ) {
const _thinkMatch = roundText . match ( /<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i ) ;
const _thinkLen = _thinkMatch ? _thinkMatch [ 1 ] . trim ( ) . length : 0 ;
if ( _thinkLen < 20 ) {
const _afterClose = roundText . replace ( /<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i , '' ) . trim ( ) ;
// Only keep waiting if there's trailing text that looks like thinking (not tool calls)
const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i . test ( _afterClose ) ;
const _hasOrphanClose = /<\/think(?:ing)?>/i . test ( _afterClose ) ;
if ( ! _hasToolCall && ( _hasOrphanClose || ( Date . now ( ) - thinkingStartTime ) < 500 ) ) {
hasUnclosedThink = true ; // keep waiting for real </think>
}
}
}
if ( hasUnclosedThink && ! isThinking ) {
isThinking = true ;
thinkingStartTime = Date . now ( ) ;
if ( spinner && spinner . element ) spinner . destroy ( ) ;
// Create a live thinking box — starts expanded so content streams visibly
var thinkBody = roundHolder . querySelector ( '.body' ) ;
var thinkContent = _ensureStreamLayout ( thinkBody ) ;
thinkContent . style . minHeight = '' ;
_liveThinkDomId = 'live-think-' + Date . now ( ) + '-' + Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) ;
thinkContent . innerHTML = `
< div class = "thinking-section" >
< div class = "thinking-header" data - thinking - id = "${_liveThinkDomId}" >
< div class = "thinking-header-left" > < span class = "live-think-header-text" > Thinking\u2026 < / s p a n > < / d i v >
< span class = "live-think-spinner-slot" style = "flex-shrink:0;margin-left:auto;" > < / s p a n >
< span class = "live-think-timer" style = "font-size:11px;opacity:0.4;font-variant-numeric:tabular-nums;margin-left:6px;margin-right:5px;" > < / s p a n >
< span class = "thinking-toggle live-think-toggle" id = "${_liveThinkDomId}-toggle" > < / s p a n >
< / d i v >
< div class = "thinking-content" id = "${_liveThinkDomId}" >
< div class = "thinking-content-inner live-think-inner" > < / d i v >
< / d i v >
< / d i v > ` ;
_liveThinkSection = thinkContent . querySelector ( '.thinking-section' ) ;
_liveThinkContent = thinkContent . querySelector ( '.thinking-content' ) ;
_liveThinkInner = thinkContent . querySelector ( '.live-think-inner' ) ;
_liveThinkHeader = thinkContent . querySelector ( '.live-think-header-text' ) ;
_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 = s + 's' ;
_thinkTimerRAF = requestAnimationFrame ( _tickThinkTimer ) ;
}
_thinkTimerRAF = requestAnimationFrame ( _tickThinkTimer ) ;
// Whirlpool spinner
if ( _liveThinkSpinnerSlot ) {
var _wp = spinnerModule . createWhirlpool ( 12 ) ;
_wp . element . style . margin = '0' ;
_wp . element . style . width = '12px' ;
_wp . element . style . height = '12px' ;
_wp . element . style . transform = 'translateY(-1px)' ; // align the whirlpool with the header text
_liveThinkSpinnerSlot . appendChild ( _wp . element ) ;
}
} else if ( hasUnclosedThink && isThinking ) {
if ( _liveThinkInner ) {
// Extract raw thinking text (strip all <think>/<thinking> open/close tags and prefixes)
var thinkText = roundText . replace ( /<\/?think(?:ing)?>/gi , '' ) ;
thinkText = thinkText . replace ( /^\s*Thinking(?:\s+Process)?:\s*/i , '' ) ;
_liveThinkInner . innerHTML = markdownModule . mdToHtml ( thinkText ) ;
// Keep thinking box scrolled to bottom
var thinkBox = _liveThinkInner . closest ( '.thinking-content' ) ;
if ( thinkBox ) thinkBox . scrollTop = thinkBox . scrollHeight ;
}
uiModule . scrollHistory ( ) ;
continue ;
} else if ( ! hasUnclosedThink && isThinking ) {
isThinking = false ;
var _thinkTextLen = _liveThinkInner ? _liveThinkInner . textContent . trim ( ) . length : 0 ;
// If thinking was trivially short (< 20 chars), remove the section entirely
// Models sometimes emit <think>The</think> or similar noise
if ( _thinkTextLen < 20 && _liveThinkSection ) {
_liveThinkSection . remove ( ) ;
_liveThinkSection = null ;
_liveThinkContent = null ;
_liveThinkInner = null ;
_liveThinkHeader = null ;
_liveThinkSpinnerSlot = null ;
_liveThinkTimerEl = null ;
_liveThinkToggle = null ;
_liveThinkDomId = null ;
// Fall through to normal streaming
if ( spinner && spinner . element ) spinner . destroy ( ) ;
_renderStream ( ) ;
_scheduleThinkingSpinner ( ) ;
continue ;
}
// 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 <think> tag for persistence on reload
if ( elapsed ) {
accumulated = accumulated . replace ( /<think>/i , '<think time="' + elapsed + '">' ) ;
roundText = roundText . replace ( /<think>/i , '<think time="' + elapsed + '">' ) ;
}
if ( _liveThinkHeader ) _liveThinkHeader . textContent = 'View thinking process' ;
if ( _liveThinkSpinnerSlot ) _liveThinkSpinnerSlot . remove ( ) ;
// Move timer to right side of header
if ( _liveThinkTimerEl && elapsed ) {
_liveThinkTimerEl . textContent = elapsed + 's' ;
_liveThinkTimerEl . style . marginLeft = 'auto' ;
_liveThinkTimerEl . style . marginRight = '5px' ;
var _hdrRow = _liveThinkTimerEl . closest ( '.thinking-header' ) ;
// Chevron furthest right, timer to its left — insert before
// the toggle (appending would put the timer after it).
if ( _hdrRow ) {
if ( _liveThinkToggle && _liveThinkToggle . parentElement === _hdrRow )
_hdrRow . insertBefore ( _liveThinkTimerEl , _liveThinkToggle ) ;
else _hdrRow . appendChild ( _liveThinkTimerEl ) ;
}
}
// Assign stable IDs (for click-toggle handler in markdown.js)
var _thinkId = 'think-' + Date . now ( ) ;
var _liveHdr = _liveThinkSection && _liveThinkSection . querySelector ( '.thinking-header' ) ;
if ( _liveHdr ) _liveHdr . dataset . thinkingId = _thinkId ;
if ( _liveThinkContent ) _liveThinkContent . id = _thinkId ;
if ( _liveThinkToggle ) _liveThinkToggle . id = _thinkId + '-toggle' ;
// Append a container for the reply text that follows thinking
var _streamEl = _liveThinkSection ? _liveThinkSection . parentElement : roundHolder . querySelector ( '.stream-content' ) ;
if ( ! _streamEl ) _streamEl = roundHolder . querySelector ( '.body' ) ;
if ( _streamEl ) {
var _replyEl = document . createElement ( 'div' ) ;
_replyEl . className = 'live-reply-content' ;
_streamEl . appendChild ( _replyEl ) ;
}
// Render any reply text that arrived with the closing </think> token
_renderStream ( ) ;
} else {
// Normal streaming
if ( spinner && spinner . element ) spinner . destroy ( ) ;
_renderStream ( ) ;
_scheduleThinkingSpinner ( ) ;
// Feed streaming TTS with accumulated text
if ( streamingTTS ) window . aiTTSManager . streamingUpdate ( roundText ) ;
}
} else if ( json . type === 'research_progress' ) {
if ( _isBg ) continue ; // Skip DOM updates in background
_researchingStreamIds . add ( streamSessionId ) ;
// Highlight research button while running
var _rToggle = document . getElementById ( 'research-toggle-btn' ) ;
if ( _rToggle ) _rToggle . classList . add ( 'research-running' ) ;
// Request notification permission on first research event
if ( 'Notification' in window && Notification . permission === 'default' ) {
Notification . requestPermission ( ) ;
}
// Mark session as researching in sidebar
var _rSid = sessionModule && sessionModule . getCurrentSessionId ( ) ;
if ( _rSid && sessionModule . markResearching ) sessionModule . markResearching ( _rSid ) ;
const rp = json . data ;
// Start research timer + synapse on first progress event
if ( ! _researchTimerEl && spinner && spinner . element ) {
_researchStartTime = rp . started _at ? rp . started _at * 1000 : Date . now ( ) ;
_researchAvgDuration = rp . avg _duration || null ;
_researchTimerEl = document . createElement ( 'div' ) ;
_researchTimerEl . className = 'research-timer' ;
// Styles in .research-timer CSS class
spinner . element . parentNode . insertBefore ( _researchTimerEl , spinner . element . nextSibling ) ;
_researchTimerInterval = setInterval ( ( ) => {
if ( ! _researchTimerEl ) return ;
var elapsed = Math . floor ( ( Date . now ( ) - _researchStartTime ) / 1000 ) ;
var mm = String ( Math . floor ( elapsed / 60 ) ) . padStart ( 2 , '0' ) ;
var ss = String ( elapsed % 60 ) . padStart ( 2 , '0' ) ;
var txt = mm + ':' + ss ;
if ( _researchAvgDuration ) {
var avgM = String ( Math . floor ( _researchAvgDuration / 60 ) ) . padStart ( 2 , '0' ) ;
var avgS = String ( Math . round ( _researchAvgDuration % 60 ) ) . padStart ( 2 , '0' ) ;
txt += ' / avg ' + avgM + ':' + avgS ;
}
_researchTimerEl . textContent = txt ;
} , 1000 ) ;
// Synapse visualization — insert right above the timer so
// it sits between the spinner message and the timer line.
try {
_researchSynapse = createResearchSynapse ( spinner . element . parentNode , {
query : holder . _researchQuery || rp . query || '' ,
startedAt : _researchStartTime ,
} ) ;
// Move it to live between spinner and timer
if ( _researchSynapse . element && _researchTimerEl ) {
spinner . element . parentNode . insertBefore ( _researchSynapse . element , _researchTimerEl ) ;
}
} catch ( e ) { console . warn ( 'synapse init failed' , e ) ; }
}
if ( _researchSynapse ) {
_researchSynapse . setPhase ( rp . phase , rp ) ;
if ( typeof rp . round === 'number' ) _researchSynapse . setRound ( rp . round ) ;
if ( typeof rp . total _sources === 'number' ) _researchSynapse . setSourceCount ( rp . total _sources ) ;
if ( rp . phase === 'error' ) _researchSynapse . complete ( ) ;
}
if ( spinner && spinner . element ) {
if ( rp . phase === 'probing' ) {
spinner . updateMessage ( ` Verifying model: ${ rp . model || '?' } ` ) ;
} else if ( rp . phase === 'planning' ) {
spinner . updateMessage ( 'Analyzing question & planning research strategy' ) ;
} else if ( rp . phase === 'searching' ) {
const q = rp . queries ? ` ${ rp . queries } queries ` : '' ;
const s = rp . total _sources ? ` · ${ rp . total _sources } sources ` : '' ;
spinner . updateMessage ( ` Round ${ rp . round || '?' } : Searching ${ q ? ' (' + q + ')' : '' } ${ s } ` ) ;
} else if ( rp . phase === 'reading' ) {
spinner . updateMessage ( rp . title ? ` Reading: ${ rp . title } ` : ` Round ${ rp . round || '?' } : Reading ${ rp . new _sources || '' } pages · ${ rp . total _sources || 0 } sources total ` ) ;
} else if ( rp . phase === 'analyzing' ) {
spinner . updateMessage ( ` Round ${ rp . round || '?' } : Analyzing ${ rp . total _findings || 0 } findings ` ) ;
} else if ( rp . phase === 'writing' ) {
spinner . updateMessage ( ` Writing report · ${ rp . total _sources || 0 } sources ` ) ;
} else if ( rp . phase === 'error' ) {
spinner . updateMessage ( rp . message || 'Search error' ) ;
}
}
} else if ( json . type === 'research_sources' ) {
if ( _isBg ) {
// Store sources HTML in background map
if ( json . data && json . data . length > 0 ) {
_sourcesHtml = _buildSourcesBox ( json . data , 'research' ) ;
var bgE = _backgroundStreams . get ( streamSessionId ) ;
if ( bgE ) bgE . sourcesHtml = _sourcesHtml ;
}
// Clear researching indicator for this background session
if ( sessionModule && sessionModule . clearResearching ) sessionModule . clearResearching ( streamSessionId ) ;
continue ;
}
// Research done — clean up timer, show sources box, then spinner for LLM response
_clearResearchTimer ( ) ;
holder . _researchSources = json . data ;
var _rSid2 = sessionModule && sessionModule . getCurrentSessionId ( ) ;
if ( _rSid2 && sessionModule . clearResearching ) sessionModule . clearResearching ( _rSid2 ) ;
if ( json . data && json . data . length > 0 ) {
_sourcesData = json . data ; _sourcesType = 'research' ;
_sourcesHtml = _buildSourcesBox ( json . data , 'research' ) ;
}
if ( document . hidden ) {
_notifyResearchComplete ( _rSid2 || '' , holder . _researchQuery || '' ) ;
}
} else if ( json . type === 'research_findings' ) {
if ( _isBg ) {
var bgEf = _backgroundStreams . get ( streamSessionId ) ;
if ( bgEf ) bgEf . findingsData = json . data ;
continue ;
}
if ( json . data && json . data . length > 0 ) {
_findingsData = json . data ;
}
} else if ( json . type === 'research_done' ) {
// Research complete — reload session to show the persisted report
_clearResearchTimer ( ) ;
if ( sessionModule && sessionModule . clearResearching ) {
sessionModule . clearResearching ( streamSessionId ) ;
}
_researchingStreamIds . delete ( streamSessionId ) ;
// Small delay then reload session history which includes the full report
setTimeout ( async ( ) => {
// Don't yank the user back to this chat if they've navigated
// away (e.g. started a new chat) while research finished —
// just refresh the sidebar so the report shows when they return.
if ( sessionModule . getCurrentSessionId && sessionModule . getCurrentSessionId ( ) === streamSessionId ) {
await sessionModule . selectSession ( streamSessionId ) ;
} else {
await sessionModule . loadSessions ( ) ;
}
} , 500 ) ;
continue ;
} else if ( json . type === 'web_sources' ) {
if ( _isBg ) {
if ( json . data && json . data . length > 0 ) {
_sourcesHtml = _buildSourcesBox ( json . data , 'web' ) ;
var bgE2 = _backgroundStreams . get ( streamSessionId ) ;
if ( bgE2 ) bgE2 . sourcesHtml = _sourcesHtml ;
}
continue ;
}
// Web search done — store sources for final render (don't render mid-stream)
holder . _webSources = json . data ;
if ( json . data && json . data . length > 0 ) {
_sourcesData = json . data ; _sourcesType = 'web' ;
_sourcesHtml = _buildSourcesBox ( json . data , 'web' ) ;
}
} else if ( json . type === 'model_fallback' ) {
// Model went offline — switched to fallback
var _fbData = json . data || { } ;
uiModule . showToast (
` Model ${ _fbData . old _model || '?' } offline — switched to ${ _fbData . new _model || '?' } ` ,
5000
) ;
// Update the model picker to reflect the new model
if ( sessionModule && sessionModule . updateModelPicker ) {
sessionModule . updateModelPicker ( ) ;
}
continue ;
} else if ( json . type === 'model_info' ) {
// Update role label with model name as soon as we know it
if ( ! _isBg && holder ) {
const roleEl = holder . querySelector ( '.role' ) ;
if ( roleEl ) {
const tsSpan = roleEl . querySelector ( '.role-timestamp' ) ;
var _modelLabel = _shortModel ( json . model ) ;
if ( json . suffix ) {
_modelLabel += ' (' + json . suffix + ')' ;
holder . _roleSuffix = json . suffix ;
}
// Prepend character name if sent by server or set locally
var _charName = json . character _name || ( presetsModule . getCharacterName ? presetsModule . getCharacterName ( ) : '' ) ;
if ( _charName ) {
_modelLabel = _charName ;
holder . _characterName = _charName ;
}
roleEl . textContent = _modelLabel + ' ' ;
_applyModelColor ( roleEl , json . model ) ;
if ( tsSpan ) roleEl . appendChild ( tsSpan ) ;
}
}
} else if ( json . type === 'attachments' ) {
if ( _isBg ) continue ;
// Update user bubble — replace file chips with image previews
const _ub = document . querySelector ( '#chat-history .msg-user:last-of-type' ) ;
if ( _ub ) {
const _aw = _ub . querySelector ( '.attach-cards' ) ;
if ( _aw ) {
for ( const _att of json . data ) {
const _isImg = ( _att . mime || '' ) . startsWith ( 'image/' ) || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i . test ( _att . name || '' ) ;
if ( _isImg && _att . id ) {
// Skip if we already have a preview for this file id —
// on a regenerate the original user bubble keeps its
// photo and the backend re-emits the attachment event
// for the same id; without this guard we'd append a
// duplicate (which visually pushes the real photo off).
const _existingPreview = _aw . querySelector ( '[data-file-id="' + _att . id + '"]' ) ;
if ( _existingPreview ) {
if ( _att . vision _model && ! _existingPreview . querySelector ( '.attach-vision-model' ) ) {
const _vl = document . createElement ( 'div' ) ;
_vl . className = 'attach-vision-model' ;
_vl . textContent = 'Vision: ' + String ( _att . vision _model ) . split ( '/' ) . pop ( ) ;
const _name = _existingPreview . querySelector ( '.attach-image-name' ) ;
if ( _name ) _existingPreview . insertBefore ( _vl , _name ) ;
else _existingPreview . appendChild ( _vl ) ;
}
continue ;
}
const _card = _aw . querySelector ( '.attach-card[data-name="' + ( _att . name || '' ) . replace ( /"/g , '\\"' ) + '"]' ) ;
const _iw = document . createElement ( 'div' ) ;
_iw . className = 'attach-image-preview' ;
_iw . dataset . fileId = _att . id ;
_iw . style . cursor = 'pointer' ;
_iw . onclick = ( ) => window . open ( API _BASE + '/api/upload/' + _att . id , '_blank' ) ;
const _im = document . createElement ( 'img' ) ;
_im . src = API _BASE + '/api/upload/' + _att . id ;
_im . alt = _att . name || 'Image' ;
_im . style . cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:block;' ;
_iw . appendChild ( _im ) ;
if ( _att . vision _model ) {
const _vl = document . createElement ( 'div' ) ;
_vl . className = 'attach-vision-model' ;
_vl . textContent = 'Vision: ' + String ( _att . vision _model ) . split ( '/' ) . pop ( ) ;
_iw . appendChild ( _vl ) ;
}
if ( _att . name ) {
const _nm = document . createElement ( 'div' ) ;
_nm . className = 'attach-image-name' ;
_nm . textContent = _att . name ;
_iw . appendChild ( _nm ) ;
}
if ( _card ) _card . replaceWith ( _iw ) ; else _aw . appendChild ( _iw ) ;
} else {
const _card = _aw . querySelector ( '.attach-card[data-name="' + ( _att . name || '' ) . replace ( /"/g , '\\"' ) + '"]' ) ;
if ( _card && _att . id ) {
_card . dataset . fileId = _att . id ;
_card . style . cursor = 'pointer' ;
_card . onclick = ( ) => window . open ( API _BASE + '/api/upload/' + _att . id , '_blank' ) ;
}
}
}
}
// Caption / OCR text is no longer rendered as an inline
// collapsible on the user bubble — the user can view/edit
// it via the "Caption" button on the photo thumbnail.
}
} else if ( json . type === 'rag_sources' ) {
if ( _isBg ) continue ;
holder . _ragSources = json . data ;
} else if ( json . type === 'memories_used' ) {
if ( _isBg ) continue ;
holder . _memoriesUsed = json . data ;
} else if ( json . type === 'compacted' ) {
if ( ! _isBg ) {
uiModule . showToast ( 'Context compacted — older messages summarized' ) ;
}
} else if ( json . type === 'metrics' ) {
metrics = json . data ;
if ( _isBg ) {
var bgM = _backgroundStreams . get ( streamSessionId ) ;
if ( bgM ) bgM . metrics = json . data ;
continue ;
}
} else if ( json . type === 'message_saved' ) {
// Wire the persisted DB id onto the just-streamed bubble so it
// can be edited/deleted immediately, without reloading the chat.
if ( _isBg ) continue ;
if ( currentHolder && json . id ) currentHolder . dataset . dbId = json . id ;
} else if ( json . type === 'tool_start' ) {
if ( _isBg ) continue ;
_cancelThinkingTimer ( ) ;
_removeThinkingSpinner ( ) ;
// Force-close thinking if still open — tools are real content, not thinking
if ( isThinking ) {
isThinking = false ;
cancelAnimationFrame ( _thinkTimerRAF ) ;
var _elapsed2 = thinkingStartTime ? ( ( Date . now ( ) - thinkingStartTime ) / 1000 ) . toFixed ( 1 ) : null ;
if ( _liveThinkHeader ) _liveThinkHeader . textContent = 'View thinking process' ;
if ( _liveThinkTimerEl ) _liveThinkTimerEl . textContent = _elapsed2 ? _elapsed2 + 's' : '' ;
if ( _liveThinkSpinnerSlot ) _liveThinkSpinnerSlot . remove ( ) ;
// Assign stable IDs
var _thinkId2 = 'think-' + Date . now ( ) ;
var _liveHdr2 = _liveThinkSection && _liveThinkSection . querySelector ( '.thinking-header' ) ;
if ( _liveHdr2 ) _liveHdr2 . dataset . thinkingId = _thinkId2 ;
if ( _liveThinkContent ) _liveThinkContent . id = _thinkId2 ;
if ( _liveThinkToggle ) _liveThinkToggle . id = _thinkId2 + '-toggle' ;
}
_renderStream ( ) ;
// --- Finalize current text bubble (only once per round) ---
if ( ! roundFinalized ) {
roundFinalized = true ;
if ( spinner && spinner . element ) spinner . destroy ( ) ;
const dt = stripToolBlocks ( roundText ) ;
if ( dt . trim ( ) ) {
var _body3 = roundHolder . querySelector ( '.body' ) ;
var _contentEl3 = _ensureStreamLayout ( _body3 ) ;
_contentEl3 . style . minHeight = '' ; // clear streaming inflate
_contentEl3 . innerHTML = markdownModule . processWithThinking ( markdownModule . squashOutsideCode ( dt ) ) ;
if ( window . hljs ) roundHolder . querySelectorAll ( 'pre code' ) . forEach ( ( b ) => window . hljs . highlightElement ( b ) ) ;
} else {
roundHolder . style . display = 'none' ;
}
}
// Track tool name for contextual spinner labels
_lastToolName = json . tool || '' ;
// --- Thread timeline: group tools in a thread container ---
const cmd = json . command || '' ;
const chatBox = document . getElementById ( 'chat-history' ) ;
// Find existing thread to append to — check last few children
// (agent_step may insert an empty msg-ai between tool rounds)
let threadWrap = null ;
for ( let ci = chatBox . children . length - 1 ; ci >= Math . max ( 0 , chatBox . children . length - 5 ) ; ci -- ) {
const child = chatBox . children [ ci ] ;
if ( child . classList . contains ( 'agent-thread' ) ) {
threadWrap = child ;
break ;
}
// Skip hidden (empty) bubbles and thinking spinners
if ( child . style . display === 'none' || child . classList . contains ( 'agent-thinking-dots' ) ) continue ;
// Stop if we hit a visible message bubble (has real content between tools)
if ( child . classList . contains ( 'msg' ) ) break ;
}
if ( threadWrap ) {
// Continuing an existing thread — remove has-bottom (agent_step may have set it
// expecting text, but we got more tools instead)
threadWrap . classList . remove ( 'has-bottom' ) ;
} else {
threadWrap = document . createElement ( 'div' ) ;
threadWrap . className = 'agent-thread' ;
// Extend line up to connect to chat bubble above (if there is one)
const _prevSib = chatBox . lastElementChild ;
const _hasBubbleAbove = _prevSib && ( _prevSib . classList . contains ( 'msg' ) && _prevSib . style . display !== 'none' ) ;
const _hasThreadAbove = _prevSib && _prevSib . classList . contains ( 'agent-thread' ) ;
if ( _hasBubbleAbove || _hasThreadAbove || ( roundText . trim ( ) && roundHolder && roundHolder . style . display !== 'none' ) ) {
threadWrap . classList . add ( 'has-top' ) ;
}
chatBox . appendChild ( threadWrap ) ;
}
threadWrap . classList . add ( 'streaming' ) ;
const toolLabel = _toolLabels [ json . tool . toLowerCase ( ) ] || json . tool ;
const node = document . createElement ( 'div' )
node . className = 'agent-thread-node running' ;
const cmdHtml = cmd ? ` <pre class="agent-thread-cmd"> ${ esc ( cmd ) } </pre> ` : '' ;
node . innerHTML = ` <div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon"> \u 25B6</span><span class="agent-thread-tool"> ${ toolLabel } </span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content"> ${ cmdHtml } </div> ` ;
// Expand/collapse via delegated click handler (init at module bottom).
threadWrap . appendChild ( node ) ;
currentToolBubble = node ;
// Animate the wave
const waveEl = node . querySelector ( '.agent-thread-wave' ) ;
if ( waveEl ) {
const waveFrames = [ '▁▂▃' , '▂▃▄' , '▃▄▅' , '▄▅▆' , '▅▆▇' , '▆▅▄' , '▅▄▃' , '▄▃▂' ] ;
let waveIdx = 0 ;
node . _waveInterval = setInterval ( ( ) => {
waveIdx = ( waveIdx + 1 ) % waveFrames . length ;
waveEl . textContent = waveFrames [ waveIdx ] ;
} , 100 ) ;
}
// Smooth per-second "cooking" timer — ticks every second (not
// just on the 2s backend heartbeat) so a long-running tool
// always shows visible motion and never reads as frozen.
node . _startTime = Date . now ( ) ;
node . _elapsedTicker = setInterval ( ( ) => {
const hdr2 = node . querySelector ( '.agent-thread-header' ) ;
if ( ! hdr2 ) return ;
let el2 = hdr2 . querySelector ( '.agent-thread-elapsed' ) ;
if ( ! el2 ) {
el2 = document . createElement ( 'span' ) ;
el2 . className = 'agent-thread-elapsed' ;
// Sits on the LEFT, right after the icon.
const icon = hdr2 . querySelector ( '.agent-thread-icon' ) ;
if ( icon && icon . nextSibling ) hdr2 . insertBefore ( el2 , icon . nextSibling ) ;
else hdr2 . appendChild ( el2 ) ;
}
const s = ( Date . now ( ) - node . _startTime ) / 1000 ;
// Hundredths so it visibly counts sub-second (1.00, 1.05, …).
el2 . textContent = s < 60 ? ` ${ s . toFixed ( 2 ) } s ` : ` ${ Math . floor ( s / 60 ) } m ${ ( s % 60 ) . toFixed ( 2 ) . padStart ( 5 , '0' ) } s ` ;
} , 50 ) ;
uiModule . scrollHistory ( ) ;
} else if ( json . type === 'tool_progress' ) {
// Long-running subprocess (bash, python) is still in
// flight — refresh the running tool card with the
// elapsed-time + tail of its stdout/stderr so the
// user doesn't stare at a blind "Running…" spinner.
if ( _isBg ) continue ;
if ( ! currentToolBubble ) continue ;
// The per-second ticker (started in tool_start) owns the
// elapsed display; here we just surface the live output tail.
const tailStr = ( json . tail || '' ) . trim ( ) ;
if ( tailStr ) {
let tailEl = currentToolBubble . querySelector ( '.agent-thread-tail' ) ;
if ( ! tailEl ) {
tailEl = document . createElement ( 'pre' ) ;
tailEl . className = 'agent-thread-tail' ;
tailEl . style . cssText = 'margin:4px 0 0;padding:6px 8px;font-size:11px;background:rgba(0,0,0,0.18);border-radius:4px;max-height:140px;overflow:auto;white-space:pre-wrap;opacity:0.85;' ;
const content = currentToolBubble . querySelector ( '.agent-thread-content' ) ;
if ( content ) content . appendChild ( tailEl ) ;
}
tailEl . textContent = tailStr ;
tailEl . scrollTop = tailEl . scrollHeight ;
}
uiModule . scrollHistory ( ) ;
} else if ( json . type === 'tool_output' ) {
if ( _isBg ) continue ;
// --- Update the current thread node ---
if ( currentToolBubble ) {
// Stop wave animation + the per-second cooking ticker
if ( currentToolBubble . _waveInterval ) {
clearInterval ( currentToolBubble . _waveInterval ) ;
currentToolBubble . _waveInterval = null ;
}
if ( currentToolBubble . _elapsedTicker ) {
clearInterval ( currentToolBubble . _elapsedTicker ) ;
currentToolBubble . _elapsedTicker = null ;
}
const ok = ( json . exit _code === 0 || json . exit _code == null ) ;
const cmd = json . command || '' ;
let outHtml = '' ;
if ( json . output && json . output . trim ( ) ) {
outHtml = ` <details class="agent-tool-output"><summary>Output</summary><pre> ${ esc ( json . output ) } </pre></details> ` ;
}
const cmdHtml2 = cmd ? ` <pre class="agent-thread-cmd"> ${ esc ( cmd ) } </pre> ` : '' ;
// Preserve the user's .open choice across the innerHTML
// rewrite \u2014 otherwise expanding a running tool collapses
// it as soon as the result lands, forcing the user to
// click again. Click handling is delegated (see init at
// bottom of file) so no per-node listener needed.
const _wasOpen = currentToolBubble . classList . contains ( 'open' ) ;
currentToolBubble . className = 'agent-thread-node' + ( ok ? '' : ' error' ) + ( _wasOpen ? ' open' : '' ) ;
currentToolBubble . innerHTML = ` <div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon"> ${ ok ? '\u2713' : '\u2717' } </span><span class="agent-thread-tool"> ${ esc ( json . tool ) } </span><span class="agent-thread-status"> ${ ok ? 'done' : 'failed' } </span><span class="agent-thread-chevron"> \u 25B6</span></div><div class="agent-thread-content"> ${ cmdHtml2 } ${ outHtml } </div> ` ;
// Reset so thinking spinner between tools says "Thinking" not the old tool's label
_lastToolName = '' ;
uiModule . scrollHistory ( ) ;
}
// --- Render generated images inline ---
if ( json . image _url ) {
const chatBox = document . getElementById ( 'chat-history' ) ;
chatBox . appendChild ( _buildImageBubble ( json . image _url , json . image _prompt , json . image _model , json . image _size , json . image _quality , json . image _id ) ) ;
uiModule . scrollHistory ( ) ;
// Notify gallery to refresh if open
window . dispatchEvent ( new CustomEvent ( 'gallery-refresh' ) ) ;
}
// --- Render browser screenshots in tool output ---
if ( json . screenshot && currentToolBubble ) {
const contentEl = currentToolBubble . querySelector ( '.agent-thread-content' ) ;
if ( contentEl ) {
const details = document . createElement ( 'details' ) ;
details . className = 'agent-tool-output' ;
details . innerHTML = ` <summary>Screenshot</summary><img src=" ${ json . screenshot } " style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /> ` ;
contentEl . appendChild ( details ) ;
}
}
// --- Reload sessions after manage_session tool (delete, rename, etc.) ---
// Debounce so bulk deletes don't fire loadSessions per call
if ( json . tool === 'manage_session' && sessionModule ) {
if ( window . _manageSessionTimer ) clearTimeout ( window . _manageSessionTimer ) ;
window . _manageSessionTimer = setTimeout ( ( ) => sessionModule . loadSessions ( ) , 1000 ) ;
}
// --- Live-refresh the calendar after manage_calendar (add/edit/delete) ---
// so a new event shows without the user hard-refreshing. Debounced
// so a batch of event creates only triggers one refetch.
if ( json . tool === 'manage_calendar' ) {
if ( window . _manageCalTimer ) clearTimeout ( window . _manageCalTimer ) ;
window . _manageCalTimer = setTimeout (
( ) => window . dispatchEvent ( new CustomEvent ( 'calendar-refresh' ) ) , 600 ) ;
}
// --- Live-refresh Memories after manage_memory changes ---
if ( json . tool === 'manage_memory' ) {
if ( window . _manageMemoryTimer ) clearTimeout ( window . _manageMemoryTimer ) ;
window . _manageMemoryTimer = setTimeout (
( ) => window . dispatchEvent ( new CustomEvent ( 'memory-refresh' ) ) , 600 ) ;
}
// --- Apply UI control actions embedded in tool_output ---
if ( json . ui _event ) {
chatStream . handleUIControl ( json ) ;
}
// Schedule a thinking spinner between tool rounds (short delay so
// agent_step in the same SSE chunk can cancel it before it shows)
_scheduleThinkingSpinner ( ) ;
uiModule . scrollHistory ( ) ;
} else if ( json . type === 'doc_stream_open' ) {
if ( _isBg ) {
// Store for replay when user returns to this session
var bgDocOpen = _backgroundStreams . get ( streamSessionId ) ;
if ( bgDocOpen ) {
bgDocOpen . _docTitle = json . title || '' ;
bgDocOpen . _docLang = json . language || '' ;
bgDocOpen . _docContent = '' ;
}
continue ;
}
if ( documentModule ) {
documentModule . streamDocOpen ( json . title || '' , json . language || '' ) ;
}
} else if ( json . type === 'doc_stream_delta' ) {
if ( _isBg ) {
var bgDocDelta = _backgroundStreams . get ( streamSessionId ) ;
if ( bgDocDelta ) bgDocDelta . _docContent = json . content || '' ;
continue ;
}
if ( documentModule ) {
documentModule . streamDocDelta ( json . content || '' ) ;
}
} else if ( json . type === 'doc_update' ) {
// doc_update means the server already saved the doc to DB.
if ( _isBg ) continue ;
if ( documentModule ) {
documentModule . handleDocUpdate ( json ) ;
}
} else if ( json . type === 'doc_suggestions' ) {
if ( _isBg ) continue ;
if ( documentModule && documentModule . handleDocSuggestions ) {
documentModule . handleDocSuggestions ( json ) ;
}
} else if ( json . type === 'ui_control' ) {
if ( _isBg ) continue ;
chatStream . handleUIControl ( json . data || { } ) ;
} else if ( json . type === 'agent_step' ) {
if ( _isBg ) continue ;
_cancelThinkingTimer ( ) ;
_removeThinkingSpinner ( ) ;
_renderStream ( ) ;
// Mark thread as connected to bubble below
const _activeThread = document . querySelector ( '.agent-thread.streaming' ) ;
if ( _activeThread ) {
_activeThread . classList . add ( 'has-bottom' ) ;
}
// --- New round: create fresh AI bubble with spinner ---
currentToolBubble = null ;
roundFinalized = false ;
isThinking = false ;
_docFenceOpened = false ;
_docFenceContentStart = - 1 ;
const box = document . getElementById ( 'chat-history' ) ;
const newWrap = document . createElement ( 'div' ) ;
newWrap . className = 'msg msg-ai msg-continuation streaming' ;
// Add model name label
const newRole = document . createElement ( 'div' ) ;
newRole . className = 'role' ;
const metaS = sessionModule . getSessions ( ) . find ( s => s . id === streamSessionId ) ;
newRole . textContent = _shortModel ( metaS ? . model ) || '' ;
_applyModelColor ( newRole , metaS ? . model ) ;
newWrap . appendChild ( newRole ) ;
const newBody = document . createElement ( 'div' ) ;
newBody . className = 'body' ;
newWrap . appendChild ( newBody ) ;
box . appendChild ( newWrap ) ;
roundHolder = newWrap ;
roundText = '' ;
// Destroy any previous spinner before creating new one
if ( spinner && spinner . element ) spinner . destroy ( ) ;
// Show spinner while waiting for text (skip for research — has its own progress)
if ( ! _researchingStreamIds . has ( streamSessionId ) ) {
spinner = spinnerModule . create ( 'Generating response' , 'right' , 'wave' ) ;
newBody . appendChild ( spinner . createElement ( ) ) ;
spinner . start ( ) ;
}
if ( streamingTTS ) window . aiTTSManager . _streamSentencesSent = 0 ;
uiModule . scrollHistory ( ) ;
} else if ( json . type === 'budget_exceeded' ) {
if ( _isBg ) continue ;
_cancelThinkingTimer ( ) ;
_removeThinkingSpinner ( ) ;
const budgetDiv = document . createElement ( 'div' ) ;
budgetDiv . style . cssText = 'font-size:11px;opacity:0.6;font-style:italic;padding:4px 8px;margin:4px 0;' ;
budgetDiv . textContent = ` Tool budget reached ( ${ json . used } / ${ json . limit } calls). Agent stopped. ` ;
const chatBox = document . getElementById ( 'chat-history' ) ;
chatBox . appendChild ( budgetDiv ) ;
} else if ( json . type === 'teacher_takeover' ) {
if ( _isBg ) continue ;
_cancelThinkingTimer ( ) ;
_removeThinkingSpinner ( ) ;
// Finalize any in-flight bubble so the takeover banner
// separates student attempt from teacher attempt.
if ( spinner && spinner . element ) { try { spinner . destroy ( ) ; } catch ( _ ) { } spinner = null ; }
const chatBox = document . getElementById ( 'chat-history' ) ;
const banner = document . createElement ( 'div' ) ;
banner . className = 'teacher-takeover-banner' ;
banner . style . cssText = 'margin:10px 0;padding:8px 12px;border-left:3px solid #c08a3e;background:rgba(192,138,62,0.08);font-size:12px;color:var(--fg);border-radius:4px;' ;
const teacherName = json . teacher _model || 'teacher' ;
const why = json . student _failure ? ` — <span style="opacity:0.7"> ${ esc ( json . student _failure ) } </span> ` : '' ;
banner . innerHTML = ` <strong>Teacher takeover:</strong> escalating to <code> ${ esc ( teacherName ) } </code> ${ why } ` ;
chatBox . appendChild ( banner ) ;
// Reset round bubble state so the teacher's first text starts a new bubble
roundHolder = null ;
roundText = '' ;
roundFinalized = false ;
currentToolBubble = null ;
uiModule . scrollHistory ( ) ;
} else if ( json . type === 'skill_saved' ) {
if ( _isBg ) continue ;
const chatBox = document . getElementById ( 'chat-history' ) ;
const note = document . createElement ( 'div' ) ;
note . className = 'skill-saved-note' ;
note . style . cssText = 'margin:6px 0;padding:6px 10px;border-left:3px solid #4a8a4a;background:rgba(74,138,74,0.07);font-size:12px;color:var(--fg);border-radius:4px;' ;
note . innerHTML = ` <strong>Skill learned:</strong> <code> ${ esc ( json . name || '' ) } </code> ${ json . category ? ` <span style="opacity:0.6">[ ${ esc ( json . category ) } ]</span> ` : '' } ` ;
chatBox . appendChild ( note ) ;
uiModule . scrollHistory ( ) ;
} else if ( json . type === 'escalation_failed' || json . type === 'skill_save_failed' ) {
if ( _isBg ) continue ;
const chatBox = document . getElementById ( 'chat-history' ) ;
const note = document . createElement ( 'div' ) ;
note . className = 'escalation-failed-note' ;
note . style . cssText = 'margin:6px 0;padding:6px 10px;border-left:3px solid #8a4a4a;background:rgba(138,74,74,0.07);font-size:12px;color:var(--fg);border-radius:4px;' ;
const label = json . type === 'escalation_failed' ? 'Teacher could not solve it' : 'Skill not saved' ;
note . innerHTML = ` <strong> ${ label } :</strong> <span style="opacity:0.75"> ${ esc ( json . reason || '' ) } </span> ` ;
chatBox . appendChild ( note ) ;
uiModule . scrollHistory ( ) ;
} else if ( json . error ) {
// --- Backend error (timeout, connection issue, etc.) ---
console . error ( 'Stream error from backend:' , json . error ) ;
if ( _isBg ) continue ;
if ( spinner && spinner . element ) spinner . destroy ( ) ;
const errDiv = document . createElement ( 'div' ) ;
errDiv . style . cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;' ;
errDiv . textContent = ` [Error: ${ json . error } ] ` ;
roundHolder . querySelector ( '.body' ) . appendChild ( errDiv ) ;
uiModule . scrollHistory ( ) ;
}
} catch ( e ) {
console . error ( 'Error parsing SSE data:' , e ) ;
}
}
}
}
2026-06-01 16:54:11 +09:00
if ( ! _streamSawDone ) {
throw new Error ( 'Stream closed before completion' ) ;
}
2026-05-31 23:58:26 +09:00
_renderStream ( ) ;
_cancelThinkingTimer ( ) ;
_removeThinkingSpinner ( ) ;
// Stop any thread pulse animations
document . querySelectorAll ( '.agent-thread.streaming' ) . forEach ( t => t . classList . remove ( 'streaming' ) ) ;
// --- Final render (skip if stream was ever backgrounded or currently in background) ---
// Remove streaming class from all round bubbles
holder . classList . remove ( 'streaming' ) ;
if ( roundHolder && roundHolder !== holder ) roundHolder . classList . remove ( 'streaming' ) ;
const _isBgFinal = ( sessionModule . getCurrentSessionId ( ) !== streamSessionId ) || _backgroundStreams . has ( streamSessionId ) ;
if ( ! _isBgFinal ) {
finalMeta = sessionModule . getSessions ( ) . find ( s => s . id === sessionModule . getCurrentSessionId ( ) ) ;
finalModelName = _shortModel ( metrics ? . model || finalMeta ? . model ) ;
// Preserve suffix (e.g. "Research") if set by model_info event
if ( holder . _roleSuffix ) finalModelName += ' (' + holder . _roleSuffix + ')' ;
// Prepend character name if set
var _charNameFinal = presetsModule . getCharacterName ? presetsModule . getCharacterName ( ) : '' ;
if ( _charNameFinal ) finalModelName = _charNameFinal ;
const roleEl = holder . querySelector ( '.role' ) ;
if ( roleEl ) {
const tsSpan = roleEl . querySelector ( '.role-timestamp' ) ;
roleEl . textContent = finalModelName + ' ' ;
_applyModelColor ( roleEl , metrics ? . model || finalMeta ? . model ) ;
if ( tsSpan ) roleEl . appendChild ( tsSpan ) ;
}
holder . dataset . raw = accumulated ;
// Anti-stall: a turn that ran tools but ended with essentially no
// final prose usually means the model stopped mid-task (the case
// where you had to type "did you finish?"). Offer a one-click
// Continue that resumes exactly where it left off — reuses the same
// resume mechanism as the user-stop "[Message interrupted]" button.
try {
const _usedTools = holder . querySelector ( '.agent-thread-node' ) ;
const _proseLen = ( accumulated || '' ) . replace ( /<[^>]*>/g , '' ) . trim ( ) . length ;
if ( _usedTools && _proseLen < 24 && ! holder . querySelector ( '.agent-continue-btn' ) ) {
const _stall = document . createElement ( 'div' ) ;
_stall . className = 'stopped-indicator' ;
const _lbl = document . createElement ( 'span' ) ;
_lbl . style . cssText = 'font-style:italic;opacity:0.7;' ;
_lbl . textContent = 'Paused mid-task' ;
_stall . appendChild ( _lbl ) ;
const _cont = document . createElement ( 'button' ) ;
_cont . className = 'continue-btn agent-continue-btn' ;
_cont . title = 'Continue — pick up where it left off' ;
_cont . textContent = '▸' ;
_cont . addEventListener ( 'click' , ( ) => {
_stall . remove ( ) ;
const mi = uiModule . el ( 'message' ) ;
if ( mi ) {
mi . value = 'Continue — you stopped before finishing. Pick up exactly where you left off and complete the task.' ;
const sb = document . querySelector ( '.send-btn' ) ;
if ( sb ) sb . click ( ) ;
}
} ) ;
_stall . appendChild ( _cont ) ;
( holder . querySelector ( '.body' ) || holder ) . appendChild ( _stall ) ;
}
} catch ( _ ) { }
// Clear streaming minHeight lock
const _streamContent = roundHolder . querySelector ( '.stream-content' ) ;
if ( _streamContent ) _streamContent . style . minHeight = '' ;
// Finalize the last round's bubble — flatten stream-content wrapper for clean DOM
const finalDisplay = stripToolBlocks ( roundText ) ;
if ( finalDisplay . trim ( ) ) {
var _body4 = roundHolder . querySelector ( '.body' ) ;
// Preserve sources expanded state before final render
var _wasExpanded = _sourcesExpanded || ! ! ( _body4 && _body4 . querySelector ( '.sources-content.expanded' ) ) ;
// If thinking was collapsed in-place during streaming, preserve it
var _liveReplyEl = _body4 && _body4 . querySelector ( '.live-reply-content' ) ;
var _extracted = _liveReplyEl ? markdownModule . extractThinkingBlocks ( finalDisplay ) : null ;
var _finalReply = '' ;
if ( _liveReplyEl ) {
// Try standard extraction first (for native <think> tags)
if ( _extracted ? . thinkingBlocks ? . length ) {
_finalReply = ( _extracted . content || '' ) . trim ( ) ;
} else {
// Non-tag thinking: extract reply from raw text
// Handle garbled <think> tag: "Thinking: reasoning\n<think>reply"
const _garbledMatch = finalDisplay . match ( /^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i ) ;
if ( _garbledMatch && _garbledMatch [ 1 ] . trim ( ) ) {
_finalReply = _garbledMatch [ 1 ] . trim ( ) ;
} else {
// Pure non-tag: find reply boundary by prefix patterns
const _rs2 = [ 'Hey' , 'Hi ' , 'Hi!' , 'Hello' , 'Sure' , 'Yes' , 'No ' , 'No,' , 'Yo' , 'OK' , 'Here' , 'Absolutely' , 'Of course' , 'Great' , 'Alright' , 'Thanks' , 'Welcome' , 'Good ' , "I'm happy" , "I'd be" ] ;
const _fr = ( finalDisplay || '' ) . trimStart ( ) ;
if ( markdownModule . startsWithReasoningPrefix ( _fr ) ) {
const _fLines = _fr . split ( '\n' ) ;
for ( let _fi = 1 ; _fi < _fLines . length ; _fi ++ ) {
const _fl = _fLines [ _fi ] . trim ( ) ;
if ( ! _fl ) continue ;
if ( _rs2 . some ( rp => _fl . startsWith ( rp ) ) ) { _finalReply = _fLines . slice ( _fi ) . join ( '\n' ) ; break ; }
}
// Within-line check
if ( ! _finalReply ) {
for ( const rp of _rs2 ) {
const rx = new RegExp ( '[.!?]\\s*(' + rp . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) + ')' ) ;
const m = rx . exec ( _fr ) ;
if ( m && m . index > 20 ) { _finalReply = _fr . slice ( m . index + 1 ) . trim ( ) ; break ; }
}
}
}
}
}
}
if ( _liveReplyEl && _finalReply ) {
// Render reply into the live-reply container (thinking bar already showing)
var _replyHtml = markdownModule . mdToHtml ( markdownModule . squashOutsideCode ( _finalReply ) ) ;
_liveReplyEl . innerHTML = _replyHtml ;
_liveReplyEl . classList . remove ( 'live-reply-content' ) ;
if ( _sourcesData ) {
var _srcEl = document . createElement ( 'div' ) ;
_srcEl . innerHTML = _buildSourcesBox ( _sourcesData , _sourcesType , _wasExpanded ) ;
_body4 . insertBefore ( _srcEl . firstChild || _srcEl , _body4 . firstChild ) ;
}
if ( _findingsData ) _body4 . insertAdjacentHTML ( 'beforeend' , chatRenderer . buildFindingsBox ( _findingsData ) ) ;
} else {
// Full re-render (reply empty or no live-reply container)
_body4 . innerHTML = ( _sourcesData ? _buildSourcesBox ( _sourcesData , _sourcesType , _wasExpanded ) : '' )
+ markdownModule . processWithThinking ( markdownModule . squashOutsideCode ( finalDisplay ) )
+ ( _findingsData ? chatRenderer . buildFindingsBox ( _findingsData ) : '' ) ;
}
} else if ( _sourcesHtml ) {
var _body4b = roundHolder . querySelector ( '.body' ) ;
var _wasExpanded2 = _sourcesExpanded || ! ! ( _body4b && _body4b . querySelector ( '.sources-content.expanded' ) ) ;
_body4b . innerHTML = _sourcesData ? _buildSourcesBox ( _sourcesData , _sourcesType , _wasExpanded2 ) : _sourcesHtml ;
} else if ( roundHolder !== holder ) {
// Check if there's thinking content worth showing
const _thinkMatch = roundText . match ( /<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i ) ;
if ( _thinkMatch && _thinkMatch [ 1 ] . trim ( ) ) {
// Show thinking in a collapsed section even if no visible reply text
const _body4c = roundHolder . querySelector ( '.body' ) ;
if ( _body4c ) _body4c . innerHTML = markdownModule . processWithThinking ( roundText ) ;
} else {
roundHolder . style . display = 'none' ;
// Thread above expected a bubble below — remove has-bottom since bubble is hidden
const _lastThread = roundHolder . previousElementSibling ;
if ( _lastThread && _lastThread . classList . contains ( 'agent-thread' ) ) {
_lastThread . classList . remove ( 'has-bottom' ) ;
}
}
}
if ( window . hljs ) {
roundHolder . querySelectorAll ( 'pre code' ) . forEach ( ( block ) => {
window . hljs . highlightElement ( block ) ;
} ) ;
}
if ( markdownModule . renderMermaid ) markdownModule . renderMermaid ( roundHolder ) ;
uiModule . scrollHistory ( ) ;
// Render RAG sources if present
if ( holder . _ragSources && holder . _ragSources . length ) {
const details = document . createElement ( 'details' ) ;
details . className = 'rag-sources' ;
const summary = document . createElement ( 'summary' ) ;
summary . textContent = ` Sources ( ${ holder . _ragSources . length } documents) ` ;
details . appendChild ( summary ) ;
holder . _ragSources . forEach ( src => {
const item = document . createElement ( 'div' ) ;
item . className = 'rag-source-item' ;
const _esc = uiModule . esc ;
item . innerHTML = ` <strong> ${ _esc ( src . filename ) } </strong> <span class="rag-similarity"> ${ ( src . similarity * 100 ) . toFixed ( 1 ) } %</span><div class="rag-snippet"> ${ _esc ( src . snippet ) } </div> ` ;
details . appendChild ( item ) ;
} ) ;
holder . querySelector ( '.body' ) . appendChild ( details ) ;
}
// Hide first bubble if it has no visible text content (e.g. agent went straight to tools)
if ( holder !== roundHolder && holder . style . display !== 'none' ) {
const _hBody = holder . querySelector ( '.body' ) ;
const _hText = _hBody ? _hBody . textContent . trim ( ) : '' ;
if ( ! _hText ) holder . style . display = 'none' ;
}
// Attach footer to the last visible bubble (roundHolder for multi-round agent, holder for single)
const footerTarget = ( roundHolder && roundHolder !== holder && roundHolder . style . display !== 'none' ) ? roundHolder : holder ;
footerTarget . appendChild ( createMsgFooter ( footerTarget ) ) ;
// Add "View Report" link for completed research
if ( _researchingStreamIds . has ( streamSessionId ) ) {
_appendViewReportLink ( footerTarget , streamSessionId ) ;
}
// Also store raw on the footer target so copy/TTS work
if ( footerTarget !== holder ) footerTarget . dataset . raw = accumulated ;
if ( addAITTSButton && accumulated && window . aiTTSManager ? . _provider !== 'disabled' && window . aiTTSManager ? . available ) {
addAITTSButton ( footerTarget , accumulated ) ;
}
// TTS auto-play: streaming mode flushes remaining text, non-streaming enqueues full message
if ( accumulated && window . aiTTSManager && window . aiTTSManager . autoPlay ) {
const ttsBtn = holder . querySelector ( '.ai-tts-button' ) ;
if ( ttsBtn ) {
var ICON _PLAY _TTS = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg>' ;
var ICON _STOP _TTS = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>' ;
const resetFn = ( ) => {
ttsBtn . innerHTML = ICON _PLAY _TTS ;
ttsBtn . classList . remove ( 'playing' , 'loading' ) ;
ttsBtn . style . color = '#6b7280' ;
ttsBtn . title = 'Read aloud' ;
} ;
if ( streamingTTS ) {
// Flush remaining partial sentence and attach the real button
window . aiTTSManager . streamingEnd ( accumulated ) ;
window . aiTTSManager . streamingAttachButton ( ttsBtn , resetFn ) ;
// If still playing sentences from the stream, show stop icon
if ( window . aiTTSManager . isPlaying || window . aiTTSManager . _processing ) {
ttsBtn . innerHTML = ICON _STOP _TTS ;
ttsBtn . classList . add ( 'playing' ) ;
ttsBtn . style . color = '#ccc' ;
ttsBtn . title = 'Stop' ;
}
} else {
// Non-streaming fallback (autoPlay toggled mid-stream, etc.)
window . aiTTSManager . enqueue ( accumulated , ttsBtn , resetFn ) ;
}
}
}
if ( metrics ) {
displayMetrics ( footerTarget , metrics ) ;
}
// Attach variant navigation if this was a regeneration
_attachVariantNav ( footerTarget ) ;
// Merge with previous stopped message if this was a continue
if ( _pendingContinue ) {
const prevEl = _pendingContinue ;
_pendingContinue = null ;
const prevBody = prevEl . querySelector ( '.body' ) ;
const newBody = footerTarget . querySelector ( '.body' ) ;
if ( prevBody && newBody && prevEl . parentNode ) {
// Merge: combine raw text with *(continued)* marker
const oldRaw = prevEl . dataset . raw || '' ;
const newRaw = footerTarget . dataset . raw || '' ;
const mergedRaw = oldRaw + '\n\n*(continued)*\n\n' + newRaw ;
prevEl . dataset . raw = mergedRaw ;
// Re-render merged content
prevBody . innerHTML = markdownModule . processWithThinking (
markdownModule . squashOutsideCode ( mergedRaw )
) ;
// Remove the new bubble and re-add footer to the merged one
footerTarget . remove ( ) ;
const oldFooter = prevEl . querySelector ( '.msg-footer' ) ;
if ( oldFooter ) oldFooter . remove ( ) ;
prevEl . appendChild ( createMsgFooter ( prevEl ) ) ;
if ( window . hljs ) {
prevEl . querySelectorAll ( 'pre code' ) . forEach ( block => window . hljs . highlightElement ( block ) ) ;
}
// Persist merge to server
const sid = sessionModule . getCurrentSessionId ( ) ;
if ( sid ) {
fetch ( ` ${ API _BASE } /api/session/ ${ sid } /merge-last-assistant ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { separator : '\n\n*(continued)*\n\n' } )
} ) . catch ( e => console . warn ( 'merge-last-assistant failed:' , e ) ) ;
}
}
}
} // end if (!_isBgFinal)
} catch ( err ) {
_renderStream ( ) ;
// Clean up any active spinner (e.g. "Generating response" during tool calls)
if ( spinner && spinner . element ) spinner . destroy ( ) ;
_cancelThinkingTimer ( ) ;
_removeThinkingSpinner ( ) ;
document . querySelectorAll ( '.agent-thread.streaming' ) . forEach ( t => t . classList . remove ( 'streaming' ) ) ;
// Check if this stream was running in background
const _isBgCatch = ( sessionModule . getCurrentSessionId ( ) !== streamSessionId ) || _backgroundStreams . has ( streamSessionId ) ;
if ( _isBgCatch ) {
// Error happened while backgrounded — update map, don't touch DOM
console . error ( 'Background stream error:' , err ) ;
var bgErr = _backgroundStreams . get ( streamSessionId ) ;
if ( bgErr && bgErr . status === 'completed' ) {
// [DONE] was already processed — this error is benign (e.g. reader.read() after close)
// Don't override the completed status; just ensure the completed dot stays
if ( sessionModule && sessionModule . clearStreaming ) {
sessionModule . clearStreaming ( streamSessionId ) ;
}
} else if ( bgErr ) {
bgErr . status = 'error' ;
if ( sessionModule && sessionModule . clearStreaming ) {
sessionModule . clearStreaming ( streamSessionId ) ;
}
}
} else {
// Stop streaming TTS on any error/abort
if ( streamingTTS && window . aiTTSManager ) window . aiTTSManager . stop ( ) ;
if ( currentAbort && currentAbort . signal . aborted ) {
const abortReason = currentAbort . _reason || '' ;
// Timeout-triggered aborts should remain visible instead of disappearing.
if ( timedOut || abortReason === 'timeout' ) {
const timeoutMsg = _isAgent
? 'Agent response timed out. Try again, switch to a faster model, or reduce tool usage.'
: 'Response timed out. Try again.' ;
if ( holder && ! accumulated ) {
holder . querySelector ( '.body' ) . innerHTML =
` <div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[ ${ timeoutMsg } ]</div> ` ;
} else if ( holder && accumulated ) {
const timeoutNote = document . createElement ( 'div' ) ;
timeoutNote . className = 'stopped-indicator' ;
timeoutNote . innerHTML =
` <span style="color: var(--color-error);">[ ${ timeoutMsg } ]</span> ` ;
holder . querySelector ( '.body' ) . appendChild ( timeoutNote ) ;
}
currentAbort = null ;
return ;
}
if ( abortReason === 'offline' ) {
const offlineMsg = 'Endpoint offline — switch model or try again.' ;
if ( holder && ! accumulated ) {
holder . querySelector ( '.body' ) . innerHTML =
` <div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[ ${ offlineMsg } ]</div> ` ;
} else if ( holder && accumulated ) {
const offlineNote = document . createElement ( 'div' ) ;
offlineNote . className = 'stopped-indicator' ;
offlineNote . innerHTML =
` <span style="color: var(--color-error);">[ ${ offlineMsg } ]</span> ` ;
holder . querySelector ( '.body' ) . appendChild ( offlineNote ) ;
}
currentAbort = null ;
return ;
}
if ( abortReason === 'recovery' ) {
const recoveryMsg = 'Streaming was interrupted after the tab went inactive. Partial output was preserved.' ;
if ( holder && ! accumulated ) {
holder . querySelector ( '.body' ) . innerHTML =
` <div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[ ${ recoveryMsg } ]</div> ` ;
} else if ( holder && accumulated ) {
const recoveryNote = document . createElement ( 'div' ) ;
recoveryNote . className = 'stopped-indicator' ;
recoveryNote . innerHTML =
` <span style="color: var(--color-error);">[ ${ recoveryMsg } ]</span> ` ;
holder . querySelector ( '.body' ) . appendChild ( recoveryNote ) ;
}
currentAbort = null ;
return ;
}
// User-initiated stop (or browser navigation abort).
// Stopped before any text arrived — keep the bubble as a
// "Cancelled by user" record (so it survives a refresh).
if ( holder && ! accumulated ) {
_renderCancelledBubble ( holder ) ;
}
// But just in case the stop button didn't render it, render it here
if ( holder && accumulated && ! currentHolder ) {
holder . dataset . raw = accumulated ;
holder . querySelector ( '.body' ) . innerHTML = markdownModule . processWithThinking (
markdownModule . squashOutsideCode ( accumulated )
) ;
if ( window . hljs ) {
holder . querySelectorAll ( 'pre code' ) . forEach ( ( block ) => {
window . hljs . highlightElement ( block ) ;
} ) ;
}
const stoppedIndicator = document . createElement ( 'div' ) ;
stoppedIndicator . className = 'stopped-indicator' ;
const stoppedLabel = document . createElement ( 'span' ) ;
stoppedLabel . textContent = '[Message interrupted]' ;
stoppedIndicator . appendChild ( stoppedLabel ) ;
const continueBtn = document . createElement ( 'button' ) ;
continueBtn . className = 'continue-btn' ;
continueBtn . title = 'Continue' ;
continueBtn . textContent = '\u25B8' ;
continueBtn . addEventListener ( 'click' , ( ) => {
stoppedIndicator . remove ( ) ;
_hideUserBubble = true ;
_pendingContinue = holder ;
const cutoff = accumulated ;
const msgInput = uiModule . el ( 'message' ) ;
if ( msgInput ) {
msgInput . value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff . slice ( - 500 ) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.' ;
const sb = document . querySelector ( '.send-btn' ) ;
if ( sb ) sb . click ( ) ;
}
} ) ;
stoppedIndicator . appendChild ( continueBtn ) ;
holder . querySelector ( '.body' ) . appendChild ( stoppedIndicator ) ;
// Tell server to mark this message as stopped
const _sid2 = sessionModule . getCurrentSessionId ( ) ;
if ( _sid2 ) fetch ( ` ${ API _BASE } /api/session/ ${ _sid2 } /mark-stopped ` , { method : 'POST' } ) . catch ( e => console . warn ( 'mark-stopped failed:' , e ) ) ;
if ( ! holder . querySelector ( '.msg-footer' ) ) {
holder . appendChild ( createMsgFooter ( holder ) ) ;
}
uiModule . scrollHistory ( ) ;
}
// Now clear the abort controller
currentAbort = null ;
} else {
console . error ( err ) ;
// Stream died with a tool node still spinning. Its per-node tickers
// (_elapsedTicker 50ms / _waveInterval 100ms) are normally cleared in
// `tool_output`, which will never arrive now — without this sweep they
// fire forever on the orphaned node (and auto-recover compounds it per
// nudge). Safe here: auto-recover's new send is deferred 200ms, so no
// fresh running nodes exist yet.
document . querySelectorAll ( '.agent-thread-node.running' ) . forEach ( node => {
if ( node . _waveInterval ) { clearInterval ( node . _waveInterval ) ; node . _waveInterval = null ; }
if ( node . _elapsedTicker ) { clearInterval ( node . _elapsedTicker ) ; node . _elapsedTicker = null ; }
node . classList . remove ( 'running' ) ;
} ) ;
// Stream died unexpectedly — the "silently died" case. Re-engage the
// model immediately (no wait) with a completion handshake, up to the
// cap. Only auto-recover from connection-class failures; deterministic
// errors (unsupported tools, 4xx/5xx, parse failures) surface right away
// instead of burning the nudge budget on a guaranteed-to-fail retry.
if ( ! ( _isRecoverableStreamErr ( err ) && _tryAutoRecover ( holder , accumulated , streamSessionId ) ) ) {
const errorHolder = document . querySelector ( '.msg-ai:last-of-type .body' ) ;
if ( errorHolder ) {
let errMsg = ` Error: ${ err . message } ` ;
// Add hint for tool-call errors
if ( err . message && ( err . message . includes ( 'tool' ) || err . message . includes ( 'auto' ) ) ) {
errMsg += '\n\nThis model may not support tools — try switching to Chat mode.' ;
}
typewriterInto ( errorHolder , errMsg ) ;
}
}
}
}
} finally {
clearProcessingProbe ( ) ;
// Always clean up research tracking regardless of background state
_researchingStreamIds . delete ( streamSessionId ) ;
if ( _researchingStreamIds . size === 0 ) {
var _rToggleCleanup = document . getElementById ( 'research-toggle-btn' ) ;
if ( _rToggleCleanup ) _rToggleCleanup . classList . remove ( 'research-running' ) ;
}
// Only reset UI state if still on the stream's session and was never backgrounded
const _isBgFinally = ( sessionModule . getCurrentSessionId ( ) !== streamSessionId ) || _backgroundStreams . has ( streamSessionId ) ;
if ( ! _isBgFinally ) {
// Reset button to idle state
updateSubmitButton ( 'idle' , submitBtn ) ;
// Re-enable message input; on mobile blur to dismiss keyboard
if ( messageInput ) {
messageInput . disabled = false ;
if ( window . innerWidth <= 768 ) {
messageInput . blur ( ) ;
} else {
messageInput . focus ( ) ;
}
}
// Clear tracking variables
currentAccumulated = '' ;
currentHolder = null ;
currentSpinner = null ;
_researchingStreamIds . delete ( streamSessionId ) ;
// Clear research-running highlight if no more active research
if ( _researchingStreamIds . size === 0 ) {
var _rToggle2 = document . getElementById ( 'research-toggle-btn' ) ;
if ( _rToggle2 ) _rToggle2 . classList . remove ( 'research-running' ) ;
}
_clearResearchTimer ( ) ;
// Re-enable research button and auto-untoggle after use
// (skip if clarification round — keep toggle on for follow-up)
const _el = uiModule . el ;
const _researchBtn = _el ( 'research-toggle-btn' ) ;
const _researchToggle = _el ( 'research-toggle' ) ;
if ( _researchToggle && _researchToggle . checked ) {
_researchToggle . checked = false ;
Storage . setToggle ( 'research' , false ) ;
}
if ( _researchBtn ) {
_researchBtn . disabled = false ;
_researchBtn . classList . remove ( 'active' ) ;
_researchBtn . style . display = 'none' ;
}
// Also sync overflow and tool sidebar buttons
const _overflowRes = _el ( 'overflow-research-btn' ) ;
if ( _overflowRes ) _overflowRes . classList . remove ( 'active' ) ;
const _toolRes = _el ( 'tool-research-btn' ) ;
if ( _toolRes ) _toolRes . classList . remove ( 'active' ) ;
}
// Research clarification timeout — if user doesn't reply within 5 min, show timeout
if ( holder && holder . _roleSuffix === 'Research' && ! _researchingStreamIds . has ( streamSessionId ) ) {
var _timeoutSessionId = streamSessionId ;
var _timeoutTimer = setTimeout ( async function ( ) {
// Check if research_pending is still active (user hasn't replied)
try {
var _box = document . getElementById ( 'chat-history' ) ;
if ( _box && sessionModule . getCurrentSessionId ( ) === _timeoutSessionId ) {
var _timeoutMsg = document . createElement ( 'div' ) ;
_timeoutMsg . className = 'msg msg-ai' ;
_timeoutMsg . innerHTML = '<div class="role">Odysseus</div><div class="body" style="opacity:0.6;font-style:italic;">Research clarification timed out. Toggle research again to start over.</div>' ;
_box . appendChild ( _timeoutMsg ) ;
uiModule . scrollHistory ( ) ;
}
} catch ( _te ) { }
} , 5 * 60 * 1000 ) ;
// Cancel timeout if user sends a message
var _origSubmit = window . _researchTimeoutTimer ;
if ( _origSubmit ) clearTimeout ( _origSubmit ) ;
window . _researchTimeoutTimer = _timeoutTimer ;
}
// Release Web Lock
if ( _webLockRelease ) {
_webLockRelease ( ) ;
_webLockRelease = null ;
}
// Refresh session list after a delay (picks up auto-generated names)
setTimeout ( ( ) => {
if ( sessionModule && sessionModule . loadSessions ) {
sessionModule . loadSessions ( ) ;
}
} , 3000 ) ;
}
}
/ * *
* Abort current chat request
* /
// stopServer=true ONLY for an explicit user Stop. The run is now DETACHED
// (survives tab close / navigation), so the generic abort used by cleanup
// paths (session switch, delete, reader teardown on tab close) must NOT stop
// the server run — otherwise closing the tab would kill the background task,
// defeating the whole point. Only the Stop button cancels the server run.
export function abortCurrentRequest ( stopServer = false ) {
if ( currentAbort ) {
currentAbort . abort ( ) ;
// Don't set to null here - let catch block handle it
}
if ( stopServer ) {
try {
const _sid = _streamSessionId
|| ( window . sessionModule && window . sessionModule . getCurrentSessionId && window . sessionModule . getCurrentSessionId ( ) ) ;
if ( _sid ) {
fetch ( ` /api/chat/stop/ ${ encodeURIComponent ( _sid ) } ` , { method : 'POST' , credentials : 'same-origin' } ) . catch ( ( ) => { } ) ;
}
} catch ( _ ) { }
}
}
// ── Stall watchdog ──────────────────────────────────────────────
// Auto-recover a turn whose stream died (connection drop) or went silent:
// preserve the partial, then re-submit a completion handshake by reusing the
// existing continue/resume path. Returns false at the cap so the caller can
// surface the failure instead of nudging forever.
// Only auto-recover from connection-class failures (the genuine "silently
// died" case). Deterministic errors — unsupported tools, HTTP 4xx/5xx, JSON
// parse failures — will fail identically on retry, so surfacing them
// immediately is both more honest and avoids wasting the nudge budget.
function _isRecoverableStreamErr ( err ) {
if ( ! err ) return false ;
if ( err . name === 'TypeError' ) return true ; // fetch/reader network failure
const m = ( err . message || '' ) . toLowerCase ( ) ;
if ( /\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/ . test ( m ) ) return false ;
return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/ . test ( m ) ;
}
function _tryAutoRecover ( holder , accumulated , sessionId ) {
if ( _autoNudges >= _AUTO _NUDGE _CAP ) return false ;
_autoNudges ++ ;
if ( holder && accumulated ) {
holder . dataset . raw = accumulated ;
try {
holder . querySelector ( '.body' ) . innerHTML =
markdownModule . processWithThinking ( markdownModule . squashOutsideCode ( accumulated ) ) ;
} catch ( _ ) { }
}
_pendingContinue = holder || null ; // merge the continuation into the same bubble
_hideUserBubble = true ; // no user bubble for the handshake
_autoContinuePending = true ; // don't reset the counter on this submit
const _abandon = ( ) => { // clear the pending flags so they can't
_pendingContinue = null ; // leak into whatever chat is now open
_hideUserBubble = false ;
_autoContinuePending = false ;
} ;
// Defer so the stream's finally resets state first — otherwise the send
// button is still in "stop" mode and clicking it would toggle, not send.
setTimeout ( ( ) => {
// The stream that died may not be the chat the user is now looking at —
// never inject the recovery handshake into the wrong conversation.
if ( sessionId && sessionModule . getCurrentSessionId ( ) !== sessionId ) { _abandon ( ) ; return ; }
const msgInput = uiModule . el ( 'message' ) ;
const sb = document . querySelector ( '.send-btn' ) ;
if ( ! msgInput || ! sb ) { _abandon ( ) ; return ; }
const tail = ( accumulated || '' ) . slice ( - 400 ) ;
msgInput . value = tail
? ` The stream dropped before you finished. It ended with: \n \n ${ tail } \n \n If the task is fully complete, reply with just: DONE. Otherwise continue exactly where you left off and finish it — do not repeat what you already wrote. `
: ` The stream dropped before you produced anything. If the task is already done, reply with just: DONE. Otherwise complete it now. ` ;
sb . click ( ) ;
} , 200 ) ;
return true ;
}
function _removeStallBanner ( ) {
const b = document . getElementById ( 'stall-banner' ) ;
if ( b ) b . remove ( ) ;
_stallBannerShown = false ;
}
function _showStallBanner ( secs ) {
if ( document . getElementById ( 'stall-banner' ) ) return ;
_stallBannerShown = true ;
const box = document . getElementById ( 'chat-history' ) ;
if ( ! box ) return ;
const bar = document . createElement ( 'div' ) ;
bar . id = 'stall-banner' ;
bar . className = 'stall-banner' ;
const mins = Math . floor ( secs / 60 ) ;
const label = mins >= 1 ? ` ${ mins } m ` : ` ${ secs } s ` ;
bar . innerHTML = ` <span class="stall-banner-txt">Quiet for ${ label } — still working?</span> ` ;
const cont = document . createElement ( 'button' ) ;
cont . className = 'stall-banner-btn' ;
cont . textContent = 'Nudge it' ;
cont . title = 'Stop the stalled stream and ask it to continue' ;
cont . addEventListener ( 'click' , ( ) => {
_removeStallBanner ( ) ;
const mi = uiModule . el ( 'message' ) ;
if ( mi ) {
mi . value = 'Are you still working? If you stopped, continue exactly where you left off and finish the task.' ;
const sb = document . querySelector ( '.send-btn' ) ;
if ( sb ) sb . click ( ) ;
}
} ) ;
const stop = document . createElement ( 'button' ) ;
stop . className = 'stall-banner-btn stall-banner-stop' ;
stop . textContent = 'Stop' ;
stop . addEventListener ( 'click' , ( ) => { _removeStallBanner ( ) ; abortCurrentRequest ( true ) ; } ) ;
bar . appendChild ( cont ) ;
bar . appendChild ( stop ) ;
box . appendChild ( bar ) ;
if ( uiModule . scrollHistory ) uiModule . scrollHistory ( ) ;
}
function _startStallWatchdog ( ) {
// Disabled: the server-side stall detector / auto-continue (agent
// loop-breaker) handles quiet/stalled streams now, so the manual
// "Quiet for Nm — still working?" banner is redundant (and annoying).
if ( _stallWatchdog ) { clearInterval ( _stallWatchdog ) ; _stallWatchdog = null ; }
_removeStallBanner ( ) ;
}
function _stopStallWatchdog ( ) {
if ( _stallWatchdog ) { clearInterval ( _stallWatchdog ) ; _stallWatchdog = null ; }
_removeStallBanner ( ) ;
}
/ * * S h o w a " C a n c e l l e d b y u s e r " r e c o r d i n ` h o l d e r ` a n d p e r s i s t a n e m p t y
* assistant placeholder server - side so the turn survives a refresh .
* Called from both abort paths when no tokens had streamed yet . * /
function _renderCancelledBubble ( holder ) {
if ( ! holder ) return ;
holder . dataset . raw = '' ;
const body = holder . querySelector ( '.body' ) ;
if ( body ) {
body . innerHTML = '' ;
const indicator = document . createElement ( 'div' ) ;
indicator . className = 'stopped-indicator' ;
const label = document . createElement ( 'span' ) ;
label . style . fontStyle = 'italic' ;
label . style . opacity = '0.7' ;
label . textContent = '[Cancelled by user]' ;
indicator . appendChild ( label ) ;
body . appendChild ( indicator ) ;
}
if ( typeof createMsgFooter === 'function' && ! holder . querySelector ( '.msg-footer' ) ) {
holder . appendChild ( createMsgFooter ( holder ) ) ;
}
// Persist as an assistant message with stopped+cancelled metadata so the
// chat-history loader renders the same indicator after a refresh.
// Include the model name so the bubble header still shows which model
// was running when the user hit Stop.
const sid = sessionModule . getCurrentSessionId ( ) ;
if ( sid ) {
let modelName = '' ;
try { modelName = sessionModule . getCurrentModel ? . ( ) || '' ; } catch { }
// Fallback: pull from the holder's existing meta (the streaming
// placeholder usually has the model set in the header already).
if ( ! modelName ) {
modelName = holder . dataset . model
|| holder . querySelector ( '.msg-header .msg-model' ) ? . textContent
|| '' ;
}
fetch ( ` ${ API _BASE } /api/session/ ${ sid } /inject_messages ` , {
method : 'POST' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
messages : [ {
role : 'assistant' ,
content : '' ,
metadata : { stopped : true , cancelled : true , model : modelName } ,
} ] ,
} ) ,
} ) . catch ( ( ) => { } ) ;
}
}
/ * *
* Detach current stream to run in background instead of aborting .
* Called when user switches sessions mid - stream .
* /
export function detachCurrentStream ( sessionId ) {
if ( ! isStreaming || ! currentAbort ) {
// Not streaming — fall through to abort
abortCurrentRequest ( ) ;
return ;
}
// Store background stream state
_backgroundStreams . set ( sessionId , {
status : 'running' ,
accumulated : currentAccumulated ,
sourcesHtml : '' ,
findingsData : null ,
abortCtrl : currentAbort ,
query : currentHolder ? ( currentHolder . _researchQuery || '' ) : '' ,
metrics : null ,
} ) ;
// Mark session with pulsing dot in sidebar
if ( sessionModule && sessionModule . markStreaming ) {
sessionModule . markStreaming ( sessionId ) ;
}
// Clear local state WITHOUT aborting the fetch
currentAbort = null ;
isStreaming = false ;
currentHolder = null ;
currentAccumulated = '' ;
// Reset submit button so the new chat is ready to send
const submitBtn = document . querySelector ( '.send-btn' ) ;
if ( submitBtn ) updateSubmitButton ( 'idle' , submitBtn ) ;
}
// _notifyStreamComplete and _insertStreamDoneToast now in chatStream.js
var _notifyStreamComplete = chatStream . notifyStreamComplete ;
var _insertStreamDoneToast = chatStream . insertStreamDoneToast ;
/ * *
* Check for background streams when switching to a session .
* Called after history loads on session switch .
* /
export function checkBackgroundStream ( sessionId ) {
if ( ! sessionId || ! _backgroundStreams . has ( sessionId ) ) return ;
var entry = _backgroundStreams . get ( sessionId ) ;
if ( entry . status === 'completed' ) {
// Response is already saved to DB and will appear in history — just clean up
_backgroundStreams . delete ( sessionId ) ;
return ;
}
if ( entry . status === 'error' ) {
_backgroundStreams . delete ( sessionId ) ;
var box = document . getElementById ( 'chat-history' ) ;
if ( box ) {
var errHolder = document . createElement ( 'div' ) ;
errHolder . className = 'msg msg-ai' ;
errHolder . innerHTML = '<div class="body"><i style="color: var(--color-error);">[Background stream encountered an error]</i></div>' ;
box . appendChild ( errHolder ) ;
}
return ;
}
if ( entry . status === 'running' ) {
// Stream is still active — show a clean spinner, poll until done,
// then reload history to show the final saved response.
var box = document . getElementById ( 'chat-history' ) ;
if ( ! box ) return ;
// Replay any doc content that was streamed in the background
if ( entry . _docTitle != null && documentModule ) {
documentModule . streamDocOpen ( entry . _docTitle , entry . _docLang || '' ) ;
if ( entry . _docContent ) {
documentModule . streamDocDelta ( entry . _docContent ) ;
}
}
var holder = document . createElement ( 'div' ) ;
holder . className = 'msg msg-ai' ;
var meta = sessionModule . getSessions ( ) . find ( function ( s ) { return s . id === sessionId ; } ) ;
var roleLabel = _shortModel ( meta && meta . model ) ;
var roleTs = new Date ( ) . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } ) ;
holder . innerHTML = '<div class="role">' + roleLabel + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>' ;
_applyModelColor ( holder . querySelector ( '.role' ) , meta && meta . model ) ;
var bodyDiv = holder . querySelector ( '.body' ) ;
var spinner = spinnerModule . create ( 'Response streaming in background' , 'right' ) ;
bodyDiv . appendChild ( spinner . createElement ( ) ) ;
spinner . start ( ) ;
box . appendChild ( holder ) ;
uiModule . scrollHistory ( ) ;
// Poll map until stream finishes, then reload history
var pollId = setInterval ( function ( ) {
if ( sessionModule . getCurrentSessionId ( ) !== sessionId ) {
clearInterval ( pollId ) ;
spinner . destroy ( ) ;
if ( holder . parentNode ) holder . remove ( ) ;
return ;
}
// Update doc content while polling
var curPoll = _backgroundStreams . get ( sessionId ) ;
if ( curPoll && curPoll . _docContent && documentModule ) {
documentModule . streamDocDelta ( curPoll . _docContent ) ;
}
if ( ! curPoll || curPoll . status !== 'running' ) {
clearInterval ( pollId ) ;
spinner . destroy ( ) ;
if ( holder . parentNode ) holder . remove ( ) ; // Remove entire holder, not just spinner
_backgroundStreams . delete ( sessionId ) ;
// Reload session to show the completed response — but only if the user
// is still on it; don't yank them back from a new chat they opened.
if ( sessionModule . getCurrentSessionId && sessionModule . getCurrentSessionId ( ) === sessionId ) {
sessionModule . selectSession ( sessionId ) ;
} else {
sessionModule . loadSessions ( ) ;
}
}
} , 500 ) ;
}
}
// Tag short single-line code blocks with .pre-compact so the CSS can
// render the Run/Edit/Copy buttons as a slim row that doesn't make a
// 1-line bash block taller than its own contents.
function _markCompactPre ( pre ) {
const code = pre . querySelector ( 'code' ) ;
if ( ! code ) return ;
const txt = code . textContent || '' ;
// Count visible lines — ignore trailing newline (common with fenced
// blocks) and treat any empty extra line as not a real second line.
const lines = txt . replace ( /\n+$/ , '' ) . split ( '\n' ) ;
const compact = lines . length <= 1 && txt . length < 200 ;
pre . classList . toggle ( 'pre-compact' , compact ) ;
}
function _scanCompactPres ( root ) {
if ( ! root || ! root . querySelectorAll ) return ;
root . querySelectorAll ( 'pre' ) . forEach ( _markCompactPre ) ;
}
// Global observer so any <pre> added anywhere in the app (chat stream,
// chat re-renders, document library chat previews, slash commands,
// research previews, etc.) gets tagged without each call site needing
// to remember.
( function _initCompactPreObserver ( ) {
if ( window . _cmpPreObserverWired ) return ;
window . _cmpPreObserverWired = true ;
_scanCompactPres ( document . body ) ;
const obs = new MutationObserver ( ( muts ) => {
for ( const m of muts ) {
for ( const n of m . addedNodes ) {
if ( n . nodeType !== 1 ) continue ;
if ( n . tagName === 'PRE' ) _markCompactPre ( n ) ;
if ( n . querySelectorAll ) _scanCompactPres ( n ) ;
}
}
} ) ;
obs . observe ( document . body , { childList : true , subtree : true } ) ;
} ) ( ) ;
/ * *
* Initialize event listeners
* /
export function initListeners ( ) {
// Global event delegation for copy-code buttons
document . addEventListener ( 'click' , ( e ) => {
const btn = e . target . closest ( '.copy-code' ) ;
if ( ! btn ) return ;
e . stopPropagation ( ) ;
const code = btn . getAttribute ( 'data-code' ) ;
if ( code && uiModule ) {
uiModule . copyToClipboard ( code ) ;
// Visual feedback: swap the icon to a checkmark (regular size)
// and add .copied which the CSS uses to flash green + pulse.
// For slim/.pre-compact buttons the label text comes from a
// CSS ::before — swap it via data-state so we don't break the
// text-button layout.
const origHTML = btn . innerHTML ;
const isCompact = ! ! btn . closest ( 'pre.pre-compact' ) ;
if ( ! isCompact ) {
btn . innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>' ;
}
btn . classList . add ( 'copied' ) ;
btn . dataset . state = 'copied' ;
setTimeout ( ( ) => {
if ( ! isCompact ) btn . innerHTML = origHTML ;
btn . classList . remove ( 'copied' ) ;
delete btn . dataset . state ;
} , 1500 ) ;
}
} ) ;
// Run code button delegation
document . addEventListener ( 'click' , ( e ) => {
const btn = e . target . closest ( '.run-code' ) ;
if ( ! btn ) return ;
e . stopPropagation ( ) ;
if ( codeRunnerModule ) codeRunnerModule . run ( btn ) ;
} ) ;
// Edit code button delegation — toggle contentEditable on the code element
document . addEventListener ( 'click' , ( e ) => {
const btn = e . target . closest ( '.edit-code' ) ;
if ( ! btn ) return ;
e . stopPropagation ( ) ;
const pre = btn . closest ( 'pre' ) ;
if ( ! pre ) return ;
const codeEl = pre . querySelector ( 'code' ) ;
if ( ! codeEl ) return ;
const isEditing = codeEl . contentEditable !== 'false' && codeEl . contentEditable !== 'inherit' ;
if ( isEditing ) {
// Save: exit edit mode, update data-code on copy/run buttons
codeEl . contentEditable = 'false' ;
codeEl . classList . remove ( 'editing' ) ;
pre . classList . remove ( 'editing' ) ;
const newCode = codeEl . textContent ;
const copyBtn = pre . querySelector ( '.copy-code' ) ;
if ( copyBtn ) copyBtn . setAttribute ( 'data-code' , newCode ) ;
const runBtn = pre . querySelector ( '.run-code' ) ;
if ( runBtn ) runBtn . setAttribute ( 'data-code' , newCode ) ;
// Swap icon back to pencil
btn . innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>' ;
btn . title = 'Edit' ;
btn . classList . remove ( 'active' ) ;
} else {
// Enter edit mode. Firefox (especially on mobile) historically lacks
// contentEditable="plaintext-only" — setting it there leaves the block
// non-editable, so the tap "just gets a checkmark" with no way to type.
// Fall back to "true" when plaintext-only didn't take.
try { codeEl . contentEditable = 'plaintext-only' ; } catch ( _ ) { /* unsupported value */ }
if ( codeEl . contentEditable !== 'plaintext-only' ) codeEl . contentEditable = 'true' ;
codeEl . classList . add ( 'editing' ) ;
pre . classList . add ( 'editing' ) ;
// preventScroll keeps the page from jumping to the codeblock when
// focusing the editable on mobile — the browser would otherwise
// scroll it into view above the keyboard, which reads as "auto-
// scroll triggered by clicking Edit".
try { codeEl . focus ( { preventScroll : true } ) ; } catch ( _ ) { codeEl . focus ( ) ; }
// Swap icon to checkmark
btn . innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>' ;
btn . title = 'Done editing' ;
btn . classList . add ( 'active' ) ;
}
} ) ;
// Tapping a code block body (not its buttons) toggles the overlay
// copy/edit/run buttons, which otherwise cover the text on mobile.
document . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.copy-code, .edit-code, .run-code' ) ) return ;
const pre = e . target . closest ( 'pre' ) ;
if ( ! pre || ! pre . querySelector ( '.copy-code' ) ) return ;
// Don't hide while editing — the buttons (incl. the Done checkmark) matter.
if ( pre . classList . contains ( 'editing' ) ) return ;
pre . classList . toggle ( 'buttons-hidden' ) ;
} ) ;
// Position copy/run buttons top or bottom based on viewport position
// — DESKTOP ONLY. On mobile this was constantly retriggering on tap
// (synthetic mouseenter) and made the buttons jump, so the user's
// finger landed on the moved target. Keep them pinned at the top on
// touch — no auto-repositioning.
document . addEventListener ( 'mouseenter' , ( e ) => {
if ( window . matchMedia ( '(max-width: 768px)' ) . matches ) return ;
const pre = e . target . closest ? e . target . closest ( 'pre' ) : null ;
if ( ! pre || pre . dataset . btnPosComputed ) return ;
const rect = pre . getBoundingClientRect ( ) ;
const threshold = window . innerHeight * 0.35 ;
const isBottom = rect . top < threshold ;
const copyBtn = pre . querySelector ( '.copy-code' ) ;
if ( copyBtn ) copyBtn . classList . toggle ( 'bottom' , isBottom ) ;
const editBtn = pre . querySelector ( '.edit-code' ) ;
if ( editBtn ) editBtn . classList . toggle ( 'bottom' , isBottom ) ;
const runBtn = pre . querySelector ( '.run-code' ) ;
if ( runBtn ) runBtn . classList . toggle ( 'bottom' , isBottom ) ;
pre . dataset . btnPosComputed = '1' ;
} , true ) ;
// Tab suspension recovery: when user tabs back in, check if stream froze
document . addEventListener ( 'visibilitychange' , ( ) => {
if ( document . visibilityState !== 'visible' ) return ;
if ( ! isStreaming ) return ;
// Stream claims to be running — check if reader is actually alive
const staleSince = Date . now ( ) - _lastReaderActivity ;
if ( staleSince < 20000 ) return ; // Active recently, probably fine
// Reader hasn't produced data in 5+ seconds after tab resume.
// Give it a short grace period then recover.
console . warn ( '[tab-recovery] Stream appears frozen (no activity for ' + Math . round ( staleSince / 1000 ) + 's). Recovering...' ) ;
setTimeout ( ( ) => {
// Re-check — maybe the reader woke up during the grace period
if ( ! isStreaming ) return ;
const stillStale = Date . now ( ) - _lastReaderActivity ;
if ( stillStale < 5000 ) return ; // Came back to life
console . warn ( '[tab-recovery] Stream confirmed dead. Aborting and reloading session.' ) ;
// Abort the frozen stream, but preserve the visible bubble.
if ( currentAbort ) {
currentAbort . _reason = 'recovery' ;
currentAbort . abort ( ) ;
}
isStreaming = false ;
// Release Web Lock
if ( _webLockRelease ) {
_webLockRelease ( ) ;
_webLockRelease = null ;
}
// Reset UI state
var _submitBtn = document . getElementById ( 'submit' ) ;
updateSubmitButton ( 'idle' , _submitBtn ) ;
var _msgInput = document . getElementById ( 'message' ) ;
if ( _msgInput ) _msgInput . disabled = false ;
} , 2000 ) ; // 2 second grace period
} ) ;
// On mobile, fade out welcome text when keyboard opens to prevent overlap
if ( window . innerWidth <= 768 ) {
const msgInput = document . getElementById ( 'message' ) ;
if ( msgInput ) {
msgInput . addEventListener ( 'focus' , ( ) => {
const ws = document . getElementById ( 'welcome-screen' ) ;
if ( ws && ! ws . classList . contains ( 'hidden' ) ) {
ws . classList . add ( 'kb-hidden' ) ;
}
} ) ;
msgInput . addEventListener ( 'blur' , ( ) => {
const ws = document . getElementById ( 'welcome-screen' ) ;
if ( ws && ! ws . classList . contains ( 'hidden' ) ) {
// Delay re-show so tapping within chatbox doesn't flash
setTimeout ( ( ) => {
if ( document . activeElement !== msgInput ) {
ws . classList . remove ( 'kb-hidden' ) ;
}
} , 200 ) ;
}
} ) ;
}
// Smooth viewport resize when keyboard opens/closes
if ( window . visualViewport ) {
window . visualViewport . addEventListener ( 'resize' , ( ) => {
document . documentElement . style . setProperty ( '--vh' , window . visualViewport . height + 'px' ) ;
} ) ;
document . documentElement . style . setProperty ( '--vh' , window . visualViewport . height + 'px' ) ;
}
}
// If the browser discarded and restored this tab, reload the current session
// so the user sees the server-saved partial response instead of a blank page
if ( document . wasDiscarded ) {
console . warn ( '[tab-recovery] Tab was discarded by browser — reloading session' ) ;
setTimeout ( ( ) => {
var _sid = sessionModule && sessionModule . getCurrentSessionId ( ) ;
if ( _sid ) sessionModule . selectSession ( _sid ) ;
} , 500 ) ;
}
}
/ * *
* Regenerate response : truncate history to the user message before this AI message ,
* then re - submit that user message .
* /
/ * *
* Edit a user message : show an input , truncate to before it , resubmit the edited text .
* /
export async function editUserMessage ( userMsgElement ) {
const box = document . getElementById ( 'chat-history' ) ;
const allMsgs = Array . from ( box . querySelectorAll ( '.msg' ) ) ;
const msgIndex = allMsgs . indexOf ( userMsgElement ) ;
if ( msgIndex < 0 ) return ;
const bodyEl = userMsgElement . querySelector ( '.body' ) ;
const currentText = bodyEl ? bodyEl . textContent . trim ( ) . replace ( /\s*\[\d+ attachment\(s\)\]$/ , '' ) : '' ;
// Replace body with an editable textarea
const editor = document . createElement ( 'textarea' ) ;
editor . className = 'edit-textarea' ;
editor . value = currentText ;
editor . rows = Math . max ( 2 , currentText . split ( '\n' ) . length ) ;
const btnRow = document . createElement ( 'div' ) ;
btnRow . style . cssText = 'display:flex; gap:6px; margin-top:4px;' ;
const saveBtn = document . createElement ( 'button' ) ;
saveBtn . className = 'edit-save-btn' ;
saveBtn . textContent = 'Send' ;
const cancelBtn = document . createElement ( 'button' ) ;
cancelBtn . className = 'edit-cancel-btn' ;
cancelBtn . textContent = 'Cancel' ;
btnRow . appendChild ( saveBtn ) ;
btnRow . appendChild ( cancelBtn ) ;
const originalHTML = bodyEl . innerHTML ;
bodyEl . innerHTML = '' ;
bodyEl . appendChild ( editor ) ;
bodyEl . appendChild ( btnRow ) ;
editor . focus ( ) ;
cancelBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
bodyEl . innerHTML = originalHTML ;
} ) ;
saveBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const newText = editor . value . trim ( ) ;
if ( ! newText ) return ;
const sessionId = sessionModule . getCurrentSessionId ( ) ;
if ( ! sessionId ) return ;
const keepCount = msgIndex ;
try {
await fetch ( ` ${ API _BASE } /api/session/ ${ sessionId } /truncate ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { keep _count : keepCount } )
} ) ;
// Remove DOM elements from msgIndex onward
for ( let i = allMsgs . length - 1 ; i >= msgIndex ; i -- ) {
allMsgs [ i ] . remove ( ) ;
}
// Submit the edited text
const messageInput = uiModule . el ( 'message' ) ;
messageInput . value = newText ;
const submitBtn = document . querySelector ( '.send-btn' ) ;
if ( submitBtn ) submitBtn . click ( ) ;
} catch ( err ) {
console . error ( 'Edit failed:' , err ) ;
if ( uiModule ) uiModule . showError ( 'Edit failed: ' + err . message ) ;
bodyEl . innerHTML = originalHTML ;
}
} ) ;
// Also submit on Enter (without shift)
editor . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Enter' && ! e . shiftKey ) {
e . preventDefault ( ) ;
saveBtn . click ( ) ;
}
} ) ;
}
/ * *
* Resend a user message — truncates history to that point and resubmits .
* /
export async function resendUserMessage ( userMsgElement ) {
const box = document . getElementById ( 'chat-history' ) ;
const allMsgs = Array . from ( box . querySelectorAll ( '.msg' ) ) ;
const msgIndex = allMsgs . indexOf ( userMsgElement ) ;
if ( msgIndex < 0 ) return ;
// Prefer dataset.raw (stripped original user text) over .body.textContent
// — the latter slurps the rendered "View image description" collapsible
// content too, which would then be sent back as the user's question and
// the AI would reply to that gibberish instead of the actual prompt.
const bodyEl = userMsgElement . querySelector ( '.body' ) ;
let text = ( userMsgElement . dataset . raw || ( bodyEl ? bodyEl . textContent : '' ) || '' ) . trim ( ) ;
text = text . replace ( /\s*\[\d+ attachment\(s\)\]$/ , '' ) ;
// Collect file_ids attached to this user message so the resend re-carries
// the photos / docs (and the chat handler picks up the user-edited OCR
// text cached server-side under those file ids).
const _attachEls = userMsgElement . querySelectorAll ( '[data-file-id]' ) ;
let _ids = Array . from ( _attachEls ) . map ( el => el . dataset . fileId ) . filter ( Boolean ) ;
if ( ! _ids . length ) {
const _imgs = userMsgElement . querySelectorAll ( '.attach-image-preview img, .attach-card img' ) ;
for ( const _im of _imgs ) {
const _m = ( _im . getAttribute ( 'src' ) || '' ) . match ( /\/api\/upload\/([A-Za-z0-9_\-]+)/ ) ;
if ( _m && _m [ 1 ] && ! _ids . includes ( _m [ 1 ] ) ) _ids . push ( _m [ 1 ] ) ;
}
}
// Rescue: legacy bubbles may have stored the filename as the message
// content (artifact of earlier broken resends). Don't re-send that as
// the user prompt if we still have the file attached. Loosen the regex
// to cover real-world camera/screenshot names with spaces, parens,
// multi-dots: "Screen Shot 2026-05-28 at 4.05.32 PM.png", "IMG (1).JPG".
if ( text && _ids . length && /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i . test ( text ) ) {
text = '' ;
}
// Empty text + no attachments → tell the user instead of silently bailing.
// The common case is a regen during a pre-upload race where the bubble
// never had an `[data-file-id]` to scrape.
if ( ! text && ! _ids . length ) {
if ( uiModule ? . showError ) uiModule . showError ( 'Nothing to resend — message has no text and no attachments yet (try again after the upload finishes).' ) ;
return ;
}
const sessionId = sessionModule . getCurrentSessionId ( ) ;
if ( ! sessionId ) return ;
// Truncate backend to keep everything before this user message
const keepCount = msgIndex ;
try {
await fetch ( ` ${ API _BASE } /api/session/ ${ sessionId } /truncate ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { keep _count : keepCount } )
} ) ;
// Drop the AI replies after the user message but KEEP the user bubble
// itself (so its photo stays visible). Then suppress the new user
// bubble that send would otherwise add — same pattern as regenerate.
let sibling = userMsgElement . nextSibling ;
while ( sibling ) {
const next = sibling . nextSibling ;
sibling . remove ( ) ;
sibling = next ;
}
_hideUserBubble = true ;
_pendingRegenAttachments = _ids ;
// Resubmit
const messageInput = uiModule . el ( 'message' ) ;
messageInput . value = text ;
const submitBtn = document . querySelector ( '.send-btn' ) ;
if ( submitBtn ) submitBtn . click ( ) ;
} catch ( err ) {
console . error ( 'Resend failed:' , err ) ;
if ( uiModule ) uiModule . showError ( 'Resend failed: ' + err . message ) ;
}
}
export async function regenerateFrom ( aiMsgElement ) {
const box = document . getElementById ( 'chat-history' ) ;
const allMsgs = Array . from ( box . querySelectorAll ( '.msg' ) ) ;
const aiIndex = allMsgs . indexOf ( aiMsgElement ) ;
if ( aiIndex < 0 ) return ;
// Find the preceding user message
let userIndex = - 1 ;
let userText = '' ;
let userMsgEl = null ;
for ( let i = aiIndex - 1 ; i >= 0 ; i -- ) {
if ( allMsgs [ i ] . classList . contains ( 'msg-user' ) ) {
userIndex = i ;
userMsgEl = allMsgs [ i ] ;
// Prefer dataset.raw (set by addMessage with the stripped, original
// user text) over the rendered body's textContent — the latter
// pulls in the "View image description" collapsible content too,
// duplicating the OCR text on regen.
const bodyEl = userMsgEl . querySelector ( '.body' ) ;
userText = ( userMsgEl . dataset . raw || ( bodyEl ? bodyEl . textContent : '' ) || '' ) . trim ( ) ;
userText = userText . replace ( /\s*\[\d+ attachment\(s\)\]$/ , '' ) ;
break ;
}
}
if ( userIndex < 0 ) {
if ( uiModule ) uiModule . showError ( 'Could not find the user message to regenerate' ) ;
return ;
}
// Collect any file_ids attached to the original user message so the
// regenerated send re-uses them. Without this the AI is regenerated on
// text alone — photos (and the user-edited OCR text cached server-side
// under that file_id) would be silently dropped.
const _attachEls = userMsgEl ? userMsgEl . querySelectorAll ( '[data-file-id]' ) : [ ] ;
let _regenIds = Array . from ( _attachEls ) . map ( el => el . dataset . fileId ) . filter ( Boolean ) ;
// Fallback for bubbles rendered before the data-file-id stamp landed:
// sniff the file id straight out of any `.attach-image-preview img`
// src URLs (matches /api/upload/<id>). Otherwise an older bubble would
// regen with zero attachments and the photo would be lost from the
// resulting message even though the file still exists on disk.
if ( ! _regenIds . length && userMsgEl ) {
const _imgs = userMsgEl . querySelectorAll ( '.attach-image-preview img, .attach-card img' ) ;
for ( const _im of _imgs ) {
const _m = ( _im . getAttribute ( 'src' ) || '' ) . match ( /\/api\/upload\/([A-Za-z0-9_\-]+)/ ) ;
if ( _m && _m [ 1 ] && ! _regenIds . includes ( _m [ 1 ] ) ) _regenIds . push ( _m [ 1 ] ) ;
}
}
_pendingRegenAttachments = _regenIds ;
// Rescue: earlier-version regens (before the dataset.raw fix) stored the
// photo's filename as the user-message content. On a follow-up regen,
// that filename would be sent back as the literal user prompt, so the
// AI thinks the question is "blue_night_preview.jpg" and replies "that's
// an image file". If userText is just a bare image filename and we have
// attachments, drop it so the OCR text (or the image bytes for vision
// models) is what the model actually sees.
if ( userText && _pendingRegenAttachments . length &&
/^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i . test ( userText . trim ( ) ) ) {
userText = '' ;
}
// A photo-only message has empty user text — regen must still proceed,
// because the attachments themselves are the message. Bail only if there
// is no text AND no attachments to send.
if ( ! userText && ! _pendingRegenAttachments . length ) {
if ( uiModule ) uiModule . showError ( 'Nothing to regenerate — the user message has no text and no attachments' ) ;
return ;
}
const sessionId = sessionModule . getCurrentSessionId ( ) ;
if ( ! sessionId ) return ;
// Save current response as a variant
const oldRaw = aiMsgElement . dataset . raw || aiMsgElement . querySelector ( '.body' ) ? . textContent || '' ;
const oldHtml = aiMsgElement . querySelector ( '.body' ) ? . innerHTML || '' ;
let variants = [ ] ;
try { variants = JSON . parse ( aiMsgElement . dataset . variants || '[]' ) ; } catch ( _ ) { }
if ( variants . length === 0 ) {
// First regen — save the original as variant 0
variants . push ( { raw : oldRaw , html : oldHtml , label : 'original' } ) ;
}
const keepCount = userIndex ;
try {
await fetch ( ` ${ API _BASE } /api/session/ ${ sessionId } /truncate ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { keep _count : keepCount } )
} ) ;
for ( let i = allMsgs . length - 1 ; i > aiIndex ; i -- ) {
allMsgs [ i ] . remove ( ) ;
}
// Remove the AI message from DOM — it will be replaced by the new streaming response
// But first, stash the variants data so we can transfer it to the new element
_pendingVariants = variants ;
_pendingVariantLabel = 'regen' ;
aiMsgElement . remove ( ) ;
_hideUserBubble = true ;
const messageInput = uiModule . el ( 'message' ) ;
messageInput . value = userText ;
const submitBtn = document . querySelector ( '.send-btn' ) ;
if ( submitBtn ) submitBtn . click ( ) ;
} catch ( err ) {
console . error ( 'Regenerate failed:' , err ) ;
if ( uiModule ) uiModule . showError ( 'Regenerate failed: ' + err . message ) ;
}
}
// Pending variants from a regeneration — transferred to new streaming element
let _pendingVariants = null ;
let _pendingVariantLabel = null ;
// File-ids carried over from the original user message during a regen, so
// photos / OCR overrides survive into the new send. Consumed once.
let _pendingRegenAttachments = null ;
/ * *
* Called after streaming completes to attach variant navigation if this was a regen .
* /
function _attachVariantNav ( msgElement ) {
if ( ! _pendingVariants ) return ;
const variants = _pendingVariants ;
_pendingVariants = null ;
// Add the new response as the latest variant
const newRaw = msgElement . dataset . raw || msgElement . querySelector ( '.body' ) ? . textContent || '' ;
const newHtml = msgElement . querySelector ( '.body' ) ? . innerHTML || '' ;
const varLabel = _pendingVariantLabel || 'regen' ;
_pendingVariantLabel = null ;
variants . push ( { raw : newRaw , html : newHtml , label : varLabel } ) ;
msgElement . dataset . variants = JSON . stringify ( variants ) ;
msgElement . dataset . variantIndex = String ( variants . length - 1 ) ;
_renderVariantNav ( msgElement , variants , variants . length - 1 ) ;
// Persist variants to server
const sid = sessionModule . getCurrentSessionId ( ) ;
if ( sid ) {
fetch ( ` ${ API _BASE } /api/session/ ${ sid } /update-last-meta ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { metadata : { variants : variants , variantIndex : variants . length - 1 } } )
} ) . catch ( e => console . warn ( 'update-last-meta (variants) failed:' , e ) ) ;
}
}
const _VARIANT _ICONS = { regen : '\u21BB' , shorter : '\u2702' , simpler : '?' , original : '\u25CB' } ;
function _variantTagText ( label ) {
return _VARIANT _ICONS [ label ] || _VARIANT _ICONS [ 'original' ] ;
}
function _renderVariantNav ( msgElement , variants , currentIdx ) {
// Remove existing nav if any
const old = msgElement . querySelector ( '.variant-nav' ) ;
if ( old ) old . remove ( ) ;
if ( variants . length < 2 ) return ;
const nav = document . createElement ( 'span' ) ;
nav . className = 'variant-nav' ;
nav . addEventListener ( 'click' , ( e ) => e . stopPropagation ( ) ) ;
// Label showing what this variant is
// Divider
const divider = document . createElement ( 'span' ) ;
divider . className = 'variant-divider' ;
divider . textContent = '|' ;
nav . appendChild ( divider ) ;
// Label
const curVariant = variants [ currentIdx ] ;
const tagLabel = document . createElement ( 'span' ) ;
tagLabel . className = 'variant-tag' + ( curVariant ? . label === 'shorter' ? ' variant-tag-scissors' : '' ) ;
tagLabel . textContent = _variantTagText ( curVariant ? . label ) ;
nav . appendChild ( tagLabel ) ;
// < button
const prevBtn = document . createElement ( 'button' ) ;
prevBtn . className = 'variant-btn' ;
prevBtn . textContent = '<' ;
prevBtn . disabled = currentIdx === 0 ;
prevBtn . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _switchVariant ( msgElement , variants , currentIdx - 1 ) ; } ) ;
nav . appendChild ( prevBtn ) ;
// Clickable number for current index (click left number = go left, right = go right)
const numLeft = document . createElement ( 'button' ) ;
numLeft . className = 'variant-num' ;
numLeft . textContent = String ( currentIdx + 1 ) ;
numLeft . disabled = currentIdx === 0 ;
numLeft . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _switchVariant ( msgElement , variants , currentIdx - 1 ) ; } ) ;
nav . appendChild ( numLeft ) ;
const slash = document . createElement ( 'span' ) ;
slash . className = 'variant-slash' ;
slash . textContent = '/' ;
nav . appendChild ( slash ) ;
const numRight = document . createElement ( 'button' ) ;
numRight . className = 'variant-num' ;
numRight . textContent = String ( variants . length ) ;
numRight . disabled = currentIdx === variants . length - 1 ;
numRight . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _switchVariant ( msgElement , variants , currentIdx + 1 ) ; } ) ;
nav . appendChild ( numRight ) ;
// > button
const nextBtn = document . createElement ( 'button' ) ;
nextBtn . className = 'variant-btn' ;
nextBtn . textContent = '>' ;
nextBtn . disabled = currentIdx === variants . length - 1 ;
nextBtn . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _switchVariant ( msgElement , variants , currentIdx + 1 ) ; } ) ;
nav . appendChild ( nextBtn ) ;
// Insert into the .role header
const roleEl = msgElement . querySelector ( '.role' ) ;
if ( roleEl ) {
roleEl . appendChild ( nav ) ;
} else {
msgElement . appendChild ( nav ) ;
}
}
function _switchVariant ( msgElement , variants , newIdx ) {
if ( newIdx < 0 || newIdx >= variants . length ) return ;
const v = variants [ newIdx ] ;
const body = msgElement . querySelector ( '.body' ) ;
if ( body ) body . innerHTML = v . html ;
msgElement . dataset . raw = v . raw ;
msgElement . dataset . variantIndex = String ( newIdx ) ;
if ( window . hljs ) {
msgElement . querySelectorAll ( 'pre code' ) . forEach ( block => window . hljs . highlightElement ( block ) ) ;
}
_renderVariantNav ( msgElement , variants , newIdx ) ;
// Persist selected variant to server
const sid = sessionModule . getCurrentSessionId ( ) ;
if ( sid ) {
fetch ( ` ${ API _BASE } /api/session/ ${ sid } /update-last-meta ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { metadata : { variantIndex : newIdx } } )
} ) . catch ( e => console . warn ( 'update-last-meta (variantIndex) failed:' , e ) ) ;
}
}
export async function forkFrom ( aiMsgElement ) {
const box = document . getElementById ( 'chat-history' ) ;
const allMsgs = Array . from ( box . querySelectorAll ( '.msg' ) ) ;
const aiIndex = allMsgs . indexOf ( aiMsgElement ) ;
if ( aiIndex < 0 ) return ;
const sessionId = sessionModule . getCurrentSessionId ( ) ;
if ( ! sessionId ) return ;
const keepCount = aiIndex + 1 ;
try {
const res = await fetch ( ` ${ API _BASE } /api/session/ ${ sessionId } /fork ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { keep _count : keepCount } ) ,
} ) ;
if ( ! res . ok ) throw new Error ( await res . text ( ) ) ;
const data = await res . json ( ) ;
await sessionModule . loadSessions ( ) ;
await sessionModule . selectSession ( data . id ) ;
if ( uiModule ) uiModule . showToast ( ` Forked → ${ data . name } ` ) ;
} catch ( err ) {
console . error ( 'Fork failed:' , err ) ;
if ( uiModule ) uiModule . showError ( 'Fork failed: ' + err . message ) ;
}
}
/ * *
* Check for pending / completed research after page refresh or session switch .
* If research is still running , show a spinner and poll until done .
* If research is done , fetch result and render it .
* /
export async function checkPendingResearch ( sessionId ) {
if ( ! sessionId ) return ;
try {
const res = await fetch ( ` ${ API _BASE } /api/research/status/ ${ sessionId } ` ) ;
if ( ! res . ok ) return ; // 404 = no research for this session
const data = await res . json ( ) ;
if ( data . status === 'done' ) {
// Fetch and render the completed result
_notifyResearchComplete ( sessionId , data . query || '' ) ;
if ( sessionModule && sessionModule . clearResearching ) sessionModule . clearResearching ( sessionId ) ;
const resultRes = await fetch ( ` ${ API _BASE } /api/research/result/ ${ sessionId } ` , { method : 'POST' } ) ;
if ( resultRes . ok ) {
const resultData = await resultRes . json ( ) ;
if ( resultData . result ) {
// Skip if history already has a research message for this session
if ( document . querySelector ( ` #chat-history .msg-ai[data-research-session=" ${ sessionId } "] ` ) ) return ;
var srcBox = '' ;
if ( resultData . sources && resultData . sources . length > 0 ) {
srcBox = _buildSourcesBox ( resultData . sources , 'research' ) ;
}
var findingsBox = chatRenderer . buildFindingsBox ( resultData . raw _findings ) ;
var cleanResult = resultData . result ;
// Build DOM directly to avoid double-processing through addMessage
chatRenderer . hideWelcomeScreen ( ) ;
var _box = document . getElementById ( 'chat-history' ) ;
if ( _box ) {
var _wrap = document . createElement ( 'div' ) ;
_wrap . className = 'msg msg-ai' ;
_wrap . dataset . researchSession = sessionId ;
var _role = document . createElement ( 'div' ) ;
_role . className = 'role' ;
var _meta = sessionModule . getSessions ( ) . find ( function ( s ) { return s . id === sessionId ; } ) ;
_role . textContent = _shortModel ( _meta ? . model ) ;
_applyModelColor ( _role , _meta ? . model ) ;
_role . appendChild ( chatRenderer . roleTimestamp ( ) ) ;
var _body = document . createElement ( 'div' ) ;
_body . className = 'body' ;
_body . innerHTML = srcBox + markdownModule . processWithThinking (
markdownModule . squashOutsideCode ( cleanResult )
) + findingsBox ;
_wrap . dataset . raw = cleanResult ;
_wrap . appendChild ( _role ) ;
_wrap . appendChild ( _body ) ;
_wrap . appendChild ( chatRenderer . createMsgFooter ( _wrap ) ) ;
_appendViewReportLink ( _wrap , sessionId ) ;
_box . appendChild ( _wrap ) ;
if ( window . hljs ) _wrap . querySelectorAll ( 'pre code' ) . forEach ( function ( b ) { window . hljs . highlightElement ( b ) ; } ) ;
uiModule . scrollHistory ( ) ;
}
}
}
return ;
}
if ( data . status !== 'running' ) return ;
// Don't show reconnect UI if we've already switched away
if ( sessionModule . getCurrentSessionId ( ) !== sessionId ) return ;
// Research is still running — show reconnect UI with spinner
const box = document . getElementById ( 'chat-history' ) ;
if ( ! box ) return ;
const holder = document . createElement ( 'div' ) ;
holder . className = 'msg msg-ai research-reconnect' ;
holder . dataset . researchSession = sessionId ;
const roleTs = new Date ( ) . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } ) ;
const agentMeta = sessionModule . getSessions ( ) . find ( s => s . id === sessionModule . getCurrentSessionId ( ) ) ;
const agentModelLabel = _shortModel ( agentMeta ? . model ) ;
holder . innerHTML = ` <div class="role"> ${ agentModelLabel } <span class="role-timestamp"> ${ roleTs } </span></div><div class="body"></div> ` ;
_applyModelColor ( holder . querySelector ( '.role' ) , agentMeta ? . model ) ;
box . appendChild ( holder ) ;
const bodyDiv = holder . querySelector ( '.body' ) ;
const spinner = spinnerModule . create ( 'Reconnecting to research...' , 'right' ) ;
bodyDiv . appendChild ( spinner . createElement ( ) ) ;
spinner . start ( ) ;
// Update spinner with current progress if available
function updateSpinnerFromProgress ( progress ) {
if ( ! progress || ! progress . phase ) return ;
const rp = progress ;
if ( rp . phase === 'probing' ) {
spinner . updateMessage ( ` Verifying model: ${ rp . model || '?' } ` ) ;
} else if ( rp . phase === 'planning' ) {
spinner . updateMessage ( 'Analyzing question & planning research strategy' ) ;
} else if ( rp . phase === 'searching' ) {
const q = rp . queries ? ` ${ rp . queries } queries ` : '' ;
const s = rp . total _sources ? ` · ${ rp . total _sources } sources ` : '' ;
spinner . updateMessage ( ` Round ${ rp . round || '?' } : Searching ${ q ? ' (' + q + ')' : '' } ${ s } ` ) ;
} else if ( rp . phase === 'reading' ) {
spinner . updateMessage ( rp . title ? ` Reading: ${ rp . title } ` : ` Round ${ rp . round || '?' } : Reading ${ rp . new _sources || '' } pages · ${ rp . total _sources || 0 } sources total ` ) ;
} else if ( rp . phase === 'analyzing' ) {
spinner . updateMessage ( ` Round ${ rp . round || '?' } : Analyzing ${ rp . total _findings || 0 } findings ` ) ;
} else if ( rp . phase === 'writing' ) {
spinner . updateMessage ( ` Writing report · ${ rp . total _sources || 0 } sources ` ) ;
}
}
updateSpinnerFromProgress ( data . progress ) ;
_researchingStreamIds . add ( sessionId ) ;
if ( sessionModule && sessionModule . markResearching ) sessionModule . markResearching ( sessionId ) ;
// Restore research timer from started_at
if ( data . started _at && spinner && spinner . element ) {
_researchStartTime = data . started _at * 1000 ;
_researchAvgDuration = data . avg _duration || null ;
_researchTimerEl = document . createElement ( 'div' ) ;
_researchTimerEl . className = 'research-timer' ;
_researchTimerEl . style . cssText = 'font-size:0.8em; opacity:0.6; margin-top:4px; font-family:monospace;' ;
spinner . element . parentNode . insertBefore ( _researchTimerEl , spinner . element . nextSibling ) ;
_researchTimerInterval = setInterval ( ( ) => {
if ( ! _researchTimerEl ) return ;
var elapsed = Math . floor ( ( Date . now ( ) - _researchStartTime ) / 1000 ) ;
var mm = String ( Math . floor ( elapsed / 60 ) ) . padStart ( 2 , '0' ) ;
var ss = String ( elapsed % 60 ) . padStart ( 2 , '0' ) ;
var txt = mm + ':' + ss ;
if ( _researchAvgDuration ) {
var avgM = String ( Math . floor ( _researchAvgDuration / 60 ) ) . padStart ( 2 , '0' ) ;
var avgS = String ( Math . round ( _researchAvgDuration % 60 ) ) . padStart ( 2 , '0' ) ;
txt += ' / avg ' + avgM + ':' + avgS ;
}
_researchTimerEl . textContent = txt ;
} , 1000 ) ;
// Reconnect synapse — seed it with whatever progress is already known
try {
_researchSynapse = createResearchSynapse ( spinner . element . parentNode , {
query : data . query || '' ,
startedAt : _researchStartTime ,
} ) ;
if ( _researchSynapse . element && _researchTimerEl ) {
spinner . element . parentNode . insertBefore ( _researchSynapse . element , _researchTimerEl ) ;
}
if ( data . progress ) {
_researchSynapse . setPhase ( data . progress . phase , data . progress ) ;
if ( typeof data . progress . round === 'number' ) _researchSynapse . setRound ( data . progress . round ) ;
if ( typeof data . progress . total _sources === 'number' ) _researchSynapse . setSourceCount ( data . progress . total _sources ) ;
}
} catch ( e ) { console . warn ( 'synapse reconnect failed' , e ) ; }
}
// Poll for completion
const pollInterval = setInterval ( async ( ) => {
// Stop polling if user switched to a different session
if ( sessionModule . getCurrentSessionId ( ) !== sessionId ) {
clearInterval ( pollInterval ) ;
spinner . destroy ( ) ;
_clearResearchTimer ( ) ;
if ( holder . parentNode ) holder . remove ( ) ;
_researchingStreamIds . delete ( sessionId ) ;
if ( _researchingStreamIds . size === 0 ) {
var _rToggleP = document . getElementById ( 'research-toggle-btn' ) ;
if ( _rToggleP ) _rToggleP . classList . remove ( 'research-running' ) ;
}
return ;
}
try {
const pollRes = await fetch ( ` ${ API _BASE } /api/research/status/ ${ sessionId } ` ) ;
if ( ! pollRes . ok ) {
clearInterval ( pollInterval ) ;
spinner . destroy ( ) ;
_clearResearchTimer ( ) ;
_researchingStreamIds . delete ( sessionId ) ;
if ( sessionModule && sessionModule . clearResearching ) sessionModule . clearResearching ( sessionId ) ;
return ;
}
const pollData = await pollRes . json ( ) ;
updateSpinnerFromProgress ( pollData . progress ) ;
if ( _researchSynapse && pollData . progress ) {
_researchSynapse . setPhase ( pollData . progress . phase , pollData . progress ) ;
if ( typeof pollData . progress . round === 'number' ) _researchSynapse . setRound ( pollData . progress . round ) ;
if ( typeof pollData . progress . total _sources === 'number' ) _researchSynapse . setSourceCount ( pollData . progress . total _sources ) ;
}
if ( pollData . status !== 'running' ) {
clearInterval ( pollInterval ) ;
spinner . destroy ( ) ;
_clearResearchTimer ( ) ;
_researchingStreamIds . delete ( sessionId ) ;
if ( sessionModule && sessionModule . clearResearching ) sessionModule . clearResearching ( sessionId ) ;
if ( pollData . status === 'done' ) {
_notifyResearchComplete ( sessionId , data . query || '' ) ;
const rRes = await fetch ( ` ${ API _BASE } /api/research/result/ ${ sessionId } ` , { method : 'POST' } ) ;
if ( rRes . ok ) {
const rData = await rRes . json ( ) ;
if ( rData . result ) {
var srcHtml = '' ;
if ( rData . sources && rData . sources . length > 0 ) {
srcHtml = _buildSourcesBox ( rData . sources , 'research' ) ;
}
var findingsHtml = chatRenderer . buildFindingsBox ( rData . raw _findings ) ;
bodyDiv . innerHTML = srcHtml + markdownModule . processWithThinking (
markdownModule . squashOutsideCode ( rData . result )
) + findingsHtml ;
holder . dataset . raw = rData . result ;
_appendViewReportLink ( holder , sessionId ) ;
if ( window . hljs ) {
holder . querySelectorAll ( 'pre code' ) . forEach ( b => window . hljs . highlightElement ( b ) ) ;
}
}
}
} else {
bodyDiv . innerHTML = '<i style="color: var(--color-error);">[Research ' + pollData . status + ']</i>' ;
}
}
} catch ( e ) {
console . error ( 'Research poll error:' , e ) ;
}
} , 2000 ) ;
} catch ( e ) {
// No research pending, that's fine
}
}
/** Set a display override for the next user message bubble */
export function setDisplayOverride ( text ) {
_displayOverride = text ;
}
/** Hide the user bubble for the next submit (e.g. continue after stop) */
export function setHideUserBubble ( ) {
_hideUserBubble = true ;
}
/** Set the AI element to merge with the next streamed response (continue after stop) */
export function setPendingContinue ( el ) {
_pendingContinue = el ;
}
/ * *
* Delete an AI message and its preceding user message from the conversation .
* /
export async function deleteMessage ( msgElement ) {
const box = document . getElementById ( 'chat-history' ) ;
const allMsgs = Array . from ( box . querySelectorAll ( '.msg' ) ) ;
const clickedIndex = allMsgs . indexOf ( msgElement ) ;
if ( clickedIndex < 0 ) return ;
const sessionId = sessionModule . getCurrentSessionId ( ) ;
if ( ! sessionId ) return ;
const clickedIsUser = msgElement . classList . contains ( 'msg-user' ) ;
// Find the user+AI pair
let userIndex = - 1 ;
let aiIndex = - 1 ;
if ( clickedIsUser ) {
userIndex = clickedIndex ;
// Find the following AI message
for ( let i = clickedIndex + 1 ; i < allMsgs . length ; i ++ ) {
if ( allMsgs [ i ] . classList . contains ( 'msg-ai' ) && ! allMsgs [ i ] . classList . contains ( 'msg-continuation' ) ) {
aiIndex = i ;
break ;
}
if ( allMsgs [ i ] . classList . contains ( 'msg-user' ) ) break ; // next user msg, no AI response
}
} else {
// If clicked on a continuation, walk back to the main AI message
let mainAiIndex = clickedIndex ;
if ( allMsgs [ mainAiIndex ] . classList . contains ( 'msg-continuation' ) ) {
for ( let i = mainAiIndex - 1 ; i >= 0 ; i -- ) {
if ( allMsgs [ i ] . classList . contains ( 'msg-ai' ) && ! allMsgs [ i ] . classList . contains ( 'msg-continuation' ) ) {
mainAiIndex = i ;
break ;
}
}
}
aiIndex = mainAiIndex ;
// Find the preceding user message
for ( let i = aiIndex - 1 ; i >= 0 ; i -- ) {
if ( allMsgs [ i ] . classList . contains ( 'msg-user' ) ) {
userIndex = i ;
break ;
}
}
}
// Collect DB message IDs and DOM elements to remove
const msgIds = [ ] ;
const domToRemove = [ ] ;
// Add the user message if found
if ( userIndex >= 0 ) {
domToRemove . push ( allMsgs [ userIndex ] ) ;
const uid = allMsgs [ userIndex ] . dataset . dbId ;
if ( uid ) msgIds . push ( uid ) ;
}
// Add the AI message if found
if ( aiIndex >= 0 ) {
domToRemove . push ( allMsgs [ aiIndex ] ) ;
const aid = allMsgs [ aiIndex ] . dataset . dbId ;
if ( aid ) msgIds . push ( aid ) ;
const aiEl = allMsgs [ aiIndex ] ;
// Also remove agent-thread elements BETWEEN user and AI
if ( userIndex >= 0 ) {
let between = allMsgs [ userIndex ] . nextElementSibling ;
while ( between && between !== aiEl ) {
domToRemove . push ( between ) ;
between = between . nextElementSibling ;
}
}
// Walk forward from the AI element to remove continuations and tool bubbles
let sibling = aiEl . nextElementSibling ;
while ( sibling ) {
if ( sibling . classList . contains ( 'msg-user' ) ||
( sibling . classList . contains ( 'msg-ai' ) && ! sibling . classList . contains ( 'msg-continuation' ) ) ) {
break ;
}
domToRemove . push ( sibling ) ;
sibling = sibling . nextElementSibling ;
}
}
if ( ! msgIds . length ) {
// Fallback: just remove DOM elements if no DB IDs available
domToRemove . forEach ( el => el . remove ( ) ) ;
if ( uiModule ) uiModule . showToast ( 'Message deleted' ) ;
return ;
}
try {
const res = await fetch ( ` ${ API _BASE } /api/session/ ${ sessionId } /delete-messages ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { msg _ids : msgIds } )
} ) ;
if ( ! res . ok ) throw new Error ( 'Server error ' + res . status ) ;
domToRemove . forEach ( el => el . remove ( ) ) ;
if ( uiModule ) uiModule . showToast ( 'Message deleted' ) ;
} catch ( err ) {
console . error ( 'Delete failed:' , err ) ;
if ( uiModule ) uiModule . showError ( 'Delete failed: ' + err . message ) ;
}
}
/ * *
* Edit an AI message inline . Makes the body contentEditable , saves to DB on confirm .
* /
export async function editAIMessage ( msgElement ) {
const body = msgElement . querySelector ( '.body' ) ;
if ( ! body ) return ;
const isEditing = body . contentEditable === 'true' || body . contentEditable === 'plaintext-only' ;
if ( isEditing ) return ; // already editing
const originalRaw = msgElement . dataset . raw || body . textContent || '' ;
// Create editable textarea overlay
const textarea = document . createElement ( 'textarea' ) ;
textarea . className = 'msg-edit-textarea' ;
textarea . value = originalRaw ;
textarea . style . width = '100%' ;
textarea . style . minHeight = Math . max ( 100 , body . offsetHeight ) + 'px' ;
body . style . display = 'none' ;
body . parentNode . insertBefore ( textarea , body . nextSibling ) ;
textarea . focus ( ) ;
// Add save/cancel bar
const bar = document . createElement ( 'div' ) ;
bar . className = 'msg-edit-bar' ;
const saveBtn = document . createElement ( 'button' ) ;
saveBtn . className = 'msg-edit-save' ;
saveBtn . textContent = 'Save' ;
const cancelBtn = document . createElement ( 'button' ) ;
cancelBtn . className = 'msg-edit-cancel' ;
cancelBtn . textContent = 'Cancel' ;
bar . appendChild ( saveBtn ) ;
bar . appendChild ( cancelBtn ) ;
textarea . parentNode . insertBefore ( bar , textarea . nextSibling ) ;
function cleanup ( ) {
textarea . remove ( ) ;
bar . remove ( ) ;
body . style . display = '' ;
}
cancelBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
cleanup ( ) ;
} ) ;
saveBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const newContent = textarea . value ;
if ( newContent === originalRaw ) { cleanup ( ) ; return ; }
const msgId = msgElement . dataset . dbId ;
if ( ! msgId ) { if ( uiModule ) uiModule . showError ( 'Cannot edit: message ID not found' ) ; cleanup ( ) ; return ; }
const sessionId = sessionModule . getCurrentSessionId ( ) ;
if ( ! sessionId ) { cleanup ( ) ; return ; }
try {
const res = await fetch ( ` ${ API _BASE } /api/session/ ${ sessionId } /edit-message ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { msg _id : msgId , content : newContent } ) ,
} ) ;
if ( ! res . ok ) throw new Error ( 'Server error ' + res . status ) ;
// Re-render body with markdown
body . innerHTML = markdownModule . processWithThinking ( markdownModule . squashOutsideCode ( newContent ) ) ;
msgElement . dataset . raw = newContent ;
// Add edited indicator if not already present
if ( ! msgElement . querySelector ( '.edited-indicator' ) ) {
const indicator = document . createElement ( 'div' ) ;
indicator . className = 'edited-indicator' ;
indicator . textContent = '[Message edited]' ;
body . parentNode . insertBefore ( indicator , body . nextSibling ) ;
}
cleanup ( ) ;
if ( uiModule ) uiModule . showToast ( 'Message edited' ) ;
} catch ( err ) {
console . error ( 'Edit failed:' , err ) ;
if ( uiModule ) uiModule . showError ( 'Edit failed: ' + err . message ) ;
}
} ) ;
}
/ * *
* Rewrite the AI ' s last response with a specific instruction .
* Uses the lightweight / api / rewrite endpoint — no tools , no agent loop .
* Just rewrites the text of the last AI bubble .
* /
export async function rewriteWith ( aiMsgElement , instruction ) {
const sessionId = sessionModule . getCurrentSessionId ( ) ;
if ( ! sessionId ) return ;
// Get the original text from the AI bubble
const oldRaw = aiMsgElement . dataset . raw || aiMsgElement . querySelector ( '.body' ) ? . textContent || '' ;
const oldHtml = aiMsgElement . querySelector ( '.body' ) ? . innerHTML || '' ;
if ( ! oldRaw . trim ( ) ) {
if ( uiModule ) uiModule . showError ( 'No text to rewrite' ) ;
return ;
}
// Save current response as a variant
let variants = [ ] ;
try { variants = JSON . parse ( aiMsgElement . dataset . variants || '[]' ) ; } catch ( _ ) { }
if ( variants . length === 0 ) {
variants . push ( { raw : oldRaw , html : oldHtml , label : 'original' } ) ;
}
// Determine label from instruction
let varLabel = 'rewrite' ;
if ( instruction . includes ( 'shorter' ) ) varLabel = 'shorter' ;
else if ( instruction . includes ( 'simpler' ) ) varLabel = 'simpler' ;
// Clear the bubble and show a whirlpool spinner while we wait for the
// rewrite (replaces the old "Rewriting..." text).
const bodyEl = aiMsgElement . querySelector ( '.body' ) ;
let _rwSpin = null ;
if ( bodyEl ) {
bodyEl . innerHTML = '' ;
_rwSpin = spinnerModule . createWhirlpool ( 18 ) ;
_rwSpin . element . style . margin = '4px 0' ;
bodyEl . appendChild ( _rwSpin . element ) ;
}
// Stop + detach the spinner (called once real content starts rendering, and
// on the failure path so it never spins forever).
const _killRwSpin = ( ) => { if ( _rwSpin ) { try { _rwSpin . destroy ( ) ; } catch ( _ ) { } _rwSpin = null ; } } ;
try {
const res = await fetch ( ` ${ API _BASE } /api/rewrite ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
session _id : sessionId ,
original _text : oldRaw ,
instruction : instruction ,
} ) ,
} ) ;
if ( ! res . ok ) {
throw new Error ( ` HTTP ${ res . status } ` ) ;
}
const reader = res . body . getReader ( ) ;
const decoder = new TextDecoder ( ) ;
let buffer = '' ;
let newText = '' ;
while ( true ) {
const { done , value } = await reader . read ( ) ;
if ( done ) break ;
buffer += decoder . decode ( value , { stream : true } ) ;
const lines = buffer . split ( '\n' ) ;
buffer = lines . pop ( ) || '' ;
for ( const line of lines ) {
if ( ! line . startsWith ( 'data: ' ) ) continue ;
const payload = line . slice ( 6 ) . trim ( ) ;
if ( payload === '[DONE]' ) continue ;
try {
const data = JSON . parse ( payload ) ;
// The endpoint streams `event: error\ndata: {error,status}` on
// failure — surface it instead of silently hanging on "Rewriting…".
if ( data . error ) {
throw new Error ( data . error || ( 'HTTP ' + ( data . status || 500 ) ) ) ;
}
// Reasoning tokens (vLLM --reasoning-parser: Qwen3 / DeepSeek-R1)
// arrive as separate {delta, thinking:true} chunks. They are NOT
// the rewrite — fold them away so they don't pollute the result.
if ( data . thinking ) continue ;
if ( data . delta ) {
newText += data . delta ;
_killRwSpin ( ) ;
if ( bodyEl ) {
bodyEl . innerHTML = markdownModule . processWithThinking (
markdownModule . squashOutsideCode ( newText )
) ;
}
}
} catch ( e ) {
if ( e instanceof Error && e . message ) throw e ; // re-throw real errors
/* ignore JSON parse noise */
}
}
}
// Strip any thinking markup from the answer. A reasoning model may emit
// an inline <think>…</think> block, a bare </think> (no opener), or — when
// its reasoning came via reasoning_content — a stray leading <think> that
// never closes (so it would otherwise hide the whole answer). Peel all of
// those off so what's left is just the rewritten text.
const _stripThink = ( t ) => {
t = t . replace ( /<think>[\s\S]*?<\/think>/gi , '' ) ; // complete blocks
if ( /<\/think>/i . test ( t ) ) t = t . replace ( /^[\s\S]*?<\/think>/i , '' ) ; // reasoning w/o opener
return t . replace ( /<\/?think>/gi , '' ) . trim ( ) ; // any orphan tag
} ;
newText = _stripThink ( newText ) ;
// Nothing left after stripping (or an empty stream) → real failure, not a
// blank bubble.
if ( ! newText . trim ( ) ) {
throw new Error ( 'model returned no rewritten text' ) ;
}
// Update the element's raw text
if ( newText ) {
aiMsgElement . dataset . raw = newText ;
// Final render with proper markdown
if ( bodyEl ) {
bodyEl . innerHTML = markdownModule . processWithThinking (
markdownModule . squashOutsideCode ( newText )
) ;
}
// Save the new response as a variant
variants . push ( { raw : newText , html : bodyEl ? bodyEl . innerHTML : '' , label : varLabel } ) ;
aiMsgElement . dataset . variants = JSON . stringify ( variants ) ;
aiMsgElement . dataset . variantIndex = String ( variants . length - 1 ) ;
// Persist variant metadata to server
try {
await fetch ( ` ${ API _BASE } /api/session/ ${ sessionId } /update-last-meta ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { metadata : { variants : variants , variantIndex : variants . length - 1 } } ) ,
} ) ;
} catch ( _ ) { }
// Re-render variant navigation
_renderVariantNav ( aiMsgElement , variants , variants . length - 1 ) ;
}
if ( uiModule ) uiModule . scrollHistory ( ) ;
} catch ( err ) {
console . error ( 'Rewrite failed:' , err ) ;
_killRwSpin ( ) ;
// Restore original content on failure
if ( bodyEl ) bodyEl . innerHTML = oldHtml ;
if ( uiModule ) uiModule . showError ( 'Rewrite failed: ' + err . message ) ;
}
}
/ * *
* Continue the AI ' s response from where it left off .
* /
export async function continueFrom ( aiMsgElement ) {
const sessionId = sessionModule . getCurrentSessionId ( ) ;
if ( ! sessionId ) return ;
const messageInput = uiModule . el ( 'message' ) ;
if ( messageInput ) {
messageInput . value = 'Continue from where you left off.' ;
const submitBtn = document . querySelector ( '.send-btn' ) ;
if ( submitBtn ) submitBtn . click ( ) ;
}
}
// Open a chat attachment in the right place: images → Gallery editor; PDFs &
// text/code/markdown → Documents viewer; anything else → raw file. A given
// upload's imported document is reused (cached by upload id) so clicking it
// again re-opens the same doc instead of making duplicates.
const _attachDocCache = new Map ( ) ; // upload id -> doc id
function _attachLang ( name ) {
const m = ( name || '' ) . toLowerCase ( ) . match ( /\.([a-z0-9]+)$/ ) ;
const ext = m ? m [ 1 ] : '' ;
const map = { md : 'markdown' , markdown : 'markdown' , js : 'javascript' , ts : 'typescript' ,
jsx : 'javascript' , tsx : 'typescript' , py : 'python' , rb : 'ruby' , go : 'go' , rs : 'rust' ,
java : 'java' , c : 'c' , cpp : 'cpp' , h : 'c' , hpp : 'cpp' , cs : 'csharp' , php : 'php' , html : 'html' ,
htm : 'html' , css : 'css' , scss : 'scss' , json : 'json' , yaml : 'yaml' , yml : 'yaml' , sh : 'bash' ,
bash : 'bash' , sql : 'sql' , csv : 'csv' , xml : 'xml' } ;
return map [ ext ] || '' ;
}
async function openAttachment ( att , isImage ) {
if ( ! att || ! att . id ) return ;
const id = att . id , name = att . name || '' , mime = att . mime || '' ;
const url = ` ${ API _BASE } /api/upload/ ${ id } ` ;
// Images → Gallery editor.
if ( isImage ) {
try {
const gx = await import ( './galleryEditor.js' ) ;
if ( gx . openEditor ) { gx . openEditor ( url , id , null , name ) ; return ; }
} catch ( e ) { console . warn ( 'gallery open failed' , e ) ; }
window . open ( url , '_blank' ) ;
return ;
}
const isPdf = mime === 'application/pdf' || /\.pdf$/i . test ( name ) ;
const TEXT _EXT = /\.(txt|md|markdown|js|ts|jsx|tsx|py|rb|go|rs|java|c|cpp|h|hpp|cs|php|html?|css|scss|sass|less|json|ya?ml|toml|ini|conf|env|sh|bash|sql|csv|tsv|xml|log|vue|svelte)$/i ;
const isTextDoc = TEXT _EXT . test ( name ) || /^text\// . test ( mime ) ;
if ( ! isPdf && ! isTextDoc ) { window . open ( url , '_blank' ) ; return ; } // binary/unknown → raw
// Reuse the doc we already imported for this upload, if it still loads.
const cached = _attachDocCache . get ( id ) ;
if ( cached ) {
try {
documentModule . openPanel && documentModule . openPanel ( ) ;
await documentModule . loadDocument ( cached ) ;
return ;
} catch ( _ ) { _attachDocCache . delete ( id ) ; }
}
// Need a session to attach the doc to (bare-session fallback, same as compose).
let sid = '' ;
try { sid = sessionModule . getCurrentSessionId ( ) || '' ; } catch ( _ ) { }
if ( ! sid ) {
try {
const _fd = new FormData ( ) ;
_fd . append ( 'name' , name || 'Attachment' ) ;
_fd . append ( 'skip_validation' , 'true' ) ;
const r = await fetch ( ` ${ API _BASE } /api/session ` , { method : 'POST' , body : _fd , credentials : 'same-origin' } ) ;
if ( r . ok ) { const d = await r . json ( ) ; if ( d && d . id ) { sid = d . id ; if ( sessionModule . loadSessions ) await sessionModule . loadSessions ( ) ; } }
} catch ( _ ) { }
}
try {
let doc ;
if ( isPdf ) {
// import-pdf wants a fresh file upload — re-fetch the stored blob and post it.
const blob = await ( await fetch ( url ) ) . blob ( ) ;
const fd = new FormData ( ) ;
fd . append ( 'file' , blob , name || 'document.pdf' ) ;
if ( sid ) fd . append ( 'session_id' , sid ) ;
const res = await fetch ( ` ${ API _BASE } /api/documents/import-pdf ` , { method : 'POST' , body : fd , credentials : 'same-origin' } ) ;
if ( ! res . ok ) throw new Error ( 'import-pdf ' + res . status ) ;
doc = await res . json ( ) ;
} else {
const text = await ( await fetch ( url ) ) . text ( ) ;
const res = await fetch ( ` ${ API _BASE } /api/document ` , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { session _id : sid || null , title : name . replace ( /\.[^.]+$/ , '' ) || 'Document' , content : text , language : _attachLang ( name ) } ) ,
} ) ;
if ( ! res . ok ) throw new Error ( 'document ' + res . status ) ;
doc = await res . json ( ) ;
}
if ( doc && doc . id ) {
_attachDocCache . set ( id , doc . id ) ;
documentModule . openPanel && documentModule . openPanel ( ) ;
if ( documentModule . injectFreshDoc ) documentModule . injectFreshDoc ( doc ) ;
else await documentModule . loadDocument ( doc . id ) ;
}
} catch ( e ) {
console . error ( 'open attachment as document failed' , e ) ;
import ( './ui.js' ) . then ( m => m . showError && m . showError ( 'Could not open attachment' ) ) . catch ( ( ) => { } ) ;
window . open ( url , '_blank' ) ; // fallback so the file is still reachable
}
}
// Public API
const chatModule = {
init ,
initListeners ,
openAttachment ,
addMessage : chatRenderer . addMessage ,
displayMetrics : chatRenderer . displayMetrics ,
handleChatSubmit ,
abortCurrentRequest ,
detachCurrentStream ,
checkBackgroundStream ,
hideWelcomeScreen : chatRenderer . hideWelcomeScreen ,
showWelcomeScreen : chatRenderer . showWelcomeScreen ,
checkPendingResearch ,
getImageCost : chatRenderer . getImageCost ,
setDisplayOverride ,
setHideUserBubble ,
setPendingContinue ,
regenerateFrom ,
forkFrom ,
editUserMessage ,
editAIMessage ,
resendUserMessage ,
deleteMessage ,
rewriteWith ,
continueFrom ,
_appendViewReportLink ,
hasActiveStream ,
} ;
// Single delegated handler for tool-call fold/expand. One listener on
// document.body covers every .agent-thread-node — running, completed,
// streaming, history-rendered, compare-mode, all of them. Re-attaching
// per-node listeners on every innerHTML rewrite was the source of the
// "needs many clicks" bug.
if ( ! window . _ _odysseus _thread _click _bound ) {
document . body . addEventListener ( 'click' , ( e ) => {
const header = e . target . closest ( '.agent-thread-header' ) ;
if ( ! header ) return ;
const node = header . closest ( '.agent-thread-node' ) ;
if ( ! node ) return ;
node . classList . toggle ( 'open' ) ;
} ) ;
window . _ _odysseus _thread _click _bound = true ;
}
export default chatModule ;
window . chatModule = chatModule ;