mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
Stabilize chat and cookbook workflows
This commit is contained in:
+17
-3
@@ -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
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user