Stabilize chat and cookbook workflows

This commit is contained in:
pewdiepie-archdaemon
2026-07-01 10:09:25 +00:00
parent d8e76003f1
commit d2959c1ae8
12 changed files with 1034 additions and 190 deletions
+17 -3
View File
@@ -2186,7 +2186,7 @@ function initializeEventListeners() {
const pickerWrap = el('model-picker-wrap');
if (!inputTop || !pickerWrap) return;
const PLACEHOLDER_HIDE_WIDTH = 400;
const PLACEHOLDER_COMPACT_WIDTH = 400;
const PICKER_HIDE_WIDTH = 220;
const TOOLBAR_HIDE_WIDTH = 160;
const textarea = el('message');
@@ -2199,9 +2199,10 @@ function initializeEventListeners() {
const w = inputTop.clientWidth;
// Hide model picker
pickerWrap.classList.toggle('picker-auto-hidden', w < PICKER_HIDE_WIDTH);
// Hide placeholder text
// Keep a prompt inside the composer even when the picker crowds the row.
// A blank placeholder makes the mobile/compact empty state feel broken.
if (textarea) {
textarea.setAttribute('placeholder', w < PLACEHOLDER_HIDE_WIDTH ? '' : 'Message Odysseus...');
textarea.setAttribute('placeholder', w < PLACEHOLDER_COMPACT_WIDTH ? 'Message...' : 'Message Odysseus...');
}
// Hide entire bottom toolbar (tools, mode toggle) — only send button remains
if (inputBottom) {
@@ -3449,6 +3450,12 @@ function initializeEventListeners() {
function startOdysseusApp() {
if (window.__odysseusAppStarted) return;
window.__odysseusAppStarted = true;
const _bumpChatPriority = (ms = 10000) => {
try {
window.__odysseusChatBusyUntil = Math.max(window.__odysseusChatBusyUntil || 0, Date.now() + ms);
} catch (_) {}
};
_bumpChatPriority(10000);
// Set CSS variables
document.documentElement.style.setProperty('--line-height', '20px');
initRailHoverLabels();
@@ -3617,9 +3624,16 @@ function startOdysseusApp() {
const chatForm = document.getElementById('chat-form');
const originalSubmit = chatModule.handleChatSubmit;
let _submitting = false;
const _messageInput = document.getElementById('message') || document.getElementById('message-input');
if (_messageInput) {
_messageInput.addEventListener('focus', () => _bumpChatPriority(15000));
_messageInput.addEventListener('input', () => _bumpChatPriority(15000));
_messageInput.addEventListener('pointerdown', () => _bumpChatPriority(15000), { passive: true });
}
function handleSubmit(e) {
if (e) e.preventDefault();
_bumpChatPriority(30000);
// Debounce: prevent double-submit while a request is being initiated
if (_submitting) return;
_submitting = true;
+270 -11
View File
@@ -43,6 +43,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
let _sendInFlight = false; // covers the window from click → streaming start
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
function _setForegroundChatBusy(active) {
try {
window.__odysseusChatBusy = !!active;
window.__odysseusChatBusyUntil = active ? Date.now() + 120000 : Date.now() + 1200;
} catch (_) {}
}
let _pendingContinue = null; // Stores the stopped AI element to merge with new response
// ── Auto-recovery: when a turn's stream silently dies (connection drop) or
// goes quiet while the connection is alive, re-engage the model with a
@@ -99,6 +106,94 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
const body = msgEl.querySelector('.body');
if (body) chatRenderer.appendReportButton(body, sessionId);
}
function _stripDocumentFenceForChat(text, { final = false } = {}) {
let s = String(text || '').replace(/<?\|end\|>?/g, '');
const markerMatch = /```(?:create_document|documen(?:t)?)\s*\n/i.exec(s);
if (!markerMatch) return s;
const before = s.slice(0, markerMatch.index).trimEnd();
const fenceStart = markerMatch.index;
const openingEnd = s.indexOf('\n', fenceStart);
const closeIdx = openingEnd >= 0 ? s.indexOf('\n```', openingEnd + 1) : -1;
const after = closeIdx >= 0 ? s.slice(closeIdx + 4).trimStart() : '';
const visible = [before, after].filter(Boolean).join('\n\n').trim();
return final && !visible ? 'Done.' : visible;
}
function _showDocumentWritingStatus(contentEl) {
const msg = contentEl && contentEl.closest ? contentEl.closest('.msg') : null;
const chatBox = document.getElementById('chat-history');
if (!msg || !chatBox) {
if (contentEl) contentEl.textContent = 'Writing...';
return;
}
let thread = msg._docWritingThread;
if (!thread || !thread.isConnected) {
thread = document.createElement('div');
thread.className = 'agent-thread streaming has-bottom';
thread.dataset.docWriting = '1';
const prev = msg.previousElementSibling;
if (prev && (prev.classList.contains('msg') || prev.classList.contains('agent-thread'))) {
thread.classList.add('has-top');
}
const node = document.createElement('div');
node.className = 'agent-thread-node running';
node.innerHTML = '<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">▶</span><span class="agent-thread-tool">Writing</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content"></div>';
thread.appendChild(node);
chatBox.insertBefore(thread, msg);
msg._docWritingThread = thread;
const waveEl = node.querySelector('.agent-thread-wave');
if (waveEl) {
const waveFrames = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
let waveIdx = 0;
node._waveInterval = setInterval(() => {
waveIdx = (waveIdx + 1) % waveFrames.length;
waveEl.textContent = waveFrames[waveIdx];
}, 100);
}
node._startTime = Date.now();
node._elapsedTicker = setInterval(() => {
const hdr = node.querySelector('.agent-thread-header');
if (!hdr) return;
let el = hdr.querySelector('.agent-thread-elapsed');
if (!el) {
el = document.createElement('span');
el.className = 'agent-thread-elapsed';
const icon = hdr.querySelector('.agent-thread-icon');
if (icon && icon.nextSibling) hdr.insertBefore(el, icon.nextSibling);
else hdr.appendChild(el);
}
const s = (Date.now() - node._startTime) / 1000;
el.textContent = s < 60 ? `${s.toFixed(2)}s` : `${Math.floor(s / 60)}m ${(s % 60).toFixed(2).padStart(5, '0')}s`;
}, 50);
}
msg.style.display = 'none';
}
function _finishDocumentWritingStatus(msg, ok = true) {
const thread = msg && msg._docWritingThread;
if (!thread || !thread.isConnected) return;
thread.classList.remove('streaming');
const node = thread.querySelector('.agent-thread-node');
if (!node) return;
if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; }
if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; }
node.classList.remove('running');
if (!ok) node.classList.add('error');
const icon = node.querySelector('.agent-thread-icon');
if (icon) icon.textContent = ok ? '✓' : '✗';
const wave = node.querySelector('.agent-thread-wave');
if (wave) wave.remove();
if (!node.querySelector('.agent-thread-status')) {
const status = document.createElement('span');
status.className = 'agent-thread-status';
status.textContent = ok ? 'done' : 'failed';
const header = node.querySelector('.agent-thread-header');
if (header) header.appendChild(status);
}
}
let currentAccumulated = ''; // Track accumulated text across function scope
let currentHolder = null; // Track current message holder
let currentSpinner = null; // Track current spinner for stop cleanup
@@ -241,12 +336,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
submitBtn.dataset.mode = 'streaming';
submitBtn.dataset.phase = 'processing';
isStreaming = true;
_setForegroundChatBusy(true);
_startStallWatchdog();
} else if (state === 'idle') {
submitBtn.dataset.mode = '';
delete submitBtn.dataset.phase;
submitBtn.classList.remove('recording');
isStreaming = false;
_setForegroundChatBusy(false);
_stopStallWatchdog();
// Defer to global updater which handles mic/newchat/send modes
if (window._updateSendBtnIcon) {
@@ -267,6 +364,126 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// API key pattern for the guard in handleChatSubmit
const API_KEY_RE = /^(sk-[a-zA-Z0-9_\-]{20,}|gsk_[a-zA-Z0-9]{20,}|AIza[a-zA-Z0-9_\-]{30,}|xai-[a-zA-Z0-9]{20,})$/;
const _queuedAgentRequests = [];
let _queuedDrainTimer = null;
let _queuedPromoteTimer = null;
let _queuedRequestSeq = 0;
let _queuedBubbleHost = null;
function _escapeQueueText(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function _ensureQueuedBubbleHost() {
const chatBox = document.getElementById('chat-history');
if (!chatBox) return null;
if (_queuedBubbleHost && _queuedBubbleHost.isConnected) return _queuedBubbleHost;
let host = document.getElementById('chat-queued-bubble-host');
if (!host) {
host = document.createElement('div');
host.id = 'chat-queued-bubble-host';
host.className = 'chat-queued-bubble-host';
}
chatBox.appendChild(host);
_queuedBubbleHost = host;
return host;
}
function _createQueuedBubble(item) {
const host = _ensureQueuedBubbleHost();
if (!host) return null;
const wrap = document.createElement('div');
wrap.className = 'msg msg-user msg-user-queued';
wrap.dataset.queueId = item.id;
wrap.title = 'Queued - click to send now and stop the current response';
wrap.innerHTML = `<div class="role">You <span class="queued-pill"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="6 4 20 12 6 20 6 4"></polygon></svg>Queued</span></div><div class="body">${_escapeQueueText(item.message)}</div>`;
wrap.addEventListener('click', (ev) => {
if (ev.target && ev.target.closest && ev.target.closest('button, a, textarea, input')) return;
_promoteQueuedRequest(item.id);
});
host.appendChild(wrap);
uiModule.scrollHistory();
return wrap;
}
function _removeQueuedRequest(id) {
const idx = _queuedAgentRequests.findIndex(item => item.id === id);
if (idx < 0) return null;
const [item] = _queuedAgentRequests.splice(idx, 1);
if (item && item.el && item.el.parentNode) item.el.remove();
return item;
}
function _setComposerAndSend(message) {
const input = uiModule.el('message');
if (!input) return false;
input.value = message;
input.dispatchEvent(new Event('input', { bubbles: true }));
if (uiModule.autoResize) uiModule.autoResize(input);
setTimeout(() => {
handleChatSubmit({ preventDefault() {} }).catch(err => {
console.error('queued send failed', err);
try { uiModule.showError && uiModule.showError('Queued send failed: ' + (err?.message || err)); } catch (_) {}
});
}, 0);
return true;
}
function _sendQueuedWhenIdle(item) {
if (!item) return;
const trySend = () => {
if (isStreaming || _sendInFlight) {
_queuedPromoteTimer = setTimeout(trySend, 220);
return;
}
_queuedPromoteTimer = null;
_setComposerAndSend(item.message);
};
if (_queuedPromoteTimer) clearTimeout(_queuedPromoteTimer);
_queuedPromoteTimer = setTimeout(trySend, 320);
}
function _promoteQueuedRequest(id) {
const item = _removeQueuedRequest(id);
if (!item) return;
if (!isStreaming && !_sendInFlight) {
_setComposerAndSend(item.message);
return;
}
try { uiModule.showToast && uiModule.showToast('Sending queued request now'); } catch (_) {}
const input = uiModule.el('message');
const submitBtn = document.querySelector('.send-btn');
if (input) {
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
if (submitBtn) submitBtn.click();
_sendQueuedWhenIdle(item);
}
function _queueAgentRequest(message) {
const msg = String(message || '').trim();
if (!msg) return false;
const item = { id: `q${++_queuedRequestSeq}`, message: msg, createdAt: Date.now(), el: null };
item.el = _createQueuedBubble(item);
_queuedAgentRequests.push(item);
try { uiModule.showToast && uiModule.showToast(_queuedAgentRequests.length === 1 ? 'Queued for after this response' : `${_queuedAgentRequests.length} requests queued`); } catch (_) {}
return true;
}
function _drainQueuedAgentRequests() {
if (isStreaming || _sendInFlight || !_queuedAgentRequests.length) return;
if (_queuedDrainTimer) return;
_queuedDrainTimer = setTimeout(() => {
_queuedDrainTimer = null;
if (isStreaming || _sendInFlight || !_queuedAgentRequests.length) return;
const next = _queuedAgentRequests[0];
if (!next) return;
_removeQueuedRequest(next.id);
_setComposerAndSend(next.message);
}, 180);
}
/**
* Handle chat form submission
@@ -290,8 +507,23 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
return;
}
// If currently streaming, stop it
// If currently streaming, a non-empty composer means "queue this next".
// Empty composer keeps the existing Stop behavior.
if (isStreaming) {
const queuedInput = uiModule.el('message');
const queuedText = (queuedInput && queuedInput.value || '').trim();
if (queuedText) {
if (fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount()) {
try { uiModule.showError && uiModule.showError('Finish the current response before queueing messages with attachments.'); } catch (_) {}
return;
}
if (_queueAgentRequest(queuedText)) {
queuedInput.value = '';
queuedInput.dispatchEvent(new Event('input', { bubbles: true }));
if (uiModule.autoResize) uiModule.autoResize(queuedInput);
}
return;
}
if (fileHandlerModule.isUploading && fileHandlerModule.isUploading()) {
fileHandlerModule.cancelUpload && fileHandlerModule.cancelUpload();
}
@@ -343,6 +575,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
const messageInput = uiModule.el('message');
if (messageInput) messageInput.disabled = false;
currentAccumulated = '';
_drainQueuedAgentRequests();
return;
}
// Render whatever was accumulated so far
@@ -420,6 +653,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// --- Send-path entry: block re-clicks between submit and stream start ---
if (_sendInFlight) return;
_sendInFlight = true;
_setForegroundChatBusy(true);
// Instant visual feedback so the user sees their click was accepted
// even before the streaming button state kicks in below.
const _earlyMessageInput = uiModule.el('message');
@@ -427,6 +661,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (submitBtn) submitBtn.classList.add('send-pending');
const _releaseSendFlag = () => {
_sendInFlight = false;
_setForegroundChatBusy(isStreaming);
if (_earlyMessageInput) _earlyMessageInput.disabled = false;
if (submitBtn) submitBtn.classList.remove('send-pending');
};
@@ -1104,12 +1339,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
'web_search': 'Searching',
'bash': 'Running',
'python': 'Running',
'create_document': 'Writing',
'update_document': 'Writing',
'read_document': 'Reading',
'edit_file': 'Editing',
'read_file': 'Reading',
'write_file': 'Writing',
'create_document': 'Writing',
'edit_document': 'Editing',
'update_document': 'Rewriting',
'suggest_document': 'Reviewing',
'list_files': 'Browsing',
'image_gen': 'Generating',
'generate_image': 'Generating',
@@ -1208,7 +1445,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Direct render helper for streaming text
_renderStream = () => {
let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText)));
const bodyEl = roundHolder.querySelector('.body');
const contentEl = _ensureStreamLayout(bodyEl);
@@ -1287,6 +1524,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// what keeps code-block hover buttons from flickering and avoids the O(N^2)
// re-parse/re-highlight of the whole message on every token.
// See streamingRenderer.js / streamingSegmenter.js.
if (_docFenceOpened && !dt.trim()) {
_showDocumentWritingStatus(contentEl);
uiModule.scrollHistory();
return;
}
const renderer = contentEl._streamRenderer ||
(contentEl._streamRenderer = createStreamRenderer(contentEl, {
render: (t) => markdownModule.processWithThinking(markdownModule.squashOutsideCode(t)),
@@ -1476,8 +1718,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n'))) {
const fenceMarker = roundText.includes('```document\n') ? '```document\n' : '```create_document\n';
if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
const fenceMarker = roundText.includes('```document\n') ? '```document\n' : (roundText.includes('```documen\n') ? '```documen\n' : '```create_document\n');
const fenceIdx = roundText.indexOf(fenceMarker);
const afterFence = roundText.slice(fenceIdx + fenceMarker.length);
const fenceLines = afterFence.split('\n');
@@ -2089,7 +2331,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!roundFinalized) {
roundFinalized = true;
if (spinner && spinner.element) spinner.destroy();
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText)));
if (dt.trim()) {
var _body3 = roundHolder.querySelector('.body');
var _contentEl3 = _ensureStreamLayout(_body3);
@@ -2559,9 +2801,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Clear streaming minHeight lock
const _streamContent = roundHolder.querySelector('.stream-content');
if (_streamContent) _streamContent.style.minHeight = '';
if (_docFenceOpened) {
_finishDocumentWritingStatus(roundHolder, true);
roundHolder.style.display = '';
}
// Finalize the last round's bubble — flatten stream-content wrapper for clean DOM
const finalDisplay = stripToolBlocks(roundText);
const finalDisplay = stripToolBlocks(_stripDocumentFenceForChat(roundText, { final: _docFenceOpened }));
if (finalDisplay.trim()) {
var _body4 = roundHolder.querySelector('.body');
// Preserve sources expanded state before final render
@@ -3033,6 +3279,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
sessionModule.loadSessions();
}
}, 3000);
_drainQueuedAgentRequests();
}
}
@@ -3240,6 +3487,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Clear local state WITHOUT aborting the fetch
currentAbort = null;
isStreaming = false;
_setForegroundChatBusy(false);
currentHolder = null;
currentAccumulated = '';
// Reset submit button so the new chat is ready to send
@@ -3302,6 +3550,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
const decoder = new TextDecoder();
let buffer = '';
let roundText = '';
let docFenceOpened = false;
let gotDelta = false;
let leftSession = false;
let metricsData = null;
@@ -3316,8 +3565,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
};
const renderDelta = () => {
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt));
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText, { final: docFenceOpened })));
if (docFenceOpened && !dt.trim()) {
_showDocumentWritingStatus(contentDiv);
} else {
contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt));
}
uiModule.scrollHistory();
};
@@ -3348,6 +3601,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
try { json = JSON.parse(payload); } catch (_) { continue; }
if (json.delta) {
roundText += json.delta;
if (!docFenceOpened && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
docFenceOpened = true;
rich = true;
}
if (!gotDelta) { gotDelta = true; try { spinner.destroy(); } catch (_) {} }
renderDelta();
} else if (json.type === 'doc_stream_open') {
@@ -3355,7 +3612,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (documentModule) documentModule.streamDocOpen(json.title || '', json.lang || '');
} else if (json.type === 'doc_stream_delta') {
rich = true;
if (documentModule && json.delta) documentModule.streamDocDelta(json.delta);
if (documentModule) documentModule.streamDocDelta(json.content || json.delta || '');
} else if (json.type === 'metrics') {
metricsData = json.data || metricsData;
} else if (json.type === 'tool_start' || json.type === 'tool_output' ||
@@ -3372,6 +3629,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
cleanup();
if (docFenceOpened) _finishDocumentWritingStatus(holder, true);
if (leftSession) { if (holder.parentNode) holder.remove(); return true; }
const onThisSession = sessionModule.getCurrentSessionId &&
@@ -3391,6 +3649,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Rich response (tools, sources, docs, multi-round) or user moved on:
// reload from the DB for the full canonical render.
if (holder._docWritingThread && holder._docWritingThread.parentNode) holder._docWritingThread.remove();
if (holder.parentNode) holder.remove();
if (onThisSession) sessionModule.selectSession(sessionId);
else sessionModule.loadSessions();
+53 -24
View File
@@ -242,8 +242,7 @@ export function _renderGpuToggles(system) {
container._activeCount = undefined; // default to the new pool's max
delete container.dataset.rendered; // force a count-button rebuild
_renderGpuToggles(system);
_hwfitCache = null;
_hwfitFetch();
_hwfitFetch(false, { keepPrevious: true, forceRevalidate: true });
});
}
@@ -275,8 +274,7 @@ export function _renderGpuToggles(system) {
}
}
}
_hwfitCache = null;
_hwfitFetch();
_hwfitFetch(false, { keepPrevious: true, forceRevalidate: true });
});
}
}
@@ -436,6 +434,27 @@ function _readScanCache(sig) {
return null;
}
function _readNearestScanCache(sig) {
try {
const wanted = JSON.parse(sig || '{}');
const all = JSON.parse(localStorage.getItem(_SCAN_CACHE_KEY) || '{}');
let best = null;
for (const [key, entry] of Object.entries(all)) {
if (!entry || !entry.data || (Date.now() - (entry.ts || 0)) >= _SCAN_CACHE_TTL) continue;
let parsed = null;
try { parsed = JSON.parse(key); } catch { continue; }
if (!parsed) continue;
if ((parsed.h || '') !== (wanted.h || '')) continue;
if ((parsed.hk || '') !== (wanted.hk || '')) continue;
if (JSON.stringify(parsed.m || {}) !== JSON.stringify(wanted.m || {})) continue;
if (JSON.stringify(parsed.d || []) !== JSON.stringify(wanted.d || [])) continue;
if (!best || (entry.ts || 0) > (best.ts || 0)) best = entry;
}
return best?.data || null;
} catch {}
return null;
}
function _writeScanCache(sig, data) {
try {
const all = JSON.parse(localStorage.getItem(_SCAN_CACHE_KEY) || '{}');
@@ -565,6 +584,8 @@ function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) {
export async function _hwfitFetch(fresh = false, opts = {}) {
const _tk = ++_hwfitFetchToken;
const allowNetwork = fresh || opts.allowNetwork !== false;
const keepPrevious = !!opts.keepPrevious;
const forceRevalidate = !!opts.forceRevalidate;
const useCase = document.getElementById('hwfit-usecase')?.value || '';
const search = document.getElementById('hwfit-search')?.value?.trim() || '';
const remoteHost = _envState.remoteHost || '';
@@ -577,7 +598,10 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
// reload shows the last result with no spinner. We still fetch fresh below and
// swap it in. If there's no cache hit, fall back to the spinner.
const _sig = _scanSig();
const _cached = fresh ? null : _readScanCache(_sig);
let _cached = fresh ? null : _readScanCache(_sig);
if (!_cached && !fresh && (!allowNetwork || keepPrevious)) {
_cached = _readNearestScanCache(_sig);
}
const wp = spinnerModule.createWhirlpool(18);
const _paintedFromCache = !!_cached;
if (_cached) {
@@ -590,7 +614,10 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
}
_hwfitRenderList(list, _applyEngineFilter(_cached.models));
} else {
if (!allowNetwork) {
const canKeepPrevious = keepPrevious && _hwfitCache && Array.isArray(_hwfitCache.models);
if (canKeepPrevious) {
try { wp.destroy(); } catch {}
} else if (!allowNetwork) {
_hwfitCache = null;
_hwfitRenderHw(hw, null);
const loadingDiv = document.createElement('div');
@@ -618,23 +645,25 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
}
return;
}
// Show spinner while scanning — stack the spinner above a text label
// (the .hwfit-loading class is a centered flex ROW, so force column here).
const loadingDiv = document.createElement('div');
loadingDiv.className = 'hwfit-loading';
loadingDiv.style.flexDirection = 'column';
loadingDiv.style.gap = '6px';
loadingDiv.appendChild(wp.element);
// Text label like the other cookbook tabs: "Loading…", then if the scan runs
// long (remote SSH hardware probe), switch to "Scanning hardware…".
const loadingLbl = document.createElement('div');
loadingLbl.textContent = 'Loading…';
loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;';
loadingDiv.appendChild(loadingLbl);
setTimeout(() => { if (loadingLbl.isConnected) loadingLbl.textContent = 'Scanning hardware…'; }, 2000);
list.innerHTML = '';
list.appendChild(loadingDiv);
_hwfitCache = null; // no instant paint — clear until the fetch returns
if (!canKeepPrevious) {
// Show spinner while scanning — stack the spinner above a text label
// (the .hwfit-loading class is a centered flex ROW, so force column here).
const loadingDiv = document.createElement('div');
loadingDiv.className = 'hwfit-loading';
loadingDiv.style.flexDirection = 'column';
loadingDiv.style.gap = '6px';
loadingDiv.appendChild(wp.element);
// Text label like the other cookbook tabs: "Loading…", then if the scan runs
// long (remote SSH hardware probe), switch to "Scanning hardware…".
const loadingLbl = document.createElement('div');
loadingLbl.textContent = 'Loading…';
loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;';
loadingDiv.appendChild(loadingLbl);
setTimeout(() => { if (loadingLbl.isConnected) loadingLbl.textContent = 'Scanning hardware…'; }, 2000);
list.innerHTML = '';
list.appendChild(loadingDiv);
_hwfitCache = null; // no instant paint — clear until the fetch returns
}
}
if (!allowNetwork) {
try { wp.destroy(); } catch {}
@@ -674,7 +703,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
_setLastCacheHost('');
});
}
if (_paintedFromCache) {
if (_paintedFromCache && !forceRevalidate) {
try { wp.destroy(); } catch {}
return;
}
+41 -1
View File
@@ -3095,8 +3095,48 @@ export function isVisible() {
let _sharedSyncInFlight = false;
let _sharedSyncLast = 0;
const SHARED_STATE_LEADER_KEY = 'odysseus-cookbook-shared-state-leader';
const SHARED_STATE_LEADER_ID = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const SHARED_STATE_LEADER_TTL_MS = 12000;
function _foregroundChatBusy() {
try {
return !!window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0);
} catch (_) {
return false;
}
}
function _claimSharedStateLeader() {
if (document.visibilityState !== 'visible') return false;
const now = Date.now();
try {
const raw = localStorage.getItem(SHARED_STATE_LEADER_KEY);
const current = raw ? JSON.parse(raw) : null;
if (
!current
|| !current.id
|| current.id === SHARED_STATE_LEADER_ID
|| now - Number(current.ts || 0) > SHARED_STATE_LEADER_TTL_MS
) {
localStorage.setItem(SHARED_STATE_LEADER_KEY, JSON.stringify({ id: SHARED_STATE_LEADER_ID, ts: now }));
return true;
}
return current.id === SHARED_STATE_LEADER_ID;
} catch (_) {
return true;
}
}
function _canRefreshSharedCookbookState() {
if (!isVisible() || _sharedSyncInFlight) return false;
if (document.visibilityState !== 'visible') return false;
if (_foregroundChatBusy()) return false;
return _claimSharedStateLeader();
}
async function _refreshSharedCookbookState(reason = '') {
if (!isVisible() || _sharedSyncInFlight) return;
if (!_canRefreshSharedCookbookState()) return;
const now = Date.now();
if (now - _sharedSyncLast < 1500) return;
_sharedSyncInFlight = true;
+111 -45
View File
@@ -369,7 +369,7 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
// Polling / timeout intervals
const TASK_POLL_INTERVAL_MS = 3000; // delay between reconnect-loop iterations
const BG_MONITOR_INTERVAL_MS = 5000; // background task status poll
const BG_MONITOR_INTERVAL_MS = 10000; // background task status poll
const STALE_PROGRESS_MS = 5 * 60 * 1000; // download with no progress this long = stale
const STARTUP_STALE_PROGRESS_MS = 45 * 1000; // 0%-forever startup stall: retry much sooner
@@ -3457,6 +3457,10 @@ async function _reconnectTask(el, task) {
// ── Background monitor ──
let _bgMonitorInterval = null;
let _bgPollInFlight = false;
const BG_LEADER_KEY = 'odysseus-cookbook-bg-leader';
const BG_LEADER_ID = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const BG_LEADER_TTL_MS = 15000;
function _hasLiveTasks(tasks = null) {
const list = tasks || _loadTasks();
@@ -3475,67 +3479,122 @@ function _isRunningTabVisible() {
return activeTab === 'Running';
}
function _foregroundChatBusy() {
try {
return !!window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0);
} catch {
return false;
}
}
function _claimBackgroundLeader() {
if (document.visibilityState !== 'visible') return false;
const now = Date.now();
try {
const raw = localStorage.getItem(BG_LEADER_KEY);
const current = raw ? JSON.parse(raw) : null;
if (
!current
|| !current.id
|| current.id === BG_LEADER_ID
|| now - Number(current.ts || 0) > BG_LEADER_TTL_MS
) {
localStorage.setItem(BG_LEADER_KEY, JSON.stringify({ id: BG_LEADER_ID, ts: now }));
return true;
}
return current.id === BG_LEADER_ID;
} catch (_) {
return true;
}
}
function _canBackgroundPoll() {
if (_foregroundChatBusy()) return false;
if (document.visibilityState !== 'visible') return false;
return _claimBackgroundLeader();
}
// Reachability check for running serve tasks. The tmux pane can stay alive
// while the model server inside it has crashed (so no "Process exited" line
// ever appears) — leaving the card showing "running" forever. So we actively
// probe the registered endpoint (same /probe-local the model picker uses) and
// flag the card "unreachable" (red) when the server stops answering.
let _serveReachabilityInFlight = false;
let _serveReachabilityLastAt = 0;
async function _checkServeReachability() {
// This reaches out to local model servers. Keep it out of the normal chat
// path unless the user is actively looking at the Running tab.
if (_foregroundChatBusy()) return;
if (!_isRunningTabVisible()) return;
const now = Date.now();
if (_serveReachabilityInFlight || now - _serveReachabilityLastAt < 10000) return;
_serveReachabilityInFlight = true;
_serveReachabilityLastAt = now;
let serveTasks;
try {
serveTasks = _loadTasks().filter(t => t.type === 'serve' && t.status === 'running');
} catch { return; }
if (!serveTasks.length) return;
} catch {
_serveReachabilityInFlight = false;
return;
}
if (!serveTasks.length) {
_serveReachabilityInFlight = false;
return;
}
let eps = [], probe = {};
try {
[eps, probe] = await Promise.all([
fetch('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []),
fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})),
]);
} catch { return; }
for (const task of serveTasks) {
const host = _connectHostFromRemote(task.remoteHost);
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
const port = portMatch ? portMatch[1] : '8000';
const baseUrl = `http://${host}:${port}/v1`;
const ep = (eps || []).find(e => e.base_url === baseUrl);
if (!ep) continue; // not registered yet — can't judge
const pr = probe[ep.id];
if (!pr || pr.alive === undefined) continue; // not probed (non-local) — skip
// Record the first time it actually answers. Until then the server is still
// LOADING/warming (the endpoint can get registered on the 300s timeout for a
// big model that hasn't finished loading), and a not-yet-answering server is
// not "unreachable" — flagging it as such while you're launching is a false
// alarm. Only treat it as unreachable once it has been reachable at least once.
if (pr.alive === true && !task._everReachable) {
task._everReachable = true;
_updateTask(task.sessionId, { _everReachable: true });
}
const unreachable = pr.alive === false;
if (unreachable && !task._everReachable) continue; // still coming up, not crashed
if (!!task._unreachable !== unreachable) {
_updateTask(task.sessionId, { _unreachable: unreachable });
}
const el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`);
if (el) {
el.classList.toggle('cookbook-task-unreachable', unreachable);
const badge = el.querySelector('.cookbook-task-status');
if (badge) {
if (unreachable) {
badge.textContent = 'unreachable';
badge.className = 'cookbook-task-status cookbook-task-error';
badge.title = pr.error || 'Server not responding — it may have crashed';
} else if (badge.textContent === 'unreachable') {
// Recovered — restore the normal running label.
badge.textContent = _statusLabel('running', task.type);
badge.className = 'cookbook-task-status cookbook-task-running';
badge.title = '';
for (const task of serveTasks) {
const host = _connectHostFromRemote(task.remoteHost);
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
const port = portMatch ? portMatch[1] : '8000';
const baseUrl = `http://${host}:${port}/v1`;
const ep = (eps || []).find(e => e.base_url === baseUrl);
if (!ep) continue; // not registered yet — can't judge
const pr = probe[ep.id];
if (!pr || pr.alive === undefined) continue; // not probed (non-local) — skip
// Record the first time it actually answers. Until then the server is still
// LOADING/warming (the endpoint can get registered on the 300s timeout for a
// big model that hasn't finished loading), and a not-yet-answering server is
// not "unreachable" — flagging it as such while you're launching is a false
// alarm. Only treat it as unreachable once it has been reachable at least once.
if (pr.alive === true && !task._everReachable) {
task._everReachable = true;
_updateTask(task.sessionId, { _everReachable: true });
}
const unreachable = pr.alive === false;
if (unreachable && !task._everReachable) continue; // still coming up, not crashed
if (!!task._unreachable !== unreachable) {
_updateTask(task.sessionId, { _unreachable: unreachable });
}
const el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`);
if (el) {
el.classList.toggle('cookbook-task-unreachable', unreachable);
const badge = el.querySelector('.cookbook-task-status');
if (badge) {
if (unreachable) {
badge.textContent = 'unreachable';
badge.className = 'cookbook-task-status cookbook-task-error';
badge.title = pr.error || 'Server not responding — it may have crashed';
} else if (badge.textContent === 'unreachable') {
// Recovered — restore the normal running label.
badge.textContent = _statusLabel('running', task.type);
badge.className = 'cookbook-task-status cookbook-task-running';
badge.title = '';
}
}
}
if (unreachable) _showCookbookNotif(true);
}
if (unreachable) _showCookbookNotif(true);
_refreshServerDots();
} catch {
// Non-fatal: the normal task status poll continues separately.
} finally {
_serveReachabilityInFlight = false;
}
_refreshServerDots();
}
function _serveTaskFailed(task) {
@@ -3687,6 +3746,7 @@ export async function _selfHealStaleTasks(opts = {}) {
export function _startBackgroundMonitor() {
if (_bgMonitorInterval) return;
_bgMonitorInterval = setInterval(() => {
if (!_canBackgroundPoll()) return;
_pollBackgroundStatus();
_checkServeReachability();
// Auto-reconnect: every cycle, look for download tasks marked finished/
@@ -3697,8 +3757,10 @@ export function _startBackgroundMonitor() {
_selfHealStaleTasks().catch(() => {});
}
}, BG_MONITOR_INTERVAL_MS);
_pollBackgroundStatus();
_checkServeReachability();
if (_canBackgroundPoll()) {
_pollBackgroundStatus();
_checkServeReachability();
}
}
function _stopBackgroundMonitor() {
@@ -3748,6 +3810,8 @@ async function _probeEndpointUntilOnline(epId, host, port) {
}
async function _pollBackgroundStatus() {
if (!_canBackgroundPoll() || _bgPollInFlight) return;
_bgPollInFlight = true;
try {
// Pull any tasks the server knows about that aren't in localStorage
// yet (e.g. agent-spawned downloads/serves). Without this merge,
@@ -4013,6 +4077,8 @@ async function _pollBackgroundStatus() {
}
} catch (e) {
// Silent fail
} finally {
_bgPollInFlight = false;
}
}
+3
View File
@@ -227,6 +227,9 @@ function _initModelPickerDropdown() {
const _LOCAL_PROBE_TTL_MS = 5000;
async function _refreshLocalProbe() {
try {
if (window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0)) return;
} catch (_) {}
const now = Date.now();
if (now - _localProbeFetchedAt < _LOCAL_PROBE_TTL_MS) return;
_localProbeFetchedAt = now;
+59
View File
@@ -1843,6 +1843,8 @@ body.bg-pattern-sparkles {
}
.chat-meta { font-size:12px; color:color-mix(in srgb, var(--fg) 60%, transparent); margin-bottom:6px; }
.chat-history {
display:flex;
flex-direction:column;
flex:1;
overflow-y:auto;
overflow-x:hidden;
@@ -1854,6 +1856,9 @@ body.bg-pattern-sparkles {
padding-left: max(0px, calc((100% - var(--chat-max)) / 2));
padding-right: max(12px, calc((100% - var(--chat-max)) / 2 + 12px));
}
.chat-history > * {
flex: 0 0 auto;
}
/* Sortable Cookbook column headers had no visual cue, so users couldn't tell
a header was clickable (the Newest sort on the Model column was invisible).
Show a pointer + hover highlight, and underline the active sort column. */
@@ -2019,6 +2024,17 @@ body.bg-pattern-sparkles {
pointer-events: none;
transform: translate(-50%, -58%) scale(0.96);
}
.chat-container.welcome-active:has(.input-icon-btn.expanded) #welcome-screen,
.chat-container.welcome-active:has(.send-btn.newchat-expanded) #welcome-screen,
.chat-container.welcome-active:has(#model-picker-wrap:not(.picker-auto-hidden) .model-picker-menu:not(.hidden)) #welcome-screen {
opacity: 0.45;
transform: translate(-50%, -76%) scale(0.94);
}
.chat-container.welcome-active:has(.input-icon-btn.expanded) #welcome-screen .welcome-tip,
.chat-container.welcome-active:has(.send-btn.newchat-expanded) #welcome-screen .welcome-tip,
.chat-container.welcome-active:has(#model-picker-wrap:not(.picker-auto-hidden) .model-picker-menu:not(.hidden)) #welcome-screen .welcome-tip {
opacity: 0.14;
}
.chat-container.welcome-active textarea#message {
max-height: min(34vh, 150px);
}
@@ -2124,6 +2140,49 @@ body.bg-pattern-sparkles {
.msg-user .body {
color: var(--fg);
}
.chat-queued-bubble-host {
order: 2147483647;
display: flex;
flex-direction: column;
align-items: stretch;
flex: 0 0 auto;
width: 100%;
min-width: 0;
}
.msg-user.msg-user-queued {
opacity: 0.62;
cursor: pointer;
animation: none;
border: 1px dashed color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);
background: color-mix(in srgb, var(--accent, var(--red)) 8%, var(--input-bg, var(--panel)));
transition: opacity 0.12s ease, border-color 0.12s ease, transform 0.12s ease;
}
.msg-user.msg-user-queued:hover {
opacity: 0.9;
border-color: color-mix(in srgb, var(--accent, var(--red)) 85%, transparent);
transform: translateY(-1px);
}
.msg-user.msg-user-queued .queued-pill {
display: inline-flex;
align-items: center;
gap: 3px;
height: 14px;
padding: 0 5px;
margin-left: 5px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent);
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
vertical-align: 1px;
}
.msg-user.msg-user-queued .queued-pill svg {
flex: 0 0 auto;
opacity: 0.9;
}
.msg-ai .body {
color: var(--fg);
}