1// static/js/chatStream.js 2// SSE event handlers extracted from chat.js handleChatSubmit 3// Handles: ui_control events, background stream management 4 5import uiModule from './ui.js'; 6import Storage from './storage.js'; 7import themeModule from './theme.js'; 8import markdownModule from './markdown.js'; 9import sessionModule from './sessions.js'; 10import documentModule from './document.js?v=20260722emailfastindex1'; 11 12/** 13 * Handle a ui_control SSE event â AI-driven UI manipulation. 14 * Extracted from the duplicated ui_control + tool_output.ui_event handlers. 15 */ 16export function handleUIControl(uiData) { 17 var uiEvent = uiData.ui_event || uiData; 18 var esc = uiModule.esc; 19 20 try { 21 if (uiEvent === 'toggle' || uiData.ui_event === 'toggle') { 22 var toggleMap = { 23 web: 'web-toggle', bash: 'bash-toggle', rag: 'rag-toggle', 24 research: 'research-toggle', incognito: 'incognito-toggle', 25 }; 26 var btnMap = { 27 web: 'web-toggle-btn', bash: 'bash-toggle-btn', rag: 'rag-indicator-btn', 28 }; 29 var chkId = toggleMap[uiData.toggle_name]; 30 var btnId = btnMap[uiData.toggle_name]; 31 if (uiData.toggle_name === 'rag' && window._syncRagIndicator) { 32 window._syncRagIndicator(!!uiData.state); 33 } else { 34 if (chkId) { 35 var chk = document.getElementById(chkId); 36 if (chk) chk.checked = !!uiData.state; 37 } 38 if (btnId) { 39 var btn = document.getElementById(btnId); 40 if (btn) btn.classList.toggle('active', !!uiData.state); 41 } 42 } 43 var ts = Storage.getJSON(Storage.KEYS.TOGGLES, {}); 44 ts[uiData.toggle_name] = !!uiData.state; 45 Storage.setJSON(Storage.KEYS.TOGGLES, ts); 46 47 } else if (uiEvent === 'set_mode' || uiData.ui_event === 'set_mode') { 48 var modeVal = uiData.mode; 49 var agentBtn = document.getElementById('mode-agent-btn'); 50 var chatBtn = document.getElementById('mode-chat-btn'); 51 if (agentBtn && chatBtn) { 52 agentBtn.classList.toggle('active', modeVal === 'agent'); 53 chatBtn.classList.toggle('active', modeVal !== 'agent'); 54 } 55 var ts2 = Storage.getJSON(Storage.KEYS.TOGGLES, {}); 56 ts2.mode = modeVal; 57 Storage.setJSON(Storage.KEYS.TOGGLES, ts2); 58 document.querySelectorAll('[data-mode-tool]').forEach(function(b) { 59 b.style.display = modeVal === 'agent' ? '' : 'none'; 60 }); 61 62 } else if (uiEvent === 'switch_model' || uiData.ui_event === 'switch_model') { 63 var modelDisplay = document.querySelector('.current-model-name, #current-model'); 64 if (modelDisplay) modelDisplay.textContent = uiData.model; 65 66 } else if (uiEvent === 'set_theme' || uiData.ui_event === 'set_theme') { 67 var tm = themeModule; 68 if (tm && tm.THEMES && tm.applyColors && tm.save) { 69 var themeName = uiData.theme_name; 70 if (themeName === 'chatgpt') themeName = 'gpt'; // renamed preset 71 var customThemes = tm.getCustomThemes ? tm.getCustomThemes() : {}; 72 var colors = tm.THEMES[themeName] || customThemes[themeName] || uiData.colors; 73 if (colors) { 74 tm.applyColors(colors); 75 tm.save(themeName, colors); 76 var grid = document.getElementById('themeGrid'); 77 if (grid) { 78 grid.querySelectorAll('.theme-swatch').forEach(function(s) { s.classList.remove('active'); }); 79 var sw = grid.querySelector('[data-theme="' + themeName + '"]'); 80 if (sw) sw.classList.add('active'); 81 } 82 } 83 } 84 85 } else if (uiEvent === 'create_theme' || uiData.ui_event === 'create_theme') { 86 var tm2 = themeModule; 87 if (tm2 && tm2.applyColors && tm2.save) {
88 var colors2 = uiData.colors; 89 var name = uiData.theme_name || 'custom'; 90 if (colors2) { 91 tm2.applyColors(colors2); 92 tm2.save(name, colors2); 93 // Background effects (animated pattern / frosted glass) the model 94 // optionally set â apply them live and persist with the theme so 95 // they survive re-applying it later. 96 var bg = uiData.bg || null; 97 var opts = {}; 98 if (bg) { 99 if (bg.pattern && tm2.applyBgPattern) { tm2.applyBgPattern(bg.pattern); opts.bgPattern = bg.pattern; } 100 if (bg.effectColor && tm2.applyBgEffectColor) { tm2.applyBgEffectColor(bg.effectColor); opts.bgEffectColor = bg.effectColor; } 101 if (bg.effectIntensity != null && tm2.applyBgEffectIntensity) { tm2.applyBgEffectIntensity(bg.effectIntensity); opts.bgEffectIntensity = bg.effectIntensity; } 102 if (bg.effectSize != null && tm2.applyBgEffectSize) { tm2.applyBgEffectSize(bg.effectSize); opts.bgEffectSize = bg.effectSize; } 103 if (bg.frosted != null && tm2.applyFrostedGlass) { tm2.applyFrostedGlass(bg.frosted); opts.frosted = bg.frosted; } 104 } 105 if (tm2.saveCustomTheme) tm2.saveCustomTheme(name, colors2, Object.keys(opts).length ? opts : undefined); 106 } 107 } 108 109 } else if (uiEvent === 'highlight' || uiData.ui_event === 'highlight') { 110 document.querySelectorAll('.odysseus-highlight').forEach(function(e) { e.classList.remove('odysseus-highlight'); }); 111 document.querySelectorAll('.odysseus-hl-label').forEach(function(e) { e.remove(); }); 112 var target = document.querySelector(uiData.selector); 113 if (target) { 114 target.classList.add('odysseus-highlight'); 115 target.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 116 if (uiData.label) { 117 var lbl = document.createElement('div'); 118 lbl.className = 'odysseus-hl-label'; 119 lbl.textContent = uiData.label; 120 if (!target.style.position) target.style.position = 'relative'; 121 target.appendChild(lbl); 122 } 123 } 124 125 } else if (uiEvent === 'clear_highlight' || uiData.ui_event === 'clear_highlight') { 126 document.querySelectorAll('.odysseus-highlight').forEach(function(e) { e.classList.remove('odysseus-highlight'); }); 127 document.querySelectorAll('.odysseus-hl-label').forEach(function(e) { e.remove(); }); 128 129 } else if (uiEvent === 'research_started' || uiData.ui_event === 'research_started') { 130 // Agent kicked off deep research â adopt the session into the 131 // sidebar immediately so the user sees it without waiting for 132 // the 12s active-poll. 133 var rsid = uiData.research_session_id || uiData.session_id; 134 if (rsid) { 135 import('./research/jobs.js').then(function(mod) { 136 var fn = mod.adoptSession || (mod.default && mod.default.adoptSession); 137 if (fn) fn(rsid); 138 }).catch(function(){}); 139 // The clickable "Open in Deep Research" link is now emitted by the 140 // agent loop as a `#research-<id>` markdown anchor in the assistant's 141 // response text â it renders as a regular clickable chat link AND 142 // persists across refresh (saved with the message). No ephemeral 143 // chip injection needed here anymore. 144 } 145 146 } else if (uiEvent === 'open_panel' || uiData.ui_event === 'open_panel') { 147 var panel = uiData.panel; 148 if (panel === 'documents') { 149 import('./documentLibrary.js').then(function(mod) { 150 var fn = mod.openLibrary || (mod.default && mod.default.openLibrary); 151 if (fn) fn(); 152 }).catch(function(){}); 153 } else if (panel === 'gallery') { 154 import('./gallery.js').then(function(mod) { 155 var fn = mod.openGallery || (mod.default && mod.default.openGallery); 156 if (fn) fn(); 157 }).catch(function(){}); 158 } else if (panel === 'email') { 159 import('./emailLibrary.js?v=20260722emailfastindex1').then(function(mod) { 160 var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary); 161 if (fn) fn(); 162 }).catch(function(){}); 163 } else if (panel === 'sessions') { 164 import('./sessions.js').then(function(mod) { 165 var fn = mod.openLibrary || (mod.default && mod.default.openLibrary); 166 if (fn) fn(); 167 }).catch(function(){}); 168 } else if (panel === 'cookbook') { 169 import('./cookbook.js').then(function(mod) { 170 var fn = mod.open || (mod.default && mod.default.open); 171 if (fn) fn(); 172 }).catch(function(){}); 173 } else if (panel === 'notes') { 174 import('./notes.js').then(function(mod) { 175 var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes)); 176 if (fn) fn(); 177 }).catch(function(){}); 178 } else if (panel === 'memories' || panel === 'skills' || panel === 'settings') { 179 // These live in the sidebar / settings drawer â most just need 180 // an existing button click. 181 var ids = { memories: 'tool-memory-btn', skills: 'skills-btn', settings: 'open-settings-btn' }; 182 var btn = document.getElementById(ids[panel]); 183 if (btn) btn.click(); 184 } 185 186 } else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') {
187 try { 188 var activeCtx = documentModule && documentModule.getActiveEmailComposerContext 189 ? documentModule.getActiveEmailComposerContext() 190 : null; 191 var sameActiveDraft = activeCtx 192 && String(activeCtx.sourceUid || '') === String(uiData.uid || '') 193 && String(activeCtx.sourceFolder || 'INBOX') === String(uiData.folder || 'INBOX'); 194 var existingDocId = sameActiveDraft && activeCtx.docId 195 ? activeCtx.docId 196 : (documentModule && documentModule.findEmailDocId 197 ? documentModule.findEmailDocId(uiData.uid, uiData.folder || 'INBOX') 198 : null); 199 if (existingDocId && documentModule.replaceEmailReplyBody) { 200 if (documentModule.loadDocument) documentModule.loadDocument(existingDocId); 201 documentModule.replaceEmailReplyBody(existingDocId, uiData.body || '', { force: true }); 202 if (uiModule && uiModule.showToast) uiModule.showToast('Wrote reply into the open email'); 203 return; 204 } 205 } catch (e) { 206 console.warn('open_email_reply existing draft update failed:', e); 207 } 208 import('./emailInbox.js?v=20260722emailfastindex1').then(function(mod) { 209 var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft); 210 if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || ''); 211 }).catch(function(e) { 212 console.warn('open_email_reply failed:', e); 213 }); 214 } 215 } catch(e) { 216 console.warn('ui_control handler error:', e); 217 } 218} 219 220/** 221 * Notify user when a background stream completes. 222 */ 223export function notifyStreamComplete(sessionId, query) { 224 var isHidden = document.hidden; 225 var isOtherSession = sessionModule && sessionModule.getCurrentSessionId() !== sessionId; 226 if (!isHidden && !isOtherSession) return; 227 if (!('Notification' in window) || Notification.permission !== 'granted') return; 228 var body = query ? 'Response to "' + query.substring(0, 60) + '" is ready' : 'Your chat response has completed'; 229 var notification = new Notification('Response Complete', { 230 body: body, 231 tag: 'stream-' + sessionId, 232 }); 233 notification.onclick = function() { 234 window.focus(); 235 if (isOtherSession && sessionModule) { 236 sessionModule.selectSession(sessionId); 237 } 238 notification.close(); 239 }; 240 setTimeout(function() { notification.close(); }, 10000); 241} 242 243/** 244 * Insert a clickable in-chat toast when a background stream finishes. 245 */ 246export function insertStreamDoneToast(sessionId, query) { 247 var box = document.getElementById('chat-history'); 248 if (!box) return; 249 var sessions = sessionModule ? sessionModule.getSessions() : []; 250 var sess = sessions.find(function(s) { return s.id === sessionId; }); 251 var name = sess ? sess.name : 'another session'; 252 var preview = query ? '"' + query.substring(0, 50) + (query.length > 50 ? '...' : '') + '"' : ''; 253 var div = document.createElement('div'); 254 div.className = 'msg msg-system stream-done-toast'; 255 div.innerHTML = '<div class="body">' 256 + '<span class="stream-done-indicator">â</span>' 257 + '<span>Response ready in <strong>' + (name || 'session').replace(/</g, '<') + '</strong>' 258 + (preview ? ' — ' + preview.replace(/</g, '<') : '') 259 + '</span>' 260 + '</div>'; 261 div.addEventListener('click', function() { 262 if (sessionModule) sessionModule.selectSession(sessionId); 263 }); 264 box.appendChild(div); 265 uiModule.scrollHistory(); 266} 267 268/** 269 * Notify when research completes (browser notification). 270 */ 271export function notifyResearchComplete(sessionId, query) { 272 var isHidden = document.hidden; 273 var isOtherSession = sessionModule && sessionModule.getCurrentSessionId() !== sessionId; 274 if (!isHidden && !isOtherSession) return; 275 if (!('Notification' in window) || Notification.permission !== 'granted') return; 276 var body = query ? 'Research on "' + query.substring(0, 60) + '" is ready' : 'Your deep research has completed'; 277 var notification = new Notification('Research Complete', { 278 body: body, 279 tag: 'research-' + sessionId, 280 }); 281 notification.onclick = function() { 282 window.focus(); 283 if (isOtherSession && sessionModule) { 284 sessionModule.selectSession(sessionId); 285 } 286 notification.close(); 287 }; 288 setTimeout(function() { notification.close(); }, 10000); 289} 290 291const chatStream = { 292 handleUIControl, 293 notifyStreamComplete, 294 insertStreamDoneToast, 295 notifyResearchComplete, 296}; 297 298export default chatStream;
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.