1// static/js/chat.js 2 3/** 4 * Main chat functionality - message handling and streaming 5 */ 6// ES6 module â IIFE removed 7 8import Storage from './storage.js'; 9import uiModule from './ui.js'; 10import sessionModule from './sessions.js'; 11import chatRenderer from './chatRenderer.js'; 12import chatStream from './chatStream.js'; 13import { addAITTSButton } from './tts-ai.js'; 14import markdownModule from './markdown.js'; 15import { svgifyEmoji } from './markdown.js'; 16import planWindowModule from './planWindow.js'; 17import spinnerModule from './spinner.js'; 18import presetsModule from './presets.js'; 19import fileHandlerModule from './fileHandler.js'; 20import searchModule from './search.js'; 21import documentModule from './document.js'; 22import * as emailInbox from './emailInbox.js'; 23import codeRunnerModule from './codeRunner.js'; 24import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js'; 25import createResearchSynapse from './researchSynapse.js'; 26import { createStreamRenderer } from './streamingRenderer.js'; 27 const RESEARCH_TIMEOUT_MS = 360000; 28 const DEFAULT_TIMEOUT_MS = 120000; 29 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>'; 30 31 let API_BASE = ''; 32 let currentAbort = null; 33 let isStreaming = false; 34 // Continuous stall watchdog: while streaming, if the SSE stream produces 35 // NOTHING for STALL_THRESHOLD_MS (no deltas, no tool heartbeat â tools beat 36 // every 2s, so a full minute of silence means it's genuinely stuck or the 37 // model quietly stopped), surface a non-destructive "still working?" prompt 38 // instead of silently hanging. Replaces relying only on the tab-refocus 39 // recovery (which fired only on visibilitychange and silently reloaded). 40 let _stallWatchdog = null; 41 let _stallBannerShown = false; 42 const STALL_THRESHOLD_MS = 60000; 43 let _sendInFlight = false; // covers the window from click â streaming start 44 let _displayOverride = null; // Override visible user bubble text (hides injected prompts) 45 let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop) 46 let _pendingContinue = null; // Stores the stopped AI element to merge with new response 47 // ââ Auto-recovery: when a turn's stream silently dies (connection drop) or 48 // goes quiet while the connection is alive, re-engage the model with a 49 // completion handshake instead of leaving it hung. Capped so it can't loop. 50 let _autoNudges = 0; // handshakes fired for the CURRENT user turn 51 let _autoContinuePending = false; // marks the next submit as an auto-continue (don't reset the counter) 52 const _AUTO_NUDGE_CAP = 3; 53 54 // shortModel and modelColor are now in chatRenderer.js 55 var _shortModel = chatRenderer.shortModel; 56 var _modelRouteLabel = chatRenderer.modelRouteLabel; 57 var _sameModelName = chatRenderer.sameModelName; 58 var _applyModelColor = chatRenderer.applyModelColor; 59 function _setRoleModelLabel(roleEl, requestedModel, actualModel, opts) { 60 if (!roleEl) return; 61 opts = opts || {}; 62 const tsSpan = roleEl.querySelector('.role-timestamp'); 63 const req = requestedModel || actualModel || ''; 64 const actual = actualModel || requestedModel || ''; 65 let label = _modelRouteLabel(req, actual); 66 if (opts.suffix) label += ' (' + opts.suffix + ')'; 67 if (opts.characterName) label = opts.characterName; 68 roleEl.textContent = label + ' '; 69 _applyModelColor(roleEl, actual || req); 70 if (req && actual && !_sameModelName(req, actual)) { 71 roleEl.title = req + ' -> ' + actual + (opts.reason ? ': ' + opts.reason : ''); 72 } else if (!opts.reason) { 73 roleEl.removeAttribute('title'); 74 } 75 if (tsSpan) roleEl.appendChild(tsSpan); 76 } 77 // Per-session research tracking (supports concurrent research across sessions) 78 const _researchingStreamIds = new Set(); 79 let _researchTimerEl = null, _researchTimerInterval = null; 80 let _researchStartTime = 0, _researchAvgDuration = null; 81 let _researchSynapse = null; 82 function _clearResearchTimer() { 83 if (_researchTimerInterval) { clearInterval(_researchTimerInterval); _researchTimerInterval = null; } 84 if (_researchTimerEl) { _researchTimerEl.remove(); _researchTimerEl = null; } 85 if (_researchSynapse) { 86 // Mark complete first so the user briefly sees the "done" state, 87 // then tear it down on next tick.
88 try { _researchSynapse.complete(); } catch {} 89 const s = _researchSynapse; 90 _researchSynapse = null; 91 setTimeout(() => { try { s.destroy(); } catch {} }, 800); 92 } 93 _researchStartTime = 0; 94 _researchAvgDuration = null; 95 } 96 97 /** Append a "Generate Visual Report" button â delegates to chatRenderer. */ 98 function _appendViewReportLink(msgEl, sessionId) { 99 const body = msgEl.querySelector('.body'); 100 if (body) chatRenderer.appendReportButton(body, sessionId); 101 } 102 let currentAccumulated = ''; // Track accumulated text across function scope 103 let currentHolder = null; // Track current message holder 104 let currentSpinner = null; // Track current spinner for stop cleanup 105 106 // Background streaming support 107 const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics } 108 const _resumingStreams = new Set(); // sessionId -> a resumeStream() reader is live (re-attach lock) 109 let _streamSessionId = null; // Session ID for the currently active reader loop 110 let _lastReaderActivity = 0; // Timestamp of last reader.read() success â used to detect frozen streams 111 let _webLockRelease = null; // Function to release the Web Lock held during streaming 112 let _forcePlanOff = false; // One-shot: suppress plan_mode for the next send (Approve & Run) 113 114 // ââ Plan store: the latest proposed/approved checklist for the CURRENT chat ââ 115 // Kept so (a) it can be sent back each turn and pinned in context (a long plan 116 // on a weak model survives history truncation), and (b) the plan window can be 117 // re-opened/docked at any time via the plan-button menu. Stored per session in 118 // localStorage so it survives a reload mid-execution. 119 function _setStoredPlan(text) { 120 const sid = sessionModule.getCurrentSessionId(); 121 if (!sid || !text || !text.trim()) return; 122 Storage.setJSON(Storage.KEYS.PLAN, { sid, text }); 123 // Live-refresh the plan window if it's open (shows progress as the agent 124 // restates the checklist with [x]). 125 try { 126 if (planWindowModule.isPlanWindowOpen && planWindowModule.isPlanWindowOpen()) { 127 planWindowModule.openPlanWindow(text, null); 128 } 129 } catch (_) {} 130 } 131 function _getStoredPlan() { 132 const sid = sessionModule.getCurrentSessionId(); 133 const rec = Storage.getJSON(Storage.KEYS.PLAN, null); 134 return (rec && rec.sid === sid && rec.text) ? rec.text : ''; 135 } 136 // A line like "- [ ] step" / "- [x] step" marks a GitHub-style checklist. 137 const _CHECKLIST_RE = /^\s*[-*]\s+\[[ xX]\]\s+/m; 138 // Exposed for app.js (plan-button menu) â re-open the stored plan window. 139 window._getStoredPlan = _getStoredPlan; 140 window.planWindowModule = planWindowModule; 141 142 /** Check if an SSE reader is still actively connected for a session. */ 143 function hasActiveStream(sessionId) { 144 return _streamSessionId === sessionId || _backgroundStreams.has(sessionId) || 145 _resumingStreams.has(sessionId); 146 } 147 148 // Sources box builder and toggleSources are now in chatRenderer.js 149 var _buildSourcesBox = chatRenderer.buildSourcesBox; 150 151 // Browser notifications now in chatStream.js 152 var _notifyResearchComplete = chatStream.notifyResearchComplete; 153 154 // Model/image pricing, _buildImageBubble now in chatRenderer.js 155 var _buildImageBubble = chatRenderer.buildImageBubble; 156 var getModelCost = chatRenderer.getModelCost; 157 var getImageCost = chatRenderer.getImageCost; 158 159 // stripToolBlocks and roleTimestamp now in chatRenderer.js 160 var stripToolBlocks = chatRenderer.stripToolBlocks; 161 162 function _normalizeEndpointForCompare(url) { 163 if (!url) return ''; 164 try { 165 const u = new URL(String(url), window.location.origin); 166 let path = u.pathname.replace(/\/+$/, ''); 167 const suffixes = [ 168 '/v1/chat/completions', '/chat/completions', 169 '/v1/completions', '/completions', 170 '/v1/messages', '/messages', 171 '/v1/models', '/models', 172 ]; 173 for (const suffix of suffixes) { 174 if (path.toLowerCase().endsWith(suffix)) { 175 path = path.slice(0, -suffix.length).replace(/\/+$/, ''); 176 break; 177 } 178 } 179 return (u.origin + path).toLowerCase(); 180 } catch (_) { 181 return String(url).trim().replace(/\/+$/, '').toLowerCase(); 182 } 183 } 184 185 async function _probeCurrentEndpointStatus(endpointUrl, signal) { 186 const target = _normalizeEndpointForCompare(endpointUrl); 187 if (!target) return null; 188 const modelsRes = await fetch(`${API_BASE}/api/models`, { credentials: 'same-origin', signal }); 189 if (!modelsRes.ok) return null; 190 const modelsData = await modelsRes.json().catch(() => ({})); 191 const item = (modelsData.items || []).find(ep => 192 _normalizeEndpointForCompare(ep.url || ep.endpoint_url || ep.base_url) === target 193 ); 194 if (!item || !item.endpoint_id) return null; 195 196 const probesRes = await fetch(`${API_BASE}/api/model-endpoints/probe-local`, { 197 credentials: 'same-origin', 198 signal, 199 }); 200 if (!probesRes.ok) return null; 201 const probes = await probesRes.json().catch(() => ({})); 202 return probes[item.endpoint_id] || null; 203 } 204 205 /** 206 * Initialize with dependencies 207 */ 208 export function init(apiBase) { 209 API_BASE = apiBase; 210 initSlashCommands({ apiBase, isStreaming: () => isStreaming }); 211 // Initialize email inbox 212 emailInbox.init(documentModule); 213 // Wire the slash-command autocomplete popup on the chat composer. The 214 // dispatcher already handles the typed command â this just surfaces the 215 // registry as a discoverable menu when the user starts a message with /.
216 import('./slashAutocomplete.js').then(mod => { 217 const ta = document.getElementById('message'); 218 if (ta && mod.initSlashAutocomplete) mod.initSlashAutocomplete(ta); 219 }).catch(() => {}); 220 } 221 222 // addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen 223 // are now in chatRenderer.js â referenced via the public API delegation above. 224 var addMessage = chatRenderer.addMessage; 225 var createMsgFooter = chatRenderer.createMsgFooter; 226 var displayMetrics = chatRenderer.displayMetrics; 227 var hideWelcomeScreen = chatRenderer.hideWelcomeScreen; 228 var showWelcomeScreen = chatRenderer.showWelcomeScreen; 229 230 /** 231 * Update submit button state 232 */ 233 function updateSubmitButton(state, submitBtn) { 234 if (!submitBtn) return; 235 236 if (state === 'streaming') { 237 // Clear any pending transitions from + â arrow swap 238 submitBtn.classList.remove('anim-spin', 'anim-spin-swap', 'anim-land', 'mic-mode', 'newchat-mode', 'newchat-expanded', 'recording'); 239 // Ensure arrow icon is showing before launch 240 var icons = window._odysseusBtnIcons; 241 if (icons) submitBtn.innerHTML = icons.send; 242 void submitBtn.offsetWidth; 243 // Arrow launches up, then stop icon lands in 244 submitBtn.classList.add('anim-launch'); 245 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>'; 246 // Wait for the launch keyframe to finish (0.3s) before swapping the 247 // arrow out for the stop icon â otherwise the swap happens mid-flight 248 // and the user sees nothing fly out. 249 setTimeout(() => { 250 submitBtn.innerHTML = _stopSvg; 251 submitBtn.classList.remove('anim-launch'); 252 void submitBtn.offsetWidth; 253 submitBtn.classList.add('anim-land'); 254 submitBtn.addEventListener('animationend', () => submitBtn.classList.remove('anim-land'), { once: true }); 255 }, 300); 256 submitBtn.title = 'Stop generation'; 257 submitBtn.dataset.mode = 'streaming'; 258 submitBtn.dataset.phase = 'processing'; 259 isStreaming = true; 260 _startStallWatchdog(); 261 } else if (state === 'idle') { 262 submitBtn.dataset.mode = ''; 263 delete submitBtn.dataset.phase; 264 submitBtn.classList.remove('recording'); 265 isStreaming = false; 266 _stopStallWatchdog(); 267 // Defer to global updater which handles mic/newchat/send modes 268 if (window._updateSendBtnIcon) { 269 setTimeout(window._updateSendBtnIcon, 50); 270 } else { 271 var icons = window._odysseusBtnIcons; 272 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>'; 273 submitBtn.title = 'Send message'; 274 submitBtn.classList.remove('mic-mode', 'newchat-mode'); 275 } 276 } 277 } 278 279 // ----------------------------------------------------------------------- 280 // Slash commands â now in slashCommands.js 281 // ----------------------------------------------------------------------- 282 283 // API key pattern for the guard in handleChatSubmit 284 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,})$/; 285 286 287 /** 288 * Handle chat form submission 289 */ 290 export async function handleChatSubmit(e) { 291 e.preventDefault(); 292 // Cancel research clarification timeout if active 293 if (window._researchTimeoutTimer) { 294 clearTimeout(window._researchTimeoutTimer); 295 window._researchTimeoutTimer = null; 296 } 297 // Get current session 298 const sessionId = sessionModule.getCurrentSessionId(); 299 const session = sessionModule.getSessions().find(s => s.id === sessionId); 300 301 const submitBtn = document.querySelector('.send-btn'); 302 303 // If compare is active, stop all compare streams 304 if (window.compareModule && window.compareModule.isActive()) { 305 window.compareModule.handleCompareSubmit(); 306 return; 307 } 308 309 // If currently streaming, stop it 310 if (isStreaming) { 311 // Cancel server-side research if in progress 312 const _cancelSid = sessionModule.getCurrentSessionId(); 313 if (_cancelSid && _researchingStreamIds.has(_cancelSid)) { 314 fetch(`${API_BASE}/api/research/cancel/${_cancelSid}`, { method: 'POST' }).catch(e => console.warn('Research cancel failed:', e)); 315 _researchingStreamIds.delete(_cancelSid); 316 _clearResearchTimer(); 317 } 318 abortCurrentRequest(true); // explicit user Stop â also cancel the detached server run 319 320 // Clean up any running agent thread nodes (stop wave animation, remove "running" state) 321 document.querySelectorAll('.agent-thread-node.running').forEach(node => { 322 if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; } 323 if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; } 324 node.classList.remove('running');
325 const wave = node.querySelector('.agent-thread-wave'); 326 if (wave) wave.textContent = ''; 327 const icon = node.querySelector('.agent-thread-icon'); 328 if (icon) icon.textContent = '\u25A0'; // stop square 329 const statusEl = node.querySelector('.agent-thread-status'); 330 if (!statusEl) { 331 const header = node.querySelector('.agent-thread-header'); 332 if (header) { 333 const s = document.createElement('span'); 334 s.className = 'agent-thread-status'; 335 s.textContent = 'stopped'; 336 header.appendChild(s); 337 } 338 } 339 }); 340 document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming')); 341 342 // Clean up any thinking spinners 343 document.querySelectorAll('.agent-thinking-dots').forEach(el => { 344 if (el._spinner) el._spinner.destroy(); 345 el.remove(); 346 }); 347 // No text accumulated â remove the empty holder with spinner 348 if (currentHolder && !currentAccumulated) { 349 if (currentSpinner) { currentSpinner.destroy(); currentSpinner = null; } 350 // Empty cancel â keep the assistant bubble around with a "Cancelled 351 // by user" indicator and persist a placeholder server-side so the 352 // turn survives a refresh instead of vanishing without a trace. 353 _renderCancelledBubble(currentHolder); 354 currentHolder = null; 355 updateSubmitButton('idle', submitBtn); 356 const messageInput = uiModule.el('message'); 357 if (messageInput) messageInput.disabled = false; 358 currentAccumulated = ''; 359 return; 360 } 361 // Render whatever was accumulated so far 362 if (currentHolder && currentAccumulated) { 363 // Store accumulated in a closure variable before it gets cleared 364 const stoppedContent = currentAccumulated; 365 366 // Store raw content in dataset for consistency with other messages 367 currentHolder.dataset.raw = stoppedContent; 368 369 currentHolder.querySelector('.body').innerHTML = markdownModule.processWithThinking( 370 markdownModule.squashOutsideCode(stoppedContent) 371 ); 372 373 // Highlight code blocks 374 if (window.hljs) { 375 currentHolder.querySelectorAll('pre code').forEach((block) => { 376 window.hljs.highlightElement(block); 377 }); 378 } 379 380 // Add the stopped indicator with continue button 381 const stoppedIndicator = document.createElement('div'); 382 stoppedIndicator.className = 'stopped-indicator'; 383 const stoppedLabel = document.createElement('span'); 384 stoppedLabel.textContent = '[Message interrupted]'; 385 stoppedIndicator.appendChild(stoppedLabel); 386 const continueBtn = document.createElement('button'); 387 continueBtn.className = 'continue-btn'; 388 continueBtn.title = 'Continue'; 389 continueBtn.textContent = '\u25B8'; 390 const _stoppedHolder = currentHolder; // capture before it gets cleared 391 continueBtn.addEventListener('click', () => { 392 stoppedIndicator.remove(); 393 _hideUserBubble = true; 394 _pendingContinue = _stoppedHolder; 395 const cutoff = stoppedContent; 396 const msgInput = uiModule.el('message'); 397 if (msgInput) { 398 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.'; 399 const sb = document.querySelector('.send-btn'); 400 if (sb) sb.click(); 401 } 402 }); 403 stoppedIndicator.appendChild(continueBtn); 404 currentHolder.querySelector('.body').appendChild(stoppedIndicator); 405 406 // Tell server to mark this message as stopped 407 const _sid = sessionModule.getCurrentSessionId(); 408 if (_sid) fetch(`${API_BASE}/api/session/${_sid}/mark-stopped`, { method: 'POST' }).catch(e => console.warn('mark-stopped failed:', e)); 409 410 // Add footer with copy/regen if not already present 411 if (!currentHolder.querySelector('.msg-footer')) { 412 currentHolder.dataset.raw = stoppedContent; 413 currentHolder.appendChild(createMsgFooter(currentHolder)); 414 } 415 416 uiModule.scrollHistory(); 417 } 418 419 // Reset button state 420 updateSubmitButton('idle', submitBtn); 421 422 // Re-enable message input 423 const messageInput = uiModule.el('message'); 424 if (messageInput) messageInput.disabled = false;
425 426 // Clear tracking variables 427 currentAccumulated = ''; 428 currentHolder = null; 429 430 return; 431 } 432 433 // --- Send-path entry: block re-clicks between submit and stream start --- 434 if (_sendInFlight) return; 435 _sendInFlight = true; 436 // Instant visual feedback so the user sees their click was accepted 437 // even before the streaming button state kicks in below. 438 const _earlyMessageInput = uiModule.el('message'); 439 if (_earlyMessageInput) _earlyMessageInput.disabled = true; 440 if (submitBtn) submitBtn.classList.add('send-pending'); 441 const _releaseSendFlag = () => { 442 _sendInFlight = false; 443 if (_earlyMessageInput) _earlyMessageInput.disabled = false; 444 if (submitBtn) submitBtn.classList.remove('send-pending'); 445 }; 446 447 // --- Setup mode: intercept next message (but let slash commands through) --- 448 { 449 const el = uiModule.el; 450 const rawMsg = (el('message').value || '').trim(); 451 const currentSetupMode = slashCommands.getSetupMode(); 452 if (currentSetupMode && rawMsg && !isCommand(rawMsg)) { 453 const mode = currentSetupMode; 454 slashCommands.clearSetupMode(mode === 'endpoint-provider' || mode === 'endpoint-key-for-provider'); 455 el('message').value = ''; 456 if (window._syncModelPickerAutohide) window._syncModelPickerAutohide(); 457 if (uiModule.autoResize) uiModule.autoResize(el('message')); 458 if (mode === true || mode === 'endpoint') { 459 handleSetupInput(rawMsg); 460 } else { 461 handleSetupWizard(mode, rawMsg); 462 } 463 _releaseSendFlag(); 464 return; 465 } 466 if (currentSetupMode && rawMsg && isCommand(rawMsg)) { 467 slashCommands.clearSetupMode(); // Clear setup mode, fall through to slash handler 468 } 469 } 470 471 const el = uiModule.el; 472 const msg = el('message').value; 473 // Allow empty text when a regen carries over the original message's 474 // attachment ids â a photo-only message still has something to send. 475 if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; } 476 477 // --- Slash commands: execute directly without AI (no session needed) --- 478 if (isCommand(msg.trim())) { 479 const handled = await handleSlashCommand(msg.trim()); 480 if (handled) { 481 el('message').value = ''; 482 if (window._syncModelPickerAutohide) window._syncModelPickerAutohide(); 483 if (uiModule.autoResize) uiModule.autoResize(el('message')); 484 _releaseSendFlag(); 485 return; 486 } 487 } 488 489 // Materialize pending session (deferred from model click) on first message 490 if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) { 491 const ok = await sessionModule.materializePendingSession(); 492 if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; } 493 } 494 495 if (!sessionModule.getCurrentSessionId()) { 496 // Auto-create a session using default chat config. Always fetch fresh 497 // so that a recent Settings change takes effect without a page reload. 498 try { 499 let dc = null; 500 try { 501 const dcRes = await fetch('/api/default-chat'); 502 dc = await dcRes.json(); 503 if (dc && dc.endpoint_url && dc.model) { 504 try { window.__odysseusDefaultChat = dc; } catch (_) {} 505 } 506 } catch (_) { 507 dc = (typeof window !== 'undefined' && window.__odysseusDefaultChat) || null; 508 } 509 if (dc.endpoint_url && dc.model) { 510 await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id); 511 const ok = await sessionModule.materializePendingSession(); 512 if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; } 513 } else { 514 el('message').value = ''; 515 if (uiModule.autoResize) uiModule.autoResize(el('message')); 516 addMessage('assistant', 517 'No chat session active. You can:\n\n' + 518 '- Open the model picker in the chat box and pick a model\n' +
519 '- Use the `+` button in the model picker to add a model endpoint\n' + 520 '- Use `/help` to see all available commands'); 521 _releaseSendFlag(); 522 return; 523 } 524 } catch (e) { 525 el('message').value = ''; 526 if (uiModule.autoResize) uiModule.autoResize(el('message')); 527 addMessage('assistant', 528 'No chat session active. You can:\n\n' + 529 '- Open the model picker in the chat box and pick a model\n' + 530 '- Use the `+` button in the model picker to add a model endpoint\n' + 531 '- Use `/help` to see all available commands'); 532 _releaseSendFlag(); 533 return; 534 } 535 } 536 537 // --- API key guard: warn if message looks like an API key --- 538 if (API_KEY_RE.test(msg.trim())) { 539 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 })) { 540 _releaseSendFlag(); 541 return; 542 } 543 } 544 545 546 const messageInput = el('message'); 547 const originalBtnText = submitBtn ? submitBtn.innerHTML : ''; 548 549 // Re-enable the textarea now that we've handed off to the stream: the 550 // user wants to compose the next message while the AI is still talking. 551 // The `isStreaming` flag is the re-click guard for the send button. 552 if (messageInput) messageInput.disabled = false; 553 updateSubmitButton('streaming', submitBtn); 554 if (submitBtn) submitBtn.classList.remove('send-pending'); 555 _sendInFlight = false; 556 557 // Capture session ID for background stream detection 558 const streamSessionId = sessionModule.getCurrentSessionId(); 559 _streamSessionId = streamSessionId; 560 const streamQuery = msg; 561 _lastReaderActivity = Date.now(); 562 563 // Acquire Web Lock to hint browser not to discard this tab while streaming 564 if (navigator.locks) { 565 navigator.locks.request('odysseus-stream-' + streamSessionId, { mode: 'exclusive', ifAvailable: true }, lock => { 566 if (!lock) return; // Another stream already holds a lock â fine 567 return new Promise(resolve => { _webLockRelease = resolve; }); 568 }).catch(e => console.warn('web lock acquire failed:', e)); // Ignore lock errors â best-effort 569 } 570 571 // Declare accumulated outside try block so it's accessible in catch 572 let accumulated = ''; 573 // Are we currently inside an unclosed <think> block? Toggled per think/answer 574 // cycle so a multi-round agent response (one reasoning phase PER round) wraps each 575 // round's reasoning in its own <think>â¦</think> instead of leaking rounds 2+ as text. 576 let _thinkOpen = false; 577 let holder = null; 578 let finalMeta = null; 579 let spinner = null; 580 let timedOut = false; 581 let processingProbeTimer = null; 582 let processingProbeAbort = null; 583 let _renderStream = () => {}; 584 let _cancelThinkingTimer = () => {}; 585 let _removeThinkingSpinner = () => {}; 586 let timeoutId = null; 587 let responseTimeoutCleared = false; 588 let clearResponseTimeout = () => {}; 589 const clearProcessingProbe = () => { 590 if (processingProbeTimer) { 591 clearTimeout(processingProbeTimer); 592 processingProbeTimer = null; 593 } 594 if (processingProbeAbort) { 595 try { processingProbeAbort.abort(); } catch (_) {} 596 processingProbeAbort = null; 597 } 598 }; 599 600 // Reset tracking variables at start 601 currentAccumulated = ''; 602 currentHolder = null; 603 604 try { 605 // Re-enable auto-scroll when user sends a message 606 uiModule.setAutoScroll(true); 607 uiModule.scrollHistoryInstant(); 608 // Clear completed dot now that user is interacting 609 if (sessionModule.clearStreamComplete) sessionModule.clearStreamComplete(sessionModule.getCurrentSessionId()); 610 611 // Check for document selection context before consuming display override 612 const docSel = documentModule && documentModule.getSelectionContext(); 613 if (docSel) { 614 const sels = Array.isArray(docSel) ? docSel : [docSel]; 615 const lineRefs = sels.map(s => 616 s.startLine === s.endLine ? `L${s.startLine}` : `L${s.startLine}-${s.endLine}` 617 ); 618 _displayOverride = `[Doc edit: ${lineRefs.join(', ')}] ${msg}`; 619 } 620 621 const userDisplay = _displayOverride || msg; 622 _displayOverride = null; 623 const skipBubble = _hideUserBubble; 624 _hideUserBubble = false;
625 // Auto-recovery counter: carries across a turn's auto-continues, but resets 626 // when the user genuinely sends a new message (so each task gets a fresh cap). 627 // A real user turn (visible bubble) ALWAYS resets the budget â even if a 628 // prior auto-continue's deferred click never cleared the pending flag â so a 629 // stuck flag can't silently eat the next turn's recovery budget. 630 if (!skipBubble) { _autoNudges = 0; _autoContinuePending = false; } 631 else if (_autoContinuePending) { _autoContinuePending = false; } 632 const _pendingAttachInfo = fileHandlerModule.getPendingCount() ? fileHandlerModule.getPendingInfo() : null; 633 // Pre-read importable file contents before upload clears pending files 634 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; 635 const _importableFiles = []; 636 if (_pendingAttachInfo && documentModule) { 637 const rawFiles = fileHandlerModule.getPendingRaw ? fileHandlerModule.getPendingRaw() : []; 638 for (let i = 0; i < _pendingAttachInfo.length; i++) { 639 const att = _pendingAttachInfo[i]; 640 if (IMPORTABLE_EXT.test(att.name) && rawFiles[i]) { 641 _importableFiles.push({ info: att, file: rawFiles[i] }); 642 } 643 } 644 } 645 let _userMsgEl = null; 646 if (!skipBubble) { 647 _userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null); 648 } 649 messageInput.value = ''; 650 messageInput.style.height = ''; 651 messageInput.dispatchEvent(new Event('input')); 652 // Mobile: dismiss the on-screen keyboard after sending. iOS in 653 // particular ignores a bare blur() in some cases (or some other 654 // listener refocuses straight after), so we temporarily mark the 655 // input readonly which forces the keyboard to retract, then blur, 656 // then drop the readonly attribute after the keyboard is gone so 657 // typing still works for the next message. 658 if (window.innerWidth <= 768) { 659 try { 660 messageInput.setAttribute('readonly', 'readonly'); 661 messageInput.blur(); 662 const _dropReadonly = () => { try { messageInput.removeAttribute('readonly'); } catch {} }; 663 setTimeout(() => { 664 // If the blur stuck, the input is no longer the active element â 665 // safe to drop readonly now so the next message can be typed. 666 // If it did NOT stick (some mobile browsers keep the textarea 667 // focused after a programmatic blur), removing readonly here would 668 // re-summon the keyboard mid-stream â the "bounce up" that then 669 // lingers until the end-of-stream blur. In that case keep readonly 670 // on (keyboard stays down) and drop it the moment the user taps to 671 // type again, so typing still works without the bounce. 672 if (document.activeElement === messageInput) { 673 messageInput.addEventListener('pointerdown', _dropReadonly, { once: true }); 674 messageInput.addEventListener('focus', _dropReadonly, { once: true }); 675 } else { 676 _dropReadonly(); 677 } 678 }, 120); 679 } catch {} 680 } 681 682 let ids = [];
683 try { 684 ids = await fileHandlerModule.uploadPending(); 685 } catch(e) { 686 console.error('upload failed', e); 687 } 688 689 // Carry over the original message's file-ids on a regenerate so the new 690 // send still references the same photos / docs (and picks up the user's 691 // edited OCR text via the server-side .vision cache). Always CONSUME the 692 // slot â even when empty / errored â so the regen ids can't bleed into 693 // an unrelated next message if uploadPending() above had thrown. 694 if (_pendingRegenAttachments && _pendingRegenAttachments.length) { 695 ids = ids.concat(_pendingRegenAttachments); 696 } 697 _pendingRegenAttachments = null; 698 699 // The optimistic user bubble was rendered before the upload assigned ids, 700 // so image previews couldn't show (the renderer needs att.id). Now that 701 // the upload resolved, stamp the ids â plus width/height for images so 702 // the skeleton can size itself to the photo's aspect ratio â and 703 // re-render so the thumbnail appears live, no refresh needed. 704 if (_userMsgEl && _pendingAttachInfo && ids.length) { 705 const _meta = fileHandlerModule.getLastUploadedMeta?.() || []; 706 for (let i = 0; i < _pendingAttachInfo.length && i < ids.length; i++) { 707 _pendingAttachInfo[i].id = ids[i]; 708 const _m = _meta[i]; 709 if (_m) { 710 if (_m.width) _pendingAttachInfo[i].width = _m.width; 711 if (_m.height) _pendingAttachInfo[i].height = _m.height; 712 } 713 } 714 chatRenderer.updateMessageAttachments(_userMsgEl, _pendingAttachInfo); 715 } 716 717 // Offer to import text files to document library 718 if (_importableFiles.length > 0) { 719 const existing = document.getElementById('import-prompt-banner'); 720 if (existing) existing.remove(); 721 const banner = document.createElement('div'); 722 banner.id = 'import-prompt-banner'; 723 banner.className = 'import-prompt-banner'; 724 const label = _importableFiles.length === 1 725 ? `Import "${_importableFiles[0].info.name}" to document library?` 726 : `Import ${_importableFiles.length} files to document library?`; 727 const textEl = document.createElement('span'); 728 textEl.textContent = label; 729 banner.appendChild(textEl); 730 const importBtn = document.createElement('button'); 731 importBtn.textContent = 'Import'; 732 importBtn.addEventListener('click', async () => { 733 importBtn.disabled = true; 734 importBtn.textContent = 'Importingâ¦'; 735 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'}; 736 let imported = 0; 737 for (const { info, file } of _importableFiles) { 738 try { 739 const content = await file.text(); 740 const dotIdx = info.name.lastIndexOf('.'); 741 const title = dotIdx > 0 ? info.name.slice(0, dotIdx) : info.name; 742 const ext = dotIdx >= 0 ? info.name.slice(dotIdx).toLowerCase() : ''; 743 await fetch(`${API_BASE}/api/document`, { 744 method: 'POST', 745 headers: { 'Content-Type': 'application/json' }, 746 body: JSON.stringify({ title, language: EXT_LANG[ext] || '', content }), 747 }); 748 imported++; 749 } catch (e) { console.error('Import failed:', info.name, e); } 750 } 751 banner.textContent = `Imported ${imported} file${imported !== 1 ? 's' : ''}`; 752 setTimeout(() => banner.remove(), 2000); 753 }); 754 banner.appendChild(importBtn); 755 const dismissBtn = document.createElement('button'); 756 dismissBtn.textContent = '\u00d7'; 757 dismissBtn.className = 'import-prompt-dismiss'; 758 dismissBtn.addEventListener('click', () => banner.remove()); 759 banner.appendChild(dismissBtn); 760 const chatBar = document.getElementById('chat-bar'); 761 if (chatBar) chatBar.parentNode.insertBefore(banner, chatBar); 762 // Auto-dismiss after 15 seconds 763 setTimeout(() => { if (banner.parentNode) banner.remove(); }, 15000); 764 } 765 766 // Auto-save document editor content before sending so the AI sees latest text 767 if (documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) { 768 try { await documentModule.saveDocument(); } catch(e) { console.warn('doc auto-save failed', e); } 769 } 770 771 // Inject document selection context if present 772 let finalMsg = msg; 773 if (docSel) { 774 const sels = Array.isArray(docSel) ? docSel : [docSel]; 775 if (sels.length === 1) { 776 const s = sels[0]; 777 const lineRef = s.startLine === s.endLine ? `line ${s.startLine}` : `lines ${s.startLine}-${s.endLine}`; 778 finalMsg = `In the document, edit this specific text (${lineRef}):\n\`\`\`\n${s.text}
778\n\`\`\`\n\nInstruction: ${msg}`; 779 } else { 780 const parts = sels.map((s, i) => { 781 const lineRef = s.startLine === s.endLine ? `line ${s.startLine}` : `lines ${s.startLine}-${s.endLine}`; 782 return `Selection ${i + 1} (${lineRef}):\n\`\`\`\n${s.text}\n\`\`\``; 783 }); 784 finalMsg = `In the document, edit these specific sections:\n\n${parts.join('\n\n')}\n\nInstruction: ${msg}`; 785 } 786 } 787 788 // Apply inject prefix/suffix 789 const _inject = presetsModule.getInject ? presetsModule.getInject() : { prefix: '', suffix: '' }; 790 let _finalMsgWithInject = finalMsg; 791 if (_inject.prefix) _finalMsgWithInject = _inject.prefix + ' ' + _finalMsgWithInject; 792 if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix; 793 794 const fd = new FormData(); 795 fd.append('message', _finalMsgWithInject); 796 fd.append('session', streamSessionId); 797 if (ids.length) fd.append('attachments', JSON.stringify(ids)); 798 // Auto-save & send active doc ID so the backend sees latest content 799 if (documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) { 800 try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ } 801 fd.append('active_doc_id', documentModule.getCurrentDocId()); 802 } 803 // Web toggle: pre-search in Chat mode, tool permission in Agent mode 804 const toggleState = Storage.loadToggleState(); 805 let isAgentMode = (toggleState.mode || 'chat') === 'agent'; 806 // Auto-escalate to agent mode when a document is open â the user expects 807 // the AI to see the document and have tools to edit it 808 if (!isAgentMode && documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) { 809 isAgentMode = true; 810 } 811 fd.append('mode', isAgentMode ? 'agent' : 'chat'); 812 if (el('web-toggle').checked) { 813 if (isAgentMode) { 814 fd.append('allow_web_search', 'true'); 815 } else { 816 fd.append('use_web', 'true'); 817 } 818 } 819 if (el('research-toggle').checked) { 820 fd.append('use_research', 'true'); 821 // Research always runs in chat mode â override agent if set 822 fd.set('mode', 'chat'); 823 } 824 if (el('bash-toggle').checked) { 825 fd.append('allow_bash', 'true'); 826 } 827 // Plan mode: agent investigates read-only and proposes a plan to approve. 828 // Only meaningful in agent mode, and never alongside deep research. 829 // _forcePlanOff is a one-shot set by "Approve & Run" so the execution turn 830 // runs with full tools even though the Plan toggle is still on. 831 const _planToggle = el('plan-toggle'); 832 const planTurn = !_forcePlanOff && isAgentMode && _planToggle && _planToggle.checked && !el('research-toggle').checked; 833 _forcePlanOff = false; 834 if (planTurn) { 835 fd.append('plan_mode', 'true'); 836 fd.set('mode', 'agent'); 837 } else if (isAgentMode) { 838 // Executing (not proposing): send the stored plan back so the backend 839 // pins it in context and the agent can always re-reference it. 840 const _sp = _getStoredPlan(); 841 if (_sp) fd.append('approved_plan', _sp); 842 } 843 const ragChk = el('rag-toggle'); 844 if (ragChk && !ragChk.checked) { 845 fd.append('use_rag', 'false'); 846 } 847 const incognitoChk = el('incognito-toggle'); 848 if (incognitoChk && incognitoChk.checked) { 849 fd.append('incognito', 'true'); 850 } 851 const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || ''; 852 if (_ws) { 853 fd.append('workspace', _ws); 854 } 855 if (presetsModule.getSelectedPreset()) { 856 fd.append('preset_id', presetsModule.getSelectedPreset()); 857 } 858 859 860 const abortCtrl = new AbortController(); 861 abortCtrl._reason = ''; 862 currentAbort = abortCtrl; 863 864 const _tState = Storage.loadToggleState(); 865 const _isAgent = (_tState.mode || 'chat') === 'agent'; 866 867 // Timeout: 6 min for research and agent mode, 3 min otherwise 868 const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS; 869 timeoutId = setTimeout(() => { 870 if (!abortCtrl.signal.aborted) { 871 timedOut = true; 872 abortCtrl._reason = 'timeout'; 873 try { 874 if (streamSessionId) { 875 fetch(`/api/chat/stop/${encodeURIComponent(streamSessionId)}`, { 876 method: 'POST', 877 credentials: 'same-origin', 878 }).catch(() => {}); 879 } 880 } catch (_) {} 881 abortCtrl.abort(); 882 } 883 }, timeoutMs); 884 clearResponseTimeout = () => { 885 if (responseTimeoutCleared) return; 886 responseTimeoutCleared = true; 887 clearTimeout(timeoutId); 888 }; 889 890 const box = el('chat-history'); 891 holder = document.createElement('div'); 892 holder.className = 'msg msg-ai streaming'; 893 894 // Track holder globally so stop button can access it 895 currentHolder = holder; 896 holder._researchQuery = msg; // Store query for notification text 897 898 const modelName = sessionModule.getCurrentModel() || null; 899 900 let loadingText = 'Initializing...'; 901 902 if (el('web-toggle').checked && !_isAgent) { 903 const _searchLabel = searchModule ? searchModule.getProviderLabel() : 'web'; 904 loadingText = `Searching via ${_searchLabel}...<br>
905 <span style="font-size: 0.9em; opacity: 0.8;"> 906 Query: "${msg.substring(0, 50)}${msg.length > 50 ? '...' : ''}"<br> 907 Fetching top results...</span>`; 908 } else if (el('research-toggle').checked) { 909 loadingText = 'Deep research mode active...'; 910 } else { 911 loadingText = 'Processing request...'; 912 } 913 914 var roleLabel = _modelRouteLabel(modelName, modelName); 915 var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : ''; 916 if (_charNameInit) roleLabel = _charNameInit; 917 const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); 918 holder.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`; 919 holder._requestedModel = modelName; 920 holder._actualModel = modelName; 921 _applyModelColor(holder.querySelector('.role'), modelName); 922 holder.style.position = 'relative'; 923 924 // Create spinner 925 spinner = spinnerModule.create('Initializing', 'right', 'wave'); 926 currentSpinner = spinner; 927 const bodyDiv = holder.querySelector('.body'); 928 bodyDiv.appendChild(spinner.createElement()); 929 spinner.start(); 930 931 // Update spinner message based on mode 932 if (el('web-toggle').checked && !_isAgent) { 933 spinner.updateMessage('Searching web with ' + (searchModule ? searchModule.getProviderLabel() : 'SearXNG')); 934 setTimeout(() => spinner.updateMessage('Processing results'), 1500); 935 } else if (el('research-toggle').checked) { 936 spinner.updateMessage('Researching'); 937 setTimeout(() => spinner.updateMessage('Analyzing sources'), 1500); 938 } else { 939 spinner.updateMessage('Processing request'); 940 const endpointUrlForProbe = sessionModule.getCurrentEndpointUrl ? sessionModule.getCurrentEndpointUrl() : null; 941 if (endpointUrlForProbe && modelName) { 942 processingProbeTimer = setTimeout(async () => { 943 processingProbeTimer = null; 944 if (accumulated || !spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted)) return; 945 processingProbeAbort = new AbortController(); 946 try { 947 spinner.updateMessage('Checking model endpoint'); 948 const status = await _probeCurrentEndpointStatus(endpointUrlForProbe, processingProbeAbort.signal); 949 if (accumulated || !spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted)) return; 950 if (!status) { 951 spinner.updateMessage('Still waiting for model'); 952 } else if (status.alive) { 953 const latency = status.latency_ms ? ` (${status.latency_ms}ms)` : ''; 954 spinner.updateMessage(`Endpoint online${latency}; waiting for first token`); 955 } else { 956 // Probe confirms the endpoint isn't responding. Don't 957 // sit on a hung fetch â give the user 5s to read the 958 // status, then auto-abort with reason='offline' so the 959 // catch handler shows a clean "switch model" message 960 // instead of leaving the spinner spinning forever. 961 if (status.error) console.warn('Model endpoint probe failed:', status.error); 962 let _countdown = 5; 963 spinner.updateMessage(`Endpoint offline â cancelling in ${_countdown}s`); 964 const _tick = setInterval(() => { 965 _countdown--; 966 if (!spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted) || accumulated) { 967 clearInterval(_tick); 968 return; 969 } 970 if (_countdown > 0) { 971 spinner.updateMessage(`Endpoint offline â cancelling in ${_countdown}s`); 972 } else { 973 clearInterval(_tick); 974 if (currentAbort && !currentAbort.signal.aborted) { 975 currentAbort._reason = 'offline'; 976 currentAbort.abort(); 977 } 978 } 979 }, 1000); 980 } 981 } catch (e) { 982 if (e && e.name !== 'AbortError' && spinner && spinner.element && !accumulated) { 983 spinner.updateMessage('Still waiting for model'); 984 } 985 } finally { 986 processingProbeAbort = null; 987 } 988 }, 10000); 989 } 990 } 991 992 const researchBtn = el('research-toggle-btn'); 993 if (el('research-toggle').checked && researchBtn) { 994 researchBtn.disabled = true; 995 researchBtn.classList.remove('active'); 996 } 997 box.appendChild(holder); 998 uiModule.scrollHistory(); 999 1000 const enableResearchBtn = () => { 1001 if (!researchBtn) return; 1002 researchBtn.disabled = false;
1003 researchBtn.classList.toggle('active', el('research-toggle').checked); 1004 }; 1005 1006 if (el('research-toggle').checked && researchBtn) { 1007 researchBtn.style.display = 'none'; 1008 // Uncheck research toggle so follow-up messages don't trigger another research 1009 el('research-toggle').checked = false; 1010 } 1011 1012 // User's current UTC offset in minutes (east of UTC). Threaded into 1013 // the agent so natural-language times like "today at 9pm" are 1014 // interpreted in YOUR timezone, not the server's. 1015 const _tzOffsetMin = -new Date().getTimezoneOffset(); 1016 const _tzName = (() => { 1017 try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } 1018 catch { return ''; } 1019 })(); 1020 const res = await fetch(`${API_BASE}/api/chat_stream`, { 1021 method: 'POST', 1022 body: fd, 1023 headers: { 'X-Tz-Offset': String(_tzOffsetMin), 'X-Tz-Name': _tzName }, 1024 signal: abortCtrl.signal 1025 }); 1026 1027 if (!res.ok) { 1028 clearResponseTimeout(); 1029 if (res.status === 404) { 1030 // Session was deleted (e.g. by AI) â reload and go to welcome 1031 holder.remove(); 1032 if (sessionModule) await sessionModule.loadSessions(); 1033 return; 1034 } 1035 let errText = `Error ${res.status}`; 1036 try { 1037 const errBody = await res.text(); 1038 // Parse nested JSON error if present 1039 const m = errBody.match(/"message"\s*:\s*"([^"]+)"/); 1040 if (m) errText = m[1].replace(/\\"/g, '"'); 1041 else if (errBody.length < 200) errText = errBody; 1042 } catch {} 1043 // Auto-switch to chat mode for tool-related errors 1044 if (errText.includes('tool') || errText.includes('auto')) { 1045 errText = 'This model doesn\'t support agent tools â switched to Chat mode. Try again.'; 1046 const _ab = document.getElementById('mode-agent-btn'); 1047 const _cb = document.getElementById('mode-chat-btn'); 1048 if (_ab && _cb) { 1049 _ab.classList.remove('active'); 1050 _cb.classList.add('active'); 1051 const _toggle = _ab.closest('.mode-toggle'); 1052 if (_toggle) _toggle.classList.add('mode-chat'); 1053 } 1054 if (typeof Storage !== 'undefined' && Storage.KEYS) { 1055 const _st = Storage.getJSON(Storage.KEYS.TOGGLES, {}); 1056 _st.mode = 'chat'; 1057 Storage.setJSON(Storage.KEYS.TOGGLES, _st); 1058 } 1059 } 1060 typewriterInto(holder.querySelector('.body'), errText); 1061 enableResearchBtn(); 1062 return; 1063 } 1064 1065 // Mark the chat log busy while streaming so screen readers wait for the 1066 // settled response instead of announcing every token. Cleared in finally. 1067 const _chatLog = document.getElementById('chat-history'); 1068 if (_chatLog) _chatLog.setAttribute('aria-busy', 'true'); 1069 1070 const reader = res.body.getReader(); 1071 const decoder = new TextDecoder(); 1072 let buffer = ''; 1073 let metrics = null; 1074 let isThinking = false; 1075 let thinkingStartTime = null; 1076 // Streaming TTS: synthesize sentence-by-sentence during streaming 1077 const streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available); 1078 if (streamingTTS) window.aiTTSManager.streamingStart(); 1079 // Multi-bubble agent tracking 1080 let roundHolder = holder; // Current AI text bubble (changes per round) 1081 let roundText = ''; // Text accumulated for current round 1082 let currentToolBubble = null; // Current tool execution bubble 1083 let roundFinalized = false; // Whether current round's text is finalized 1084 let _sourcesHtml = ''; // Sources box HTML to prepend to body 1085 let _sourcesExpanded = false; // Track if user expanded sources during stream 1086 let _sourcesData = null; // Raw sources data for rebuilding 1087 let _sourcesType = ''; // 'web' or 'research' 1088 let _findingsData = null; // Raw findings data for collapsible box 1089 // _keepResearchOn removed â clarification state now persisted server-side via DB mode 1090 // Insert sources box as a stable DOM node that won't be replaced during streaming. 1091 // Returns the content container to use for innerHTML updates. 1092 function _ensureStreamLayout(body) { 1093 if (!body) return body; 1094 // Sources are deferred to final render â don't insert during streaming 1095 // Ensure a stable content div exists for text content 1096 var contentDiv = body.querySelector('.stream-content'); 1097 if (!contentDiv) { 1098 contentDiv = document.createElement('div'); 1099 contentDiv.className = 'stream-content'; 1100 body.appendChild(contentDiv); 1101 } 1102 return contentDiv; 1103 } 1104 const esc = uiModule.esc; 1105 // Remove thinking spinner helper 1106 _removeThinkingSpinner = () => { 1107 const el = document.querySelector('.agent-thinking-dots'); 1108 if (el) { 1109 if (el._spinner) el._spinner.destroy(); 1110 el.remove(); 1111 } 1112 }; 1113 1114 // Tool-aware thinking spinner 1115 let _lastToolName = ''; 1116 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>'; 1117 const _toolLabels = { 1118 'web_search': _searchIcon + 'Searching', 1119 'bash': 'Running', 1120 'python': 'Running', 1121 'create_document': 'Writing', 1122 'update_document': 'Writing', 1123 'read_document': 'Reading', 1124 'edit_file': 'Editing', 1125 'read_file': 'Reading', 1126 'write_file': 'Writing', 1127 'list_files': 'Browsing', 1128 'image_gen': 'Generating', 1129 'generate_image': 'Generating', 1130 'manage_memory': 'Remembering',
1131 'save_memory': 'Remembering', 1132 'search_memory': 'Recalling', 1133 'manage_session': 'Organizing', 1134 'deep_research': 'Researching', 1135 'list_models': 'Browsing', 1136 'ui_control': 'Adjusting', 1137 }; 1138 function _thinkingLabel() { 1139 if (!_lastToolName) { 1140 return 'Thinking'; 1141 } 1142 // Check exact match first, then prefix match 1143 const lower = _lastToolName.toLowerCase(); 1144 if (_toolLabels[lower]) return _toolLabels[lower]; 1145 for (const [key, label] of Object.entries(_toolLabels)) { 1146 if (lower.includes(key) || key.includes(lower)) return label; 1147 } 1148 return 'Thinking'; 1149 } 1150 1151 function _showThinkingSpinner(label) { 1152 if (document.querySelector('.agent-thinking-dots')) return; 1153 const _thinkMsg = document.createElement('div'); 1154 _thinkMsg.className = 'msg msg-ai agent-thinking-dots'; 1155 const _thinkBody = document.createElement('div'); 1156 _thinkBody.className = 'body'; 1157 const _ts = spinnerModule.create(label || 'Thinking', 'right', 'wave'); 1158 _thinkBody.appendChild(_ts.createElement()); 1159 _ts.start(120); 1160 _thinkMsg._spinner = _ts; 1161 _thinkMsg.appendChild(_thinkBody); 1162 document.getElementById('chat-history').appendChild(_thinkMsg); 1163 uiModule.scrollHistory(); 1164 } 1165 1166 // Auto-show thinking spinner after text stops streaming 1167 let _textPauseTimer = null; 1168 function _scheduleThinkingSpinner() { 1169 if (_textPauseTimer) clearTimeout(_textPauseTimer); 1170 _textPauseTimer = setTimeout(() => { 1171 if (!document.querySelector('.agent-thinking-dots') && isStreaming) { 1172 _showThinkingSpinner(_thinkingLabel()); 1173 } 1174 }, 400); 1175 } 1176 _cancelThinkingTimer = () => { 1177 if (_textPauseTimer) { clearTimeout(_textPauseTimer); _textPauseTimer = null; } 1178 }; 1179 1180 // Document streaming state (text-fence detection) 1181 let _docFenceOpened = false; 1182 let _docFenceContentStart = -1; 1183 let _liveThinkSection = null; 1184 let _liveThinkContent = null; 1185 let _liveThinkInner = null; 1186 let _liveThinkHeader = null; 1187 let _liveThinkSpinnerSlot = null; 1188 let _liveThinkTimerEl = null; 1189 let _liveThinkToggle = null; 1190 let _liveThinkDomId = null; 1191 1192 function _replyAfterClosedThinking(text) { 1193 const closeRe = /<\/(?:think(?:ing)?|thought)>|<channel\|>/gi; 1194 let match = null; 1195 let last = null; 1196 while ((match = closeRe.exec(text || '')) !== null) last = match; 1197 if (!last) return ''; 1198 return (text || '').slice(last.index + last[0].length).trimStart(); 1199 } 1200 1201 // Direct render helper for streaming text 1202 _renderStream = () => { 1203 let dt = stripToolBlocks(roundText); 1204 const bodyEl = roundHolder.querySelector('.body'); 1205 const contentEl = _ensureStreamLayout(bodyEl); 1206 1207 // If thinking was already collapsed in-place, only render the reply portion 1208 let liveReply = contentEl.querySelector('.live-reply-content'); 1209 if (liveReply) { 1210 // Extract reply text â handle native <think> tags and non-tag patterns 1211 const closedThinkReply = _replyAfterClosedThinking(dt); 1212 const { thinkingBlocks, content: replyText } = closedThinkReply 1213 ? { thinkingBlocks: [''], content: closedThinkReply } 1214 : markdownModule.extractThinkingBlocks(dt); 1215 let replyTrimmed = ''; 1216 if (thinkingBlocks.length) { 1217 replyTrimmed = (replyText || '').trim(); 1218 } else { 1219 // Non-tag: check for garbled <think> (reasoning\n<think>reply) 1220 const _gm = dt.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i); 1221 if (_gm && _gm[1].trim()) { 1222 replyTrimmed = _gm[1].trim(); 1223 } else { 1224 // Pure non-tag: find reply boundary 1225 const _rPrefixes = markdownModule.startsWithReasoningPrefix; 1226 const _rpStarts = ['Hey', 'Hi ', 'Hi!', 'Hello', '
1226Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"]; 1227 const _rt = (replyText || '').trimStart(); 1228 if (_rPrefixes(_rt)) { 1229 const _rLines = _rt.split('\n'); 1230 for (let _ri = 1; _ri < _rLines.length; _ri++) { 1231 const _rl = _rLines[_ri].trim(); 1232 if (!_rl) continue; 1233 if (_rpStarts.some(rp => _rl.startsWith(rp))) { replyTrimmed = _rLines.slice(_ri).join('\n'); break; } 1234 } 1235 if (!replyTrimmed) { 1236 for (const rp of _rpStarts) { 1237 const rx = new RegExp('[.!?]\\s*(' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')'); 1238 const m = rx.exec(_rt); 1239 if (m && m.index > 20) { replyTrimmed = _rt.slice(m.index + 1).trim(); break; } 1240 } 1241 } 1242 } 1243 } 1244 } 1245 if (replyTrimmed) { 1246 const r = liveReply._streamRenderer || 1247 (liveReply._streamRenderer = createStreamRenderer(liveReply, { 1248 render: (t) => markdownModule.mdToHtml(markdownModule.squashOutsideCode(t)), 1249 hljs: window.hljs, 1250 })); 1251 r.update(replyTrimmed); 1252 } 1253 // Reply empty or not â preserve thinking bar, don't fall through to full re-render 1254 uiModule.scrollHistory(); 1255 return; 1256 } 1257 1258 // If thinking is still streaming (unclosed <think>), show indicator instead of raw text 1259 if (markdownModule.hasUnclosedThinkTag && markdownModule.hasUnclosedThinkTag(dt)) { 1260 const thinkStart = dt.search(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i); 1261 const thinkContent = dt.substring(Math.max(thinkStart, 0)) 1262 .replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought\s*\n?/i, '') 1263 .replace(/<channel\|>/gi, '') 1264 .trim(); 1265 const lines = thinkContent.split('\n').length; 1266 // Don't show beforeThink text during streaming â it'll appear in the final render 1267 // This prevents the "split into two" duplication 1268 contentEl.innerHTML = 1269 '<div class="thinking-section"><div class="thinking-header"><div class="thinking-header-left">Thinking' + 1270 (lines > 1 ? ` (${lines} lines)` : '') + '</div></div></div>'; 1271 // The stream renderer self-heals when it next sees this overwritten 1272 // container (streamingRenderer.js), so no explicit reset is needed here. 1273 uiModule.scrollHistory(); 1274 return; 1275 } 1276 1277 // Incremental streaming render: freeze finalized blocks, re-render only the 1278 // growing tail, and highlight each code block once on completion. This is 1279 // what keeps code-block hover buttons from flickering and avoids the O(N^2) 1280 // re-parse/re-highlight of the whole message on every token. 1281 // See streamingRenderer.js / streamingSegmenter.js. 1282 const renderer = contentEl._streamRenderer || 1283 (contentEl._streamRenderer = createStreamRenderer(contentEl, { 1284 render: (t) => markdownModule.processWithThinking(markdownModule.squashOutsideCode(t)), 1285 hljs: window.hljs, 1286 })); 1287 renderer.update(dt); 1288 uiModule.scrollHistory(); 1289 }; 1290 1291 let _nextIsError = false; 1292 let _streamSawDone = false; 1293 1294 while (true) { 1295 const { done, value } = await reader.read(); 1296 _lastReaderActivity = Date.now(); 1297 if (done) break; 1298 1299 buffer += decoder.decode(value, { stream: true }); 1300 const lines = buffer.split('\n'); 1301 buffer = lines.pop() || ''; 1302 1303 for (const line of lines) { 1304 // Log SSE event types (e.g. "event: error") for debugging 1305 if (line.startsWith('event: ')) { 1306 const evtType = line.slice(7).trim(); 1307 if (evtType === 'error') _nextIsError = true; 1308 continue; 1309 } 1310 if (line.startsWith('data: ')) { 1311 const data = line.slice(6); 1312 1313 // (thinking spinner removal is handled in agent_step / tool_start / content handlers) 1314
1315 // Background detection: are we on a different session? 1316 const _isBg = (sessionModule.getCurrentSessionId() !== streamSessionId); 1317 1318 // On first transition to background, store state in map 1319 if (_isBg && !_backgroundStreams.has(streamSessionId)) { 1320 _backgroundStreams.set(streamSessionId, { 1321 status: 'running', 1322 accumulated: accumulated, 1323 sourcesHtml: _sourcesHtml, 1324 findingsData: null, 1325 abortCtrl: currentAbort, 1326 query: streamQuery, 1327 metrics: null, 1328 }); 1329 if (sessionModule && sessionModule.markStreaming) { 1330 sessionModule.markStreaming(streamSessionId); 1331 } 1332 } 1333 1334 if (data === '[DONE]') { 1335 _streamSawDone = true; 1336 // Always update background map if entry exists (even if user switched back) 1337 var bgDone = _backgroundStreams.get(streamSessionId); 1338 if (bgDone) { 1339 bgDone.status = 'completed'; 1340 bgDone.accumulated = accumulated; 1341 if (_isBg) { 1342 try { 1343 _notifyStreamComplete(streamSessionId, streamQuery); 1344 _insertStreamDoneToast(streamSessionId, streamQuery); 1345 } catch (toastErr) { 1346 console.warn('[bg-stream] Toast/notification error:', toastErr); 1347 } 1348 } 1349 // CRITICAL: always mark stream complete for the sidebar dot 1350 try { 1351 if (sessionModule && sessionModule.markStreamComplete) { 1352 sessionModule.markStreamComplete(streamSessionId); 1353 } 1354 } catch (dotErr) { 1355 console.warn('[bg-stream] markStreamComplete error:', dotErr); 1356 } 1357 // Don't do foreground final render â the checkBackgroundStream poll 1358 // will detect 'completed' and reload history cleanly 1359 break; 1360 } 1361 // Force-close thinking if still open (model never output boundary) 1362 if (isThinking) { 1363 isThinking = false; 1364 cancelAnimationFrame(_thinkTimerRAF); 1365 var _elapsedDone = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null; 1366 if (_elapsedDone) { 1367 accumulated = accumulated.replace(/<think>/i, '<think time="' + _elapsedDone + '">'); 1368 roundText = roundText.replace(/<think>/i, '<think time="' + _elapsedDone + '">'); 1369 } 1370 if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process'; 1371 if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove(); 1372 if (_liveThinkTimerEl && _elapsedDone) { 1373 _liveThinkTimerEl.textContent = _elapsedDone + 's'; 1374 _liveThinkTimerEl.style.marginLeft = 'auto'; 1375 _liveThinkTimerEl.style.marginRight = '5px'; 1376 var _hdrDone = _liveThinkTimerEl.closest('.thinking-header'); 1377 // Keep the chevron furthest right with the timer to its left 1378 // (match the live + final-render layout) â insert before the 1379 // toggle rather than appending (which would land after it). 1380 if (_hdrDone) { 1381 if (_liveThinkToggle && _liveThinkToggle.parentElement === _hdrDone) 1382 _hdrDone.insertBefore(_liveThinkTimerEl, _liveThinkToggle); 1383 else _hdrDone.appendChild(_liveThinkTimerEl); 1384 } 1385 } 1386 // Assign stable IDs 1387 var _thinkIdDone = 'think-' + Date.now(); 1388 var _liveHdrDone = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header'); 1389 if (_liveHdrDone) _liveHdrDone.dataset.thinkingId = _thinkIdDone; 1390 if (_liveThinkContent) _liveThinkContent.id = _thinkIdDone; 1391 if (_liveThinkToggle) _liveThinkToggle.id = _thinkIdDone + '-toggle'; 1392 // Create live-reply container so final render preserves thinking bar 1393 var _streamElDone = _liveThinkSection ? _liveThinkSection.parentElement : roundHolder.querySelector('.stream-content'); 1394 if (!_streamElDone) _streamElDone = roundHolder.querySelector('.body'); 1395 if (_streamElDone && !_streamElDone.querySelector('.live-reply-content')) { 1396 var _replyElDone = document.createElement('div'); 1397 _replyElDone.className = 'live-reply-content'; 1398 _streamElDone.appendChild(_replyElDone); 1399 } 1400 } 1401 // Normal foreground completion â metrics will be displayed in the final render block below 1402 break; 1403 } 1404 try { 1405 const json = JSON.parse(data); 1406 // Handle SSE error events (e.g. HTTP 404 from provider) 1407 if (_nextIsError || json.status >= 400) { 1408 _nextIsError = false;
1409 const errMsg = json.text || json.error?.message || `Error ${json.status || 'unknown'}`; 1410 console.error('Stream error:', errMsg); 1411 if (spinner && spinner.element) spinner.destroy(); 1412 typewriterInto(roundHolder.querySelector('.body'), errMsg); 1413 break; 1414 } 1415 if (json.delta || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') { 1416 clearResponseTimeout(); 1417 clearProcessingProbe(); 1418 } 1419 if (json.delta) { 1420 _cancelThinkingTimer(); 1421 _removeThinkingSpinner(); 1422 // Text arrived after tools â connect thread line to this bubble 1423 const _threadAbove = roundHolder?.previousElementSibling; 1424 if (_threadAbove && _threadAbove.classList.contains('agent-thread') && !_threadAbove.classList.contains('has-bottom')) { 1425 _threadAbove.classList.add('has-bottom'); 1426 } 1427 // VLLM reasoning tokens: wrap in <think> tags for the thinking UI. 1428 // Stateful open/close (not a whole-message substring check) so each round 1429 // of a multi-round agent response gets its own <think>â¦</think> â otherwise 1430 // only round 1 is wrapped and rounds 2+ reasoning leaks into the answer. 1431 let _delta = json.delta; 1432 if (json.thinking) { 1433 if (!_thinkOpen) { _delta = '<think>' + _delta; _thinkOpen = true; } 1434 } else if (_thinkOpen) { 1435 _delta = '</think>' + _delta; _thinkOpen = false; 1436 } 1437 const wasEmpty = !accumulated; 1438 accumulated += _delta; 1439 roundText += _delta; 1440 currentAccumulated = accumulated; // Update global tracker 1441 // First token arrived â switch stop button from processing to streaming 1442 if (wasEmpty && submitBtn && !_isBg) { 1443 submitBtn.dataset.phase = 'receiving'; 1444 } 1445 1446 // Update background map if running in background 1447 if (_isBg) { 1448 var bgEntry = _backgroundStreams.get(streamSessionId); 1449 if (bgEntry) bgEntry.accumulated = accumulated; 1450 continue; // Skip all DOM writes 1451 } 1452 1453 // --- Text-fence doc streaming (for models that don't use native tool calls) --- 1454 if (!_docFenceOpened && documentModule && roundText.includes('```create_document\n')) { 1455 const fenceIdx = roundText.indexOf('```create_document\n'); 1456 const afterFence = roundText.slice(fenceIdx + '```create_document\n'.length); 1457 const fenceLines = afterFence.split('\n'); 1458 if (fenceLines.length >= 1 && fenceLines[0].trim()) { 1459 _docFenceOpened = true; 1460 const title = fenceLines[0].trim(); 1461 // Keep in sync with backend _KNOWN_LANGS in src/tool_implementations.py 1462 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']; 1463 const isLang = fenceLines.length >= 2 && knownLangs.includes(fenceLines[1].trim().toLowerCase()); 1464 const lang = isLang ? fenceLines[1].trim() : ''; 1465 _docFenceContentStart = fenceIdx + '```create_document\n'.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0); 1466 documentModule.streamDocOpen(title, lang); 1467 } 1468 } 1469 if (_docFenceOpened && _docFenceContentStart > 0 && documentModule) { 1470 let raw = roundText.slice(_docFenceContentStart); 1471 const closeIdx = raw.indexOf('\n```'); 1472 if (closeIdx >= 0) raw = raw.slice(0, closeIdx); 1473 documentModule.streamDocDelta(raw); 1474 } 1475 1476 // Detect thinking-in-progress: 1477 // 1. Normal: <think>...no closing tag yet 1478 // 2. Malformed: <think></think>\n...text but no second </think> yet 1479 // 3. Qwen3.5: "Thinking Process:" without <think> tags 1480 let hasUnclosedThink = markdownModule.hasUnclosedThinkTag(roundText); 1481 // Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning 1482 // These patterns don't use <think> tags, so we simulate unclosed thinking during streaming 1483 const _replyPrefixes = ['Hey', 'Hi ', 'Hi!', 'Hello', '
1483Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"]; 1484 if (!hasUnclosedThink && !/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i.test(roundText)) { 1485 const _trimmedRT = roundText.trimStart(); 1486 const _isReasoning = markdownModule.startsWithReasoningPrefix(_trimmedRT); 1487 if (_isReasoning) { 1488 // Check if we can see a reply boundary yet (newline then reply pattern) 1489 const _lines = _trimmedRT.split('\n'); 1490 let _replyFound = false; 1491 for (let li = 1; li < _lines.length; li++) { 1492 const _l = _lines[li].trim(); 1493 if (!_l) continue; 1494 if (_replyPrefixes.some(rp => _l.startsWith(rp))) { 1495 _replyFound = true; 1496 break; 1497 } 1498 } 1499 if (!_replyFound) { 1500 // Also check within-line: "reasoning text.Reply text" 1501 const _inlineReply = _replyPrefixes.some(rp => { 1502 const rx = new RegExp('[.!?]\\s*' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); 1503 const m = rx.exec(_trimmedRT); 1504 return m && m.index > 20; 1505 }); 1506 if (!_inlineReply) hasUnclosedThink = true; 1507 } 1508 } 1509 } 1510 if (!hasUnclosedThink && /^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i.test(roundText)) { 1511 // Empty <think></think> â the model likely put thinking outside the tags 1512 const afterEmpty = roundText.replace(/^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i, '').trim(); 1513 const closeTags = (afterEmpty.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length; 1514 if (closeTags === 0 && afterEmpty.length > 0) { 1515 hasUnclosedThink = true; // still waiting for real closing tag 1516 } 1517 } 1518 // Detect false close: <think>short</think> where real thinking follows untagged 1519 // Only applies when there's a second </think> later (model leaked thinking outside tags) 1520 // Do NOT trigger if the text after </think> contains tool calls (that's real content) 1521 if (!hasUnclosedThink && isThinking) { 1522 const _thinkMatch = roundText.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i); 1523 const _thinkLen = _thinkMatch ? _thinkMatch[1].trim().length : 0; 1524 if (_thinkLen < 20) { 1525 const _afterClose = roundText.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i, '').trim(); 1526 // Only keep waiting if there's trailing text that looks like thinking (not tool calls) 1527 const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i.test(_afterClose); 1528 const _hasOrphanClose = /<\/(?:think(?:ing)?|thought)>/i.test(_afterClose); 1529 if (!_hasToolCall && (_hasOrphanClose || (Date.now() - thinkingStartTime) < 500)) { 1530 hasUnclosedThink = true; // keep waiting for real </think> 1531 } 1532 } 1533 } 1534 1535 if (hasUnclosedThink && !isThinking) { 1536 isThinking = true; 1537 thinkingStartTime = Date.now(); 1538 if (spinner && spinner.element) spinner.destroy(); 1539 1540 // Create a live thinking box â starts expanded so content streams visibly 1541 var thinkBody = roundHolder.querySelector('.body'); 1542 var thinkContent = _ensureStreamLayout(thinkBody); 1543 thinkContent.style.minHeight = ''; 1544 _liveThinkDomId = 'live-think-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); 1545 thinkContent.innerHTML = ` 1546 <div class="thinking-section"> 1547 <div class="thinking-header" data-thinking-id="${_liveThinkDomId}"> 1548 <div class="thinking-header-left"><span class="live-think-header-text">Thinking\u2026</span></div>
1549 <span class="live-think-spinner-slot" style="flex-shrink:0;margin-left:auto;"></span> 1550 <span class="live-think-timer" style="font-size:11px;opacity:0.4;font-variant-numeric:tabular-nums;margin-left:6px;margin-right:5px;"></span> 1551 <span class="thinking-toggle live-think-toggle" id="${_liveThinkDomId}-toggle"></span> 1552 </div> 1553 <div class="thinking-content" id="${_liveThinkDomId}"> 1554 <div class="thinking-content-inner live-think-inner"></div> 1555 </div> 1556 </div>`; 1557 _liveThinkSection = thinkContent.querySelector('.thinking-section'); 1558 _liveThinkContent = thinkContent.querySelector('.thinking-content'); 1559 _liveThinkInner = thinkContent.querySelector('.live-think-inner'); 1560 _liveThinkHeader = thinkContent.querySelector('.live-think-header-text'); 1561 _liveThinkSpinnerSlot = thinkContent.querySelector('.live-think-spinner-slot'); 1562 _liveThinkTimerEl = thinkContent.querySelector('.live-think-timer'); 1563 _liveThinkToggle = thinkContent.querySelector('.live-think-toggle'); 1564 // Live timer 1565 var _thinkTimerStart = Date.now(); 1566 var _thinkTimerRAF = 0; 1567 function _tickThinkTimer() { 1568 if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) return; 1569 var s = ((Date.now() - _thinkTimerStart) / 1000).toFixed(1); 1570 _liveThinkTimerEl.textContent = s + 's'; 1571 _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer); 1572 } 1573 _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer); 1574 // Whirlpool spinner 1575 if (_liveThinkSpinnerSlot) { 1576 var _wp = spinnerModule.createWhirlpool(12); 1577 _wp.element.style.margin = '0'; 1578 _wp.element.style.width = '12px'; 1579 _wp.element.style.height = '12px'; 1580 _wp.element.style.transform = 'translateY(-1px)'; // align the whirlpool with the header text 1581 _liveThinkSpinnerSlot.appendChild(_wp.element); 1582 } 1583 } else if (hasUnclosedThink && isThinking) { 1584 if (_liveThinkInner) { 1585 // Extract raw thinking text (strip known thinking wrappers and prefixes) 1586 var thinkText = roundText 1587 .replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '') 1588 .replace(/<\|channel>thought\s*\n?/gi, '') 1589 .replace(/<\|channel>response\s*\n?/gi, '') 1590 .replace(/<channel\|>/gi, ''); 1591 thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, ''); 1592 _liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText); 1593 // Keep thinking box scrolled to bottom 1594 var thinkBox = _liveThinkInner.closest('.thinking-content'); 1595 if (thinkBox) thinkBox.scrollTop = thinkBox.scrollHeight; 1596 } 1597 uiModule.scrollHistory(); 1598 continue; 1599 } else if (!hasUnclosedThink && isThinking) { 1600 isThinking = false; 1601 var _thinkTextLen = _liveThinkInner ? _liveThinkInner.textContent.trim().length : 0; 1602 1603 // If thinking was trivially short (< 20 chars), remove the section entirely 1604 // Models sometimes emit <think>The</think> or similar noise 1605 if (_thinkTextLen < 20 && _liveThinkSection) { 1606 _liveThinkSection.remove(); 1607 _liveThinkSection = null; 1608 _liveThinkContent = null; 1609 _liveThinkInner = null; 1610 _liveThinkHeader = null; 1611 _liveThinkSpinnerSlot = null; 1612 _liveThinkTimerEl = null; 1613 _liveThinkToggle = null; 1614 _liveThinkDomId = null; 1615 // Fall through to normal streaming 1616 if (spinner && spinner.element) spinner.destroy(); 1617 _renderStream(); 1618 _scheduleThinkingSpinner(); 1619 continue; 1620 } 1621 1622 // Thinking ended â smooth transition: update header, pause, then collapse 1623 // Stop live timer and spinner 1624 cancelAnimationFrame(_thinkTimerRAF);
1625 var elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null; 1626 // Embed thinking time in the <think> tag for persistence on reload 1627 if (elapsed) { 1628 accumulated = accumulated.replace(/<think>/i, '<think time="' + elapsed + '">'); 1629 roundText = roundText.replace(/<think>/i, '<think time="' + elapsed + '">'); 1630 } 1631 if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process'; 1632 if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove(); 1633 // Move timer to right side of header 1634 if (_liveThinkTimerEl && elapsed) { 1635 _liveThinkTimerEl.textContent = elapsed + 's'; 1636 _liveThinkTimerEl.style.marginLeft = 'auto'; 1637 _liveThinkTimerEl.style.marginRight = '5px'; 1638 var _hdrRow = _liveThinkTimerEl.closest('.thinking-header'); 1639 // Chevron furthest right, timer to its left â insert before 1640 // the toggle (appending would put the timer after it). 1641 if (_hdrRow) { 1642 if (_liveThinkToggle && _liveThinkToggle.parentElement === _hdrRow) 1643 _hdrRow.insertBefore(_liveThinkTimerEl, _liveThinkToggle); 1644 else _hdrRow.appendChild(_liveThinkTimerEl); 1645 } 1646 } 1647 1648 // Assign stable IDs (for click-toggle handler in markdown.js) 1649 var _thinkId = 'think-' + Date.now(); 1650 var _liveHdr = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header'); 1651 if (_liveHdr) _liveHdr.dataset.thinkingId = _thinkId; 1652 if (_liveThinkContent) _liveThinkContent.id = _thinkId; 1653 if (_liveThinkToggle) _liveThinkToggle.id = _thinkId + '-toggle'; 1654 1655 // Append a container for the reply text that follows thinking 1656 var _streamEl = _liveThinkSection ? _liveThinkSection.parentElement : roundHolder.querySelector('.stream-content'); 1657 if (!_streamEl) _streamEl = roundHolder.querySelector('.body'); 1658 if (_streamEl) { 1659 var _replyEl = document.createElement('div'); 1660 _replyEl.className = 'live-reply-content'; 1661 _streamEl.appendChild(_replyEl); 1662 } 1663 1664 // Render any reply text that arrived with the closing </think> token 1665 _renderStream(); 1666 } else { 1667 // Normal streaming 1668 if (spinner && spinner.element) spinner.destroy(); 1669 _renderStream(); 1670 _scheduleThinkingSpinner(); 1671 // Feed streaming TTS with accumulated text 1672 if (streamingTTS) window.aiTTSManager.streamingUpdate(roundText); 1673 } 1674 } else if (json.type === 'research_progress') { 1675 if (_isBg) continue; // Skip DOM updates in background 1676 _researchingStreamIds.add(streamSessionId); 1677 // Highlight research button while running 1678 var _rToggle = document.getElementById('research-toggle-btn'); 1679 if (_rToggle) _rToggle.classList.add('research-running'); 1680 // Request notification permission on first research event 1681 if ('Notification' in window && Notification.permission === 'default') { 1682 Notification.requestPermission(); 1683 } 1684 // Mark session as researching in sidebar 1685 var _rSid = sessionModule && sessionModule.getCurrentSessionId(); 1686 if (_rSid && sessionModule.markResearching) sessionModule.markResearching(_rSid); 1687 const rp = json.data; 1688 // Start research timer + synapse on first progress event 1689 if (!_researchTimerEl && spinner && spinner.element) { 1690 _researchStartTime = rp.started_at ? rp.started_at * 1000 : Date.now(); 1691 _researchAvgDuration = rp.avg_duration || null; 1692 _researchTimerEl = document.createElement('div'); 1693 _researchTimerEl.className = 'research-timer'; 1694 // Styles in .research-timer CSS class 1695 spinner.element.parentNode.insertBefore(_researchTimerEl, spinner.element.nextSibling); 1696 _researchTimerInterval = setInterval(() => { 1697 if (!_researchTimerEl) return;
1698 var elapsed = Math.floor((Date.now() - _researchStartTime) / 1000); 1699 var mm = String(Math.floor(elapsed / 60)).padStart(2, '0'); 1700 var ss = String(elapsed % 60).padStart(2, '0'); 1701 var txt = mm + ':' + ss; 1702 if (_researchAvgDuration) { 1703 var avgM = String(Math.floor(_researchAvgDuration / 60)).padStart(2, '0'); 1704 var avgS = String(Math.round(_researchAvgDuration % 60)).padStart(2, '0'); 1705 txt += ' / avg ' + avgM + ':' + avgS; 1706 } 1707 _researchTimerEl.textContent = txt; 1708 }, 1000); 1709 // Synapse visualization â insert right above the timer so 1710 // it sits between the spinner message and the timer line. 1711 try { 1712 _researchSynapse = createResearchSynapse(spinner.element.parentNode, { 1713 query: holder._researchQuery || rp.query || '', 1714 startedAt: _researchStartTime, 1715 }); 1716 // Move it to live between spinner and timer 1717 if (_researchSynapse.element && _researchTimerEl) { 1718 spinner.element.parentNode.insertBefore(_researchSynapse.element, _researchTimerEl); 1719 } 1720 } catch (e) { console.warn('synapse init failed', e); } 1721 } 1722 if (_researchSynapse) { 1723 _researchSynapse.setPhase(rp.phase, rp); 1724 if (typeof rp.round === 'number') _researchSynapse.setRound(rp.round); 1725 if (typeof rp.total_sources === 'number') _researchSynapse.setSourceCount(rp.total_sources); 1726 if (rp.phase === 'error') _researchSynapse.complete(); 1727 } 1728 if (spinner && spinner.element) { 1729 if (rp.phase === 'probing') { 1730 spinner.updateMessage(`Verifying model: ${rp.model || '?'}`); 1731 } else if (rp.phase === 'planning') { 1732 spinner.updateMessage('Analyzing question & planning research strategy'); 1733 } else if (rp.phase === 'searching') { 1734 const q = rp.queries ? `${rp.queries} queries` : ''; 1735 const s = rp.total_sources ? ` · ${rp.total_sources} sources` : ''; 1736 spinner.updateMessage(`Round ${rp.round || '?'}: Searching${q ? ' (' + q + ')' : ''}${s}`); 1737 } else if (rp.phase === 'reading') { 1738 spinner.updateMessage(rp.title ? `Reading: ${rp.title}` : `Round ${rp.round || '?'}: Reading ${rp.new_sources || ''} pages · ${rp.total_sources || 0} sources total`); 1739 } else if (rp.phase === 'analyzing') { 1740 spinner.updateMessage(`Round ${rp.round || '?'}: Analyzing ${rp.total_findings || 0} findings`); 1741 } else if (rp.phase === 'writing') { 1742 spinner.updateMessage(`Writing report · ${rp.total_sources || 0} sources`); 1743 } else if (rp.phase === 'error') { 1744 spinner.updateMessage(rp.message || 'Search error'); 1745 } 1746 } 1747 } else if (json.type === 'research_sources') { 1748 if (_isBg) { 1749 // Store sources HTML in background map 1750 if (json.data && json.data.length > 0) { 1751 _sourcesHtml = _buildSourcesBox(json.data, 'research'); 1752 var bgE = _backgroundStreams.get(streamSessionId); 1753 if (bgE) bgE.sourcesHtml = _sourcesHtml; 1754 } 1755 // Clear researching indicator for this background session 1756 if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(streamSessionId); 1757 continue; 1758 } 1759 // Research done â clean up timer, show sources box, then spinner for LLM response 1760 _clearResearchTimer(); 1761 holder._researchSources = json.data; 1762 var _rSid2 = sessionModule && sessionModule.getCurrentSessionId();
1763 if (_rSid2 && sessionModule.clearResearching) sessionModule.clearResearching(_rSid2); 1764 if (json.data && json.data.length > 0) { 1765 _sourcesData = json.data; _sourcesType = 'research'; 1766 _sourcesHtml = _buildSourcesBox(json.data, 'research'); 1767 } 1768 if (document.hidden) { 1769 _notifyResearchComplete(_rSid2 || '', holder._researchQuery || ''); 1770 } 1771 } else if (json.type === 'research_findings') { 1772 if (_isBg) { 1773 var bgEf = _backgroundStreams.get(streamSessionId); 1774 if (bgEf) bgEf.findingsData = json.data; 1775 continue; 1776 } 1777 if (json.data && json.data.length > 0) { 1778 _findingsData = json.data; 1779 } 1780 } else if (json.type === 'research_done') { 1781 // Research complete â reload session to show the persisted report 1782 _clearResearchTimer(); 1783 if (sessionModule && sessionModule.clearResearching) { 1784 sessionModule.clearResearching(streamSessionId); 1785 } 1786 _researchingStreamIds.delete(streamSessionId); 1787 // Small delay then reload session history which includes the full report 1788 setTimeout(async () => { 1789 // Don't yank the user back to this chat if they've navigated 1790 // away (e.g. started a new chat) while research f
1790inished â 1791 // just refresh the sidebar so the report shows when they return. 1792 if (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() === streamSessionId) { 1793 await sessionModule.selectSession(streamSessionId); 1794 } else { 1795 await sessionModule.loadSessions(); 1796 } 1797 }, 500); 1798 continue; 1799 } else if (json.type === 'web_sources') { 1800 if (_isBg) { 1801 if (json.data && json.data.length > 0) { 1802 _sourcesHtml = _buildSourcesBox(json.data, 'web'); 1803 var bgE2 = _backgroundStreams.get(streamSessionId); 1804 if (bgE2) bgE2.sourcesHtml = _sourcesHtml; 1805 } 1806 continue; 1807 } 1808 // Web search done â store sources for final render (don't render mid-stream) 1809 holder._webSources = json.data; 1810 if (json.data && json.data.length > 0) { 1811 _sourcesData = json.data; _sourcesType = 'web'; 1812 _sourcesHtml = _buildSourcesBox(json.data, 'web'); 1813 } 1814 } else if (json.type === 'model_fallback') { 1815 // Model went offline â switched to fallback 1816 var _fbData = json.data || {}; 1817 uiModule.showToast( 1818 `Model ${_fbData.old_model || '?'} offline â switched to ${_fbData.new_model || '?'}`, 1819 5000 1820 ); 1821 // Update the model picker to reflect the new model 1822 if (sessionModule && sessionModule.updateModelPicker) { 1823 sessionModule.updateModelPicker(); 1824 } 1825 continue; 1826 } else if (json.type === 'model_info') { 1827 // Update role label with model name as soon as we know it 1828 if (!_isBg && holder) { 1829 const roleEl = holder.querySelector('.role'); 1830 if (roleEl) { 1831 holder._requestedModel = json.requested_model || json.model || holder._requestedModel; 1832 holder._actualModel = json.model || holder._actualModel || holder._requestedModel; 1833 if (json.suffix) holder._roleSuffix = json.suffix; 1834 // Prepend character name if sent by server or set locally 1835 var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : ''); 1836 if (_charName) holder._characterName = _charName; 1837 _setRoleModelLabel(roleEl, holder._requestedModel, holder._actualModel, { 1838 suffix: holder._roleSuffix, 1839 characterName: holder._characterName, 1840 }); 1841 } 1842 } 1843 } else if (json.type === 'fallback') { 1844 // The selected model failed and another provider answered. Make 1845 // it visible so a misconfigured provider is never silently 1846 // masked under the selected model's name. 1847 if (!_isBg) { 1848 var _selM = _shortModel(json.selected_model || ''); 1849 var _ansM = _shortModel(json.answered_by || ''); 1850 uiModule.showToast('â ' + _selM + ' failed â answered by ' + _ansM, 6000); 1851 if (holder) { 1852 var _rEl = holder.querySelector('.role'); 1853 if (_rEl) { 1854 var _tsS = _rEl.querySelector('.role-timestamp'); 1855 _rEl.textContent = _ansM + ' (fallback) '; 1856 _rEl.title = (json.selected_model || '') + ' failed' + 1857 (json.reason ? ': ' + json.reason : '') + ' â answered by ' + (json.answered_by || ''); 1858 _applyModelColor(_rEl, json.answered_by); 1859 if (_tsS) _rEl.appendChild(_tsS); 1860 holder._requestedModel = json.selected_model || holder._requestedModel || modelName; 1861 const _hasResolvedActual = holder._actualModel && !_sameModelName(holder._actualModel, holder._requestedModel); 1862 holder._actualModel = _hasResolvedActual ? holder._actualModel : (json.answered_by || holder._actualModel || holder._requestedModel); 1863 _setRoleModelLabel(_rEl, holder._requestedModel, holder._actualModel, { 1864 suffix: holder._roleSuffix, 1865 characterName: holder._characterName, 1866 reason: json.reason, 1867 }); 1868 } 1869 } 1870 } 1871 } else if (json.type === 'rounds_exhausted') { 1872 // The agent hit the per-turn step limit while still working. 1873 // Offer a Continue button instead of stalling silently. 1874 // NOTE: append to the chat-history container (bottom), NOT the 1875 // message body â the body innerHTML is re-rendered at stream 1876 // finalize, which would wipe a note placed inside it. 1877 const _chatBox = document.getElementById('chat-history'); 1878 if (!_isBg && _chatBox) { 1879 // Drop any prior box so repeated cap-hits each get a fresh 1880 // Continue at the bottom (multiple continues in a row). 1881 const _old = _chatBox.querySelector('.rounds-exhausted'); 1882 if (_old) _old.remove(); 1883 const note = document.createElement('div'); 1884 note.className = 'stopped-indicator rounds-exhausted'; 1885 const label = document.createElement('span'); 1886 label.className = 'rounds-exhausted-label'; 1887 label.textContent = `Reached the ${json.rounds || ''}-step limit â not finished.`; 1888 note.appendChild(label); 1889 const contBtn = document.createElement('button'); 1890 contBtn.className = 'continue-btn'; 1891 contBtn.title = 'Continue the task'; 1892 contBtn.textContent = 'Continue â¸'; 1893 const _holder = currentHolder; 1894 contBtn.addEventListener('click', () => { 1895 note.remove(); 1896 _hideUserBubble = true; 1897 _pendingContinue = _holder; 1898 const msgInput = uiModule.el('message'); 1899 if (msgInput) { 1900 msgInput.value = 'You hit the step limit before finishing â the task is not complete. Continue from exactly where you left off and keep going until it is done. Do NOT repeat work already done.'; 1901 const sb = document.querySelector('.send-btn'); 1902 if (sb) sb.click(); 1903 } 1904 }); 1905 note.appendChild(contBtn); 1906 _chatBox.appendChild(note); 1907 try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); } 1908 } 1909 } else if (json.type === 'model_actual') { 1910 if (!_isBg && holder) { 1911 holder._requestedModel = json.requested_model || holder._requestedModel || modelName; 1912 holder._actualModel = json.model || holder._actualModel || holder._requestedModel; 1913 _setRoleModelLabel(holder.querySelector('.role'), holder._requestedModel, holder._actualModel, { 1914 suffix: holder._roleSuffix, 1915 characterName: holder._characterName, 1916 }); 1917 } 1918 } else if (json.type === 'attachments') { 1919 if (_isBg) continue; 1920 // Update user bubble â replace file chips with image previews 1921 const _ub = document.querySelector('#chat-history .msg-user:last-of-type'); 1922 if (_ub) { 1923 const _aw = _ub.querySelector('.attach-cards'); 1924 if (_aw) { 1925 for (const _att of json.data) { 1926 const _isImg = (_att.mime || '').startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(_att.name || ''); 1927 if (_isImg && _att.id) { 1928 // Skip if we already have a preview for this file id â 1929 // on a regenerate the original user bubble keeps its 1930 // photo and the backend re-emits the attachment event 1931 // for the same id; without this guard we'd append a 1932 // duplicate (which visually pushes the real photo off). 1933 const _existingPreview = _aw.querySelector('[data-file-id="' + _att.id + '"]'); 1934 if (_existingPreview) { 1935 if (_att.vision_model && !_existingPreview.querySelector('.attach-vision-model')) { 1936 const _vl = document.createElement('div'); 1937 _vl.className = 'attach-vision-model'; 1938 _vl.textContent = 'Vision: ' + String(_att.vision_model).split('/').pop(); 1939 const _name = _existingPreview.querySelector('.attach-image-name'); 1940 if (_name) _existingPreview.insertBefore(_vl, _name); 1941 else _existingPreview.appendChild(_vl); 1942 } 1943 continue; 1944 } 1945 const _card = _aw.querySelector('.attach-card[data-name="' + (_att.name || '').replace(/"/g, '\\"') + '"]'); 1946 const _iw = document.createElement('div'); 1947 _iw.className = 'attach-image-preview';
1948 _iw.dataset.fileId = _att.id; 1949 _iw.style.cursor = 'pointer'; 1950 _iw.onclick = () => window.open(API_BASE + '/api/upload/' + _att.id, '_blank'); 1951 const _im = document.createElement('img'); 1952 _im.src = API_BASE + '/api/upload/' + _att.id; 1953 _im.alt = _att.name || 'Image'; 1954 _im.style.cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:block;'; 1955 _iw.appendChild(_im); 1956 if (_att.vision_model) { 1957 const _vl = document.createElement('div'); 1958 _vl.className = 'attach-vision-model'; 1959 _vl.textContent = 'Vision: ' + String(_att.vision_model).split('/').pop(); 1960 _iw.appendChild(_vl); 1961 } 1962 if (_att.name) { 1963 const _nm = document.createElement('div'); 1964 _nm.className = 'attach-image-name'; 1965 _nm.textContent = _att.name; 1966 _iw.appendChild(_nm); 1967 } 1968 if (_card) _card.replaceWith(_iw); else _aw.appendChild(_iw); 1969 } else { 1970 const _card = _aw.querySelector('.attach-card[data-name="' + (_att.name || '').replace(/"/g, '\\"') + '"]'); 1971 if (_card && _att.id) { 1972 _card.dataset.fileId = _att.id; 1973 _card.style.cursor = 'pointer'; 1974 _card.onclick = () => window.open(API_BASE + '/api/upload/' + _att.id, '_blank'); 1975 } 1976 } 1977 } 1978 } 1979 // Caption / OCR text is no longer rendered as an inline 1980 // collapsible on the user bubble â the user can view/edit 1981 // it via the "Caption" button on the photo thumbnail. 1982 } 1983 } else if (json.type === 'rag_sources') { 1984 if (_isBg) continue; 1985 holder._ragSources = json.data; 1986 } else if (json.type === 'memories_used') { 1987 if (_isBg) continue; 1988 holder._memoriesUsed = json.data; 1989 } else if (json.type === 'compacted') { 1990 if (!_isBg) { 1991 uiModule.showToast('Context compacted â older messages summarized'); 1992 } 1993 } else if (json.type === 'metrics') { 1994 metrics = json.data; 1995 if (!_isBg && holder && metrics) { 1996 holder._requestedModel = metrics.requested_model || holder._requestedModel || modelName; 1997 holder._actualModel = metrics.model || holder._actualModel || holder._requestedModel; 1998 } 1999 if (_isBg) { 2000 var bgM = _backgroundStreams.get(streamSessionId); 2001 if (bgM) bgM.metrics = json.data; 2002 continue; 2003 } 2004 2005 } else if (json.type === 'message_saved') {
2006 // Wire the persisted DB id onto the just-streamed bubble so it 2007 // can be edited/deleted immediately, without reloading the chat. 2008 if (_isBg) continue; 2009 if (currentHolder && json.id) currentHolder.dataset.dbId = json.id; 2010 2011 } else if (json.type === 'tool_start') { 2012 if (_isBg) continue; 2013 _cancelThinkingTimer(); 2014 _removeThinkingSpinner(); 2015 // Force-close thinking if still open â tools are real content, not thinking 2016 if (isThinking) { 2017 isThinking = false; 2018 cancelAnimationFrame(_thinkTimerRAF); 2019 var _elapsed2 = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null; 2020 if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process'; 2021 if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _elapsed2 + 's' : ''; 2022 if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove(); 2023 // Assign stable IDs 2024 var _thinkId2 = 'think-' + Date.now(); 2025 var _liveHdr2 = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header'); 2026 if (_liveHdr2) _liveHdr2.dataset.thinkingId = _thinkId2; 2027 if (_liveThinkContent) _liveThinkContent.id = _thinkId2; 2028 if (_liveThinkToggle) _liveThinkToggle.id = _thinkId2 + '-toggle'; 2029 } 2030 _renderStream(); 2031 // --- Finalize current text bubble (only once per round) --- 2032 if (!roundFinalized) { 2033 roundFinalized = true; 2034 if (spinner && spinner.element) spinner.destroy(); 2035 const dt = stripToolBlocks(roundText); 2036 if (dt.trim()) { 2037 var _body3 = roundHolder.querySelector('.body'); 2038 var _contentEl3 = _ensureStreamLayout(_body3); 2039 _contentEl3.style.minHeight = ''; // clear streaming inflate 2040 _contentEl3.innerHTML = markdownModule.processWithThinking(markdownModule.squashOutsideCode(dt)); 2041 if (window.hljs) roundHolder.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b)); 2042 } else { 2043 roundHolder.style.display = 'none'; 2044 } 2045 } 2046 2047 // Track tool name for contextual spinner labels 2048 _lastToolName = json.tool || ''; 2049 2050 // --- Thread timeline: group tools in a thread container --- 2051 const cmd = json.command || ''; 2052 const chatBox = document.getElementById('chat-history'); 2053 // Find existing thread to append to â check last few children 2054 // (agent_step may insert an empty msg-ai between tool rounds) 2055 let threadWrap = null; 2056 for (let ci = chatBox.children.length - 1; ci >= Math.max(0, chatBox.children.length - 5); ci--) { 2057 const child = chatBox.children[ci]; 2058 if (child.classList.contains('agent-thread')) { 2059 threadWrap = child; 2060 break; 2061 } 2062 // Skip hidden (empty) bubbles and thinking spinners 2063 if (child.style.display === 'none' || child.classList.contains('agent-thinking-dots')) continue; 2064 // Stop if we hit a visible message bubble (has real content between tools) 2065 if (child.classList.contains('msg')) break; 2066 } 2067 if (threadWrap) { 2068 // Continuing an existing thread â remove has-bottom (agent_step may have set it 2069 // expecting text, but we got more tools instead) 2070 threadWrap.classList.remove('has-bottom'); 2071 } else { 2072 threadWrap = document.createElement('div'); 2073 threadWrap.className = 'agent-thread'; 2074 // Extend line up to connect to chat bubble above (if there is one) 2075 const _prevSib = chatBox.lastElementChild; 2076 const _hasBubbleAbove = _prevSib && (_prevSib.classList.contains('msg') && _prevSib.style.display !== 'none'); 2077 const _hasThreadAbove = _prevSib && _prevSib.classList.contains('agent-thread'); 2078 if (_hasBubbleAbove || _hasThreadAbove || (rou
2078ndText.trim() && roundHolder && roundHolder.style.display !== 'none')) { 2079 threadWrap.classList.add('has-top'); 2080 } 2081 chatBox.appendChild(threadWrap); 2082 } 2083 threadWrap.classList.add('streaming'); 2084 const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool; 2085 const node = document.createElement('div') 2086 node.className = 'agent-thread-node running'; 2087 const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : ''; 2088 node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">âââ</span></div><div class="agent-thread-content">${cmdHtml}</div>`; 2089 // Expand/collapse via delegated click handler (init at module bottom). 2090 threadWrap.appendChild(node); 2091 currentToolBubble = node; 2092 // Animate the wave 2093 const waveEl = node.querySelector('.agent-thread-wave'); 2094 if (waveEl) { 2095 const waveFrames = ['âââ', 'âââ', 'âââ ', 'ââ â', 'â ââ', 'ââ â', 'â ââ', 'âââ']; 2096 let waveIdx = 0; 2097 node._waveInterval = setInterval(() => { 2098 waveIdx = (waveIdx + 1) % waveFrames.length; 2099 waveEl.textContent = waveFrames[waveIdx]; 2100 }, 100); 2101 } 2102 // Smooth per-second "cooking" timer â ticks every second (not 2103 // just on the 2s backend heartbeat) so a long-running tool 2104 // always shows visible motion and never reads as frozen. 2105 node._startTime = Date.now(); 2106 node._elapsedTicker = setInterval(() => { 2107 const hdr2 = node.querySelector('.agent-thread-header'); 2108 if (!hdr2) return; 2109 let el2 = hdr2.querySelector('.agent-thread-elapsed'); 2110 if (!el2) { 2111 el2 = document.createElement('span'); 2112 el2.className = 'agent-thread-elapsed'; 2113 // Sits on the LEFT, right after the icon. 2114 const icon = hdr2.querySelector('.agent-thread-icon'); 2115 if (icon && icon.nextSibling) hdr2.insertBefore(el2, icon.nextSibling); 2116 else hdr2.appendChild(el2); 2117 } 2118 const s = (Date.now() - node._startTime) / 1000; 2119 // Hundredths so it visibly counts sub-second (1.00, 1.05, â¦). 2120 el2.textContent = s < 60 ? `${s.toFixed(2)}s` : `${Math.floor(s / 60)}m ${(s % 60).toFixed(2).padStart(5, '0')}s`; 2121 }, 50); 2122 uiModule.scrollHistory(); 2123 2124 } else if (json.type === 'tool_progress') { 2125 // Long-running subprocess (bash, python) is still in 2126 // flight â refresh the running tool card with the 2127 // elapsed-time + tail of its stdout/stderr so the 2128 // user doesn't stare at a blind "Runningâ¦" spinner. 2129 if (_isBg) continue; 2130 if (!currentToolBubble) continue; 2131 // The per-second ticker (started in tool_start) owns the 2132 // elapsed display; here we just surface the live output tail. 2133 const tailStr = (json.tail || '').trim(); 2134 if (tailStr) { 2135 let tailEl = currentToolBubble.querySelector('.agent-thread-tail'); 2136 if (!tailEl) { 2137 tailEl = document.createElement('pre'); 2138 tailEl.className = 'agent-thread-tail'; 2139 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;'; 2140 const content = currentToolBubble.querySelector('.agent-thread-content'); 2141 if (content) content.appendChild(tailEl); 2142 } 2143 tailEl.textContent = tailStr; 2144 tailEl.scrollTop = tailEl.scrollHeight; 2145 } 2146 uiModule.scrollHistory(); 2147 2148 } else if (json.type === 'tool_output') { 2149 if (_isBg) continue; 2150 // --- Update the current thread node --- 2151 if (currentToolBubble) { 2152 // Stop wave animation + the per-second cooking ticker 2153 if (currentToolBubble._waveInterval) { 2154 clearInterval(currentToolBubble._waveInterval); 2155 currentToolBubble._waveInterval = null; 2156 } 2157 if (currentToolBubble._elapsedTicker) { 2158 clearInterval(currentToolBubble._elapsedTicker); 2159 currentToolBubble._elapsedTicker = null; 2160 } 2161 const ok = (json.exit_code === 0 || json.exit_code == null); 2162 const cmd = json.command || ''; 2163 let outHtml = ''; 2164 if (json.output && json.output.trim()) { 2165 outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(json.output)}</pre></details>`; 2166 } 2167 // File-write diff (write_file): show a before/after unified diff. 2168 let diffHtml = ''; 2169 if (json.diff && json.diff.text) { 2170 const d = json.diff;
2171 // Collapsed summary: filename + +adds (green) / âdels (red). 2172 const stat = [ 2173 d.new_file ? '<span class="diff-stat-new">new</span>' : '', 2174 d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '', 2175 d.removed ? `<span class="diff-stat-del">â${d.removed}</span>` : '', 2176 ].filter(Boolean).join(' '); 2177 const rows = d.text.split('\n').map(line => { 2178 let cls = 'diff-ctx', text = line; 2179 if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta'; 2180 else if (line.startsWith('@@')) cls = 'diff-hunk'; 2181 // Drop the leading diff marker (+/-/space) â the row colour 2182 // already encodes add/del, and keeping it doubles up with 2183 // markdown "- " bullets (reads as "+-"/"--"). 2184 else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); } 2185 else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); } 2186 else if (line.startsWith(' ')) { text = line.slice(1); } 2187 return `<span class="${cls}">${esc(text) || ' '}</span>`; 2188 }).join(''); // spans are display:block â a literal \n here would double-space the diff 2189 diffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`; 2190 } 2191 // For file edits the "command" is the raw JSON args â redundant 2192 // next to the diff, so hide it when we have a diff to show. 2193 const cmdHtml2 = (cmd && !(json.diff && json.diff.text)) ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : ''; 2194 // Preserve the user's .open choice across the innerHTML 2195 // rewrite \u2014 otherwise expanding a running tool collapses 2196 // it as soon as the result lands, forcing the user to 2197 // click again. Click handling is delegated (see init at 2198 // bottom of file) so no per-node listener needed. 2199 const _wasOpen = currentToolBubble.classList.contains('open'); 2200 currentToolBubble.className = 'agent-thread-node' + (ok ? '' : ' error') + (_wasOpen ? ' open' : ''); 2201 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">\u25B6</span></div><div class="agent-thread-content">${cmdHtml2}${outHtml}${diffHtml}</div>`; 2202 // Reset so thinking spinner between tools says "Thinking" not the old tool's label 2203 _lastToolName = ''; 2204 uiModule.scrollHistory(); 2205 } 2206 // --- Render generated images inline --- 2207 if (json.image_url) { 2208 const chatBox = document.getElementById('chat-history'); 2209 chatBox.appendChild(_buildImageBubble(json.image_url, json.image_prompt, json.image_model, json.image_size, json.image_quality, json.image_id)); 2210 uiModule.scrollHistory(); 2211 // Notify gallery to refresh if open 2212 window.dispatchEvent(new CustomEvent('gallery-refresh')); 2213 } 2214 // --- Render browser screenshots in tool output --- 2215 if (json.screenshot && currentToolBubble) { 2216 const contentEl = currentToolBubble.querySelector('.agent-thread-content'); 2217 if (contentEl) { 2218 const screenshotSrc = chatRenderer.safeToolScreenshotSrc(json.screenshot); 2219 if (screenshotSrc) { 2220 const details = document.createElement('details'); 2221 details.className = 'agent-tool-output'; 2222 const summary = document.createElement('summary'); 2223 summary.textContent = 'Screenshot'; 2224 const img = document.createElement('img'); 2225 img.src = screenshotSrc; 2226 img.style.cssText = 'max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)'; 2227 details.appendChild(summary); 2228 details.appendChild(img); 2229 contentEl.appendChild(details); 2230 } 2231 } 2232 } 2233 // --- Reload sessions after manage_session tool (delete, rename, etc.) --- 2234 // Debounce so bulk deletes don't fire loadSessions per call
2235 if (json.tool === 'manage_session' && sessionModule) { 2236 if (window._manageSessionTimer) clearTimeout(window._manageSessionTimer); 2237 window._manageSessionTimer = setTimeout(() => sessionModule.loadSessions(), 1000); 2238 } 2239 // --- Live-refresh the calendar after manage_calendar (add/edit/delete) --- 2240 // so a new event shows without the user hard-refreshing. Debounced 2241 // so a batch of event creates only triggers one refetch. 2242 if (json.tool === 'manage_calendar') { 2243 if (window._manageCalTimer) clearTimeout(window._manageCalTimer); 2244 window._manageCalTimer = setTimeout( 2245 () => window.dispatchEvent(new CustomEvent('calendar-refresh')), 600); 2246 } 2247 // --- Live-refresh Memories after manage_memory changes --- 2248 if (json.tool === 'manage_memory') { 2249 if (window._manageMemoryTimer) clearTimeout(window._manageMemoryTimer); 2250 window._manageMemoryTimer = setTimeout( 2251 () => window.dispatchEvent(new CustomEvent('memory-refresh')), 600); 2252 } 2253 // --- Apply UI control actions embedded in tool_output --- 2254 if (json.ui_event) { 2255 chatStream.handleUIControl(json); 2256 } 2257 2258 // Schedule a thinking spinner between tool rounds (short delay so 2259 // agent_step in the same SSE chunk can cancel it before it shows) 2260 _scheduleThinkingSpinner(); 2261 uiModule.scrollHistory(); 2262 2263 } else if (json.type === 'doc_stream_open') { 2264 if (_isBg) { 2265 // Store for replay when user returns to this session 2266 var bgDocOpen = _backgroundStreams.get(streamSessionId); 2267 if (bgDocOpen) { 2268 bgDocOpen._docTitle = json.title || ''; 2269 bgDocOpen._docLang = json.language || ''; 2270 bgDocOpen._docContent = ''; 2271 } 2272 continue; 2273 } 2274 if (documentModule) { 2275 documentModule.streamDocOpen(json.title || '', json.language || ''); 2276 } 2277 2278 } else if (json.type === 'doc_stream_delta') { 2279 if (_isBg) { 2280 var bgDocDelta = _backgroundStreams.get(streamSessionId); 2281 if (bgDocDelta) bgDocDelta._docContent = json.content || ''; 2282 continue; 2283 } 2284 if (documentModule) { 2285 documentModule.streamDocDelta(json.content || ''); 2286 } 2287 2288 } else if (json.type === 'doc_update') { 2289 // doc_update means the server already saved the doc to DB. 2290 if (_isBg) continue; 2291 if (documentModule) { 2292 documentModule.handleDocUpdate(json); 2293 } 2294 2295 } else if (json.type === 'doc_suggestions') { 2296 if (_isBg) continue; 2297 if (documentModule && documentModule.handleDocSuggestions) { 2298 documentModule.handleDocSuggestions(json); 2299 } 2300 2301 } else if (json.type === 'ui_control') { 2302 if (_isBg) continue; 2303 chatStream.handleUIControl(json.data || {}); 2304 2305 } else if (json.type === 'ask_user') { 2306 if (_isBg) continue; 2307 // The agent posed a multiple-choice question; the turn has ended. 2308 // Render clickable options at the bottom of the history. The 2309 // user's pick is sent as the next message and the agent resumes. 2310 _cancelThinkingTimer(); 2311 _removeThinkingSpinner(); 2312 const _aq = json.data || {}; 2313 const _opts = Array.isArray(_aq.options) ? _aq.options : []; 2314 if (_aq.question && _opts.length) { 2315 const chatBox = document.getElementById('chat-history'); 2316 // Drop any prior unanswered card so only the latest shows. 2317 chatBox.querySelectorAll('.ask-user-card').forEach(n => n.remove()); 2318 const card = document.createElement('div'); 2319 card.className = 'ask-user-card'; 2320 const multi = !!_aq.multi; 2321 // Group the choices for assistive tech and label the group with 2322 // the question (set below); make the card focusable so it can be 2323 // moved to when it appears. 2324 card.setAttribute('role', 'group'); 2325 card.tabIndex = -1; 2326 // Render any emoji in agent-supplied text through the app's 2327 // pipeline: escape, then svgify to monochrome theme-tinted 2328 // glyphs (project rule: never colorful emoji; respects the 2329 // "Text-only Emojis" setting like the rest of the chat). 2330 const _emo = (s) => svgifyEmoji(uiModule.esc(String(s))); 2331 2332 // Header row holds the close (Ã) to dismiss the affordances and 2333 // just type a reply instead. 2334 const head = document.createElement('div'); 2335 head.className = 'ask-user-head'; 2336 const closeBtn = document.createElement('button'); 2337 closeBtn.type = 'button'; 2338 closeBtn.className = 'modal-close ask-user-close'; 2339 closeBtn.setAttribute('aria-label', 'Dismiss question'); 2340 closeBtn.textContent = 'Ã'; 2341 closeBtn.addEventListener('click', () => { 2342 card.remove(); 2343 const mi = uiModule.el('message'); 2344 if (mi) mi.focus(); 2345 }); 2346 head.appendChild(closeBtn); 2347 card.appendChild(head); 2348 2349 // Render the question inside the card so it's self-contained: 2350 // some models call ask_user without first narrating the question 2351 // as assistant text, in which case the card would otherwise show
2352 // bare options with no prompt. 2353 if (_aq.question) { 2354 const q = document.createElement('div'); 2355 q.className = 'ask-user-question'; 2356 q.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`; 2357 q.innerHTML = _emo(_aq.question); 2358 card.appendChild(q); 2359 // Label the choice group with the question for screen readers. 2360 card.setAttribute('aria-labelledby', q.id); 2361 } else { 2362 card.setAttribute('aria-label', 'Question from the assistant'); 2363 } 2364 2365 const list = document.createElement('div'); 2366 list.className = 'ask-user-options'; 2367 card.appendChild(list); 2368 2369 const _send = (text) => { 2370 if (!text) return; 2371 // Remove the card once answered â the choice is sent as a 2372 // normal user message (and the question persists as the 2373 // assistant text above), so the affordances are spent. 2374 card.remove(); 2375 const mi = uiModule.el('message'); 2376 if (mi) mi.value = text; 2377 const sb = document.querySelector('.send-btn'); 2378 if (sb) sb.click(); 2379 }; 2380 2381 _opts.forEach((opt, i) => { 2382 const label = (opt && opt.label) ? String(opt.label) : String(opt || ''); 2383 if (!label) return; 2384 const descr = (opt && opt.description) ? String(opt.description) : ''; 2385 const row = document.createElement(multi ? 'label' : 'button'); 2386 row.className = 'ask-user-option'; 2387 if (multi) { 2388 const cb = document.createElement('input'); 2389 cb.type = 'checkbox'; 2390 cb.value = label; 2391 row.appendChild(cb); 2392 } 2393 const txt = document.createElement('span'); 2394 txt.className = 'ask-user-option-label'; 2395 txt.innerHTML = _emo(label); 2396 row.appendChild(txt); 2397 if (descr) { 2398 const d = document.createElement('span'); 2399 d.className = 'ask-user-option-desc'; 2400 d.innerHTML = _emo(descr); 2401 row.appendChild(d); 2402 } 2403 if (!multi) { 2404 row.type = 'button'; 2405 row.addEventListener('click', () => _send(label)); 2406 } 2407 list.appendChild(row); 2408 }); 2409 2410 // Free-text "Other" â type a custom answer + send (Enter or â). 2411 const other = document.createElement('div'); 2412 other.className = 'ask-user-other'; 2413 const otherInput = document.createElement('input'); 2414 otherInput.type = 'text'; 2415 otherInput.className = 'styled-prompt-input ask-user-other-input'; 2416 otherInput.placeholder = multi ? 'Other (added to selection)â¦' : 'Other⦠(type your own answer)'; 2417 otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer'); 2418 const otherSend = document.createElement('button'); 2419 otherSend.type = 'button'; 2420 otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send'; 2421 otherSend.setAttribute('aria-label', 'Send answer'); 2422 otherSend.textContent = multi ? 'Send selection' : 'Send'; 2423 const _submit = () => { 2424 const free = otherInput.value.trim(); 2425 if (multi) { 2426 const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map(c => c.value); 2427 if (free) picked.push(free); 2428 if (picked.length) _send(picked.join(', ')); 2429 } else if (free) { 2430 _send(free); 2431 } 2432 }; 2433 otherSend.addEventListener('click', _submit); 2434 otherInput.addEventListener('keydown', (e) => { 2435 if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { 2436 e.preventDefault(); 2437 _submit(); 2438 } 2439 }); 2440 other.appendChild(otherInput); 2441 other.appendChild(otherSend); 2442 card.appendChild(other); 2443 2444 chatBox.appendChild(card); 2445 card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 2446 // Move focus to the card so keyboard/screen-reader users land on 2447 // the question + choices when it appears. 2448 try { card.focus(); } catch (_) {} 2449 } 2450 2451 } else if (json.type === 'plan_update') { 2452 if (_isBg) continue;
2453 // Agent wrote back to the plan (ticked a step / revised). Update 2454 // the stored plan + live-refresh the docked plan window. 2455 const _pu = (json.data && json.data.plan) ? json.data.plan : ''; 2456 if (_pu) _setStoredPlan(_pu); 2457 2458 } else if (json.type === 'agent_step') { 2459 if (_isBg) continue; 2460 _cancelThinkingTimer(); 2461 _removeThinkingSpinner(); 2462 _renderStream(); 2463 // Mark thread as connected to bubble below 2464 const _activeThread = document.querySelector('.agent-thread.streaming'); 2465 if (_activeThread) { 2466 _activeThread.classList.add('has-bottom'); 2467 } 2468 // --- New round: create fresh AI bubble with spinner --- 2469 currentToolBubble = null; 2470 roundFinalized = false; 2471 isThinking = false; 2472 _docFenceOpened = false; 2473 _docFenceContentStart = -1; 2474 const box = document.getElementById('chat-history'); 2475 const newWrap = document.createElement('div'); 2476 newWrap.className = 'msg msg-ai msg-continuation streaming'; 2477 // Add model name label 2478 const newRole = document.createElement('div'); 2479 newRole.className = 'role'; 2480 const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId); 2481 const _roundRequested = holder?._requestedModel || metaS?.model; 2482 const _roundActual = holder?._actualModel || _roundRequested; 2483 newRole.textContent = _modelRouteLabel(_roundRequested, _roundActual) || ''; 2484 _applyModelColor(newRole, _roundActual); 2485 newWrap.appendChild(newRole); 2486 const newBody = document.createElement('div'); 2487 newBody.className = 'body'; 2488 newWrap.appendChild(newBody); 2489 box.appendChild(newWrap); 2490 roundHolder = newWrap; 2491 roundText = ''; 2492 // Destroy any previous spinner before creating new one 2493 if (spinner && spinner.element) spinner.destroy(); 2494 // Show spinner while waiting for text (skip for research â has its own progress) 2495 if (!_researchingStreamIds.has(streamSessionId)) { 2496 spinner = spinnerModule.create('Generating response', 'right', 'wave'); 2497 newBody.appendChild(spinner.createElement()); 2498 spinner.start(); 2499 } 2500 if (streamingTTS) window.aiTTSManager._streamSentencesSent = 0; 2501 uiModule.scrollHistory(); 2502 } else if (json.type === 'budget_exceeded') { 2503 if (_isBg) continue; 2504 _cancelThinkingTimer(); 2505 _removeThinkingSpinner(); 2506 const budgetDiv = document.createElement('div'); 2507 budgetDiv.style.cssText = 'font-size:11px;opacity:0.6;font-style:italic;padding:4px 8px;margin:4px 0;'; 2508 budgetDiv.textContent = `Tool budget reached (${json.used}/${json.limit} calls). Agent stopped.`; 2509 const chatBox = document.getElementById('chat-history'); 2510 chatBox.appendChild(budgetDiv); 2511 2512 } else if (json.type === 'teacher_takeover') { 2513 if (_isBg) continue; 2514 _cancelThinkingTimer(); 2515 _removeThinkingSpinner(); 2516 // Finalize any in-flight bubble so the takeover banner 2517 // separates student attempt from teacher attempt. 2518 if (spinner && spinner.element) { try { spinner.destroy(); } catch(_){} spinner = null; } 2519 const chatBox = document.getElementById('chat-history'); 2520 const banner = document.createElement('div'); 2521 banner.className = 'teacher-takeover-banner'; 2522 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;'; 2523 const teacherName = json.teacher_model || 'teacher'; 2524 const why = json.student_failure ? ` — <span style="opacity:0.7">${esc(json.student_failure)}</span>` : ''; 2525 banner.innerHTML = `<strong>Teacher takeover:</strong> escalating to <code>${esc(teacherName)}</code>${why}`; 2526 chatBox.appendChild(banner); 2527 // Reset round bubble state so the teacher's first text starts a new bubble 2528 roundHolder = null; 2529 roundText = ''; 2530 roundFinalized = false;
2531 currentToolBubble = null; 2532 uiModule.scrollHistory(); 2533 2534 } else if (json.type === 'skill_saved') { 2535 if (_isBg) continue; 2536 const chatBox = document.getElementById('chat-history'); 2537 const note = document.createElement('div'); 2538 note.className = 'skill-saved-note'; 2539 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;'; 2540 note.innerHTML = `<strong>Skill learned:</strong> <code>${esc(json.name || '')}</code>${json.category ? ` <span style="opacity:0.6">[${esc(json.category)}]</span>` : ''}`; 2541 chatBox.appendChild(note); 2542 uiModule.scrollHistory(); 2543 2544 } else if (json.type === 'escalation_failed' || json.type === 'skill_save_failed') { 2545 if (_isBg) continue; 2546 const chatBox = document.getElementById('chat-history'); 2547 const note = document.createElement('div'); 2548 note.className = 'escalation-failed-note'; 2549 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;'; 2550 const label = json.type === 'escalation_failed' ? 'Teacher could not solve it' : 'Skill not saved'; 2551 note.innerHTML = `<strong>${label}:</strong> <span style="opacity:0.75">${esc(json.reason || '')}</span>`; 2552 chatBox.appendChild(note); 2553 uiModule.scrollHistory(); 2554 2555 } else if (json.error) { 2556 // --- Backend error (timeout, connection issue, etc.) --- 2557 console.error('Stream error from backend:', json.error); 2558 if (_isBg) continue; 2559 if (spinner && spinner.element) spinner.destroy(); 2560 const errDiv = document.createElement('div'); 2561 errDiv.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;'; 2562 errDiv.textContent = `[Error: ${json.error}]`; 2563 roundHolder.querySelector('.body').appendChild(errDiv); 2564 uiModule.scrollHistory(); 2565 } 2566 } catch (e) { 2567 console.error('Error parsing SSE data:', e); 2568 } 2569 } 2570 } 2571 } 2572 2573 if (!_streamSawDone) { 2574 throw new Error('Stream closed before completion'); 2575 } 2576 2577 _renderStream(); 2578 _cancelThinkingTimer(); 2579 _removeThinkingSpinner(); 2580 // Stop any thread pulse animations 2581 document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming')); 2582 // --- Final render (skip if stream was ever backgrounded or currently in background) --- 2583 // Remove streaming class from all round bubbles 2584 holder.classList.remove('streaming'); 2585 if (roundHolder && roundHolder !== holder) roundHolder.classList.remove('streaming'); 2586 2587 const _isBgFinal = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId); 2588 if (!_isBgFinal) { 2589 finalMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId()); 2590 const _finalActualModel = metrics?.model || holder._actualModel || finalMeta?.model; 2591 const _finalRequestedModel = metrics?.requested_model || holder._requestedModel || finalMeta?.model || _finalActualModel; 2592 // Prepend character name if set 2593 var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : ''; 2594 const roleEl = holder.querySelector('.role'); 2595 if (roleEl) { 2596 _setRoleModelLabel(roleEl, _finalRequestedModel, _finalActualModel, { 2597 suffix: holder._roleSuffix, 2598 characterName: _charNameFinal || holder._characterName, 2599 }); 2600 } 2601 holder.dataset.raw = accumulated; 2602 2603 // Anti-stall: a turn that ran tools but ended with essentially no 2604 // final prose usually means the model stopped mid-task (the case 2605 // where you had to type "did you finish?"). Offer a one-click 2606 // Continue that resumes exactly where it left off â reuses the same 2607 // resume mechanism as the user-stop "[Message interrupted]" button. 2608 try { 2609 const _usedTools = holder.querySelector('.agent-thread-node'); 2610 const _proseLen = (accumulated || '').replace(/<[^>]*>/g, '').trim().length; 2611 if (_usedTools && _proseLen < 24 && !holder.querySelector('.agent-continue-btn')) { 2612 const _stall = document.createElement('div'); 2613 _stall.className = 'stopped-indicator'; 2614 const _lbl = document.createElement('span'); 2615 _lbl.style.cssText = 'font-style:italic;opacity:0.7;'; 2616 _lbl.textContent = 'Paused mid-task'; 2617 _stall.appendChild(_lbl); 2618 const _cont = document.createElement('button'); 2619 _cont.className = 'continue-btn agent-continue-btn'; 2620 _cont.title = 'Continue â pick up where it left off'; 2621 _cont.textContent = 'â¸'; 2622 _cont.addEventListener('click', () => { 2623 _stall.remove(); 2624 const mi = uiModule.el('message'); 2625 if (mi) { 2626 mi.value = 'Continue â you stopped before finishing. Pick up exactly where you left off and complete the task.'; 2627 const sb = document.querySelector('.send-btn'); 2628 if (sb) sb.click(); 2629 } 2630 }); 2631 _stall.appendChild(_cont); 2632 (holder.querySelector('.body') || holder).appendChild(_stall); 2633 } 2634 } catch (_) {} 2635 2636 // Clear streaming minHeight lock 2637 const _streamContent = roundHolder.querySelector('.stream-content'); 2638 if (_streamContent) _streamContent.style.minHeight = ''; 2639 2640 // Finalize the last round's bubble â flatten stream-content wrapper for clean DOM 2641 const finalDisplay = stripToolBlocks(roundText); 2642 if (finalDisplay.trim()) { 2643 var _body4 = roundHolder.querySelector('.body'); 2644 // Preserve sources expanded state before final render 2645 var _wasExpanded = _sourcesExpanded || !!(_body4 && _body4.querySelector('.sources-content.expanded')); 2646 2647 // If thinking was collapsed in-place during streaming, preserve it 2648 var _liveReplyEl = _body4 && _body4.querySelector('.live-reply-content'); 2649 var _extracted = _liveReplyEl ? markdownModule.extractThinkingBlocks(finalDisplay) : null; 2650 var _finalReply = ''; 2651 if (_liveReplyEl) { 2652 // Try standard extraction first (for native <think> tags) 2653 if (_extracted?.thinkingBlocks?.length) { 2654 _finalReply = (_extracted.content || '').trim(); 2655 } else { 2656 // Non-tag thinking: extract reply from raw text 2657 // Handle garbled thinking tag: "Thinking: reasoning\n<think>reply" 2658 const _garbledMatch = finalDisplay.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i); 2659 if (_garbledMatch && _garbledMatch[1].trim()) { 2660 _finalReply = _garbledMatch[1].trim(); 2661 } else { 2662 // Pure non-tag: find reply boundary by prefix patterns
2663 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"]; 2664 const _fr = (finalDisplay || '').trimStart(); 2665 if (markdownModule.startsWithReasoningPrefix(_fr)) { 2666 const _fLines = _fr.split('\n'); 2667 for (let _fi = 1; _fi < _fLines.length; _fi++) { 2668 const _fl = _fLines[_fi].trim(); 2669 if (!_fl) continue; 2670 if (_rs2.some(rp => _fl.startsWith(rp))) { _finalReply = _fLines.slice(_fi).join('\n'); break; } 2671 } 2672 // Within-line check 2673 if (!_finalReply) { 2674 for (const rp of _rs2) { 2675 const rx = new RegExp('[.!?]\\s*(' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')'); 2676 const m = rx.exec(_fr); 2677 if (m && m.index > 20) { _finalReply = _fr.slice(m.index + 1).trim(); break; } 2678 } 2679 } 2680 } 2681 } 2682 } 2683 } 2684 if (_liveReplyEl && _finalReply) { 2685 // Render reply into the live-reply container (thinking bar already showing) 2686 var _replyHtml = markdownModule.mdToHtml(markdownModule.squashOutsideCode(_finalReply)); 2687 _liveReplyEl.innerHTML = _replyHtml; 2688 _liveReplyEl.classList.remove('live-reply-content'); 2689 if (_sourcesData) { 2690 var _srcEl = document.createElement('div'); 2691 _srcEl.innerHTML = _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded); 2692 _body4.insertBefore(_srcEl.firstChild || _srcEl, _body4.firstChild); 2693 } 2694 if (_findingsData) _body4.insertAdjacentHTML('beforeend', chatRenderer.buildFindingsBox(_findingsData)); 2695 } else { 2696 // Full re-render (reply empty or no live-reply container) 2697 _body4.innerHTML = (_sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded) : '') 2698 + markdownModule.processWithThinking(markdownModule.squashOutsideCode(finalDisplay)) 2699 + (_findingsData ? chatRenderer.buildFindingsBox(_findingsData) : ''); 2700 } 2701 } else if (_sourcesHtml) { 2702 var _body4b = roundHolder.querySelector('.body'); 2703 var _wasExpanded2 = _sourcesExpanded || !!(_body4b && _body4b.querySelector('.sources-content.expanded')); 2704 _body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml; 2705 } else if (roundHolder !== holder) { 2706 // Check if there's thinking content worth showing 2707 const _thinkingOnly = markdownModule.extractThinkingBlocks(roundText); 2708 if (_thinkingOnly.thinkingBlocks?.length && !_thinkingOnly.content) { 2709 // Show thinking in a collapsed section even if no visible reply text 2710 const _body4c = roundHolder.querySelector('.body'); 2711 if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText); 2712 } else { 2713 roundHolder.style.display = 'none'; 2714 // Thread above expected a bubble below â remove has-bottom since bubble is hidden 2715 const _lastThread = roundHolder.previousElementSibling; 2716 if (_lastThread && _lastThread.classList.contains('agent-thread')) { 2717 _lastThread.classList.remove('has-bottom'); 2718 } 2719 } 2720 } 2721 2722 2723 if (window.hljs) { 2724 roundHolder.querySelectorAll('pre code').forEach((block) => { 2725 window.hljs.highlightElement(block); 2726 }); 2727 } 2728 if (markdownModule.renderMermaid) markdownModule.renderMermaid(roundHolder); 2729 2730 uiModule.scrollHistory(); 2731 // Render RAG sources if present 2732 if (holder._ragSources && holder._ragSources.length) { 2733 const details = document.createElement('details'); 2734 details.className = 'rag-sources'; 2735 const summary = document.createElement('summary'); 2736 summary.textContent = `Sources (${holder._ragSources.length} documents)`; 2737 details.appendChild(summary);
2738 holder._ragSources.forEach(src => { 2739 const item = document.createElement('div'); 2740 item.className = 'rag-source-item'; 2741 const _esc = uiModule.esc; 2742 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>`; 2743 details.appendChild(item); 2744 }); 2745 holder.querySelector('.body').appendChild(details); 2746 } 2747 2748 // Hide first bubble if it has no visible text content (e.g. agent went straight to tools) 2749 if (holder !== roundHolder && holder.style.display !== 'none') { 2750 const _hBody = holder.querySelector('.body'); 2751 const _hText = _hBody ? _hBody.textContent.trim() : ''; 2752 if (!_hText) holder.style.display = 'none'; 2753 } 2754 2755 // Attach footer to the last visible bubble (roundHolder for multi-round agent, holder for single) 2756 const footerTarget = (roundHolder && roundHolder !== holder && roundHolder.style.display !== 'none') ? roundHolder : holder; 2757 footerTarget.appendChild(createMsgFooter(footerTarget)); 2758 // Capture any checklist this message produced as the current plan â both 2759 // the initial proposal AND restated progress during execution. Keeps the 2760 // stored plan (and the docked plan window) in sync with the latest state. 2761 if (accumulated && _CHECKLIST_RE.test(accumulated)) { 2762 _setStoredPlan(accumulated); 2763 } 2764 // Plan mode: the agent has proposed a plan â offer to approve & execute it. 2765 // Approving re-sends with plan_mode suppressed (full tools) for one turn. 2766 if (planTurn && accumulated.trim()) { 2767 const _planText = accumulated; 2768 const _runApproved = () => { 2769 _approveWrap.remove(); 2770 _forcePlanOff = true; 2771 // Persist the approved plan for THIS chat so it's (a) re-sent and 2772 // pinned in context every execution turn, and (b) re-openable via the 2773 // plan-button menu. Do this BEFORE flipping the toggle, since the menu 2774 // intercept keys off a stored plan existing. 2775 _setStoredPlan(_planText); 2776 // Approving exits plan mode for good â turn it OFF directly (NOT via 2777 // the button's click, which would now open the plan menu instead of 2778 // toggling) so execution and every follow-up keep full write tools. 2779 try { if (window._setPlanMode) window._setPlanMode(false); } catch (_) {} 2780 const _inp = el('message'); 2781 if (_inp) { 2782 _inp.value = 'Approved â execute the plan. The full approved checklist is pinned ' 2783 + 'for you under "## ACTIVE PLAN"; do NOT go looking for it in tasks, notes, or ' 2784 + 'memory. Work through it in order, and after each step call the update_plan tool ' 2785 + 'with the full checklist and that step marked `- [x]`. Do the next unchecked item ' 2786 + 'until all are done.'; 2787 _inp.dispatchEvent(new Event('input')); 2788 } 2789 // Show a clean bubble; the full instruction still goes to the model. 2790 _displayOverride = 'Approved the plan.'; 2791 handleChatSubmit({ preventDefault() {} }); 2792 }; 2793 var _approveWrap = document.createElement('div'); 2794 _approveWrap.className = 'plan-approve-bar'; 2795 const _approveBtn = document.createElement('button'); 2796 _approveBtn.type = 'button'; 2797 _approveBtn.className = 'plan-approve-btn'; 2798 _approveBtn.textContent = 'Approve & Run'; 2799 _approveBtn.addEventListener('click', _runApproved); 2800 // Open the plan in a draggable, side-dockable window (reuses the 2801 // shared modal framework). Approving from the window runs it too. 2802 const _openBtn = document.createElement('button'); 2803 _openBtn.type = 'button'; 2804 _openBtn.className = 'plan-open-btn'; 2805 _openBtn.textContent = 'Open in window'; 2806 _openBtn.addEventListener('click', () => { 2807 planWindowModule.openPlanWindow(_planText, _runApproved); 2808 }); 2809 _approveWrap.appendChild(_approveBtn); 2810 _approveWrap.appendChild(_openBtn); 2811 footerTarget.appendChild(_approveWrap); 2812 } 2813 // Add "View Report" link for completed research 2814 if (_researchingStreamIds.has(streamSessionId)) {
2815 _appendViewReportLink(footerTarget, streamSessionId); 2816 } 2817 // Also store raw on the footer target so copy/TTS work 2818 if (footerTarget !== holder) footerTarget.dataset.raw = accumulated; 2819 if (addAITTSButton && accumulated && window.aiTTSManager?._provider !== 'disabled' && window.aiTTSManager?.available) { 2820 addAITTSButton(footerTarget, accumulated); 2821 } 2822 // TTS auto-play: streaming mode flushes remaining text, non-streaming enqueues full message 2823 if (accumulated && window.aiTTSManager && window.aiTTSManager.autoPlay) { 2824 const ttsBtn = holder.querySelector('.ai-tts-button'); 2825 if (ttsBtn) { 2826 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>'; 2827 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>'; 2828 const resetFn = () => { 2829 ttsBtn.innerHTML = ICON_PLAY_TTS; 2830 ttsBtn.classList.remove('playing', 'loading'); 2831 ttsBtn.style.color = '#6b7280'; 2832 ttsBtn.title = 'Read aloud'; 2833 }; 2834 if (streamingTTS) { 2835 // Flush remaining partial sentence and attach the real button 2836 window.aiTTSManager.streamingEnd(accumulated); 2837 window.aiTTSManager.streamingAttachButton(ttsBtn, resetFn); 2838 // If still playing sentences from the stream, show stop icon 2839 if (window.aiTTSManager.isPlaying || window.aiTTSManager._processing) { 2840 ttsBtn.innerHTML = ICON_STOP_TTS; 2841 ttsBtn.classList.add('playing'); 2842 ttsBtn.style.color = '#ccc'; 2843 ttsBtn.title = 'Stop'; 2844 } 2845 } else { 2846 // Non-streaming fallback (autoPlay toggled mid-stream, etc.) 2847 window.aiTTSManager.enqueue(accumulated, ttsBtn, resetFn); 2848 } 2849 } 2850 } 2851 if (metrics) { 2852 displayMetrics(footerTarget, metrics); 2853 } 2854 // Attach variant navigation if this was a regeneration 2855 _attachVariantNav(footerTarget); 2856 2857 // Merge with previous stopped message if this was a continue 2858 if (_pendingContinue) { 2859 const prevEl = _pendingContinue; 2860 _pendingContinue = null; 2861 const prevBody = prevEl.querySelector('.body'); 2862 const newBody = footerTarget.querySelector('.body'); 2863 if (prevBody && newBody && prevEl.parentNode) { 2864 // Merge: combine raw text with *(continued)* marker 2865 const oldRaw = prevEl.dataset.raw || ''; 2866 const newRaw = footerTarget.dataset.raw || ''; 2867 const mergedRaw = oldRaw + '\n\n*(continued)*\n\n' + newRaw; 2868 prevEl.dataset.raw = mergedRaw; 2869 // Re-render merged content 2870 prevBody.innerHTML = markdownModule.processWithThinking( 2871 markdownModule.squashOutsideCode(mergedRaw) 2872 ); 2873 // Remove the new bubble and re-add footer to the merged one 2874 footerTarget.remove(); 2875 const oldFooter = prevEl.querySelector('.msg-footer'); 2876 if (oldFooter) oldFooter.remove(); 2877 prevEl.appendChild(createMsgFooter(prevEl)); 2878 if (window.hljs) { 2879 prevEl.querySelectorAll('pre code').forEach(block => window.hljs.highlightElement(block)); 2880 } 2881 2882 // Persist merge to server 2883 const sid = sessionModule.getCurrentSessionId(); 2884 if (sid) { 2885 fetch(`${API_BASE}/api/session/${sid}/merge-last-assistant`, { 2886 method: 'POST', 2887 headers: { 'Content-Type': 'application/json' }, 2888 body: JSON.stringify({ separator: '\n\n*(continued)*\n\n' }) 2889 }).catch(e => console.warn('merge-last-assistant failed:', e)); 2890 } 2891 } 2892 } 2893 } // end if (!_isBgFinal) 2894 2895 } catch (err) { 2896 _renderStream(); 2897 // Clean up any active spinner (e.g. "Generating response" during tool calls) 2898 if (spinner && spinner.element) spinner.destroy(); 2899 _cancelThinkingTimer(); 2900 _removeThinkingSpinner(); 2901 document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming')); 2902 // Check if this stream was running in background 2903 const _isBgCatch = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId); 2904 2905 if (_isBgCatch) { 2906 // Error happened while backgrounded â update map, don't touch DOM
2907 console.error('Background stream error:', err); 2908 var bgErr = _backgroundStreams.get(streamSessionId); 2909 if (bgErr && bgErr.status === 'completed') { 2910 // [DONE] was already processed â this error is benign (e.g. reader.read() after close) 2911 // Don't override the completed status; just ensure the completed dot stays 2912 if (sessionModule && sessionModule.clearStreaming) { 2913 sessionModule.clearStreaming(streamSessionId); 2914 } 2915 } else if (bgErr) { 2916 bgErr.status = 'error'; 2917 if (sessionModule && sessionModule.clearStreaming) { 2918 sessionModule.clearStreaming(streamSessionId); 2919 } 2920 } 2921 } else { 2922 // Stop streaming TTS on any error/abort 2923 if (streamingTTS && window.aiTTSManager) window.aiTTSManager.stop(); 2924 2925 if (currentAbort && currentAbort.signal.aborted) { 2926 const abortReason = currentAbort._reason || ''; 2927 // Timeout-triggered aborts should remain visible instead of disappearing. 2928 if (timedOut || abortReason === 'timeout') { 2929 const timeoutMsg = _isAgent 2930 ? 'Agent response timed out. Try again, switch to a faster model, or reduce tool usage.' 2931 : 'Response timed out. Try again.'; 2932 2933 if (holder && !accumulated) { 2934 holder.querySelector('.body').innerHTML = 2935 `<div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[${timeoutMsg}]</div>`; 2936 } else if (holder && accumulated) { 2937 const timeoutNote = document.createElement('div'); 2938 timeoutNote.className = 'stopped-indicator'; 2939 timeoutNote.innerHTML = 2940 `<span style="color: var(--color-error);">[${timeoutMsg}]</span>`; 2941 holder.querySelector('.body').appendChild(timeoutNote); 2942 } 2943 currentAbort = null; 2944 return; 2945 } 2946 2947 if (abortReason === 'offline') { 2948 const offlineMsg = 'Endpoint offline â switch model or try again.'; 2949 if (holder && !accumulated) { 2950 holder.querySelector('.body').innerHTML = 2951 `<div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[${offlineMsg}]</div>`; 2952 } else if (holder && accumulated) { 2953 const offlineNote = document.createElement('div'); 2954 offlineNote.className = 'stopped-indicator'; 2955 offlineNote.innerHTML = 2956 `<span style="color: var(--color-error);">[${offlineMsg}]</span>`; 2957 holder.querySelector('.body').appendChild(offlineNote); 2958 } 2959 currentAbort = null; 2960 return; 2961 } 2962 2963 if (abortReason === 'recovery') { 2964 const recoveryMsg = 'Streaming was interrupted after the tab went inactive. Partial output was preserved.'; 2965 if (holder && !accumulated) { 2966 holder.querySelector('.body').innerHTML = 2967 `<div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[${recoveryMsg}]</div>`; 2968 } else if (holder && accumulated) { 2969 const recoveryNote = document.createElement('div'); 2970 recoveryNote.className = 'stopped-indicator'; 2971 recoveryNote.innerHTML = 2972 `<span style="color: var(--color-error);">[${recoveryMsg}]</span>`; 2973 holder.querySelector('.body').appendChild(recoveryNote); 2974 } 2975 currentAbort = null; 2976 return; 2977 } 2978 2979 // User-initiated stop (or browser navigation abort). 2980 // Stopped before any text arrived â keep the bubble as a 2981 // "Cancelled by user" record (so it survives a refresh). 2982 if (holder && !accumulated) { 2983 _renderCancelledBubble(holder); 2984 } 2985 2986 // But just in case the stop button didn't render it, render it here 2987 if (holder && accumulated && !currentHolder) { 2988 holder.dataset.raw = accumulated; 2989 holder.querySelector('.body').innerHTML = markdownModule.processWithThinking( 2990 markdownModule.squashOutsideCode(accumulated) 2991 ); 2992 2993 if (window.hljs) { 2994 holder.querySelectorAll('pre code').forEach((block) => { 2995 window.hljs.highlightElement(block); 2996 }); 2997 } 2998 2999 const stoppedIndicator = document.createElement('div'); 3000 stoppedIndicator.className = 'stopped-indicator'; 3001 const stoppedLabel = document.createElement('span'); 3002 stoppedLabel.textContent = '[Message interrupted]'; 3003 stoppedIndicator.appendChild(stoppedLabel); 3004 const continueBtn = document.createElement('button'); 3005 continueBtn.className = 'continue-btn'; 3006 continueBtn.title = 'Continue'; 3007 continueBtn.textContent = '\u25B8'; 3008 continueBtn.addEventListener('click', () => { 3009 stoppedIndicator.remove(); 3010 _hideUserBubble = true; 3011 _pendingContinue = holder; 3012 const cutoff = accumulated; 3013 const msgInput = uiModule.el('message'); 3014 if (msgInput) { 3015 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.'; 3016 const sb = document.querySelector('.send-btn'); 3017 if (sb) sb.click(); 3018 } 3019 }); 3020 stoppedIndicator.appendChild(continueBtn); 3021 holder.querySelector('.body').appendChild(stoppedIndicator); 3022 3023 // Tell server to mark this message as stopped 3024 const _sid2 = sessionModule.getCurrentSessionId(); 3025 if (_sid2) fetch(`${API_BASE}/api/session/${_sid2}/mark-stopped`, { method: 'POST' }).catch(e => console.warn('mark-stopped failed:', e)); 3026 3027 if (!holder.querySelector('.msg-footer')) { 3028 holder.appendChild(createMsgFooter(holder)); 3029 } 3030 3031 uiModule.scrollHistory(); 3032 } 3033 3034 // Now clear the abort controller 3035 currentAbort = null; 3036 } else { 3037 console.error(err); 3038 // Stream died with a tool node still spinning. Its per-node tickers 3039 // (_elapsedTicker 50ms / _waveInterval 100ms) are normally cleared in 3040 // `tool_output`, which will never arrive now â without this sweep they 3041 // fire forever on the orphaned node (and auto-recover compounds it per 3042 // nudge). Safe here: auto-recover's new send is deferred 200ms, so no 3043 // fresh running nodes exist yet. 3044 document.querySelectorAll('.agent-thread-node.running').forEach(node => { 3045 if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; } 3046 if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; } 3047 node.classList.remove('running'); 3048 }); 3049 // Stream died unexpectedly â the "silently died" case. Re-engage the 3050 // model immediately (no wait) with a completion handshake, up to the 3051 // cap. Only auto-recover from connection-class failures; deterministic 3052 // errors (unsupported tools, 4xx/5xx, parse failures) surface right away 3053 // instead of burning the nudge budget on a guaranteed-to-fail retry. 3054 if (!(_isRecoverableStreamErr(err) && _tryAutoRecover(holder, accumulated, streamSessionId))) { 3055 const errorHolder = document.querySelector('.msg-ai:last-of-type .body'); 3056 if (errorHolder) { 3057 let errMsg = `Error: ${err.message}`; 3058 // Add hint for tool-call errors 3059 if (err.message && (err.message.includes('tool') || err.message.includes('auto'))) { 3060 errMsg += '\n\nThis model may not support tools â try switching to Chat mode.'; 3061 } 3062 typewriterInto(errorHolder, errMsg); 3063 } 3064 } 3065 } 3066 } 3067 } finally { 3068 clearResponseTimeout(); 3069 clearProcessingProbe(); 3070 // Streaming done â let screen readers announce the settled response. 3071 const _chatLogDone = document.getElementById('chat-history'); 3072 if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false'); 3073 // Always clean up research tracking regardless of background state 3074 _researchingStreamIds.delete(streamSessionId); 3075 if (_researchingStreamIds.size === 0) { 3076 var _rToggleCleanup = document.getElementById('research-toggle-btn'); 3077 if (_rToggleCleanup) _rToggleCleanup.classList.remove('research-running'); 3078 } 3079 3080 // Only reset UI state if still on the stream's session and was never backgrounded 3081 const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId); 3082 3083 if (!_isBgFinally) { 3084 // Reset button to idle state 3085 updateSubmitButton('idle', submitBtn); 3086 3087 // Re-enable message input; on mobile blur to dismiss keyboard 3088 if (messageInput) { 3089 messageInput.disabled = false;
3090 if (window.innerWidth <= 768) { 3091 messageInput.blur(); 3092 } else { 3093 messageInput.focus(); 3094 } 3095 } 3096 3097 // Clear tracking variables 3098 currentAccumulated = ''; 3099 currentHolder = null; 3100 currentSpinner = null; 3101 _researchingStreamIds.delete(streamSessionId); 3102 // Clear research-running highlight if no more active research 3103 if (_researchingStreamIds.size === 0) { 3104 var _rToggle2 = document.getElementById('research-toggle-btn'); 3105 if (_rToggle2) _rToggle2.classList.remove('research-running'); 3106 } 3107 _clearResearchTimer(); 3108 3109 // Re-enable research button and auto-untoggle after use 3110 // (skip if clarification round â keep toggle on for follow-up) 3111 const _el = uiModule.el; 3112 const _researchBtn = _el('research-toggle-btn'); 3113 const _researchToggle = _el('research-toggle'); 3114 if (_researchToggle && _researchToggle.checked) { 3115 _researchToggle.checked = false; 3116 Storage.setToggle('research', false); 3117 } 3118 if (_researchBtn) { 3119 _researchBtn.disabled = false; 3120 _researchBtn.classList.remove('active'); 3121 _researchBtn.style.display = 'none'; 3122 } 3123 // Also sync overflow and tool sidebar buttons 3124 const _overflowRes = _el('overflow-research-btn'); 3125 if (_overflowRes) _overflowRes.classList.remove('active'); 3126 const _toolRes = _el('tool-research-btn'); 3127 if (_toolRes) _toolRes.classList.remove('active'); 3128 3129 } 3130 3131 // Research clarification timeout â if user doesn't reply within 5 min, show timeout 3132 if (holder && holder._roleSuffix === 'Research' && !_researchingStreamIds.has(streamSessionId)) { 3133 var _timeoutSessionId = streamSessionId; 3134 var _timeoutTimer = setTimeout(async function() { 3135 // Check if research_pending is still active (user hasn't replied) 3136 try { 3137 var _box = document.getElementById('chat-history'); 3138 if (_box && sessionModule.getCurrentSessionId() === _timeoutSessionId) { 3139 var _timeoutMsg = document.createElement('div'); 3140 _timeoutMsg.className = 'msg msg-ai'; 3141 _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>'; 3142 _box.appendChild(_timeoutMsg); 3143 uiModule.scrollHistory(); 3144 } 3145 } catch(_te) {} 3146 }, 5 * 60 * 1000); 3147 // Cancel timeout if user sends a message 3148 var _origSubmit = window._researchTimeoutTimer; 3149 if (_origSubmit) clearTimeout(_origSubmit); 3150 window._researchTimeoutTimer = _timeoutTimer; 3151 } 3152 3153 // Release Web Lock 3154 if (_webLockRelease) { 3155 _webLockRelease(); 3156 _webLockRelease = null; 3157 } 3158 3159 // Refresh session list after a delay (picks up auto-generated names) 3160 setTimeout(() => { 3161 if (sessionModule && sessionModule.loadSessions) { 3162 sessionModule.loadSessions(); 3163 } 3164 }, 3000); 3165 } 3166 } 3167 3168 /** 3169 * Abort current chat request 3170 */ 3171 // stopServer=true ONLY for an explicit user Stop. The run is now DETACHED 3172 // (survives tab close / navigation), so the generic abort used by cleanup 3173 // paths (session switch, delete, reader teardown on tab close) must NOT stop 3174 // the server run â otherwise closing the tab would kill the background task, 3175 // defeating the whole point. Only the Stop button cancels the server run. 3176 export function abortCurrentRequest(stopServer = false) { 3177 if (currentAbort) { 3178 currentAbort.abort(); 3179 // Don't set to null here - let catch block handle it 3180 } 3181 if (stopServer) { 3182 try { 3183 const _sid = _streamSessionId 3184 || (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId()); 3185 if (_sid) { 3186 fetch(`/api/chat/stop/${encodeURIComponent(_sid)}`, { method: 'POST', credentials: 'same-origin' }).catch(() => {}); 3187 } 3188 } catch (_) {} 3189 } 3190 } 3191 3192 // ââ Stall watchdog ââââââââââââââââââââââââââââââââââââââââââââââ
3193 // Auto-recover a turn whose stream died (connection drop) or went silent: 3194 // preserve the partial, then re-submit a completion handshake by reusing the 3195 // existing continue/resume path. Returns false at the cap so the caller can 3196 // surface the failure instead of nudging forever. 3197 // Only auto-recover from connection-class failures (the genuine "silently 3198 // died" case). Deterministic errors â unsupported tools, HTTP 4xx/5xx, JSON 3199 // parse failures â will fail identically on retry, so surfacing them 3200 // immediately is both more honest and avoids wasting the nudge budget. 3201 function _isRecoverableStreamErr(err) { 3202 if (!err) return false; 3203 if (err.name === 'TypeError') return true; // fetch/reader network failure 3204 const m = (err.message || '').toLowerCase(); 3205 if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(m)) return false; 3206 return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(m); 3207 } 3208 3209 function _tryAutoRecover(holder, accumulated, sessionId) { 3210 if (_autoNudges >= _AUTO_NUDGE_CAP) return false; 3211 _autoNudges++; 3212 if (holder && accumulated) { 3213 holder.dataset.raw = accumulated;
3214 try { 3215 holder.querySelector('.body').innerHTML = 3216 markdownModule.processWithThinking(markdownModule.squashOutsideCode(accumulated)); 3217 } catch (_) {} 3218 } 3219 _pendingContinue = holder || null; // merge the continuation into the same bubble 3220 _hideUserBubble = true; // no user bubble for the handshake 3221 _autoContinuePending = true; // don't reset the counter on this submit 3222 const _abandon = () => { // clear the pending flags so they can't 3223 _pendingContinue = null; // leak into whatever chat is now open 3224 _hideUserBubble = false; 3225 _autoContinuePending = false; 3226 }; 3227 // Defer so the stream's finally resets state first â otherwise the send 3228 // button is still in "stop" mode and clicking it would toggle, not send. 3229 setTimeout(() => { 3230 // The stream that died may not be the chat the user is now looking at â 3231 // never inject the recovery handshake into the wrong conversation. 3232 if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) { _abandon(); return; } 3233 const msgInput = uiModule.el('message'); 3234 const sb = document.querySelector('.send-btn'); 3235 if (!msgInput || !sb) { _abandon(); return; } 3236 const tail = (accumulated || '').slice(-400); 3237 msgInput.value = tail 3238 ? `The stream dropped before you finished. It ended with:\n\n${tail}\n\nIf 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.` 3239 : `The stream dropped before you produced anything. If the task is already done, reply with just: DONE. Otherwise complete it now.`; 3240 sb.click(); 3241 }, 200); 3242 return true; 3243 } 3244 3245 function _removeStallBanner() { 3246 const b = document.getElementById('stall-banner'); 3247 if (b) b.remove(); 3248 _stallBannerShown = false; 3249 } 3250 function _showStallBanner(secs) { 3251 if (document.getElementById('stall-banner')) return; 3252 _stallBannerShown = true; 3253 const box = document.getElementById('chat-history'); 3254 if (!box) return; 3255 const bar = document.createElement('div'); 3256 bar.id = 'stall-banner'; 3257 bar.className = 'stall-banner'; 3258 const mins = Math.floor(secs / 60); 3259 const label = mins >= 1 ? `${mins}m` : `${secs}s`; 3260 bar.innerHTML = `<span class="stall-banner-txt">Quiet for ${label} â still working?</span>`; 3261 const cont = document.createElement('button'); 3262 cont.className = 'stall-banner-btn'; 3263 cont.textContent = 'Nudge it'; 3264 cont.title = 'Stop the stalled stream and ask it to continue'; 3265 cont.addEventListener('click', () => { 3266 _removeStallBanner(); 3267 const mi = uiModule.el('message'); 3268 if (mi) { 3269 mi.value = 'Are you still working? If you stopped, continue exactly where you left off and finish the task.'; 3270 const sb = document.querySelector('.send-btn'); 3271 if (sb) sb.click(); 3272 } 3273 }); 3274 const stop = document.createElement('button'); 3275 stop.className = 'stall-banner-btn stall-banner-stop'; 3276 stop.textContent = 'Stop'; 3277 stop.addEventListener('click', () => { _removeStallBanner(); abortCurrentRequest(true); }); 3278 bar.appendChild(cont); 3279 bar.appendChild(stop); 3280 box.appendChild(bar); 3281 if (uiModule.scrollHistory) uiModule.scrollHistory(); 3282 } 3283 function _startStallWatchdog() { 3284 // Disabled: the server-side stall detector / auto-continue (agent 3285 // loop-breaker) handles quiet/stalled streams now, so the manual 3286 // "Quiet for Nm â still working?" banner is redundant (and annoying). 3287 if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; } 3288 _removeStallBanner(); 3289 } 3290 function _stopStallWatchdog() { 3291 if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; } 3292 _removeStallBanner(); 3293 } 3294 3295 /** Show a "Cancelled by user" record in `holder` and persist an empty 3296 * assistant placeholder server-side so the turn survives a refresh. 3297 * Called from both abort paths when no tokens had streamed yet. */ 3298 function _renderCancelledBubble(holder) { 3299 if (!holder) return; 3300 holder.dataset.raw = ''; 3301 const body = holder.querySelector('.body'); 3302 if (body) { 3303 body.innerHTML = ''; 3304 const indicator = document.createElement('div'); 3305 indicator.className = 'stopped-indicator'; 3306 const label = document.createElement('span'); 3307 label.style.fontStyle = 'italic'; 3308 label.style.opacity = '0.7'; 3309 label.textContent = '[Cancelled by user]'; 3310 indicator.appendChild(label); 3311 body.appendChild(indicator); 3312 } 3313 if (typeof createMsgFooter === 'function' && !holder.querySelector('.msg-footer')) { 3314 holder.appendChild(createMsgFooter(holder)); 3315 } 3316 // Persist as an assistant message with stopped+cancelled metadata so the 3317 // chat-history loader renders the same indicator after a refresh. 3318 // Include the model name so the bubble header still shows which model 3319 // was running when the user hit Stop. 3320 const sid = sessionModule.getCurrentSessionId(); 3321 if (sid) { 3322 let modelName = '';
3323 try { modelName = sessionModule.getCurrentModel?.() || ''; } catch {} 3324 // Fallback: pull from the holder's existing meta (the streaming 3325 // placeholder usually has the model set in the header already). 3326 if (!modelName) { 3327 modelName = holder.dataset.model 3328 || holder.querySelector('.msg-header .msg-model')?.textContent 3329 || ''; 3330 } 3331 fetch(`${API_BASE}/api/session/${sid}/inject_messages`, { 3332 method: 'POST', credentials: 'same-origin', 3333 headers: { 'Content-Type': 'application/json' }, 3334 body: JSON.stringify({ 3335 messages: [{ 3336 role: 'assistant', 3337 content: '', 3338 metadata: { stopped: true, cancelled: true, model: modelName }, 3339 }], 3340 }), 3341 }).catch(() => {}); 3342 } 3343 } 3344 3345 /** 3346 * Detach current stream to run in background instead of aborting. 3347 * Called when user switches sessions mid-stream. 3348 */ 3349 export function detachCurrentStream(sessionId) { 3350 if (!isStreaming || !currentAbort) { 3351 // Not streaming â fall through to abort 3352 abortCurrentRequest(); 3353 return; 3354 } 3355 // Store background stream state 3356 _backgroundStreams.set(sessionId, { 3357 status: 'running', 3358 accumulated: currentAccumulated, 3359 sourcesHtml: '', 3360 findingsData: null, 3361 abortCtrl: currentAbort, 3362 query: currentHolder ? (currentHolder._researchQuery || '') : '', 3363 metrics: null, 3364 }); 3365 // Mark session with pulsing dot in sidebar 3366 if (sessionModule && sessionModule.markStreaming) { 3367 sessionModule.markStreaming(sessionId); 3368 } 3369 // Clear local state WITHOUT aborting the fetch 3370 currentAbort = null; 3371 isStreaming = false; 3372 currentHolder = null; 3373 currentAccumulated = ''; 3374 // Reset submit button so the new chat is ready to send 3375 const submitBtn = document.querySelector('.send-btn'); 3376 if (submitBtn) updateSubmitButton('idle', submitBtn); 3377 } 3378 3379 // _notifyStreamComplete and _insertStreamDoneToast now in chatStream.js 3380 var _notifyStreamComplete = chatStream.notifyStreamComplete; 3381 var _insertStreamDoneToast = chatStream.insertStreamDoneToast; 3382 3383 /** 3384 * Live-resume a chat run still streaming detached on the server (#2539). 3385 * 3386 * On session re-entry, GET /api/chat/resume/{id} replays the run's buffer then 3387 * streams live; reply tokens render as they arrive. On completion a plain text 3388 * reply is finalized in place (canonical bubble via chatRenderer.addMessage, no 3389 * reload); a "rich" reply (tool calls, sources, doc streaming, multi-round) is 3390 * reloaded from the DB so its full render stays faithful. Returns true if it 3391 * attached, false to let the caller fall back to spinner+poll. 3392 */ 3393 export async function resumeStream(sessionId) { 3394 if (!sessionId) return false; 3395 if (hasActiveStream(sessionId)) return false; 3396 3397 let res; 3398 try { 3399 res = await fetch(`${API_BASE}/api/chat/resume/${sessionId}`); 3400 } catch (e) { 3401 return false; 3402 } 3403 if (!res.ok || !res.body) return false; 3404 3405 const box = document.getElementById('chat-history'); 3406 if (!box) return false; 3407 3408 // Block duplicate re-attach attempts while this reader is live. A dedicated 3409 // set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this 3410 // for a same-tab POST stream and spawn its own spinner+poll on re-entry. 3411 _resumingStreams.add(sessionId); 3412 3413 const holder = document.createElement('div'); 3414 holder.className = 'msg msg-ai'; 3415 const meta = sessionModule.getSessions().find(s => s.id === sessionId); 3416 const roleLabel = _shortModel(meta && meta.model); 3417 const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 3418 holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) + 3419 ' <span class="role-timestamp">' + roleTs + '</span></div>' + 3420 '<div class="body"><div class="stream-content"></div></div>'; 3421 _applyModelColor(holder.querySelector('.role'), meta && meta.model); 3422 const contentDiv = holder.querySelector('.stream-content'); 3423 box.appendChild(holder); 3424 3425 const spinner = spinnerModule.create('Generating response...', 'right'); 3426 holder.querySelector('.body').appendChild(spinner.createElement()); 3427 spinner.start();
3428 uiModule.scrollHistory(); 3429 3430 const reader = res.body.getReader(); 3431 const decoder = new TextDecoder(); 3432 let buffer = ''; 3433 let roundText = ''; 3434 let gotDelta = false; 3435 let leftSession = false; 3436 let metricsData = null; 3437 // "Rich" responses (tool calls, sources, doc streaming, multi-round) need the 3438 // full canonical render, which is rebuilt from the saved DB record on reload. 3439 // Plain text replies can be finalized in place without a reload. 3440 let rich = false; 3441 3442 const cleanup = () => { 3443 try { spinner.destroy(); } catch (_) {} 3444 _resumingStreams.delete(sessionId); 3445 }; 3446 3447 const renderDelta = () => { 3448 const dt = stripToolBlocks(roundText); 3449 contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt)); 3450 uiModule.scrollHistory(); 3451 }; 3452 3453 try { 3454 readLoop: 3455 while (true) { 3456 // User left this session: stop rendering, the run continues server-side. 3457 if (sessionModule.getCurrentSessionId && 3458 sessionModule.getCurrentSessionId() !== sessionId) { 3459 leftSession = true; 3460 try { await reader.cancel(); } catch (_) {} 3461 break; 3462 } 3463 const { done, value } = await reader.read(); 3464 if (done) break; 3465 buffer += decoder.decode(value, { stream: true }); 3466 const parts = buffer.split('\n\n'); 3467 buffer = parts.pop(); 3468 for (const part of parts) { 3469 const line = part.split('\n').find(l => l.startsWith('data: ')); 3470 if (!line) continue; 3471 const payload = line.slice(6); 3472 if (payload === '[DONE]') { 3473 try { await reader.cancel(); } catch (_) {} 3474 break readLoop; 3475 } 3476 let json; 3477 try { json = JSON.parse(payload); } catch (_) { continue; } 3478 if (json.delta) { 3479 roundText += json.delta; 3480 if (!gotDelta) { gotDelta = true; try { spinner.destroy(); } catch (_) {} } 3481 renderDelta(); 3482 } else if (json.type === 'doc_stream_open') { 3483 rich = true; 3484 if (documentModule) documentModule.streamDocOpen(json.title || '', json.lang || ''); 3485 } else if (json.type === 'doc_stream_delta') { 3486 rich = true; 3487 if (documentModule && json.delta) documentModule.streamDocDelta(json.delta); 3488 } else if (json.type === 'metrics') { 3489 metricsData = json.data || metricsData; 3490 } else if (json.type === 'tool_start' || json.type === 'tool_output' || 3491 json.type === 'tool_progress' || json.type === 'agent_step' || 3492 json.type === 'web_sources' || json.type === 'rag_sources' || 3493 json.type === 'research_progress' || json.type === 'research_sources' || 3494 json.type === 'research_findings' || json.type === 'research_done') { 3495 rich = true; 3496 } 3497 } 3498 } 3499 } catch (e) { 3500 // Network drop or parse failure: fall through to the reload below. 3501 } 3502 3503 cleanup(); 3504 if (leftSession) { if (holder.parentNode) holder.remove(); return true; } 3505 3506 const onThisSession = sessionModule.getCurrentSessionId && 3507 sessionModule.getCurrentSessionId() === sessionId; 3508 3509 // Plain text reply: finalize in place. Replace the live bubble with a 3510 // canonical single message (markdown + footer actions + metrics) using the 3511 // same renderer history does. No history refetch, no end-of-stream flicker. 3512 if (onThisSession && !rich && roundText.trim()) { 3513 if (holder.parentNode) holder.remove(); 3514 const model = meta && meta.model; 3515 const meta_ = metricsData ? Object.assign({ model }, metricsData) : { model }; 3516 chatRenderer.addMessage('assistant', roundText, model, meta_); 3517 uiModule.scrollHistory(); 3518 return true; 3519 } 3520 3521 // Rich response (tools, sources, docs, multi-round) or user moved on: 3522 // reload from the DB for the full canonical render. 3523 if (holder.parentNode) holder.remove(); 3524 if (onThisSession) sessionModule.selectSession(sessionId); 3525 else sessionModule.loadSessions(); 3526 return true; 3527 } 3528 3529 /** 3530 * Check for background streams when switching to a session. 3531 * Called after history loads on session switch. 3532 */
3533 export function checkBackgroundStream(sessionId) { 3534 if (!sessionId || !_backgroundStreams.has(sessionId)) return; 3535 var entry = _backgroundStreams.get(sessionId); 3536 3537 if (entry.status === 'completed') { 3538 // Response is already saved to DB and will appear in history â just clean up 3539 _backgroundStreams.delete(sessionId); 3540 return; 3541 } 3542 3543 if (entry.status === 'error') { 3544 _backgroundStreams.delete(sessionId); 3545 var box = document.getElementById('chat-history'); 3546 if (box) { 3547 var errHolder = document.createElement('div'); 3548 errHolder.className = 'msg msg-ai'; 3549 errHolder.innerHTML = '<div class="body"><i style="color: var(--color-error);">[Background stream encountered an error]</i></div>'; 3550 box.appendChild(errHolder); 3551 } 3552 return; 3553 } 3554 3555 if (entry.status === 'running') { 3556 // Stream is still active â show a clean spinner, poll until done, 3557 // then reload history to show the final saved response. 3558 var box = document.getElementById('chat-history'); 3559 if (!box) return; 3560 3561 // Replay any doc content that was streamed in the background 3562 if (entry._docTitle != null && documentModule) { 3563 documentModule.streamDocOpen(entry._docTitle, entry._docLang || ''); 3564 if (entry._docContent) { 3565 documentModule.streamDocDelta(entry._docContent); 3566 } 3567 } 3568 3569 var holder = document.createElement('div'); 3570 holder.className = 'msg msg-ai'; 3571 var meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; }); 3572 var roleLabel = _shortModel(meta && meta.model); 3573 var roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); 3574 holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>'; 3575 _applyModelColor(holder.querySelector('.role'), meta && meta.model); 3576 3577 var bodyDiv = holder.querySelector('.body'); 3578 var spinner = spinnerModule.create('Response streaming in background', 'right'); 3579 bodyDiv.appendChild(spinner.createElement()); 3580 spinner.start(); 3581 3582 box.appendChild(holder); 3583 uiModule.scrollHistory(); 3584 3585 // Poll map until stream finishes, then reload history 3586 var pollId = setInterval(function() { 3587 if (sessionModule.getCurrentSessionId() !== sessionId) { 3588 clearInterval(pollId); 3589 spinner.destroy(); 3590 if (holder.parentNode) holder.remove(); 3591 return; 3592 } 3593 // Update doc content while polling 3594 var curPoll = _backgroundStreams.get(sessionId); 3595 if (curPoll && curPoll._docContent && documentModule) { 3596 documentModule.streamDocDelta(curPoll._docContent); 3597 } 3598 if (!curPoll || curPoll.status !== 'running') { 3599 clearInterval(pollId); 3600 spinner.destroy(); 3601 if (holder.parentNode) holder.remove(); // Remove entire holder, not just spinner 3602 _backgroundStreams.delete(sessionId); 3603 // Reload session to show the completed response â but only if the user 3604 // is still on it; don't yank them back from a new chat they opened. 3605 if (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() === sessionId) { 3606 sessionModule.selectSession(sessionId); 3607 } else { 3608 sessionModule.loadSessions(); 3609 } 3610 } 3611 }, 500); 3612 } 3613 } 3614 3615 // Tag short single-line code blocks with .pre-compact so the CSS can 3616 // render the Run/Edit/Copy buttons as a slim row that doesn't make a 3617 // 1-line bash block taller than its own contents. 3618 function _markCompactPre(pre) { 3619 const code = pre.querySelector('code'); 3620 if (!code) return; 3621 const txt = code.textContent || ''; 3622 // Count visible lines â ignore trailing newline (common with fenced 3623 // blocks) and treat any empty extra line as not a real second line. 3624 const lines = txt.replace(/\n+$/, '').split('\n'); 3625 const compact = lines.length <= 1 && txt.length < 200; 3626 pre.classList.toggle('pre-compact', compact); 3627 } 3628 function _scanCompactPres(root) { 3629 if (!root || !root.querySelectorAll) return; 3630 root.querySelectorAll('pre').forEach(_markCompactPre); 3631 } 3632 // Global observer so any <pre> added anywhere in the app (chat stream, 3633 // chat re-renders, document library chat previews, slash commands, 3634 // research previews, etc.) gets tagged without each call site needing 3635 // to remember. 3636 (function _initCompactPreObserver() { 3637 if (window._cmpPreObserverWired) return; 3638 window._cmpPreObserverWired = true; 3639 _scanCompactPres(document.body); 3640 const obs = new MutationObserver((muts) => { 3641 for (const m of muts) { 3642 for (const n of m.addedNodes) { 3643 if (n.nodeType !== 1) continue; 3644 if (n.tagName === 'PRE') _markCompactPre(n); 3645 if (n.querySelectorAll) _scanCompactPres(n); 3646 } 3647 } 3648 }); 3649 obs.observe(document.body, { childList: true, subtree: true }); 3650 })(); 3651 3652 /** 3653 * Initialize event listeners 3654 */ 3655 export function initListeners() { 3656 // Global event delegation for copy-code buttons 3657 document.addEventListener('click', (e) => { 3658 const btn = e.target.closest('.copy-code'); 3659 if (!btn) return; 3660 e.stopPropagation(); 3661 const code = btn.getAttribute('data-code');
3662 if (code && uiModule) { 3663 uiModule.copyToClipboard(code); 3664 // Visual feedback: swap the icon to a checkmark (regular size) 3665 // and add .copied which the CSS uses to flash green + pulse. 3666 // For slim/.pre-compact buttons the label text comes from a 3667 // CSS ::before â swap it via data-state so we don't break the 3668 // text-button layout. 3669 const origHTML = btn.innerHTML; 3670 const isCompact = !!btn.closest('pre.pre-compact'); 3671 if (!isCompact) { 3672 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>'; 3673 } 3674 btn.classList.add('copied'); 3675 btn.dataset.state = 'copied'; 3676 setTimeout(() => { 3677 if (!isCompact) btn.innerHTML = origHTML; 3678 btn.classList.remove('copied'); 3679 delete btn.dataset.state; 3680 }, 1500); 3681 } 3682 }); 3683 3684 // Run code button delegation 3685 document.addEventListener('click', (e) => { 3686 const btn = e.target.closest('.run-code'); 3687 if (!btn) return; 3688 e.stopPropagation(); 3689 if (codeRunnerModule) codeRunnerModule.run(btn); 3690 }); 3691 3692 // Edit code button delegation â toggle contentEditable on the code element 3693 document.addEventListener('click', (e) => { 3694 const btn = e.target.closest('.edit-code'); 3695 if (!btn) return; 3696 e.stopPropagation(); 3697 const pre = btn.closest('pre'); 3698 if (!pre) return; 3699 const codeEl = pre.querySelector('code'); 3700 if (!codeEl) return; 3701 const isEditing = codeEl.contentEditable !== 'false' && codeEl.contentEditable !== 'inherit'; 3702 if (isEditing) { 3703 // Save: exit edit mode, update data-code on copy/run buttons 3704 codeEl.contentEditable = 'false'; 3705 codeEl.classList.remove('editing'); 3706 pre.classList.remove('editing'); 3707 const newCode = codeEl.textContent; 3708 const copyBtn = pre.querySelector('.copy-code'); 3709 if (copyBtn) copyBtn.setAttribute('data-code', newCode); 3710 const runBtn = pre.querySelector('.run-code'); 3711 if (runBtn) runBtn.setAttribute('data-code', newCode); 3712 // Swap icon back to pencil 3713 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>'; 3714 btn.title = 'Edit'; 3715 btn.classList.remove('active'); 3716 } else { 3717 // Enter edit mode. Firefox (especially on mobile) historically lacks 3718 // contentEditable="plaintext-only" â setting it there leaves the block 3719 // non-editable, so the tap "just gets a checkmark" with no way to type. 3720 // Fall back to "true" when plaintext-only didn't take. 3721 try { codeEl.contentEditable = 'plaintext-only'; } catch (_) { /* unsupported value */ } 3722 if (codeEl.contentEditable !== 'plaintext-only') codeEl.contentEditable = 'true'; 3723 codeEl.classList.add('editing'); 3724 pre.classList.add('editing'); 3725 // preventScroll keeps the page from jumping to the codeblock when 3726 // focusing the editable on mobile â the browser would otherwise 3727 // scroll it into view above the keyboard, which reads as "auto- 3728 // scroll triggered by clicking Edit". 3729 try { codeEl.focus({ preventScroll: true }); } catch (_) { codeEl.focus(); } 3730 // Swap icon to checkmark 3731 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>'; 3732 btn.title = 'Done editing'; 3733 btn.classList.add('active'); 3734 } 3735 }); 3736 3737 // Tapping a code block body (not its buttons) toggles the overlay 3738 // copy/edit/run buttons, which otherwise cover the text on mobile. 3739 document.addEventListener('click', (e) => { 3740 if (e.target.closest('.copy-code, .edit-code, .run-code')) return; 3741 const pre = e.target.closest('pre'); 3742 if (!pre || !pre.querySelector('.copy-code')) return; 3743 // Don't hide while editing â the buttons (incl. the Done checkmark) matter. 3744 if (pre.classList.contains('editing')) return; 3745 pre.classList.toggle('buttons-hidden'); 3746 }); 3747 3748 // Position copy/run buttons top or bottom based on viewport position 3749 // â DESKTOP ONLY. On mobile this was constantly retriggering on tap 3750 // (synthetic mouseenter) and made the buttons jump, so the user's 3751 // finger landed on the moved target. Keep them pinned at the top on 3752 // touch â no auto-repositioning. 3753 document.addEventListener('mouseenter', (e) => { 3754 if (window.matchMedia('(max-width: 768px)').matches) return; 3755 const pre = e.target.closest ? e.target.closest('pre') : null; 3756 if (!pre || pre.dataset.btnPosComputed) return; 3757 const rect = pre.getBoundingClientRect(); 3758 const threshold = window.innerHeight * 0.35; 3759 const isBottom = rect.top < threshold; 3760 const copyBtn = pre.querySelector('.copy-code'); 3761 if (copyBtn) copyBtn.classList.toggle('bottom', isBottom); 3762 const editBtn = pre.querySelector('.edit-code'); 3763 if (editBtn) editBtn.classList.toggle('bottom', isBottom); 3764 const runBtn = pre.querySelector('.run-code'); 3765 if (runBtn) runBtn.classList.toggle('bottom', isBottom); 3766 pre.dataset.btnPosComputed = '1'; 3767 }, true); 3768 3769 // Tab suspension recovery: when user tabs back in, check if stream froze 3770 document.addEventListener('visibilitychange', () => { 3771 if (document.visibilityState !== 'visible') return; 3772 if (!isStreaming) return; 3773 3774 // Stream claims to be running â check if reader is actually alive 3775 const staleSince = Date.now() - _lastReaderActivity; 3776 if (staleSince < 20000) return;
3776 // Active recently, probably fine 3777 3778 // Reader hasn't produced data in 5+ seconds after tab resume. 3779 // Give it a short grace period then recover. 3780 console.warn('[tab-recovery] Stream appears frozen (no activity for ' + Math.round(staleSince/1000) + 's). Recovering...'); 3781 3782 setTimeout(() => { 3783 // Re-check â maybe the reader woke up during the grace period 3784 if (!isStreaming) return; 3785 const stillStale = Date.now() - _lastReaderActivity; 3786 if (stillStale < 5000) return; // Came back to life 3787 3788 console.warn('[tab-recovery] Stream confirmed dead. Aborting and reloading session.'); 3789 3790 // Abort the frozen stream, but preserve the visible bubble. 3791 if (currentAbort) { 3792 currentAbort._reason = 'recovery'; 3793 currentAbort.abort(); 3794 } 3795 isStreaming = false; 3796 3797 // Release Web Lock 3798 if (_webLockRelease) { 3799 _webLockRelease(); 3800 _webLockRelease = null; 3801 } 3802 3803 // Reset UI state 3804 var _submitBtn = document.getElementById('submit'); 3805 updateSubmitButton('idle', _submitBtn); 3806 var _msgInput = document.getElementById('message'); 3807 if (_msgInput) _msgInput.disabled = false; 3808 }, 2000); // 2 second grace period 3809 }); 3810 3811 // On mobile, fade out welcome text when keyboard opens to prevent overlap 3812 if (window.innerWidth <= 768) { 3813 const msgInput = document.getElementById('message'); 3814 if (msgInput) { 3815 msgInput.addEventListener('focus', () => { 3816 const ws = document.getElementById('welcome-screen'); 3817 if (ws && !ws.classList.contains('hidden')) { 3818 ws.classList.add('kb-hidden'); 3819 } 3820 }); 3821 msgInput.addEventListener('blur', () => { 3822 const ws = document.getElementById('welcome-screen'); 3823 if (ws && !ws.classList.contains('hidden')) { 3824 // Delay re-show so tapping within chatbox doesn't flash 3825 setTimeout(() => { 3826 if (document.activeElement !== msgInput) { 3827 ws.classList.remove('kb-hidden'); 3828 } 3829 }, 200); 3830 } 3831 }); 3832 } 3833 // Smooth viewport resize when keyboard opens/closes 3834 if (window.visualViewport) { 3835 window.visualViewport.addEventListener('resize', () => { 3836 document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px'); 3837 }); 3838 document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px'); 3839 } 3840 } 3841 3842 // If the browser discarded and restored this tab, reload the current session 3843 // so the user sees the server-saved partial response instead of a blank page 3844 if (document.wasDiscarded) { 3845 console.warn('[tab-recovery] Tab was discarded by browser â reloading session'); 3846 setTimeout(() => { 3847 var _sid = sessionModule && sessionModule.getCurrentSessionId(); 3848 if (_sid) sessionModule.selectSession(_sid); 3849 }, 500); 3850 } 3851 } 3852 3853 /** 3854 * Regenerate response: truncate history to the user message before this AI message, 3855 * then re-submit that user message. 3856 */ 3857 /** 3858 * Edit a user message: show an input, truncate to before it, resubmit the edited text. 3859 */ 3860 export async function editUserMessage(userMsgElement) { 3861 const box = document.getElementById('chat-history'); 3862 const allMsgs = Array.from(box.querySelectorAll('.msg')); 3863 const msgIndex = allMsgs.indexOf(userMsgElement); 3864 if (msgIndex < 0) return; 3865 3866 const bodyEl = userMsgElement.querySelector('.body'); 3867 const currentText = bodyEl ? bodyEl.textContent.trim().replace(/\s*\[\d+ attachment\(s\)\]$/, '') : ''; 3868 3869 // Replace body with an editable textarea 3870 const editor = document.createElement('textarea'); 3871 editor.className = 'edit-textarea'; 3872 editor.value = currentText; 3873 editor.rows = Math.max(2, currentText.split('\n').length); 3874 3875 const btnRow = document.createElement('div'); 3876 btnRow.style.cssText = 'display:flex; gap:6px; margin-top:4px;'; 3877 3878 const saveBtn = document.createElement('button'); 3879 saveBtn.className = 'edit-save-btn'; 3880 saveBtn.textContent = 'Send'; 3881 const cancelBtn = document.createElement('button'); 3882 cancelBtn.className = 'edit-cancel-btn'; 3883 cancelBtn.textContent = 'Cancel'; 3884 btnRow.appendChild(saveBtn); 3885 btnRow.appendChild(cancelBtn); 3886 3887 const originalHTML = bodyEl.innerHTML; 3888 bodyEl.innerHTML = ''; 3889 bodyEl.appendChild(editor); 3890 bodyEl.appendChild(btnRow); 3891 editor.focus(); 3892 3893 cancelBtn.addEventListener('click', (e) => { 3894 e.stopPropagation(); 3895 bodyEl.innerHTML = originalHTML; 3896 }); 3897 3898 saveBtn.addEventListener('click', async (e) => { 3899 e.stopPropagation(); 3900 const newText = editor.value.trim(); 3901 if (!newText) return; 3902 3903 const sessionId = sessionModule.getCurrentSessionId(); 3904 if (!sessionId) return; 3905
3906 const keepCount = msgIndex; 3907 try { 3908 await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { 3909 method: 'POST', 3910 headers: { 'Content-Type': 'application/json' }, 3911 body: JSON.stringify({ keep_count: keepCount }) 3912 }); 3913 3914 // Remove DOM elements from msgIndex onward 3915 for (let i = allMsgs.length - 1; i >= msgIndex; i--) { 3916 allMsgs[i].remove(); 3917 } 3918 3919 // Submit the edited text 3920 const messageInput = uiModule.el('message'); 3921 messageInput.value = newText; 3922 const submitBtn = document.querySelector('.send-btn'); 3923 if (submitBtn) submitBtn.click(); 3924 } catch (err) { 3925 console.error('Edit failed:', err); 3926 if (uiModule) uiModule.showError('Edit failed: ' + err.message); 3927 bodyEl.innerHTML = originalHTML; 3928 } 3929 }); 3930 3931 // Also submit on Enter (without shift) 3932 editor.addEventListener('keydown', (e) => { 3933 if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { 3934 e.preventDefault(); 3935 saveBtn.click(); 3936 } 3937 }); 3938 } 3939 3940 /** 3941 * Resend a user message â truncates history to that point and resubmits. 3942 */ 3943 export async function resendUserMessage(userMsgElement) { 3944 const box = document.getElementById('chat-history'); 3945 const allMsgs = Array.from(box.querySelectorAll('.msg')); 3946 const msgIndex = allMsgs.indexOf(userMsgElement); 3947 if (msgIndex < 0) return; 3948 3949 // Prefer dataset.raw (stripped original user text) over .body.textContent 3950 // â the latter slurps the rendered "View image description" collapsible 3951 // content too, which would then be sent back as the user's question and 3952 // the AI would reply to that gibberish instead of the actual prompt. 3953 const bodyEl = userMsgElement.querySelector('.body'); 3954 let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim(); 3955 text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, ''); 3956 3957 // Collect file_ids attached to this user message so the resend re-carries 3958 // the photos / docs (and the chat handler picks up the user-edited OCR 3959 // text cached server-side under those file ids). 3960 const _attachEls = userMsgElement.querySelectorAll('[data-file-id]'); 3961 let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean); 3962 if (!_ids.length) { 3963 const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img'); 3964 for (const _im of _imgs) { 3965 const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/); 3966 if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]); 3967 } 3968 } 3969 3970 // Rescue: legacy bubbles may have stored the filename as the message 3971 // content (artifact of earlier broken resends). Don't re-send that as 3972 // the user prompt if we still have the file attached. Loosen the regex 3973 // to cover real-world camera/screenshot names with spaces, parens, 3974 // multi-dots: "Screen Shot 2026-05-28 at 4.05.32 PM.png", "IMG (1).JPG". 3975 if (text && _ids.length && /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(text)) { 3976 text = ''; 3977 } 3978 // Empty text + no attachments â tell the user instead of silently bailing. 3979 // The common case is a regen during a pre-upload race where the bubble 3980 // never had an `[data-file-id]` to scrape. 3981 if (!text && !_ids.length) { 3982 if (uiModule?.showError) uiModule.showError('Nothing to resend â message has no text and no attachments yet (try again after the upload finishes).'); 3983 return; 3984 } 3985 3986 const sessionId = sessionModule.getCurrentSessionId(); 3987 if (!sessionId) return; 3988 3989 // Truncate backend to keep everything before this user message 3990 const keepCount = msgIndex; 3991 try { 3992 await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { 3993 method: 'POST', 3994 headers: { 'Content-Type': 'application/json' }, 3995 body: JSON.stringify({ keep_count: keepCount }) 3996 }); 3997 3998 // Drop the AI replies after the user message but KEEP the user bubble 3999 // itself (so its photo stays visible). Then suppress the new user 4000 // bubble that send would otherwise add â same pattern as regenerate. 4001 let sibling = userMsgElement.nextSibling; 4002 while (sibling) { 4003 const next = sibling.nextSibling; 4004 sibling.remove(); 4005 sibling = next; 4006 } 4007 _hideUserBubble = true; 4008 _pendingRegenAttachments = _ids; 4009 4010 // Resubmit 4011 const messageInput = uiModule.el('message'); 4012 messageInput.value = text; 4013 const submitBtn = document.querySelector('.send-btn'); 4014 if (submitBtn) submitBtn.click(); 4015 } catch (err) { 4016 console.error('Resend failed:', err); 4017 if (uiModule) uiModule.showError('Resend failed: ' + err.message); 4018 } 4019 } 4020 4021 export async function regenerateFrom(aiMsgElement) { 4022 const box = document.getElementById('chat-history'); 4023 const allMsgs = Array.from(box.querySelectorAll('.msg')); 4024 const aiIndex = allMsgs.indexOf(aiMsgElement); 4025 if (aiIndex < 0) return; 4026 4027 // Find the preceding user message 4028 let userIndex = -1; 4029 let userText = ''; 4030 let userMsgEl = null; 4031 for (let i = aiIndex - 1; i >= 0; i--) { 4032 if (allMsgs[i].classList.contains('msg-user')) { 4033 userIndex = i; 4034 userMsgEl = allMsgs[i]; 4035 // Prefer dataset.raw (set by addMessage with the stripped, original 4036 // user text) over the rendered body's textContent â the latter 4037 // pulls in the "View image description" collapsible content too, 4038 // duplicating the OCR text on regen. 4039 const bodyEl = userMsgEl.querySelector('.body'); 4040 userText = (userMsgEl.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim(); 4041 userText = userText.replace(/\s*\[\d+ attachment\(s\)\]$/, ''); 4042 break; 4043 } 4044 } 4045 4046 if (userIndex < 0) { 4047 if (uiModule) uiModule.showError('Could not find the user message to regenerate'); 4048 return; 4049 } 4050 4051 // Collect any file_ids attached to the original user message so the 4052 // regenerated send re-uses them. Without this the AI is regenerated on 4053 // text alone â photos (and the user-edited OCR text cached server-side 4054 // under that file_id) would be silently dropped. 4055 const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
4056 let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean); 4057 // Fallback for bubbles rendered before the data-file-id stamp landed: 4058 // sniff the file id straight out of any `.attach-image-preview img` 4059 // src URLs (matches /api/upload/<id>). Otherwise an older bubble would 4060 // regen with zero attachments and the photo would be lost from the 4061 // resulting message even though the file still exists on disk. 4062 if (!_regenIds.length && userMsgEl) { 4063 const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img'); 4064 for (const _im of _imgs) { 4065 const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/); 4066 if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]); 4067 } 4068 } 4069 _pendingRegenAttachments = _regenIds; 4070 4071 // Rescue: earlier-version regens (before the dataset.raw fix) stored the 4072 // photo's filename as the user-message content. On a follow-up regen, 4073 // that filename would be sent back as the literal user prompt, so the 4074 // AI thinks the question is "blue_night_preview.jpg" and replies "that's 4075 // an image file". If userText is just a bare image filename and we have 4076 // attachments, drop it so the OCR text (or the image bytes for vision 4077 // models) is what the model actually sees. 4078 if (userText && _pendingRegenAttachments.length && 4079 /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) { 4080 userText = ''; 4081 } 4082 4083 // A photo-only message has empty user text â regen must still proceed, 4084 // because the attachments themselves are the message. Bail only if there 4085 // is no text AND no attachments to send. 4086 if (!userText && !_pendingRegenAttachments.length) { 4087 if (uiModule) uiModule.showError('Nothing to regenerate â the user message has no text and no attachments'); 4088 return; 4089 } 4090 4091 const sessionId = sessionModule.getCurrentSessionId(); 4092 if (!sessionId) return; 4093 4094 // Save current response as a variant 4095 const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || ''; 4096 const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || ''; 4097 let variants = []; 4098 try { variants = JSON.parse(aiMsgElement.dataset.variants || '[]'); } catch(_) {} 4099 if (variants.length === 0) { 4100 // First regen â save the original as variant 0 4101 variants.push({ raw: oldRaw, html: oldHtml, label: 'original' }); 4102 } 4103 4104 const keepCount = userIndex; 4105 4106 try { 4107 await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { 4108 method: 'POST', 4109 headers: { 'Content-Type': 'application/json' }, 4110 body: JSON.stringify({ keep_count: keepCount }) 4111 }); 4112 4113 for (let i = allMsgs.length - 1; i > aiIndex; i--) { 4114 allMsgs[i].remove(); 4115 } 4116 4117 // Remove the AI message from DOM â it will be replaced by the new streaming response 4118 // But first, stash the variants data so we can transfer it to the new element 4119 _pendingVariants = variants; 4120 _pendingVariantLabel = 'regen'; 4121 aiMsgElement.remove(); 4122 4123 _hideUserBubble = true; 4124 const messageInput = uiModule.el('message'); 4125 messageInput.value = userText; 4126 const submitBtn = document.querySelector('.send-btn'); 4127 if (submitBtn) submitBtn.click(); 4128 4129 } catch (err) { 4130 console.error('Regenerate failed:', err); 4131 if (uiModule) uiModule.showError('Regenerate failed: ' + err.message); 4132 } 4133 } 4134 4135 // Pending variants from a regeneration â transferred to new streaming element 4136 let _pendingVariants = null; 4137 let _pendingVariantLabel = null; 4138 // File-ids carried over from the original user message during a regen, so 4139 // photos / OCR overrides survive into the new send. Consumed once. 4140 let _pendingRegenAttachments = null; 4141 4142 /** 4143 * Called after streaming completes to attach variant navigation if this was a regen. 4144 */ 4145 function _attachVariantNav(msgElement) { 4146 if (!_pendingVariants) return; 4147 const variants = _pendingVariants; 4148 _pendingVariants = null; 4149 4150 // Add the new response as the latest variant 4151 const newRaw = msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || ''; 4152 const newHtml = msgElement.querySelector('.body')?.innerHTML || ''; 4153 const varLabel = _pendingVariantLabel || 'regen'; 4154 _pendingVariantLabel = null;
4155 variants.push({ raw: newRaw, html: newHtml, label: varLabel }); 4156 4157 msgElement.dataset.variants = JSON.stringify(variants); 4158 msgElement.dataset.variantIndex = String(variants.length - 1); 4159 4160 _renderVariantNav(msgElement, variants, variants.length - 1); 4161 4162 // Persist variants to server 4163 const sid = sessionModule.getCurrentSessionId(); 4164 if (sid) { 4165 fetch(`${API_BASE}/api/session/${sid}/update-last-meta`, { 4166 method: 'POST', 4167 headers: { 'Content-Type': 'application/json' }, 4168 body: JSON.stringify({ metadata: { variants: variants, variantIndex: variants.length - 1 } }) 4169 }).catch(e => console.warn('update-last-meta (variants) failed:', e)); 4170 } 4171 } 4172 4173 const _VARIANT_ICONS = { regen: '\u21BB', shorter: '\u2702', simpler: '?', original: '\u25CB' }; 4174 function _variantTagText(label) { 4175 return _VARIANT_ICONS[label] || _VARIANT_ICONS['original']; 4176 } 4177 4178 function _renderVariantNav(msgElement, variants, currentIdx) { 4179 // Remove existing nav if any 4180 const old = msgElement.querySelector('.variant-nav'); 4181 if (old) old.remove(); 4182 4183 if (variants.length < 2) return; 4184 4185 const nav = document.createElement('span'); 4186 nav.className = 'variant-nav'; 4187 nav.addEventListener('click', (e) => e.stopPropagation()); 4188 4189 // Label showing what this variant is 4190 // Divider 4191 const divider = document.createElement('span'); 4192 divider.className = 'variant-divider'; 4193 divider.textContent = '|'; 4194 nav.appendChild(divider); 4195 4196 // Label 4197 const curVariant = variants[currentIdx]; 4198 const tagLabel = document.createElement('span'); 4199 tagLabel.className = 'variant-tag' + (curVariant?.label === 'shorter' ? ' variant-tag-scissors' : ''); 4200 tagLabel.textContent = _variantTagText(curVariant?.label); 4201 nav.appendChild(tagLabel); 4202 4203 // < button 4204 const prevBtn = document.createElement('button'); 4205 prevBtn.className = 'variant-btn'; 4206 prevBtn.textContent = '<'; 4207 prevBtn.disabled = currentIdx === 0; 4208 prevBtn.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx - 1); }); 4209 nav.appendChild(prevBtn); 4210 4211 // Clickable number for current index (click left number = go left, right = go right) 4212 const numLeft = document.createElement('button'); 4213 numLeft.className = 'variant-num'; 4214 numLeft.textContent = String(currentIdx + 1); 4215 numLeft.disabled = currentIdx === 0; 4216 numLeft.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx - 1); }); 4217 nav.appendChild(numLeft); 4218 4219 const slash = document.createElement('span'); 4220 slash.className = 'variant-slash'; 4221 slash.textContent = '/'; 4222 nav.appendChild(slash); 4223 4224 const numRight = document.createElement('button'); 4225 numRight.className = 'variant-num'; 4226 numRight.textContent = String(variants.length); 4227 numRight.disabled = currentIdx === variants.length - 1; 4228 numRight.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx + 1); }); 4229 nav.appendChild(numRight); 4230 4231 // > button 4232 const nextBtn = document.createElement('button'); 4233 nextBtn.className = 'variant-btn'; 4234 nextBtn.textContent = '>'; 4235 nextBtn.disabled = currentIdx === variants.length - 1; 4236 nextBtn.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx + 1); }); 4237 nav.appendChild(nextBtn); 4238 4239 // Insert into the .role header 4240 const roleEl = msgElement.querySelector('.role'); 4241 if (roleEl) { 4242 roleEl.appendChild(nav); 4243 } else { 4244 msgElement.appendChild(nav); 4245 } 4246 } 4247 4248 function _switchVariant(msgElement, variants, newIdx) { 4249 if (newIdx < 0 || newIdx >= variants.length) return; 4250 const v = variants[newIdx]; 4251 const body = msgElement.querySelector('.body'); 4252 if (body) body.innerHTML = v.html; 4253 msgElement.dataset.raw = v.raw; 4254 msgElement.dataset.variantIndex = String(newIdx); 4255 if (window.hljs) { 4256 msgElement.querySelectorAll('pre code').forEach(block => window.hljs.highlightElement(block)); 4257 } 4258 _renderVariantNav(msgElement, variants, newIdx); 4259 4260 // Persist selected variant to server 4261 const sid = sessionModule.getCurrentSessionId(); 4262 if (sid) { 4263 fetch(`${API_BASE}/api/session/${sid}/update-last-meta`, { 4264 method: 'POST', 4265 headers: { 'Content-Type': 'application/json' }, 4266 body: JSON.stringify({ metadata: { variantIndex: newIdx } }) 4267 }).catch(e => console.warn('update-last-meta (variantIndex) failed:', e)); 4268 } 4269 } 4270 4271 export async function forkFrom(aiMsgElement) { 4272 const box = document.getElementById('chat-history'); 4273 const allMsgs = Array.from(box.querySelectorAll('.msg')); 4274 const aiIndex = allMsgs.indexOf(aiMsgElement); 4275 if (aiIndex < 0) return; 4276 4277 const sessionId = sessionModule.getCurrentSessionId(); 4278 if (!sessionId) return; 4279
4280 const keepCount = aiIndex + 1; 4281 4282 try { 4283 const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, { 4284 method: 'POST', 4285 headers: { 'Content-Type': 'application/json' }, 4286 body: JSON.stringify({ keep_count: keepCount }), 4287 }); 4288 if (!res.ok) throw new Error(await res.text()); 4289 const data = await res.json(); 4290 4291 await sessionModule.loadSessions(); 4292 await sessionModule.selectSession(data.id); 4293 if (uiModule) uiModule.showToast(`Forked â ${data.name}`); 4294 } catch (err) { 4295 console.error('Fork failed:', err); 4296 if (uiModule) uiModule.showError('Fork failed: ' + err.message); 4297 } 4298 } 4299 4300 /** 4301 * Check for pending/completed research after page refresh or session switch. 4302 * If research is still running, show a spinner and poll until done. 4303 * If research is done, fetch result and render it. 4304 */ 4305 export async function checkPendingResearch(sessionId) { 4306 if (!sessionId) return; 4307 try { 4308 const res = await fetch(`${API_BASE}/api/research/status/${sessionId}`); 4309 if (!res.ok) return; // 404 = no research for this session 4310 const data = await res.json(); 4311 4312 if (data.status === 'done') { 4313 // Fetch and render the completed result 4314 _notifyResearchComplete(sessionId, data.query || ''); 4315 if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId); 4316 const resultRes = await fetch(`${API_BASE}/api/research/result/${sessionId}`, { method: 'POST' }); 4317 if (resultRes.ok) { 4318 const resultData = await resultRes.json(); 4319 if (resultData.result) { 4320 // Skip if history already has a research message for this session 4321 if (document.querySelector(`#chat-history .msg-ai[data-research-session="${sessionId}"]`)) return; 4322 4323 var srcBox = ''; 4324 if (resultData.sources && resultData.sources.length > 0) { 4325 srcBox = _buildSourcesBox(resultData.sources, 'research'); 4326 } 4327 var findingsBox = chatRenderer.buildFindingsBox(resultData.raw_findings); 4328 var cleanResult = resultData.result; 4329 // Build DOM directly to avoid double-processing through addMessage 4330 chatRenderer.hideWelcomeScreen(); 4331 var _box = document.getElementById('chat-history'); 4332 if (_box) { 4333 var _wrap = document.createElement('div'); 4334 _wrap.className = 'msg msg-ai'; 4335 _wrap.dataset.researchSession = sessionId; 4336 var _role = document.createElement('div'); 4337 _role.className = 'role'; 4338 var _meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; }); 4339 _role.textContent = _shortModel(_meta?.model); 4340 _applyModelColor(_role, _meta?.model); 4341 _role.appendChild(chatRenderer.roleTimestamp()); 4342 var _body = document.createElement('div'); 4343 _body.className = 'body'; 4344 _body.innerHTML = srcBox + markdownModule.processWithThinking( 4345 markdownModule.squashOutsideCode(cleanResult) 4346 ) + findingsBox; 4347 _wrap.dataset.raw = cleanResult; 4348 _wrap.appendChild(_role); 4349 _wrap.appendChild(_body); 4350 _wrap.appendChild(chatRenderer.createMsgFooter(_wrap)); 4351 _appendViewReportLink(_wrap, sessionId); 4352 _box.appendChild(_wrap); 4353 if (window.hljs) _wrap.querySelectorAll('pre code').forEach(function(b) { window.hljs.highlightElement(b); }); 4354 uiModule.scrollHistory(); 4355 } 4356 } 4357 } 4358 return; 4359 } 4360 4361 if (data.status !== 'running') return; 4362 4363 // Don't show reconnect UI if we've already switched away 4364 if (sessionModule.getCurrentSessionId() !== sessionId) return; 4365 4366 // Research is still running â show reconnect UI with spinner 4367 const box = document.getElementById('chat-history'); 4368 if (!box) return; 4369 4370 const holder = document.createElement('div'); 4371 holder.className = 'msg msg-ai research-reconnect'; 4372 holder.dataset.researchSession = sessionId; 4373 const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); 4374 const agentMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId()); 4375 const agentModelLabel = _shortModel(agentMeta?.model); 4376 holder.innerHTML = `<div class="role">
4376${uiModule.esc(agentModelLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`; 4377 _applyModelColor(holder.querySelector('.role'), agentMeta?.model); 4378 box.appendChild(holder); 4379 4380 const bodyDiv = holder.querySelector('.body'); 4381 const spinner = spinnerModule.create('Reconnecting to research...', 'right'); 4382 bodyDiv.appendChild(spinner.createElement()); 4383 spinner.start(); 4384 4385 // Update spinner with current progress if available 4386 function updateSpinnerFromProgress(progress) { 4387 if (!progress || !progress.phase) return; 4388 const rp = progress; 4389 if (rp.phase === 'probing') { 4390 spinner.updateMessage(`Verifying model: ${rp.model || '?'}`); 4391 } else if (rp.phase === 'planning') { 4392 spinner.updateMessage('Analyzing question & planning research strategy'); 4393 } else if (rp.phase === 'searching') { 4394 const q = rp.queries ? `${rp.queries} queries` : ''; 4395 const s = rp.total_sources ? ` · ${rp.total_sources} sources` : ''; 4396 spinner.updateMessage(`Round ${rp.round || '?'}: Searching${q ? ' (' + q + ')' : ''}${s}`); 4397 } else if (rp.phase === 'reading') { 4398 spinner.updateMessage(rp.title ? `Reading: ${rp.title}` : `Round ${rp.round || '?'}: Reading ${rp.new_sources || ''} pages · ${rp.total_sources || 0} sources total`); 4399 } else if (rp.phase === 'analyzing') { 4400 spinner.updateMessage(`Round ${rp.round || '?'}: Analyzing ${rp.total_findings || 0} findings`); 4401 } else if (rp.phase === 'writing') { 4402 spinner.updateMessage(`Writing report · ${rp.total_sources || 0} sources`); 4403 } 4404 } 4405 4406 updateSpinnerFromProgress(data.progress); 4407 _researchingStreamIds.add(sessionId); 4408 if (sessionModule && sessionModule.markResearching) sessionModule.markResearching(sessionId); 4409 4410 // Restore research timer from started_at 4411 if (data.started_at && spinner && spinner.element) { 4412 _researchStartTime = data.started_at * 1000; 4413 _researchAvgDuration = data.avg_duration || null; 4414 _researchTimerEl = document.createElement('div'); 4415 _researchTimerEl.className = 'research-timer'; 4416 _researchTimerEl.style.cssText = 'font-size:0.8em; opacity:0.6; margin-top:4px; font-family:monospace;'; 4417 spinner.element.parentNode.insertBefore(_researchTimerEl, spinner.element.nextSibling); 4418 _researchTimerInterval = setInterval(() => { 4419 if (!_researchTimerEl) return; 4420 var elapsed = Math.floor((Date.now() - _researchStartTime) / 1000); 4421 var mm = String(Math.floor(elapsed / 60)).padStart(2, '0'); 4422 var ss = String(elapsed % 60).padStart(2, '0'); 4423 var txt = mm + ':' + ss; 4424 if (_researchAvgDuration) { 4425 var avgM = String(Math.floor(_researchAvgDuration / 60)).padStart(2, '0'); 4426 var avgS = String(Math.round(_researchAvgDuration % 60)).padStart(2, '0'); 4427 txt += ' / avg ' + avgM + ':' + avgS; 4428 } 4429 _researchTimerEl.textContent = txt; 4430 }, 1000); 4431 // Reconnect synapse â seed it with whatever progress is already known 4432 try { 4433 _researchSynapse = createResearchSynapse(spinner.element.parentNode, { 4434 query: data.query || '', 4435 startedAt: _researchStartTime, 4436 }); 4437 if (_researchSynapse.element && _researchTimerEl) { 4438 spinner.element.parentNode.insertBefore(_researchSynapse.element, _researchTimerEl); 4439 } 4440 if (data.progress) { 4441 _researchSynapse.setPhase(data.progress.phase, data.progress); 4442 if (typeof data.progress.round === 'number') _researchSynapse.setRound(data.progress.round); 4443 if (typeof data.progress.total_sources === 'number') _researchSynapse.setSourceCount(data.progress.total_sources); 4444 } 4445 } catch (e) { console.warn('synapse reconnect failed', e); } 4446 } 4447 4448 // Poll for completion 4449 const pollInterval = setInterval(async () => { 4450 // Stop polling if user switched to a different session 4451 if (sessionModule.getCurrentSessionId() !== sessionId) { 4452 clearInterval(pollInterval); 4453 spinner.destroy(); 4454 _clearResearchTimer(); 4455 if (holder.parentNode) holder.remove(); 4456 _researchingStreamIds.delete(sessionId); 4457 if (_researchingStreamIds.size === 0) { 4458 var _rToggleP = document.getElementById('research-toggle-btn'); 4459 if (_rToggleP) _rToggleP.classList.remove('research-running'); 4460 } 4461 return; 4462 } 4463 try { 4464 const pollRes = await fetch(`${API_BASE}/api/research/status/${sessionId}`); 4465 if (!pollRes.ok) { 4466 clearInterval(pollInterval); 4467 spinner.destroy(); 4468 _clearResearchTimer(); 4469 _researchingStreamIds.delete(sessionId); 4470 if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId); 4471 return; 4472 } 4473 const pollData = await pollRes.json(); 4474 updateSpinnerFromProgress(pollData.progress); 4475 if (_researchSynapse && pollData.progress) { 4476 _researchSynapse.setPhase(pollData.progress.phase, pollData.progress); 4477 if (typeof pollData.progress.round === 'number') _researchSynapse.setRound(pollData.progress.round); 4478 if (typeof pollData.progress.total_sources === 'number') _researchSynapse.setSourceCount(pollData.progress.total_sources); 4479 } 4480 4481 if (pollData.status !== 'running') { 4482 clearInterval(pollInterval); 4483 spinner.destroy(); 4484 _clearResearchTimer(); 4485 _researchingStreamIds.delete(sessionId); 4486 if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId); 4487 4488 if (pollData.status === 'done') { 4489 _notifyResearchComplete(sessionId, data.query || ''); 4490 const rRes = await fetch(`${API_BASE}/api/research/result/${sessionId}`, { method: 'POST' }); 4491 if (rRes.ok) { 4492 const rData = await rRes.json(); 4493 if (rData.result) { 4494 var srcHtml = ''; 4495 if (rData.sources && rData.sources.length > 0) { 4496 srcHtml = _buildSourcesBox(rData.sources, 'research'); 4497 } 4498 var findingsHtml = chatRenderer.buildFindingsBox(rData.raw_findings);
4499 bodyDiv.innerHTML = srcHtml + markdownModule.processWithThinking( 4500 markdownModule.squashOutsideCode(rData.result) 4501 ) + findingsHtml; 4502 holder.dataset.raw = rData.result; 4503 _appendViewReportLink(holder, sessionId); 4504 if (window.hljs) { 4505 holder.querySelectorAll('pre code').forEach(b => window.hljs.highlightElement(b)); 4506 } 4507 } 4508 } 4509 } else { 4510 bodyDiv.innerHTML = '<i style="color: var(--color-error);">[Research ' + pollData.status + ']</i>'; 4511 } 4512 } 4513 } catch (e) { 4514 console.error('Research poll error:', e); 4515 } 4516 }, 2000); 4517 } catch (e) { 4518 // No research pending, that's fine 4519 } 4520 } 4521 4522 /** Set a display override for the next user message bubble */ 4523 export function setDisplayOverride(text) { 4524 _displayOverride = text; 4525 } 4526 4527 /** Hide the user bubble for the next submit (e.g. continue after stop) */ 4528 export function setHideUserBubble() { 4529 _hideUserBubble = true; 4530 } 4531 4532 /** Set the AI element to merge with the next streamed response (continue after stop) */ 4533 export function setPendingContinue(el) { 4534 _pendingContinue = el; 4535 } 4536 4537 /** 4538 * Delete an AI message and its preceding user message from the conversation. 4539 */ 4540 export async function deleteMessage(msgElement) { 4541 const box = document.getElementById('chat-history'); 4542 const allMsgs = Array.from(box.querySelectorAll('.msg')); 4543 const clickedIndex = allMsgs.indexOf(msgElement); 4544 if (clickedIndex < 0) return; 4545 4546 // No early-out on a missing session: an output shown before any model was 4547 // selected (issue #1428) has no session/persisted rows, but its "x" must 4548 // still remove it. We only need the session id for the server-side delete 4549 // below; without one we fall back to removing the DOM. 4550 const sessionId = sessionModule.getCurrentSessionId(); 4551 4552 const clickedIsUser = msgElement.classList.contains('msg-user'); 4553 4554 // Find the user+AI pair 4555 let userIndex = -1; 4556 let aiIndex = -1; 4557 if (clickedIsUser) { 4558 userIndex = clickedIndex; 4559 // Find the following AI message 4560 for (let i = clickedIndex + 1; i < allMsgs.length; i++) { 4561 if (allMsgs[i].classList.contains('msg-ai') && !allMsgs[i].classList.contains('msg-continuation')) { 4562 aiIndex = i; 4563 break; 4564 } 4565 if (allMsgs[i].classList.contains('msg-user')) break; // next user msg, no AI response 4566 } 4567 } else { 4568 // If clicked on a continuation, walk back to the main AI message 4569 let mainAiIndex = clickedIndex; 4570 if (allMsgs[mainAiIndex].classList.contains('msg-continuation')) { 4571 for (let i = mainAiIndex - 1; i >= 0; i--) { 4572 if (allMsgs[i].classList.contains('msg-ai') && !allMsgs[i].classList.contains('msg-continuation')) { 4573 mainAiIndex = i; 4574 break; 4575 } 4576 } 4577 } 4578 aiIndex = mainAiIndex; 4579 // Find the preceding user message 4580 for (let i = aiIndex - 1; i >= 0; i--) { 4581 if (allMsgs[i].classList.contains('msg-user')) { 4582 userIndex = i; 4583 break; 4584 } 4585 } 4586 } 4587 4588 // Collect DB message IDs and DOM elements to remove 4589 const msgIds = []; 4590 const domToRemove = []; 4591 4592 // Add the user message if found 4593 if (userIndex >= 0) { 4594 domToRemove.push(allMsgs[userIndex]); 4595 const uid = allMsgs[userIndex].dataset.dbId; 4596 if (uid) msgIds.push(uid); 4597 } 4598 4599 // Add the AI message if found 4600 if (aiIndex >= 0) { 4601 domToRemove.push(allMsgs[aiIndex]); 4602 const aid = allMsgs[aiIndex].dataset.dbId; 4603 if (aid) msgIds.push(aid); 4604 4605 const aiEl = allMsgs[aiIndex]; 4606 // Also remove agent-thread elements BETWEEN user and AI 4607 if (userIndex >= 0) { 4608 let between = allMsgs[userIndex].nextElementSibling; 4609 while (between && between !== aiEl) { 4610 domToRemove.push(between); 4611 between = between.nextElementSibling; 4612 } 4613 } 4614 // Walk forward from the AI element to remove continuations and tool bubbles 4615 let sibling = aiEl.nextElementSibling; 4616 while (sibling) { 4617 if (sibling.classList.contains('msg-user') || 4618 (sibling.classList.contains('msg-ai') && !sibling.classList.contains('msg-continuation'))) { 4619 break; 4620 } 4621 domToRemove.push(sibling); 4622 sibling = sibling.nextElementSibling; 4623 } 4624 } 4625 4626 if (!msgIds.length || !sessionId) { 4627 // No persisted rows to delete (no DB IDs, or no session at all â e.g. an 4628 // error output shown before a model was selected, #1428). Just remove the 4629 // DOM so the "x" works regardless. 4630 domToRemove.forEach(el => el.remove()); 4631 if (uiModule) uiModule.showToast('Message deleted'); 4632 return; 4633 } 4634 4635 try { 4636 const res = await fetch(`${API_BASE}/api/session/${sessionId}/delete-messages`, { 4637 method: 'POST', 4638 headers: { 'Content-Type': 'application/json' }, 4639 body: JSON.stringify({ msg_ids: msgIds }) 4640 }); 4641 if (!res.ok) throw new Error('Server error ' + res.status); 4642 domToRemove.forEach(el => el.remove()); 4643 if (uiModule) uiModule.showToast('Message deleted'); 4644 } catch (err) { 4645 console.error('Delete failed:', err); 4646 if (uiModule) uiModule.showError('Delete failed: ' + err.message); 4647 } 4648 } 4649 4650 /** 4651 * Edit an AI message inline. Makes the body contentEditable, saves to DB on confirm. 4652 */ 4653 export async function editAIMessage(msgElement) { 4654 const body = msgElement.querySelector('.body'); 4655 if (!body) return; 4656 4657 const isEditing = body.contentEditable === 'true' || body.contentEditable === 'plaintext-only'; 4658 if (isEditing) return; // already editing 4659 4660 const originalRaw = msgElement.dataset.raw || body.textContent || ''; 4661 4662 // Create editable textarea overlay 4663 const textarea = document.createElement('textarea'); 4664 textarea.className = 'msg-edit-textarea'; 4665 textarea.value = originalRaw; 4666 textarea.style.width = '100%'; 4667 textarea.style.minHeight = Math.max(100, body.offsetHeight) + 'px'; 4668 body.style.display = 'none'; 4669 body.parentNode.insertBefore(textarea, body.nextSibling); 4670 textarea.focus(); 4671 4672 // Add save/cancel bar 4673 const bar = document.createElement('div'); 4674 bar.className = 'msg-edit-bar'; 4675 const saveBtn = document.createElement('button'); 4676 saveBtn.className = 'msg-edit-save'; 4677 saveBtn.textContent = 'Save'; 4678 const cancelBtn = document.createElement('button'); 4679 cancelBtn.className = 'msg-edit-cancel'; 4680 cancelBtn.textContent = 'Cancel'; 4681 bar.appendChild(saveBtn); 4682 bar.appendChild(cancelBtn); 4683 textarea.parentNode.insertBefore(bar, textarea.nextSibling); 4684 4685 function cleanup() { 4686 textarea.remove(); 4687 bar.remove(); 4688 body.style.display = ''; 4689 } 4690 4691 cancelBtn.addEventListener('click', (e) => { 4692 e.stopPropagation(); 4693 cleanup(); 4694 }); 4695 4696 saveBtn.addEventListener('click', async (e) => { 4697 e.stopPropagation(); 4698 const newContent = textarea.value; 4699 if (newContent === originalRaw) { cleanup(); return; } 4700 4701 const msgId = msgElement.dataset.dbId; 4702 if (!msgId) { if (uiModule) uiModule.showError('Cannot edit: message ID not found'); cleanup(); return; } 4703 4704 const sessionId = sessionModule.getCurrentSessionId(); 4705 if (!sessionId) { cleanup(); return; } 4706
4707 try { 4708 const res = await fetch(`${API_BASE}/api/session/${sessionId}/edit-message`, { 4709 method: 'POST', 4710 headers: { 'Content-Type': 'application/json' }, 4711 body: JSON.stringify({ msg_id: msgId, content: newContent }), 4712 }); 4713 if (!res.ok) throw new Error('Server error ' + res.status); 4714 4715 // Re-render body with markdown 4716 body.innerHTML = markdownModule.processWithThinking(markdownModule.squashOutsideCode(newContent)); 4717 msgElement.dataset.raw = newContent; 4718 4719 // Add edited indicator if not already present 4720 if (!msgElement.querySelector('.edited-indicator')) { 4721 const indicator = document.createElement('div'); 4722 indicator.className = 'edited-indicator'; 4723 indicator.textContent = '[Message edited]'; 4724 body.parentNode.insertBefore(indicator, body.nextSibling); 4725 } 4726 4727 cleanup(); 4728 if (uiModule) uiModule.showToast('Message edited'); 4729 } catch (err) { 4730 console.error('Edit failed:', err); 4731 if (uiModule) uiModule.showError('Edit failed: ' + err.message); 4732 } 4733 }); 4734 } 4735 4736 /** 4737 * Rewrite the AI's last response with a specific instruction. 4738 * Uses the lightweight /api/rewrite endpoint â no tools, no agent loop. 4739 * Just rewrites the text of the last AI bubble. 4740 */ 4741 export async function rewriteWith(aiMsgElement, instruction) { 4742 const sessionId = sessionModule.getCurrentSessionId(); 4743 if (!sessionId) return; 4744 4745 // Get the original text from the AI bubble 4746 const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || ''; 4747 const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || ''; 4748 4749 if (!oldRaw.trim()) { 4750 if (uiModule) uiModule.showError('No text to rewrite'); 4751 return; 4752 } 4753 4754 // Save current response as a variant 4755 let variants = []; 4756 try { variants = JSON.parse(aiMsgElement.dataset.variants || '[]'); } catch(_) {} 4757 if (variants.length === 0) { 4758 variants.push({ raw: oldRaw, html: oldHtml, label: 'original' }); 4759 } 4760 4761 // Determine label from instruction 4762 let varLabel = 'rewrite'; 4763 if (instruction.includes('shorter')) varLabel = 'shorter'; 4764 else if (instruction.includes('simpler')) varLabel = 'simpler'; 4765 4766 // Clear the bubble and show a whirlpool spinner while we wait for the 4767 // rewrite (replaces the old "Rewriting..." text). 4768 const bodyEl = aiMsgElement.querySelector('.body'); 4769 let _rwSpin = null; 4770 if (bodyEl) { 4771 bodyEl.innerHTML = ''; 4772 _rwSpin = spinnerModule.createWhirlpool(18); 4773 _rwSpin.element.style.margin = '4px 0'; 4774 bodyEl.appendChild(_rwSpin.element); 4775 } 4776 // Stop + detach the spinner (called once real content starts rendering, and 4777 // on the failure path so it never spins forever). 4778 const _killRwSpin = () => { if (_rwSpin) { try { _rwSpin.destroy(); } catch (_) {} _rwSpin = null; } }; 4779 4780 try { 4781 const res = await fetch(`${API_BASE}/api/rewrite`, { 4782 method: 'POST', 4783 headers: { 'Content-Type': 'application/json' }, 4784 body: JSON.stringify({ 4785 session_id: sessionId, 4786 original_text: oldRaw, 4787 instruction: instruction, 4788 }), 4789 }); 4790 4791 if (!res.ok) { 4792 throw new Error(`HTTP ${res.status}`); 4793 } 4794 4795 const reader = res.body.getReader(); 4796 const decoder = new TextDecoder(); 4797 let buffer = ''; 4798 let newText = ''; 4799 4800 while (true) { 4801 const { done, value } = await reader.read(); 4802 if (done) break; 4803 buffer += decoder.decode(value, { stream: true }); 4804 const lines = buffer.split('\n'); 4805 buffer = lines.pop() || ''; 4806 4807 for (const line of lines) { 4808 if (!line.startsWith('data: ')) continue; 4809 const payload = line.slice(6).trim(); 4810 if (payload === '[DONE]') continue; 4811 try { 4812 const data = JSON.parse(payload); 4813 // The endpoint streams `event: error\ndata: {error,status}` on 4814 // failure â surface it instead of silently hanging on "Rewritingâ¦". 4815 if (data.error) { 4816 throw new Error(data.error || ('HTTP ' + (data.status || 500))); 4817 } 4818 // Reasoning tokens (vLLM --reasoning-parser: Qwen3 / DeepSeek-R1) 4819 // arrive as separate {delta, thinking:true} chunks. They are NOT 4820 // the rewrite â fold them away so they don't pollute the result. 4821 if (data.thinking) continue; 4822 if (data.delta) { 4823 newText += data.delta; 4824 _killRwSpin(); 4825 if (bodyEl) { 4826 bodyEl.innerHTML = markdownModule.processWithThinking( 4827 markdownModule.squashOutsideCode(newText) 4828 ); 4829 } 4830 } 4831 } catch (e) { 4832 if (e instanceof Error && e.message) throw e; // re-throw real errors 4833 /* ignore JSON parse noise */ 4834 } 4835 } 4836 } 4837 4838 // Strip any thinking markup from the answer. A reasoning model may emit 4839 // an inline <think>â¦</think> block, a bare </think> (no opener), or â when 4840 // its reasoning came via reasoning_content â a stray leading <think> that 4841 // never closes (so it would otherwise hide the whole answer). Peel all of 4842 // those off so what's left is just the rewritten text.
4843 const _stripThink = (t) => { 4844 t = markdownModule.normalizeThinkingMarkup(t || ''); 4845 t = t.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>[\s\S]*?<\/(?:think(?:ing)?|thought)>/gi, ''); // complete blocks 4846 if (/<\/(?:think(?:ing)?|thought)>/i.test(t)) t = t.replace(/^[\s\S]*?<\/(?:think(?:ing)?|thought)>/i, ''); // reasoning w/o opener 4847 return t.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '').trim(); // any orphan tag 4848 }; 4849 newText = _stripThink(newText); 4850 4851 // Nothing left after stripping (or an empty stream) â real failure, not a 4852 // blank bubble. 4853 if (!newText.trim()) { 4854 throw new Error('model returned no rewritten text'); 4855 } 4856 4857 // Update the element's raw text 4858 if (newText) { 4859 aiMsgElement.dataset.raw = newText; 4860 // Final render with proper markdown 4861 if (bodyEl) { 4862 bodyEl.innerHTML = markdownModule.processWithThinking( 4863 markdownModule.squashOutsideCode(newText) 4864 ); 4865 } 4866 4867 // Save the new response as a variant 4868 variants.push({ raw: newText, html: bodyEl ? bodyEl.innerHTML : '', label: varLabel }); 4869 aiMsgElement.dataset.variants = JSON.stringify(variants); 4870 aiMsgElement.dataset.variantIndex = String(variants.length - 1); 4871 4872 // Persist variant metadata to server 4873 try { 4874 await fetch(`${API_BASE}/api/session/${sessionId}/update-last-meta`, { 4875 method: 'POST', 4876 headers: { 'Content-Type': 'application/json' }, 4877 body: JSON.stringify({ metadata: { variants: variants, variantIndex: variants.length - 1 } }), 4878 }); 4879 } catch (_) {} 4880 4881 // Re-render variant navigation 4882 _renderVariantNav(aiMsgElement, variants, variants.length - 1); 4883 } 4884 4885 if (uiModule) uiModule.scrollHistory(); 4886 4887 } catch (err) { 4888 console.error('Rewrite failed:', err); 4889 _killRwSpin(); 4890 // Restore original content on failure 4891 if (bodyEl) bodyEl.innerHTML = oldHtml; 4892 if (uiModule) uiModule.showError('Rewrite failed: ' + err.message); 4893 } 4894 } 4895 4896 /** 4897 * Continue the AI's response from where it left off. 4898 */ 4899 export async function continueFrom(aiMsgElement) { 4900 const sessionId = sessionModule.getCurrentSessionId(); 4901 if (!sessionId) return; 4902 4903 const messageInput = uiModule.el('message'); 4904 if (messageInput) { 4905 messageInput.value = 'Continue from where you left off.'; 4906 const submitBtn = document.querySelector('.send-btn'); 4907 if (submitBtn) submitBtn.click(); 4908 } 4909 } 4910 4911 // Open a chat attachment in the right place: images â Gallery editor; PDFs & 4912 // text/code/markdown â Documents viewer; anything else â raw file. A given 4913 // upload's imported document is reused (cached by upload id) so clicking it 4914 // again re-opens the same doc instead of making duplicates. 4915 const _attachDocCache = new Map(); // upload id -> doc id 4916 function _attachLang(name) { 4917 const m = (name || '').toLowerCase().match(/\.([a-z0-9]+)$/); 4918 const ext = m ? m[1] : ''; 4919 const map = { md:'markdown', markdown:'markdown', js:'javascript', ts:'typescript', 4920 jsx:'javascript', tsx:'typescript', py:'python', rb:'ruby', go:'go', rs:'rust', 4921 java:'java', c:'c', cpp:'cpp', h:'c', hpp:'cpp', cs:'csharp', php:'php', html:'html', 4922 htm:'html', css:'css', scss:'scss', json:'json', yaml:'yaml', yml:'yaml', sh:'bash', 4923 bash:'bash', sql:'sql', csv:'csv', xml:'xml' }; 4924 return map[ext] || ''; 4925 } 4926 async function openAttachment(att, isImage) { 4927 if (!att || !att.id) return; 4928 const id = att.id, name = att.name || '', mime = att.mime || ''; 4929 const url = `${API_BASE}/api/upload/${id}`; 4930 4931 // Images â Gallery editor. 4932 if (isImage) { 4933 try { 4934 const gx = await import('./galleryEditor.js'); 4935 if (gx.openEditor) { gx.openEditor(url, id, null, name); return; } 4936 } catch (e) { console.warn('gallery open failed', e); } 4937 window.open(url, '_blank'); 4938 return; 4939 } 4940 4941 const isPdf = mime === 'application/pdf' || /\.pdf$/i.test(name); 4942 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; 4943 const isTextDoc = TEXT_EXT.test(name) || /^text\//.test(mime); 4944 if (!isPdf && !isTextDoc) { window.open(url, '_blank'); return; } // binary/unknown â raw 4945 4946 // Reuse the doc we already imported for this upload, if it still loads. 4947 const cached = _attachDocCache.get(id); 4948 if (cached) {
4949 try { 4950 documentModule.openPanel && documentModule.openPanel(); 4951 await documentModule.loadDocument(cached); 4952 return; 4953 } catch (_) { _attachDocCache.delete(id); } 4954 } 4955 4956 // Need a session to attach the doc to (bare-session fallback, same as compose). 4957 let sid = ''; 4958 try { sid = sessionModule.getCurrentSessionId() || ''; } catch (_) {} 4959 if (!sid) { 4960 try { 4961 const _fd = new FormData(); 4962 _fd.append('name', name || 'Attachment'); 4963 _fd.append('skip_validation', 'true'); 4964 const r = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' }); 4965 if (r.ok) { const d = await r.json(); if (d && d.id) { sid = d.id; if (sessionModule.loadSessions) await sessionModule.loadSessions(); } } 4966 } catch (_) {} 4967 } 4968 4969 try { 4970 let doc; 4971 if (isPdf) { 4972 // import-pdf wants a fresh file upload â re-fetch the stored blob and post it. 4973 const blob = await (await fetch(url)).blob(); 4974 const fd = new FormData(); 4975 fd.append('file', blob, name || 'document.pdf'); 4976 if (sid) fd.append('session_id', sid); 4977 const res = await fetch(`${API_BASE}/api/documents/import-pdf`, { method: 'POST', body: fd, credentials: 'same-origin' }); 4978 if (!res.ok) throw new Error('import-pdf ' + res.status); 4979 doc = await res.json(); 4980 } else { 4981 const text = await (await fetch(url)).text(); 4982 const res = await fetch(`${API_BASE}/api/document`, { 4983 method: 'POST', headers: { 'Content-Type': 'application/json' }, 4984 body: JSON.stringify({ session_id: sid || null, title: name.replace(/\.[^.]+$/, '') || 'Document', content: text, language: _attachLang(name) }), 4985 }); 4986 if (!res.ok) throw new Error('document ' + res.status); 4987 doc = await res.json(); 4988 } 4989 if (doc && doc.id) { 4990 _attachDocCache.set(id, doc.id); 4991 documentModule.openPanel && documentModule.openPanel(); 4992 if (documentModule.injectFreshDoc) documentModule.injectFreshDoc(doc); 4993 else await documentModule.loadDocument(doc.id); 4994 } 4995 } catch (e) { 4996 console.error('open attachment as document failed', e); 4997 import('./ui.js').then(m => m.showError && m.showError('Could not open attachment')).catch(() => {}); 4998 window.open(url, '_blank'); // fallback so the file is still reachable 4999 } 5000 } 5001 5002 // Public API 5003 const chatModule = { 5004 init, 5005 initListeners, 5006 openAttachment, 5007 addMessage: chatRenderer.addMessage, 5008 displayMetrics: chatRenderer.displayMetrics, 5009 handleChatSubmit, 5010 abortCurrentRequest, 5011 detachCurrentStream, 5012 checkBackgroundStream, 5013 resumeStream, 5014 hideWelcomeScreen: chatRenderer.hideWelcomeScreen, 5015 showWelcomeScreen: chatRenderer.showWelcomeScreen, 5016 checkPendingResearch, 5017 getImageCost: chatRenderer.getImageCost, 5018 setDisplayOverride, 5019 setHideUserBubble, 5020 setPendingContinue, 5021 regenerateFrom, 5022 forkFrom, 5023 editUserMessage, 5024 editAIMessage, 5025 resendUserMessage, 5026 deleteMessage, 5027 rewriteWith, 5028 continueFrom, 5029 _appendViewReportLink, 5030 hasActiveStream, 5031 }; 5032 5033 // Single delegated handler for tool-call fold/expand. One listener on 5034 // document.body covers every .agent-thread-node â running, completed, 5035 // streaming, history-rendered, compare-mode, all of them. Re-attaching 5036 // per-node listeners on every innerHTML rewrite was the source of the 5037 // "needs many clicks" bug. 5038 if (!window.__odysseus_thread_click_bound) { 5039 document.body.addEventListener('click', (e) => { 5040 const header = e.target.closest('.agent-thread-header'); 5041 if (!header) return; 5042 const node = header.closest('.agent-thread-node'); 5043 if (!node) return; 5044 node.classList.toggle('open'); 5045 }); 5046 window.__odysseus_thread_click_bound = true; 5047 } 5048 5049 export default chatModule; 5050 window.chatModule = chatModule;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.