Files
odysseus/static/js/chatStream.js

299 lines
13 KiB
JavaScript
Raw Permalink Normal View History

2026-05-31 23:58:26 +09:00
// static/js/chatStream.js
// SSE event handlers extracted from chat.js handleChatSubmit
// Handles: ui_control events, background stream management
import uiModule from './ui.js';
import Storage from './storage.js';
import themeModule from './theme.js';
import markdownModule from './markdown.js';
import sessionModule from './sessions.js';
2026-06-30 10:48:08 +00:00
import documentModule from './document.js';
2026-05-31 23:58:26 +09:00
/**
* Handle a ui_control SSE event — AI-driven UI manipulation.
* Extracted from the duplicated ui_control + tool_output.ui_event handlers.
*/
export function handleUIControl(uiData) {
var uiEvent = uiData.ui_event || uiData;
var esc = uiModule.esc;
try {
if (uiEvent === 'toggle' || uiData.ui_event === 'toggle') {
var toggleMap = {
web: 'web-toggle', bash: 'bash-toggle', rag: 'rag-toggle',
research: 'research-toggle', incognito: 'incognito-toggle',
};
var btnMap = {
web: 'web-toggle-btn', bash: 'bash-toggle-btn', rag: 'rag-indicator-btn',
};
var chkId = toggleMap[uiData.toggle_name];
var btnId = btnMap[uiData.toggle_name];
if (uiData.toggle_name === 'rag' && window._syncRagIndicator) {
window._syncRagIndicator(!!uiData.state);
} else {
if (chkId) {
var chk = document.getElementById(chkId);
if (chk) chk.checked = !!uiData.state;
}
if (btnId) {
var btn = document.getElementById(btnId);
if (btn) btn.classList.toggle('active', !!uiData.state);
}
}
var ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
ts[uiData.toggle_name] = !!uiData.state;
Storage.setJSON(Storage.KEYS.TOGGLES, ts);
} else if (uiEvent === 'set_mode' || uiData.ui_event === 'set_mode') {
var modeVal = uiData.mode;
var agentBtn = document.getElementById('mode-agent-btn');
var chatBtn = document.getElementById('mode-chat-btn');
if (agentBtn && chatBtn) {
agentBtn.classList.toggle('active', modeVal === 'agent');
chatBtn.classList.toggle('active', modeVal !== 'agent');
}
var ts2 = Storage.getJSON(Storage.KEYS.TOGGLES, {});
ts2.mode = modeVal;
Storage.setJSON(Storage.KEYS.TOGGLES, ts2);
document.querySelectorAll('[data-mode-tool]').forEach(function(b) {
b.style.display = modeVal === 'agent' ? '' : 'none';
});
} else if (uiEvent === 'switch_model' || uiData.ui_event === 'switch_model') {
var modelDisplay = document.querySelector('.current-model-name, #current-model');
if (modelDisplay) modelDisplay.textContent = uiData.model;
} else if (uiEvent === 'set_theme' || uiData.ui_event === 'set_theme') {
var tm = themeModule;
if (tm && tm.THEMES && tm.applyColors && tm.save) {
var themeName = uiData.theme_name;
if (themeName === 'chatgpt') themeName = 'gpt'; // renamed preset
var customThemes = tm.getCustomThemes ? tm.getCustomThemes() : {};
var colors = tm.THEMES[themeName] || customThemes[themeName] || uiData.colors;
if (colors) {
tm.applyColors(colors);
tm.save(themeName, colors);
var grid = document.getElementById('themeGrid');
if (grid) {
grid.querySelectorAll('.theme-swatch').forEach(function(s) { s.classList.remove('active'); });
var sw = grid.querySelector('[data-theme="' + themeName + '"]');
if (sw) sw.classList.add('active');
}
}
}
} else if (uiEvent === 'create_theme' || uiData.ui_event === 'create_theme') {
var tm2 = themeModule;
if (tm2 && tm2.applyColors && tm2.save) {
var colors2 = uiData.colors;
var name = uiData.theme_name || 'custom';
if (colors2) {
tm2.applyColors(colors2);
tm2.save(name, colors2);
// Background effects (animated pattern / frosted glass) the model
// optionally set — apply them live and persist with the theme so
// they survive re-applying it later.
var bg = uiData.bg || null;
var opts = {};
if (bg) {
if (bg.pattern && tm2.applyBgPattern) { tm2.applyBgPattern(bg.pattern); opts.bgPattern = bg.pattern; }
if (bg.effectColor && tm2.applyBgEffectColor) { tm2.applyBgEffectColor(bg.effectColor); opts.bgEffectColor = bg.effectColor; }
if (bg.effectIntensity != null && tm2.applyBgEffectIntensity) { tm2.applyBgEffectIntensity(bg.effectIntensity); opts.bgEffectIntensity = bg.effectIntensity; }
if (bg.effectSize != null && tm2.applyBgEffectSize) { tm2.applyBgEffectSize(bg.effectSize); opts.bgEffectSize = bg.effectSize; }
if (bg.frosted != null && tm2.applyFrostedGlass) { tm2.applyFrostedGlass(bg.frosted); opts.frosted = bg.frosted; }
}
if (tm2.saveCustomTheme) tm2.saveCustomTheme(name, colors2, Object.keys(opts).length ? opts : undefined);
}
}
} else if (uiEvent === 'highlight' || uiData.ui_event === 'highlight') {
document.querySelectorAll('.odysseus-highlight').forEach(function(e) { e.classList.remove('odysseus-highlight'); });
document.querySelectorAll('.odysseus-hl-label').forEach(function(e) { e.remove(); });
var target = document.querySelector(uiData.selector);
if (target) {
target.classList.add('odysseus-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (uiData.label) {
var lbl = document.createElement('div');
lbl.className = 'odysseus-hl-label';
lbl.textContent = uiData.label;
if (!target.style.position) target.style.position = 'relative';
target.appendChild(lbl);
}
}
} else if (uiEvent === 'clear_highlight' || uiData.ui_event === 'clear_highlight') {
document.querySelectorAll('.odysseus-highlight').forEach(function(e) { e.classList.remove('odysseus-highlight'); });
document.querySelectorAll('.odysseus-hl-label').forEach(function(e) { e.remove(); });
} else if (uiEvent === 'research_started' || uiData.ui_event === 'research_started') {
// Agent kicked off deep research — adopt the session into the
// sidebar immediately so the user sees it without waiting for
// the 12s active-poll.
var rsid = uiData.research_session_id || uiData.session_id;
if (rsid) {
import('./research/jobs.js').then(function(mod) {
var fn = mod.adoptSession || (mod.default && mod.default.adoptSession);
if (fn) fn(rsid);
}).catch(function(){});
// The clickable "Open in Deep Research" link is now emitted by the
// agent loop as a `#research-<id>` markdown anchor in the assistant's
// response text — it renders as a regular clickable chat link AND
// persists across refresh (saved with the message). No ephemeral
// chip injection needed here anymore.
}
} else if (uiEvent === 'open_panel' || uiData.ui_event === 'open_panel') {
var panel = uiData.panel;
if (panel === 'documents') {
import('./documentLibrary.js').then(function(mod) {
var fn = mod.openLibrary || (mod.default && mod.default.openLibrary);
if (fn) fn();
}).catch(function(){});
} else if (panel === 'gallery') {
import('./gallery.js').then(function(mod) {
var fn = mod.openGallery || (mod.default && mod.default.openGallery);
if (fn) fn();
}).catch(function(){});
} else if (panel === 'email') {
import('./emailLibrary.js').then(function(mod) {
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (fn) fn();
}).catch(function(){});
} else if (panel === 'sessions') {
import('./sessions.js').then(function(mod) {
var fn = mod.openLibrary || (mod.default && mod.default.openLibrary);
if (fn) fn();
}).catch(function(){});
} else if (panel === 'cookbook') {
import('./cookbook.js').then(function(mod) {
var fn = mod.open || (mod.default && mod.default.open);
if (fn) fn();
}).catch(function(){});
} else if (panel === 'notes') {
import('./notes.js').then(function(mod) {
var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes));
if (fn) fn();
}).catch(function(){});
} else if (panel === 'memories' || panel === 'skills' || panel === 'settings') {
// These live in the sidebar / settings drawer — most just need
// an existing button click.
var ids = { memories: 'tool-memory-btn', skills: 'skills-btn', settings: 'open-settings-btn' };
var btn = document.getElementById(ids[panel]);
if (btn) btn.click();
}
} else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') {
2026-06-30 10:48:08 +00:00
try {
2026-07-07 00:50:07 +00:00
var activeCtx = documentModule && documentModule.getActiveEmailComposerContext
? documentModule.getActiveEmailComposerContext()
2026-06-30 10:48:08 +00:00
: null;
2026-07-07 00:50:07 +00:00
var sameActiveDraft = activeCtx
&& String(activeCtx.sourceUid || '') === String(uiData.uid || '')
&& String(activeCtx.sourceFolder || 'INBOX') === String(uiData.folder || 'INBOX');
var existingDocId = sameActiveDraft && activeCtx.docId
? activeCtx.docId
: (documentModule && documentModule.findEmailDocId
? documentModule.findEmailDocId(uiData.uid, uiData.folder || 'INBOX')
: null);
2026-06-30 10:48:08 +00:00
if (existingDocId && documentModule.replaceEmailReplyBody) {
if (documentModule.loadDocument) documentModule.loadDocument(existingDocId);
documentModule.replaceEmailReplyBody(existingDocId, uiData.body || '', { force: true });
if (uiModule && uiModule.showToast) uiModule.showToast('Wrote reply into the open email');
return;
}
} catch (e) {
console.warn('open_email_reply existing draft update failed:', e);
}
2026-05-31 23:58:26 +09:00
import('./emailInbox.js').then(function(mod) {
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
Open email context for agent, email search across All Mail, cookbook serve polish - Agent: pass the open email reader (uid/folder/account/from/subject/body preview) on every chat submit so 'reply to this' / 'write email saying hi' route to ui_control open_email_reply with the right UID instead of inventing a new .md draft. Code-level enforcement (chat_routes strips create_document + send_email when active_email is set); cross-session active_doc_id is now trusted instead of being silently dropped. set_active_email/clear_active_email tool-layer helpers in tool_implementations. - ui_control open_email_reply: optional body argument so the agent can open-and-write in one call; envelope now forwards uid/folder/account/ body/panel through tool_output. Tool description sharpened and the parser rejects empty bodies on reply/reply-all (forces the agent to write rather than open an empty draft). - Email library: search now runs against [Gmail]/All Mail when the current folder is INBOX (archived emails surface). Whirlpool spinner + 'Searching…' placeholder while in flight. Each search result is stamped with its source folder so clicks open the right email instead of whatever shares its UID in INBOX. Search no longer re-applies the same text pill locally (which only checks subject/from/snippet, never body) so body-only matches don't get dropped after IMAP returns them. Initial inbox load bumped 100→500. - Email favorites: 'Favorite (pin to top)' / 'Unfavorite' in both the card menu and the open-reader more menu, backed by a new /api/email/flag/{uid}?on=true|false endpoint. Flagged emails always bubble to the top of the grid regardless of active sort. - AI reply in doc editor: never overwrites existing draft text or the quoted history. AI suggestion is prepended; AI-generated 'On … wrote:' re-quotes are stripped so the original quote isn't visually edited. - Cookbook serve: pre-launch GPU driver / has_gpu / install / version- floor checks (vllm minimax_m2 needs 0.10.0+, deepseek_r1 needs 0.7.0 etc.) before the launch chain starts. Detect 'another model already running on this host' and offer Stop & launch (with graceful then force tmux kill helpers, port release wait). Per-vendor deep-link buttons (vLLM recipe / SGLang cookbook) with hardware hash. Backend picker is now a custom dropdown with accent-coloured logos for vLLM, SGLang, llama.cpp, Ollama, Diffusers; same glyphs added next to package names in Dependencies. Runtime-readiness note moved inside the panel (green when ready, red when missing) with an × dismiss. Esc collapses the expanded card; expanded card scrolls when it overflows; Trust Remote / Auto Tool / Reasoning Parser / Enforce Eager / Prefix Caching / Expert Parallel / Speculative / MoE Env on one row (Reasoning Parser auto-detected per model family). Dtype→Row 1, GPUs→Row 2 (rightmost). Removed redundant GPU 'auto' input — command builders read from the GPU button strip. Default cookbook open is Download tab. - Cookbook hwfit: 'Model (latest)' / 'Model (oldest)' header sorts by release_date; release dates can be backfilled with the new scripts/backfill_model_release_dates.py and recipe metadata pulled with scripts/import_from_vllm_recipes.py against the upstream vllm-project/recipes catalog (vllm_recipe + min_vllm_version stamped on entries). - Calendar: Quick add hint cycles a random Odysseus-themed example per open (wooden horse Friday, crew muster 10am daily, council on Ithaca, …). Typing a time like '11pm' in the event title updates the hero clock live. - Doc editor: email-mode Reply button (sparkle icon, accent) opens the same Fast/Full + context popover the email reader uses; Ctrl+Alt+M toggles markdown preview. - Memories panel: custom sort picker with per-option icons, default 'Latest', visible Enabled/Disabled toggle text matching the section description style.
2026-06-15 20:47:51 +09:00
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
2026-05-31 23:58:26 +09:00
}).catch(function(e) {
console.warn('open_email_reply failed:', e);
});
}
} catch(e) {
console.warn('ui_control handler error:', e);
}
}
/**
* Notify user when a background stream completes.
*/
export function notifyStreamComplete(sessionId, query) {
var isHidden = document.hidden;
var isOtherSession = sessionModule && sessionModule.getCurrentSessionId() !== sessionId;
if (!isHidden && !isOtherSession) return;
if (!('Notification' in window) || Notification.permission !== 'granted') return;
var body = query ? 'Response to "' + query.substring(0, 60) + '" is ready' : 'Your chat response has completed';
var notification = new Notification('Response Complete', {
body: body,
tag: 'stream-' + sessionId,
});
notification.onclick = function() {
window.focus();
if (isOtherSession && sessionModule) {
sessionModule.selectSession(sessionId);
}
notification.close();
};
setTimeout(function() { notification.close(); }, 10000);
}
/**
* Insert a clickable in-chat toast when a background stream finishes.
*/
export function insertStreamDoneToast(sessionId, query) {
var box = document.getElementById('chat-history');
if (!box) return;
var sessions = sessionModule ? sessionModule.getSessions() : [];
var sess = sessions.find(function(s) { return s.id === sessionId; });
var name = sess ? sess.name : 'another session';
var preview = query ? '"' + query.substring(0, 50) + (query.length > 50 ? '...' : '') + '"' : '';
var div = document.createElement('div');
div.className = 'msg msg-system stream-done-toast';
div.innerHTML = '<div class="body">'
+ '<span class="stream-done-indicator">●</span>'
+ '<span>Response ready in <strong>' + (name || 'session').replace(/</g, '&lt;') + '</strong>'
+ (preview ? ' &mdash; ' + preview.replace(/</g, '&lt;') : '')
+ '</span>'
+ '</div>';
div.addEventListener('click', function() {
if (sessionModule) sessionModule.selectSession(sessionId);
});
box.appendChild(div);
uiModule.scrollHistory();
}
/**
* Notify when research completes (browser notification).
*/
export function notifyResearchComplete(sessionId, query) {
var isHidden = document.hidden;
var isOtherSession = sessionModule && sessionModule.getCurrentSessionId() !== sessionId;
if (!isHidden && !isOtherSession) return;
if (!('Notification' in window) || Notification.permission !== 'granted') return;
var body = query ? 'Research on "' + query.substring(0, 60) + '" is ready' : 'Your deep research has completed';
var notification = new Notification('Research Complete', {
body: body,
tag: 'research-' + sessionId,
});
notification.onclick = function() {
window.focus();
if (isOtherSession && sessionModule) {
sessionModule.selectSession(sessionId);
}
notification.close();
};
setTimeout(function() { notification.close(); }, 10000);
}
const chatStream = {
handleUIControl,
notifyStreamComplete,
insertStreamDoneToast,
notifyResearchComplete,
};
export default chatStream;