← Back to Files
chat.js
let connection = null;
let currentConversationId = '';
let isStreaming = false;
let streamVisibleOutputStarted = false;
let allMessagesDiv = document.getElementById('messages');
let loadedMessageCount = 0;
let loadedOldestMessageIndex = 0;
let pendingMessage = null; // text queued while waiting for conversation creation
let reconnectTimer = null;
let reconnectAttempt = 0;
let viewportResizeTimer = null;
let streamResumeCheckTimer = null;
let scrollPinToken = 0;
let inputFocusBottomPinUntil = 0;
let outboundQueue = loadOutboundQueue();
let queueProcessing = false;
let appVersion = window.__kaiChatVersion || '';
let appVersionCheck = null;
let updateReloading = false;
let conversationOpenTimer = null;
let activePersona = 'Kai';
let availablePersonas = [];
let activeCompactionMessage = null;
let activeCompactionId = null;
let activeMemorySweepId = null;
let activeToolCalls = {};
let activeToolCallHost = null;
let activeAssistantTurn = null;
let svgRenderSources = {};
let svgRenderCounter = 0;
let svgLightboxKeyHandler = null;
let memoryUpdatePollTimer = null;
let memoryUpdateBusy = false;
let kaiNotificationRegistration = null;
let kaiForegroundGraceUntil = 0;
let kaiAnyClientForegroundUntil = 0;
let kaiForegroundHeartbeatTimer = null;
let kaiForegroundReportInFlight = false;
let kaiForegroundReportQueued = false;
let kaiForegroundReportLastActive = null;
const PAGE_SIZE = 50;
const DRAFT_KEY = 'kaichat.currentDraft';
const KAI_NOTIFICATIONS_KEY = 'kaichat.notifications.enabled';
const PERSONA_QUERY_KEY = 'persona';
function getInput() { return document.getElementById('messageInput'); }
function getSendBtn() { return document.getElementById('sendBtn'); }
function getStopBtn() { return document.getElementById('stopBtn'); }
function normalizePersona(value) {
var candidate = String(value || '').trim();
return candidate || 'Kai';
}
function getConversationUrl(id, personaOverride) {
var persona = normalizePersona(personaOverride || activePersona);
if (id) return '/?convo=' + encodeURIComponent(id) + '&persona=' + encodeURIComponent(persona || 'Kai');
return '/?persona=' + encodeURIComponent(persona || 'Kai');
}
function getConversationApiUrl(conversationId) {
var base = conversationId ? '/api/conversations/' + encodeURIComponent(conversationId) : '/api/conversations';
return base + '?persona=' + encodeURIComponent(activePersona || 'Kai');
}
function bootstrapApp() {
ensurePersonaSwitcherElement();
var isChatPage = !!document.getElementById('messages');
var params = new URLSearchParams(window.location.search);
var convoId = params.get('convo') || '';
activePersona = normalizePersona(params.get('persona'));
if (!params.get('persona')) {
try {
activePersona = normalizePersona(localStorage.getItem('kaichat.activePersona'));
} catch (e) {
activePersona = normalizePersona(activePersona);
}
}
var personaBootstrap = initializePersonaState()
.catch(function () { return null; });
setupViewportHandling();
setupAppLifecycleHandlers();
// Ensure persona and conversation list are initialized in a consistent order.
Promise.all([personaBootstrap])
.then(function () {
loadConversations();
})
.catch(function () {
loadConversations();
});
// Check test mode from API on page load
if (isChatPage) {
fetch('/api/settings/testmode')
.then(function (r) { return r.json(); })
.then(function (data) {
var badge = document.getElementById('testModeBadge');
if (badge) badge.style.display = data.testMode ? '' : 'none';
})
.catch(function () {});
}
if (isChatPage) {
setupInputHandlers();
restoreDraft(convoId);
setInputState(true);
showSystemMessage('Connecting to server...');
if (outboundQueue.length > 0) {
showSystemMessage(outboundQueue.length === 1 ? '1 message queued' : outboundQueue.length + ' messages queued');
}
setupConnection();
} else {
// Sub-pages: set up a lightweight connection just for sidebar conversation list updates
setupSidebarConnection();
}
setupMemoryUpdateControls();
setupKaiNotifications();
Promise.all([personaBootstrap])
.then(function () {
if (convoId) {
loadConversation(convoId);
}
})
.catch(function () {
if (convoId) {
loadConversation(convoId);
}
});
window.addEventListener('beforeunload', saveCurrentDraft);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
bootstrapApp();
});
} else {
bootstrapApp();
}
function isChatPage() {
return !!document.getElementById('messages');
}
function ensurePersonaSwitcherElement() {
if (document.getElementById('personaSwitcher')) return;
var topBar = document.querySelector('.top-bar');
if (!topBar) return;
var label = document.createElement('label');
label.htmlFor = 'personaSwitcher';
label.className = 'sr-only';
label.textContent = 'Persona';
var select = document.createElement('select');
select.id = 'personaSwitcher';
select.className = 'top-bar-persona-select';
select.title = 'Current persona';
select.onchange = function () { onPersonaSwitcherChange(this.value); };
var option = document.createElement('option');
option.value = 'Kai';
option.textContent = 'Kai';
select.appendChild(option);
// Insert before title, matching markup order used by layout template.
var title = topBar.querySelector('#conversationTitle');
if (title && title.parentNode) {
topBar.insertBefore(label, title);
topBar.insertBefore(select, title);
} else {
topBar.appendChild(label);
topBar.appendChild(select);
}
}
function extractPersonasFromMarkup() {
var selector = document.getElementById('personaSwitcher');
if (!selector || !selector.options || selector.options.length === 0) return [];
var seen = {};
var fallbackPersonas = [];
for (var i = 0; i < selector.options.length; i++) {
var option = selector.options[i];
var value = normalizePersona(option.value || option.textContent || option.label);
if (!value) continue;
var key = value.toLowerCase();
if (seen[key]) continue;
seen[key] = true;
fallbackPersonas.push({
name: option.textContent || value,
key: value,
hasConversations: false,
conversationCount: 0
});
}
return fallbackPersonas;
}
function getCurrentReloadTarget() {
return currentConversationId ? getConversationUrl(currentConversationId) : window.location.href;
}
function refreshPersonaSwitcher(persona) {
var selector = document.getElementById('personaSwitcher');
if (!selector) return;
activePersona = normalizePersona(persona || activePersona);
var options = availablePersonas.length
? availablePersonas
: [{ name: 'Kai', key: 'Kai' }];
selector.innerHTML = options
.map(function (item) {
var value = normalizePersona(item.key || item.name || item);
var label = item.name || value;
var escapedValue = escapeHtml(value);
var escapedLabel = escapeHtml(label);
return '<option value="' + escapedValue + '">' + escapedLabel + '</option>';
})
.join('');
selector.value = activePersona;
if (selector.value !== activePersona) {
var fallback = document.createElement('option');
fallback.value = activePersona;
fallback.textContent = activePersona;
selector.appendChild(fallback);
selector.value = activePersona;
}
}
function resolveAvailablePersona(value) {
var normalized = normalizePersona(value);
// If the persona isn't in the catalog yet, just return the normalized name.
if (!Array.isArray(availablePersonas)) return normalized || 'Kai';
for (var i = 0; i < availablePersonas.length; i++) {
var item = availablePersonas[i];
var candidate = normalizePersona(item.key || item.name || item);
if (candidate.toLowerCase() === normalized.toLowerCase()) {
return candidate;
}
}
// Not found in catalog: fall back to the first persona with conversations.
var fallback = getFirstPersonaWithConversation();
if (fallback) return normalizePersona(fallback);
return normalized || 'Kai';
}
function hasConversationData(item) {
if (!item) return false;
if (item.hasConversations === true) return true;
var count = Number(item.conversationCount || 0);
return count > 0;
}
function getFirstPersonaWithConversation() {
if (!Array.isArray(availablePersonas) || availablePersonas.length === 0) return '';
for (var i = 0; i < availablePersonas.length; i++) {
if (hasConversationData(availablePersonas[i])) {
return availablePersonas[i].key || availablePersonas[i].name || availablePersonas[i];
}
}
return '';
}
function applyPersonaToNavigation(persona, forceUrl) {
var normalized = resolveAvailablePersona(persona);
var didChange = normalized !== normalizePersona(activePersona);
activePersona = normalized;
try {
localStorage.setItem('kaichat.activePersona', activePersona);
} catch (e) {}
refreshPersonaSwitcher(activePersona);
if (isChatPage() && forceUrl) {
var nextUrl = getConversationUrl(currentConversationId || '', activePersona);
if (window.location.pathname === '/' && window.location.href !== nextUrl) {
window.history.replaceState({}, document.title, nextUrl);
}
}
return didChange;
}
function initializePersonaState() {
return loadPersonas()
.then(function () {
return applyPersonaToNavigation(activePersona);
})
.catch(function () {
availablePersonas = [{ name: 'Kai', key: 'Kai' }];
return applyPersonaToNavigation(activePersona);
});
}
function loadPersonas() {
var selector = document.getElementById('personaSwitcher');
if (!selector) return Promise.resolve();
selector.disabled = true;
return fetch('/api/personas')
.then(function (response) {
if (!response.ok) throw new Error('Unable to load personas.');
return response.json();
})
.then(function (data) {
if (Array.isArray(data)) {
availablePersonas = data;
} else {
availablePersonas = extractPersonasFromMarkup();
if (!availablePersonas.length) {
availablePersonas = [{ name: 'Kai', key: 'Kai' }];
}
}
})
.catch(function () {
availablePersonas = extractPersonasFromMarkup();
if (!availablePersonas.length) {
availablePersonas = [{ name: 'Kai', key: 'Kai' }];
}
})
.finally(function () {
selector.disabled = false;
});
}
function onPersonaSwitcherChange(persona) {
if (!persona) return;
var nextPersona = normalizePersona(persona);
if (nextPersona === activePersona) return;
activePersona = nextPersona;
try { localStorage.setItem('kaichat.activePersona', activePersona); } catch (e) {}
var selector = document.getElementById('personaSwitcher');
if (selector) selector.value = activePersona;
if (!isChatPage()) {
return;
}
var targetConversationId = currentConversationId || (new URLSearchParams(window.location.search).get('convo') || '');
if (!targetConversationId) {
var newUrl = getConversationUrl('', activePersona);
if (window.location.pathname === '/' && window.location.href === new URL(newUrl, window.location.origin).href) return;
window.location.href = newUrl;
return;
}
fetch('/api/conversations/' + encodeURIComponent(targetConversationId) + '?persona=' + encodeURIComponent(activePersona))
.then(function (response) {
if (response.ok) {
window.location.href = getConversationUrl(targetConversationId, activePersona);
return;
}
window.location.href = getConversationUrl('', activePersona);
})
.catch(function () {
window.location.href = getConversationUrl('', activePersona);
});
}
function cacheBustedUrl(targetUrl) {
var url = new URL(targetUrl || window.location.href, window.location.origin);
url.searchParams.set('_kc_update', Date.now().toString());
return url.toString();
}
function isServerUpdateAvailable() {
if (!appVersion || updateReloading) return Promise.resolve(false);
if (appVersionCheck) return appVersionCheck;
var request = fetch('/api/app/version?_=' + Date.now(), {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
})
.then(function (r) {
if (!r.ok) return false;
return r.json();
})
.then(function (data) {
var serverVersion = data && data.version ? String(data.version) : '';
return !!serverVersion && serverVersion !== String(appVersion);
})
.catch(function () { return false; });
appVersionCheck = request.then(function (result) {
appVersionCheck = null;
return result;
}, function () {
appVersionCheck = null;
return false;
});
return appVersionCheck;
}
function checkForServerUpdate(targetUrl) {
return isServerUpdateAvailable().then(function (updateAvailable) {
if (updateAvailable) reloadForServerUpdate(targetUrl);
return updateAvailable;
});
}
function reloadForServerUpdate(targetUrl) {
if (updateReloading) return;
updateReloading = true;
saveCurrentDraft();
saveOutboundQueue();
setConnectionStatus('reconnecting', 'Updating', 'Refreshing KaiChat...');
showSystemMessage('KaiChat updated. Refreshing...');
setTimeout(function () {
window.location.replace(cacheBustedUrl(targetUrl || getCurrentReloadTarget()));
}, 80);
}
function saveCurrentDraft() {
var input = getInput();
if (!input) return;
var text = input.value || '';
try {
if (!text) {
localStorage.removeItem(DRAFT_KEY);
return;
}
localStorage.setItem(DRAFT_KEY, JSON.stringify({
conversationId: currentConversationId || new URLSearchParams(window.location.search).get('convo') || '',
text: text,
savedAt: new Date().toISOString()
}));
} catch (e) {}
}
function restoreDraft(preferredConversationId) {
var input = getInput();
if (!input || input.value) return;
try {
var raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
var draft = JSON.parse(raw);
if (!draft || !draft.text) return;
if ((draft.conversationId || '') !== (preferredConversationId || '')) return;
input.value = draft.text;
resizeInput(input);
} catch (e) {}
}
function clearCurrentDraft() {
try { localStorage.removeItem(DRAFT_KEY); } catch (e) {}
}
function resizeInput(input) {
if (!input) return;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
}
function setupViewportHandling() {
if (navigator.virtualKeyboard) {
try { navigator.virtualKeyboard.overlaysContent = false; } catch (e) {}
}
updateAppViewportHeight();
window.addEventListener('resize', function () {
var keepMessagesPinned = shouldKeepMessagesPinnedForViewport();
updateAppViewportHeight();
queueViewportSettledScroll(keepMessagesPinned);
});
window.addEventListener('orientationchange', function () {
var keepMessagesPinned = shouldKeepMessagesPinnedForViewport();
updateAppViewportHeight();
queueViewportSettledScroll(keepMessagesPinned);
});
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', function () {
var keepMessagesPinned = shouldKeepMessagesPinnedForViewport();
updateAppViewportHeight();
queueViewportSettledScroll(keepMessagesPinned);
});
window.visualViewport.addEventListener('scroll', function () {
var keepMessagesPinned = shouldKeepMessagesPinnedForViewport();
updateAppViewportHeight();
queueViewportSettledScroll(keepMessagesPinned);
});
}
}
function setupAppLifecycleHandlers() {
markKaiForegroundActive();
startKaiForegroundHeartbeat();
window.addEventListener('focus', function () {
markKaiForegroundActive();
scheduleStreamingResumeCheck('focus');
});
window.addEventListener('blur', function () {
if (document.hidden) markKaiForegroundInactive();
});
window.addEventListener('pageshow', function () {
markKaiForegroundActive();
scheduleStreamingResumeCheck('pageshow');
});
window.addEventListener('pagehide', function () {
markKaiForegroundInactive();
});
document.addEventListener('visibilitychange', function () {
if (!document.hidden) {
markKaiForegroundActive();
scheduleStreamingResumeCheck('visible');
} else {
markKaiForegroundInactive();
}
});
}
function markKaiForegroundActive() {
kaiForegroundGraceUntil = Date.now() + 15000;
reportKaiForegroundState(true);
}
function markKaiForegroundInactive() {
kaiForegroundGraceUntil = 0;
reportKaiForegroundState(false);
}
function startKaiForegroundHeartbeat() {
if (kaiForegroundHeartbeatTimer) return;
kaiForegroundHeartbeatTimer = setInterval(function () {
if (isKaiPageForeground()) {
markKaiForegroundActive();
}
}, 10000);
}
function isKaiPageForeground() {
if (document.visibilityState === 'visible' || document.hidden === false) return true;
if (typeof document.hasFocus === 'function' && document.hasFocus()) return true;
return Date.now() < kaiForegroundGraceUntil;
}
function isAnyKaiClientForeground() {
return isKaiPageForeground() || Date.now() < kaiAnyClientForegroundUntil;
}
function reportKaiForegroundState(active) {
kaiForegroundReportLastActive = !!active;
if (!isConnected()) {
kaiForegroundReportQueued = true;
return;
}
if (kaiForegroundReportInFlight) {
kaiForegroundReportQueued = true;
return;
}
kaiForegroundReportInFlight = true;
kaiForegroundReportQueued = false;
connection.invoke('ReportClientForeground', kaiForegroundReportLastActive)
.catch(function () {})
.finally(function () {
kaiForegroundReportInFlight = false;
if (kaiForegroundReportQueued) {
reportKaiForegroundState(kaiForegroundReportLastActive);
}
});
}
function flushKaiForegroundState() {
if (!isConnected()) return;
reportKaiForegroundState(isKaiPageForeground());
}
function shouldSuppressKaiNotification() {
if (isAnyKaiClientForeground()) return Promise.resolve(true);
if (!isConnected()) return Promise.resolve(false);
return connection.invoke('HasForegroundClient')
.then(function (active) {
if (active) kaiAnyClientForegroundUntil = Date.now() + 20000;
return !!active || isKaiPageForeground();
})
.catch(function () {
return isKaiPageForeground();
});
}
function updateAppViewportHeight() {
var viewport = window.visualViewport;
var height = viewport && viewport.height ? viewport.height : window.innerHeight;
if (!height) return;
document.documentElement.style.setProperty('--app-height', Math.round(height) + 'px');
}
function queueViewportSettledScroll(keepMessagesPinned) {
if (!isChatPage()) return;
if (viewportResizeTimer) clearTimeout(viewportResizeTimer);
viewportResizeTimer = setTimeout(function () {
viewportResizeTimer = null;
if (keepMessagesPinned || window.__stickToBottom || shouldPinBottomForFocusedInput()) {
scrollBottom(true);
}
}, 80);
}
function isTouchLikeDevice() {
return !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
}
function isMessageInputFocused() {
var input = getInput();
return !!input && document.activeElement === input;
}
function shouldPinBottomForFocusedInput() {
return isMessageInputFocused() && Date.now() < inputFocusBottomPinUntil;
}
function shouldKeepMessagesPinnedForViewport() {
return isChatPage() && (window.__stickToBottom || isAtBottom(180) || shouldPinBottomForFocusedInput());
}
function pinBottomForInputFocus() {
if (!isChatPage()) return;
var mobileFocus = isTouchLikeDevice();
var wasPinned = window.__stickToBottom || isAtBottom(180);
if (!mobileFocus && !wasPinned) return;
inputFocusBottomPinUntil = Date.now() + 1500;
scrollBottom(true);
queueViewportSettledScroll(true);
[120, 280, 600, 1000, 1450].forEach(function (delay) {
setTimeout(function () {
if (shouldPinBottomForFocusedInput()) scrollBottom(true);
}, delay);
});
}
function shouldAutoFocusInput() {
return !isTouchLikeDevice();
}
function focusInputIfAppropriate() {
var input = getInput();
if (input && shouldAutoFocusInput()) input.focus();
}
function setConnectionStatus(state, text, detail) {
var bar = document.getElementById('statusBar');
var textEl = document.getElementById('statusText');
var detailEl = document.getElementById('statusDetail');
if (!bar || !textEl || !detailEl) return;
bar.dataset.state = state;
textEl.textContent = text;
detailEl.textContent = detail || '';
}
function setupMemoryUpdateControls() {
var panel = document.getElementById('memoryUpdatePanel');
if (!panel) return;
var btn = document.getElementById('memoryUpdateForceBtn');
if (btn) {
btn.addEventListener('click', function () {
forceMemoryUpdate();
});
}
var reconcileBtn = document.getElementById('memoryReconcileAllBtn');
if (reconcileBtn) {
reconcileBtn.addEventListener('click', function () {
reconcileAllMemory();
});
}
var resetBtn = document.getElementById('memoryUpdateResetFuseBtn');
if (resetBtn) {
resetBtn.addEventListener('click', function () {
resetMemoryUpdateFuseAndRun();
});
}
refreshMemoryUpdateStatus(true);
if (memoryUpdatePollTimer) clearInterval(memoryUpdatePollTimer);
memoryUpdatePollTimer = setInterval(function () {
refreshMemoryUpdateStatus(true);
}, 30000);
}
function refreshMemoryUpdateStatus(silent) {
if (!document.getElementById('memoryUpdatePanel')) return Promise.resolve();
return fetch('/api/memory/update/status?_=' + Date.now(), {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
})
.then(function (r) {
if (!r.ok) throw new Error('Status ' + r.status);
return r.json();
})
.then(function (data) {
updateMemoryUpdatePanel(data);
return data;
})
.catch(function (err) {
if (!silent) setMemoryUpdatePanelError(err && err.message ? err.message : String(err || 'Status failed'));
});
}
function forceMemoryUpdate() {
if (memoryUpdateBusy) return;
memoryUpdateBusy = true;
setMemoryUpdatePanelBusy('Starting...');
fetch('/api/memory/update/force', {
method: 'POST',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
})
.then(function (r) {
if (!r.ok) throw new Error('Force run failed: ' + r.status);
return r.json();
})
.then(function (data) {
if (data && data.status) updateMemoryUpdatePanel(data.status, data.message);
setTimeout(function () { refreshMemoryUpdateStatus(true); }, 1000);
setTimeout(function () { refreshMemoryUpdateStatus(true); }, 5000);
})
.catch(function (err) {
setMemoryUpdatePanelError(err && err.message ? err.message : String(err || 'Force run failed'));
})
.finally(function () {
memoryUpdateBusy = false;
refreshMemoryUpdateStatus(true);
});
}
function resetMemoryUpdateFuseAndRun() {
if (memoryUpdateBusy) return;
memoryUpdateBusy = true;
setMemoryUpdatePanelBusy('Resetting fuse and running...');
fetch('/api/memory/update/reset-and-run', {
method: 'POST',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
})
.then(function (r) {
if (!r.ok) throw new Error('Fuse reset run failed: ' + r.status);
return r.json();
})
.then(function (data) {
if (data && data.status) updateMemoryUpdatePanel(data.status, data.message);
setTimeout(function () { refreshMemoryUpdateStatus(true); }, 1000);
setTimeout(function () { refreshMemoryUpdateStatus(true); }, 5000);
})
.catch(function (err) {
setMemoryUpdatePanelError(err && err.message ? err.message : String(err || 'Fuse reset failed'));
})
.finally(function () {
memoryUpdateBusy = false;
refreshMemoryUpdateStatus(true);
});
}
function reconcileAllMemory() {
if (memoryUpdateBusy) return;
memoryUpdateBusy = true;
setMemoryUpdatePanelBusy('Starting full reconciliation...');
fetch('/api/memory/update/reconcile-all', {
method: 'POST',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
})
.then(function (r) {
if (!r.ok) throw new Error('Full reconciliation failed: ' + r.status);
return r.json();
})
.then(function (data) {
if (data && data.status) updateMemoryUpdatePanel(data.status, data.message);
setTimeout(function () { refreshMemoryUpdateStatus(true); }, 1000);
setTimeout(function () { refreshMemoryUpdateStatus(true); }, 5000);
})
.catch(function (err) {
setMemoryUpdatePanelError(err && err.message ? err.message : String(err || 'Full reconciliation failed'));
})
.finally(function () {
memoryUpdateBusy = false;
refreshMemoryUpdateStatus(true);
});
}
function updateMemoryUpdatePanel(data, transientMessage) {
var stateEl = document.getElementById('memoryUpdateState');
var detailEl = document.getElementById('memoryUpdateDetail');
var btn = document.getElementById('memoryUpdateForceBtn');
var reconcileBtn = document.getElementById('memoryReconcileAllBtn');
var resetBtn = document.getElementById('memoryUpdateResetFuseBtn');
if (!stateEl || !detailEl) return;
var fuseTripped = data && data.fuseIsTripped;
var state = 'Waiting';
var stateClass = 'waiting';
if (data && data.isRunning) {
state = 'Running';
stateClass = 'running';
} else if (fuseTripped) {
state = 'Fuse tripped';
stateClass = 'warn';
} else if (data && data.lastError) {
state = 'Error';
stateClass = 'error';
} else if (data && data.isDue) {
state = 'Due';
stateClass = 'due';
} else if (data && !data.latestPendingUserMessageUtc) {
state = 'Idle';
stateClass = 'idle';
}
stateEl.className = 'memory-update-state ' + stateClass;
stateEl.textContent = state;
detailEl.textContent = transientMessage || buildMemoryUpdateDetail(data);
if (btn) btn.disabled = memoryUpdateBusy || !!(data && data.isRunning);
if (reconcileBtn) reconcileBtn.disabled = memoryUpdateBusy || !!(data && data.isRunning);
if (resetBtn) {
resetBtn.style.display = fuseTripped ? '' : 'none';
resetBtn.disabled = memoryUpdateBusy || !!(data && data.isRunning);
}
}
function setMemoryUpdatePanelBusy(text) {
var stateEl = document.getElementById('memoryUpdateState');
var detailEl = document.getElementById('memoryUpdateDetail');
var btn = document.getElementById('memoryUpdateForceBtn');
var reconcileBtn = document.getElementById('memoryReconcileAllBtn');
var resetBtn = document.getElementById('memoryUpdateResetFuseBtn');
if (stateEl) {
stateEl.className = 'memory-update-state running';
stateEl.textContent = 'Running';
}
if (detailEl) detailEl.textContent = text || '';
if (btn) btn.disabled = true;
if (reconcileBtn) reconcileBtn.disabled = true;
if (resetBtn) resetBtn.disabled = true;
}
function setMemoryUpdatePanelError(message) {
var stateEl = document.getElementById('memoryUpdateState');
var detailEl = document.getElementById('memoryUpdateDetail');
var btn = document.getElementById('memoryUpdateForceBtn');
var reconcileBtn = document.getElementById('memoryReconcileAllBtn');
var resetBtn = document.getElementById('memoryUpdateResetFuseBtn');
if (stateEl) {
stateEl.className = 'memory-update-state error';
stateEl.textContent = 'Error';
}
if (detailEl) detailEl.textContent = message || 'Memory status failed';
if (btn) btn.disabled = false;
if (reconcileBtn) reconcileBtn.disabled = false;
if (resetBtn && resetBtn.style.display !== 'none') resetBtn.disabled = false;
}
function buildMemoryUpdateDetail(data) {
if (!data) return '';
if (data.isRunning) {
return 'Started ' + formatMemoryClock(data.lastRunStartedUtc) + ' - ' + (data.lastRunStatus || 'Running memory update...');
}
if (data.fuseIsTripped) {
var failureRate = (data.fuseConsecutiveFailureCount || 0) + '/' + (data.fuseFailureThreshold || 0);
var tripped = data.fuseTrippedUtc ? (' (since ' + formatMemoryClock(data.fuseTrippedUtc) + ')') : '';
var reason = data.fuseLastFailureReason ? (' Last failure: ' + data.fuseLastFailureReason) : '';
return 'Fuse tripped after ' + failureRate + ' consecutive failures' + tripped + '. ' + reason + ' Press "Reset fuse + run now" to retry.';
}
if (data.lastError) {
return data.lastError;
}
if (data.isDue) {
return 'Ready now' + (data.lastRunStatus ? ' - ' + data.lastRunStatus : '');
}
if (data.nextEligibleUpdateUtc) {
return 'Next ' + formatMemoryClock(data.nextEligibleUpdateUtc) + ' - in ' + formatMemoryTimeSpan(data.delayUntilEligible);
}
if (data.lastRunCompletedUtc) {
return 'Last ' + formatMemoryClock(data.lastRunCompletedUtc) + ' - ' + (data.lastRunStatus || 'Completed');
}
if (data.marker && !data.marker.exists) {
return 'No pending user messages - marker absent';
}
if (data.marker && data.marker.exists && !data.marker.parsedUtc) {
return 'Marker exists but its timestamp is invalid';
}
if (data.lastUpdateUtc) {
return 'No pending user messages newer than marker ' + formatMemoryClock(data.lastUpdateUtc);
}
return 'No pending user messages';
}
function formatMemoryClock(value) {
if (!value) return '--';
var d = new Date(value);
if (isNaN(d.getTime())) return '--';
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function formatMemoryTimeSpan(value) {
if (!value || typeof value !== 'string') return '--';
var days = 0;
var clean = value;
var dot = clean.indexOf('.');
if (dot >= 0) {
var beforeDot = clean.slice(0, dot);
var afterDot = clean.slice(dot + 1);
if (beforeDot.indexOf(':') < 0 && afterDot.indexOf(':') >= 0) {
days = parseInt(beforeDot, 10) || 0;
clean = afterDot;
} else {
clean = beforeDot;
}
}
var parts = clean.split(':').map(function (part) { return parseInt(part, 10) || 0; });
var hours = parts.length > 2 ? parts[0] : 0;
var minutes = parts.length > 2 ? parts[1] : (parts[0] || 0);
if (days > 0) return days + 'd ' + hours + 'h';
if (hours > 0) return hours + 'h ' + minutes + 'm';
return Math.max(0, minutes) + 'm';
}
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function scheduleReconnect(reason) {
clearReconnectTimer();
reconnectAttempt += 1;
var delay = Math.min(30000, 1000 * Math.pow(2, Math.min(reconnectAttempt, 5)));
var seconds = Math.ceil(delay / 1000);
setConnectionStatus('reconnecting', 'Reconnecting', 'Retrying in ' + seconds + 's' + (reason ? ': ' + reason : ''));
reconnectTimer = setTimeout(function () {
reconnectTimer = null;
startHubConnection(false);
}, delay);
}
function startHubConnection(isInitial) {
if (!connection) return;
if (connection.state && connection.state !== 'Disconnected') return;
var reconnecting = reconnectAttempt > 0;
setConnectionStatus(reconnecting ? 'reconnecting' : 'connecting', reconnecting ? 'Reconnecting' : 'Connecting', 'Contacting KaiChat...');
if (isChatPage() && !isStreaming) setInputState(true);
connection.start().then(function () {
clearReconnectTimer();
reconnectAttempt = 0;
setConnectionStatus('connected', 'Connected', 'KaiChat is ready');
removeSystemMessage('Connecting to server...');
removeSystemMessage('Still connecting to server...');
removeSystemMessage('Reconnecting to server...');
removeSystemMessage('Connection lost. Reconnecting...');
removeSystemMessage('Server unreachable. Reconnecting...');
if (isChatPage() && !isStreaming) {
setInputState(true);
focusInputIfAppropriate();
}
loadConversations();
joinCurrentConversation();
flushKaiForegroundState();
scheduleStreamingResumeCheck('connected');
processOutboundQueue();
}).catch(function (err) {
var message = err && err.message ? err.message : String(err || 'Connection failed');
if (isInitial) removeSystemMessage('Connecting to server...');
setConnectionStatus('offline', 'Offline', message);
if (isChatPage() && !isStreaming) setInputState(true);
if (isInitial && isChatPage()) {
showSystemMessage('Server unreachable. Reconnecting...');
}
scheduleReconnect(message);
});
}
function joinCurrentConversation() {
if (!isConnected() || !currentConversationId) return;
connection.invoke('JoinConversation', currentConversationId, activePersona)
.catch(function (err) {
showErrorMessage('Could not rejoin stream: ' + normalizeErrorMessage(err));
});
}
function loadOutboundQueue() {
try {
var raw = localStorage.getItem('kaichat.outboundQueue');
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function saveOutboundQueue() {
try {
localStorage.setItem('kaichat.outboundQueue', JSON.stringify(outboundQueue));
} catch (e) {}
}
function enqueueOutboundMessage(item, replaceMessageId) {
var existingIndex = findQueuedMessageIndexForReplacement(item, replaceMessageId);
if (existingIndex >= 0) {
outboundQueue[existingIndex] = item;
} else {
outboundQueue.push(item);
}
saveOutboundQueue();
showOutboundQueueStatus();
}
function buildOutboundMessage(text, title, msgId, truncateBeforeMessageId, conversationId, persona) {
return {
conversationId: conversationId !== undefined ? conversationId : (currentConversationId || ''),
text: text,
title: title,
msgId: msgId || generateId(),
truncateBeforeMessageId: truncateBeforeMessageId || null,
persona: normalizePersona(persona || activePersona),
queuedAt: new Date().toISOString()
};
}
function findQueuedMessageForRenderedMessage(messageId) {
if (!messageId) return null;
for (var i = 0; i < outboundQueue.length; i++) {
var item = outboundQueue[i];
if (item && item.msgId === messageId) return item;
}
return null;
}
function findQueuedMessageIndexForReplacement(item, replaceMessageId) {
var conversationId = String(item && item.conversationId || '');
var persona = normalizePersona(item && item.persona || activePersona);
var msgId = item && item.msgId;
var truncateBeforeMessageId = item && item.truncateBeforeMessageId;
for (var i = 0; i < outboundQueue.length; i++) {
var queued = outboundQueue[i];
if (!queued) continue;
if (String(queued.conversationId || '') !== conversationId) continue;
if (normalizePersona(queued.persona || activePersona) !== persona) continue;
if (replaceMessageId && queued.msgId === replaceMessageId) return i;
if (replaceMessageId && queued.truncateBeforeMessageId === replaceMessageId) return i;
if (msgId && queued.msgId === msgId) return i;
if (truncateBeforeMessageId && queued.truncateBeforeMessageId === truncateBeforeMessageId) return i;
}
return -1;
}
function removeOutboundQueueStatusMessages() {
if (!allMessagesDiv) return;
var msgs = allMessagesDiv.querySelectorAll('.message.system');
for (var i = 0; i < msgs.length; i++) {
var text = msgs[i].textContent || '';
if (text === 'Message queued' || text === '1 message queued' || /^\d+ messages queued$/.test(text)) {
msgs[i].remove();
}
}
}
function showOutboundQueueStatus() {
removeOutboundQueueStatusMessages();
if (outboundQueue.length === 0) return;
showSystemMessage(outboundQueue.length === 1 ? 'Message queued' : outboundQueue.length + ' messages queued');
}
function processOutboundQueue() {
if (!isChatPage() || queueProcessing || isStreaming || !isConnected() || outboundQueue.length === 0) return;
queueProcessing = true;
isServerUpdateAvailable().then(function (updateAvailable) {
if (updateAvailable) {
queueProcessing = false;
reloadForServerUpdate(getCurrentReloadTarget());
return;
}
processNextOutboundMessage();
}).catch(function () {
processNextOutboundMessage();
});
}
function processNextOutboundMessage() {
if (!isChatPage() || isStreaming || !isConnected() || outboundQueue.length === 0) {
queueProcessing = false;
return;
}
var item = outboundQueue.shift();
saveOutboundQueue();
var persona = normalizePersona(item.persona || activePersona);
setStreamingState(true);
removeOutboundQueueStatusMessages();
removeSystemMessage('Sending queued message...');
showSystemMessage('Sending queued message...');
var conversationId = item.conversationId || currentConversationId || '';
var title = conversationId ? null : item.title;
ensureQueuedMessageRendered(item);
connection.invoke('SendMessage', conversationId, item.text, title, item.msgId, item.truncateBeforeMessageId || null, persona)
.then(function () {
queueProcessing = false;
removeSystemMessage('Sending queued message...');
})
.catch(function (err) {
outboundQueue.unshift(item);
saveOutboundQueue();
queueProcessing = false;
setStreamingState(false);
handleError(err);
});
}
function ensureQueuedMessageRendered(item) {
if (!allMessagesDiv || hasRenderedUserMessage(item.msgId)) return;
appendUserMsg(item.text, item.msgId);
removeWelcome();
}
function findRenderedUserMessage(msgId) {
if (!msgId || !allMessagesDiv) return false;
var messages = allMessagesDiv.querySelectorAll('.message.user');
for (var i = 0; i < messages.length; i++) {
if (messages[i].dataset.msgId === msgId) return messages[i];
}
return null;
}
function hasRenderedUserMessage(msgId) {
return !!findRenderedUserMessage(msgId);
}
function updateRenderedUserMessageFromSaved(msg) {
var msgId = msg && (msg.id || msg.Id || '');
var rendered = findRenderedUserMessage(msgId);
if (!rendered) return false;
setStoredTimestamp(rendered, msg);
return true;
}
function createHubConnection() {
if (typeof window.signalR === 'undefined') return null;
var options = {};
if (signalR.HttpTransportType) {
// Remote tunnels/reverse proxies often mishandle WebSocket upgrades.
// SSE/long-polling are slower but much more reliable over plain HTTP proxies.
options.transport = signalR.HttpTransportType.ServerSentEvents | signalR.HttpTransportType.LongPolling;
}
return new signalR.HubConnectionBuilder()
.withUrl('/chatHub', options)
.withAutomaticReconnect()
.build();
}
function setupConnection() {
connection = createHubConnection();
if (!connection) {
removeSystemMessage('Connecting to server...');
setConnectionStatus('offline', 'Offline', 'Chat connection script failed to load');
showSystemMessage('Chat connection script failed to load.');
setInputState(false);
return;
}
connection.on('StreamReplayStarted', function (conversationId) {
if (currentConversationId && conversationId && conversationId !== currentConversationId) return;
resetActiveStreamUi();
setStreamingState(true);
removeSystemMessage('Connecting to server...');
removeSystemMessage('Reconnecting to server...');
removeSystemMessage('Still connecting to server...');
});
connection.on('UserMessageSaved', function (conversationId, msg) {
if (!allMessagesDiv) return;
if (currentConversationId && conversationId && conversationId !== currentConversationId) return;
if (!currentConversationId && conversationId) {
currentConversationId = conversationId;
document.getElementById('currentConversationId').value = conversationId;
history.replaceState(null, '', getConversationUrl(conversationId));
}
var msgId = msg && (msg.id || msg.Id || '');
if (updateRenderedUserMessageFromSaved(msg)) return;
appendUserMsg(getMessageContent(msg), msgId, msg);
removeWelcome();
});
connection.on('KaiForegroundClientsChanged', function (active) {
kaiAnyClientForegroundUntil = active ? Date.now() + 20000 : 0;
});
connection.on('ReceiveToken', function (token) {
markStreamVisibleOutputStarted();
removeSystemMessage('Thinking...');
var shouldAutoScroll = isAtBottom();
var lastMsg = ensureAssistantResponseBlock();
if (!lastMsg) return;
// Accumulate raw text (with [TOOL_CALL:...] markers) for eventual rendering.
lastMsg.dataset.raw += token;
// Live rendering: throttle to every 200ms so we don't re-render on every token.
var now = Date.now();
if (!lastMsg._lastRender || now - lastMsg._lastRender > 200) {
lastMsg._lastRender = now;
var displayText = sanitizeAssistantDisplayText(lastMsg.dataset.translated || lastMsg.dataset.raw);
lastMsg.innerHTML = renderMarkdown(displayText);
}
scrollBottom(shouldAutoScroll);
});
connection.on('StreamStart', function () {
setStreamingState(true);
streamVisibleOutputStarted = false;
activeToolCallHost = null;
ensureAssistantTurn();
window.__curThinkingContent = null;
});
connection.on('ReceiveThinking', function (raw) {
markStreamVisibleOutputStarted();
removeSystemMessage('Thinking...');
var shouldAutoScroll = isAtBottom(180) || window.__stickToBottom;
var div = ensureThinkingContentBlock();
if (!div) return;
div.textContent += raw;
scrollThinkingContentToBottom(div);
scrollBottom(shouldAutoScroll);
});
connection.on('ThinkingTranslated', function (translated) {
var pre = window.__curThinkingContent || document.querySelector('.thinking-block:last-of-type .thinking-content');
if (pre) {
pre.textContent = translated;
var summary = pre.closest('details').querySelector('summary');
if (summary) summary.textContent = 'Thinking (translated from Chinese)';
}
});
connection.on('ThinkingTranslationStarted', function (attempt, maxAttempts) {
if (attempt && maxAttempts) {
updateStreamRetryNotice('Translating thinking ' + attempt + '/' + maxAttempts);
}
var pre = window.__curThinkingContent || document.querySelector('.thinking-block:last-of-type .thinking-content');
if (pre) {
pre.textContent = '';
var summary = pre.closest('details').querySelector('summary');
if (summary) summary.textContent = 'Thinking (translated from Chinese)';
scrollThinkingContentToBottom(pre);
}
});
connection.on('ThinkingTranslationDelta', function (token) {
var pre = window.__curThinkingContent || document.querySelector('.thinking-block:last-of-type .thinking-content');
if (pre) {
var shouldAutoScroll = isAtBottom(180) || window.__stickToBottom;
pre.textContent += token;
scrollThinkingContentToBottom(pre);
scrollBottom(shouldAutoScroll);
}
});
connection.on('ResponseTranslated', function (translated) {
var lastMsg = getLastOpenAssistantResponseMessage();
if (lastMsg) {
var shouldAutoScroll = isAtBottom();
// Keep data-raw intact (it holds [TOOL_CALL:...] markers) so
// renderLastMessageMarkdown can still render tool call blocks.
lastMsg.dataset.translated = translated;
delete lastMsg.dataset.translationActive;
delete lastMsg.dataset.translationOriginal;
delete lastMsg.dataset.translationConsumed;
lastMsg.innerHTML = renderMarkdown(translated);
lastMsg.dataset.rendered = '1';
scrollBottom(shouldAutoScroll);
}
});
connection.on('ResponseTranslationStarted', function (attempt, maxAttempts) {
markStreamVisibleOutputStarted();
var lastMsg = ensureAssistantForTranslation();
if (!lastMsg) return;
if (attempt && maxAttempts) {
updateStreamRetryNotice((attempt > 1 ? 'Retrying' : 'Translating') + ' response translation ' + attempt + '/' + maxAttempts + (attempt > 1 ? ': Chinese output detected' : ''));
}
var shouldAutoScroll = isAtBottom();
lastMsg.dataset.translationOriginal = lastMsg.dataset.translationOriginal || lastMsg.dataset.raw || lastMsg.textContent || '';
lastMsg.dataset.translationConsumed = '0';
lastMsg.dataset.translated = '';
lastMsg.dataset.translationActive = '1';
lastMsg._lastTranslationRender = 0;
delete lastMsg.dataset.rendered;
lastMsg.innerHTML = renderTranslationProgress('', lastMsg.dataset.translationOriginal, 0);
scrollBottom(shouldAutoScroll);
});
connection.on('ResponseTranslationDelta', function (token) {
var lastMsg = ensureAssistantForTranslation();
if (!lastMsg) return;
var shouldAutoScroll = isAtBottom();
lastMsg.dataset.translated = (lastMsg.dataset.translated || '') + token;
var original = lastMsg.dataset.translationOriginal || lastMsg.dataset.raw || '';
var consumed = parseInt(lastMsg.dataset.translationConsumed || '0', 10);
consumed += Math.max(1, Math.ceil(countSymbols(token) / 4));
consumed = Math.min(countSymbols(original), consumed);
lastMsg.dataset.translationConsumed = String(consumed);
delete lastMsg.dataset.rendered;
var now = Date.now();
if (!lastMsg._lastTranslationRender || now - lastMsg._lastTranslationRender > 100) {
lastMsg._lastTranslationRender = now;
lastMsg.innerHTML = renderTranslationProgress(lastMsg.dataset.translated, original, consumed);
}
scrollBottom(shouldAutoScroll);
});
connection.on('ResponseTranslationRetry', function (attempt, maxAttempts) {
updateStreamRetryNotice('Retrying response translation ' + attempt + '/' + maxAttempts + ': Chinese output detected');
});
connection.on('ThinkingTranslationRetry', function (attempt, maxAttempts) {
updateStreamRetryNotice('Retrying thinking translation ' + attempt + '/' + maxAttempts + ': Chinese output detected');
});
connection.on('ToolCall', function (toolName, status) {
markStreamVisibleOutputStarted();
removeSystemMessage('Thinking...');
appendToolNotice(status || toolName || 'Tool activity');
});
connection.on('ToolCallStarted', function (id, method, path, status, body) {
markStreamVisibleOutputStarted();
removeSystemMessage('Thinking...');
createToolCallCard(id, method, path, status, body || '');
});
connection.on('ToolCallResult', function (id, status, result, isError) {
updateToolCallCard(id, status, result, !!isError);
});
connection.on('DiscardAssistantDraft', function () {
markStreamVisibleOutputStarted();
removeAssistantDraft();
});
connection.on('AssistantRetry', function (attempt, maxAttempts, reason) {
markStreamVisibleOutputStarted();
removeAssistantAttemptDraft();
updateStreamRetryNotice('Retrying response ' + attempt + '/' + maxAttempts + ': ' + (reason || 'Chinese output detected'));
});
connection.on('AssistantSegment', function (content) {
markStreamVisibleOutputStarted();
appendAssistantSegment(content);
});
connection.on('KaiMessageAppended', function (conversationId, msg, source) {
loadConversations();
if (isChatPage() && (!currentConversationId || currentConversationId === conversationId)) {
if (!currentConversationId && conversationId) {
currentConversationId = conversationId;
document.getElementById('currentConversationId').value = conversationId;
history.replaceState(null, '', getConversationUrl(conversationId));
}
removeWelcome();
var state = { assistantTurn: null };
appendStoredAssistantMessage(allMessagesDiv, state, msg);
scrollBottom(isAtBottom());
}
notifyKaiMessage(conversationId, msg, source || 'assistant');
});
connection.on('ReplaceAssistantDraft', function (content) {
replaceAssistantDraft(content || '');
});
connection.on('TokenUpdate', function (promptTokens, completionTokens, combined, compactionPct) {
var display = document.getElementById('tokenDisplay');
if (display) {
display.textContent = promptTokens + ' + ' + completionTokens + ' = ' + combined + ' tokens';
}
// Update compaction progress bar
var barContainer = document.getElementById('compactionBarContainer');
var barFill = document.getElementById('compactionBarFill');
var label = document.getElementById('compactionLabel');
if (barContainer && barFill && label) {
if (compactionPct !== null && compactionPct !== undefined && compactionPct > 0) {
barContainer.style.display = '';
var pct = Math.min(100, compactionPct);
barFill.style.width = pct + '%';
label.textContent = pct + '%';
} else {
barContainer.style.display = 'none';
}
}
});
connection.on('SystemMessage', function (message) {
showSystemMessage(message);
});
connection.on('MemorySweepStarted', function (evt) {
var data = normalizeMemorySweepEvent(evt);
activeMemorySweepId = data.id;
renderLiveMemorySweepEvent(data);
});
connection.on('MemorySweepStatus', function (evt) {
updateLiveMemorySweepStatus(normalizeMemorySweepEvent(evt));
});
connection.on('MemorySweepComplete', function (evt) {
completeLiveMemorySweepEvent(normalizeMemorySweepEvent(evt));
activeMemorySweepId = null;
});
connection.on('CompactionStarted', function (evt) {
var data = normalizeCompactionEvent(evt);
activeCompactionId = data.id;
renderLiveCompactionEvent(data);
});
connection.on('CompactionStatus', function (evt) {
updateLiveCompactionStatus(normalizeCompactionEvent(evt));
});
connection.on('CompactionThinkingDelta', function (evt) {
appendLiveCompactionText(normalizeCompactionEvent(evt), 'thinking');
});
connection.on('CompactionSummaryDelta', function (evt) {
appendLiveCompactionText(normalizeCompactionEvent(evt), 'summary');
});
connection.on('CompactionComplete', function (evt) {
completeLiveCompactionEvent(normalizeCompactionEvent(evt));
activeCompactionId = null;
});
connection.on('StreamComplete', function () {
setStreamingState(false);
removeSystemMessage('Thinking...');
// Final render of ALL unrendered assistant messages
var shouldAutoScroll = isAtBottom();
renderLastMessageMarkdown();
// Mark the streaming message as closed so the next response creates a new element
var lastMsg = getLastAssistantMessage();
if (lastMsg) {
lastMsg.dataset.streamClosed = '1';
if (!lastMsg.dataset.timestamp) {
lastMsg.dataset.timestamp = formatTimestamp(new Date());
}
}
closeStreamingThinkingBlock();
activeToolCallHost = null;
finalizeStreamRetryNotice();
if (activeAssistantTurn) activeAssistantTurn.dataset.streamClosed = '1';
activeAssistantTurn = null;
if (lastMsg && lastMsg.dataset.raw) {
lastMsg.dataset.notifyId = lastMsg.dataset.notifyId || generateId();
notifyKaiMessage(currentConversationId, {
id: lastMsg.dataset.notifyId,
content: lastMsg.dataset.raw
}, 'assistant_stream');
}
queueProcessing = false;
scrollBottom(shouldAutoScroll);
focusInputIfAppropriate();
loadConversations();
processOutboundQueue();
});
connection.on('StreamCancelled', function () {
setStreamingState(false);
queueProcessing = false;
removeSystemMessage('Thinking...');
closeStreamingThinkingBlock();
var container = activeAssistantTurn || allMessagesDiv;
var last = container ? container.lastElementChild : null;
if (last && last.classList.contains('assistant') && !last.dataset.streamClosed) last.remove();
if (activeAssistantTurn && activeAssistantTurn.children.length === 0) activeAssistantTurn.remove();
activeToolCallHost = null;
if (activeAssistantTurn) activeAssistantTurn.dataset.streamClosed = '1';
activeAssistantTurn = null;
showSystemMessage('Response stopped');
});
connection.on('Error', function (error) {
setStreamingState(false);
queueProcessing = false;
removeSystemMessage('Thinking...');
closeStreamingThinkingBlock();
activeToolCallHost = null;
if (activeAssistantTurn) activeAssistantTurn.dataset.streamClosed = '1';
activeAssistantTurn = null;
showErrorMessage(error);
processOutboundQueue();
});
connection.on('ConversationCreated', function (conv) {
currentConversationId = conv.id;
document.getElementById('currentConversationId').value = conv.id;
document.getElementById('conversationTitle').textContent = conv.title || 'Kai';
history.replaceState(null, '', getConversationUrl(conv.id));
joinCurrentConversation();
loadConversations();
// If there's a message queued waiting for this conversation, send it now
if (pendingMessage) {
var text = pendingMessage;
pendingMessage = null;
removeSystemMessage('Creating conversation...');
showSystemMessage('Thinking...');
setStreamingState(true);
var msgId = generateId();
appendUserMsg(text, msgId);
connection.invoke('SendMessage', currentConversationId, text, null, msgId, null, activePersona)
.catch(function (err) { handleError(err); });
} else {
setInputState(!isStreaming);
if (!isStreaming) focusInputIfAppropriate();
}
});
connection.on('ConversationDeleted', function (id) {
if (currentConversationId === id) {
currentConversationId = '';
document.getElementById('currentConversationId').value = '';
document.getElementById('conversationTitle').textContent = 'Kai';
clearMessages();
showWelcome();
}
loadConversations();
});
connection.on('ConversationRenamed', function (id, title) {
if (id === currentConversationId && title) {
document.getElementById('conversationTitle').textContent = title;
}
loadConversations();
});
connection.on('ConversationsChanged', function () { loadConversations(); });
connection.onreconnecting(function () {
if (!isStreaming) setInputState(true);
setConnectionStatus('reconnecting', 'Reconnecting', 'Connection interrupted');
showSystemMessage('Reconnecting to server...');
});
connection.onreconnected(function () {
clearReconnectTimer();
reconnectAttempt = 0;
setConnectionStatus('connected', 'Connected', 'KaiChat is ready');
removeSystemMessage('Reconnecting to server...');
removeSystemMessage('Still connecting to server...');
removeSystemMessage('Connection lost. Reconnecting...');
removeSystemMessage('Server unreachable. Reconnecting...');
if (!isStreaming) {
setInputState(true);
focusInputIfAppropriate();
}
loadConversations();
joinCurrentConversation();
flushKaiForegroundState();
scheduleStreamingResumeCheck('reconnected');
processOutboundQueue();
showToast('Reconnected', 2000);
});
connection.onclose(function () {
if (!isStreaming) applyStreamingControls(false);
showSystemMessage('Connection lost. Reconnecting...');
scheduleReconnect('Connection closed');
});
startHubConnection(true);
}
function setupSidebarConnection() {
// Lightweight SignalR connection for sidebar updates on sub-pages
connection = createHubConnection();
if (!connection) {
setConnectionStatus('offline', 'Offline', 'Chat connection script failed to load');
return;
}
connection.on('ConversationRenamed', function () { loadConversations(); });
connection.on('ConversationsChanged', function () { loadConversations(); });
connection.onreconnecting(function () {
setConnectionStatus('reconnecting', 'Reconnecting', 'Connection interrupted');
});
connection.onreconnected(function () {
clearReconnectTimer();
reconnectAttempt = 0;
setConnectionStatus('connected', 'Connected', 'KaiChat is ready');
loadConversations();
flushKaiForegroundState();
});
connection.onclose(function () {
scheduleReconnect('Connection closed');
});
startHubConnection(true);
}
function setupInputHandlers() {
const input = getInput();
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
input.addEventListener('input', function () {
resizeInput(input);
saveCurrentDraft();
});
input.addEventListener('focus', function () {
updateAppViewportHeight();
pinBottomForInputFocus();
});
input.addEventListener('blur', function () {
updateAppViewportHeight();
});
// Wire Send button click
getSendBtn().addEventListener('click', function () {
sendMessage();
});
// Wire Stop button click
getStopBtn().addEventListener('click', function () {
stopStreaming();
});
}
function sendMessage() {
const input = getInput();
const text = input.value.trim();
if (!text) return;
if (isStreaming && !canSendPreTokenSteering()) return;
input.value = '';
resizeInput(input);
clearCurrentDraft();
if (isStreaming) {
sendSteeringMessage(text);
return;
}
var msgId = generateId();
var title = currentConversationId ? null : (text.length > 40 ? text.substring(0, 40) + '...' : text);
// Render the user message immediately so it's always visible — even if
// the page reloads for a server update or the connection is offline.
appendUserMsg(text, msgId);
removeWelcome();
isServerUpdateAvailable().then(function (updateAvailable) {
if (updateAvailable) {
enqueueOutboundMessage(buildOutboundMessage(text, title, msgId, null, currentConversationId || '', activePersona));
reloadForServerUpdate(getCurrentReloadTarget());
return;
}
sendPreparedMessage(text, title, msgId, null);
});
}
function canSendPreTokenSteering() {
return isStreaming && !streamVisibleOutputStarted && isConnected() && !!currentConversationId;
}
function sendSteeringMessage(text) {
var msgId = generateId();
appendUserMsg(text, msgId);
removeWelcome();
showSystemMessage('Updating pending response...');
setStreamingState(true);
connection.invoke('SendMessage', currentConversationId, text, null, msgId, null, activePersona)
.then(function () {
removeSystemMessage('Updating pending response...');
showSystemMessage('Thinking...');
})
.catch(function (err) {
removeSystemMessage('Updating pending response...');
handleError(err);
});
}
function sendPreparedMessage(text, title, msgId, truncateBeforeMessageId) {
if (!isConnected()) {
enqueueOutboundMessage(buildOutboundMessage(text, title, msgId, truncateBeforeMessageId, currentConversationId || '', activePersona));
setInputState(true);
return;
}
showSystemMessage('Thinking...');
setStreamingState(true);
connection.invoke('SendMessage', currentConversationId || '', text, title, msgId, truncateBeforeMessageId || null, activePersona)
.catch(function (err) {
if (shouldQueueAfterSendError(err)) {
enqueueOutboundMessage(buildOutboundMessage(text, title, msgId, truncateBeforeMessageId, currentConversationId || '', activePersona));
recoverAfterQueuedSend(err);
return;
}
handleError(err);
});
}
function shouldQueueAfterSendError(err) {
if (!isConnected()) return true;
var msg = ((err && err.message) ? err.message : String(err || '')).toLowerCase();
return msg.indexOf('connection') !== -1
|| msg.indexOf('disconnect') !== -1
|| msg.indexOf('timeout') !== -1
|| msg.indexOf('transport') !== -1
|| msg.indexOf('network') !== -1;
}
function recoverAfterQueuedSend(err) {
var msg = err && err.message ? err.message : String(err || 'Connection interrupted');
setStreamingState(false);
queueProcessing = false;
removeSystemMessage('Thinking...');
setConnectionStatus('reconnecting', 'Reconnecting', msg);
if (connection && connection.state === 'Connected') {
connection.stop().catch(function () {});
}
scheduleReconnect(msg);
}
function createAndSend(text) {
if (!isConnected()) {
var msgId = generateId();
appendUserMsg(text, msgId);
removeWelcome();
enqueueOutboundMessage(buildOutboundMessage(text, text.length > 40 ? text.substring(0, 40) + '...' : text, msgId, null, '', activePersona));
return;
}
appendUserMsg(text);
removeWelcome();
showSystemMessage('Creating conversation...');
pendingMessage = text;
var title = text.length > 40 ? text.substring(0, 40) + '...' : text;
connection.invoke('CreateConversation', title, activePersona)
.catch(function (err) {
pendingMessage = null;
showErrorMessage(err);
});
}
function stopStreaming() {
if (!isStreaming) return;
if (!isConnected()) return;
connection.invoke('StopStreaming', currentConversationId || null).catch(function () {});
}
function retryLastMessage() {
var msgs = allMessagesDiv.querySelectorAll('.message.user');
if (msgs.length === 0) return;
var last = msgs[msgs.length - 1];
getInput().value = last.dataset.originalText || last.textContent;
resizeInput(getInput());
saveCurrentDraft();
getInput().focus();
}
function editMessage(btn) {
var msgDiv = btn.closest('.message.user');
if (!msgDiv) return;
var original = msgDiv.dataset.originalText || msgDiv.textContent;
msgDiv.innerHTML = '<div class="msg-edit-area">'
+ '<textarea class="msg-edit-input">' + escapeHtml(original) + '</textarea>'
+ '<div class="msg-edit-actions">'
+ '<button class="btn btn-small btn-primary" onclick="saveEdit(this)">Save</button>'
+ '<button class="btn btn-small" onclick="cancelEdit(this)">Cancel</button>'
+ '</div></div>';
msgDiv.querySelector('.msg-edit-input').focus();
}
function saveEdit(btn) {
var area = btn.closest('.msg-edit-area');
var textarea = area.querySelector('.msg-edit-input');
var newText = textarea.value.trim();
if (!newText) return;
var msgDiv = btn.closest('.message.user');
msgDiv.innerHTML = '';
msgDiv.textContent = newText;
msgDiv.dataset.originalText = newText;
addMsgActions(msgDiv, newText);
resendText(newText, msgDiv);
}
function cancelEdit(btn) {
var area = btn.closest('.msg-edit-area');
var msgDiv = area.closest('.message.user');
var original = msgDiv.dataset.originalText || msgDiv.textContent;
msgDiv.innerHTML = '';
msgDiv.textContent = original;
addMsgActions(msgDiv, original);
}
function resendText(text, afterMsg) {
if (isStreaming) return;
// Capture the message ID before removing DOM for server-side truncation
var msgId = afterMsg.dataset.msgId || '';
var queuedMessage = findQueuedMessageForRenderedMessage(msgId);
var truncateBeforeMessageId = queuedMessage ? (queuedMessage.truncateBeforeMessageId || null) : msgId;
var replaceQueuedMessageId = queuedMessage ? msgId : null;
// Remove the target message and everything after it (including assistant responses)
var found = false;
while (allMessagesDiv.lastChild) {
if (allMessagesDiv.lastChild === afterMsg) {
found = true;
allMessagesDiv.lastChild.remove();
break;
}
allMessagesDiv.lastChild.remove();
}
if (!found) return;
// Re-add the user message before sending
var newMsgId = generateId();
appendUserMsg(text, newMsgId);
removeWelcome();
if (!isConnected()) {
enqueueOutboundMessage(
buildOutboundMessage(text, null, newMsgId, truncateBeforeMessageId, currentConversationId || '', activePersona),
replaceQueuedMessageId);
setInputState(true);
return;
}
showSystemMessage('Thinking...');
setStreamingState(true);
connection.invoke('SendMessage', currentConversationId, text, null, newMsgId, truncateBeforeMessageId, activePersona)
.catch(function (err) {
if (shouldQueueAfterSendError(err)) {
enqueueOutboundMessage(
buildOutboundMessage(text, null, newMsgId, truncateBeforeMessageId, currentConversationId || '', activePersona),
replaceQueuedMessageId);
recoverAfterQueuedSend(err);
return;
}
handleError(err);
});
}
// --- UI helpers ---
function resetActiveStreamUi() {
if (!allMessagesDiv) return;
var streamingThinking = allMessagesDiv.querySelectorAll('.thinking-block[data-streaming="1"]');
streamingThinking.forEach(function (node) { node.remove(); });
var assistantMessages = allMessagesDiv.querySelectorAll('.message.assistant');
assistantMessages.forEach(function (msg) {
if (!msg.dataset.streamClosed && !msg.dataset.rendered) msg.remove();
});
activeToolCalls = {};
activeToolCallHost = null;
activeAssistantTurn = null;
window.__curThinkingContent = null;
forceScrollBottom();
}
function removeAssistantDraft() {
if (!allMessagesDiv) return;
var shouldAutoScroll = isAtBottom();
closeStreamingThinkingBlock();
var lastMsg = getLastAssistantResponseMessage();
if (lastMsg && !lastMsg.dataset.streamClosed) {
lastMsg.remove();
}
window.__curThinkingContent = null;
scrollBottom(shouldAutoScroll);
}
function removeAssistantAttemptDraft() {
if (!allMessagesDiv) return;
var shouldAutoScroll = isAtBottom();
var turn = activeAssistantTurn || allMessagesDiv;
var last = turn.lastElementChild;
if (isAssistantResponseBlock(last) && !last.dataset.streamClosed) {
last.remove();
last = turn.lastElementChild;
}
if (last && last.classList && last.classList.contains('thinking-block')) {
last.remove();
}
window.__curThinkingContent = null;
scrollBottom(shouldAutoScroll);
}
function updateStreamRetryNotice(text) {
if (!allMessagesDiv) return;
var shouldAutoScroll = isAtBottom();
var turn = ensureAssistantTurn() || allMessagesDiv;
var notice = turn.querySelector('.stream-retry-notice[data-active="1"]');
if (!notice) {
notice = document.createElement('div');
notice.className = 'stream-retry-notice';
notice.dataset.active = '1';
turn.appendChild(notice);
}
notice.textContent = text;
scrollBottom(shouldAutoScroll);
}
function finalizeStreamRetryNotice() {
var turn = activeAssistantTurn;
if (!turn) return;
var notice = turn.querySelector('.stream-retry-notice[data-active="1"]');
if (notice) delete notice.dataset.active;
}
function scrollThinkingContentToBottom(node) {
if (!node) return;
node.scrollTop = node.scrollHeight;
}
function closeStreamingThinkingBlock() {
if (!allMessagesDiv) return;
var streamingThinking = allMessagesDiv.querySelector('.thinking-block[data-streaming="1"]:last-of-type');
if (!streamingThinking) return;
var content = streamingThinking.querySelector('.thinking-content');
if (!content || !content.textContent.trim()) {
streamingThinking.remove();
} else {
delete streamingThinking.dataset.streaming;
}
window.__curThinkingContent = null;
}
function isAssistantResponseBlock(node) {
return !!node
&& node.classList
&& node.classList.contains('message')
&& node.classList.contains('assistant')
&& !node.classList.contains('tool-call-host');
}
function isStreamingThinkingBlock(node) {
return !!node
&& node.classList
&& node.classList.contains('thinking-block')
&& node.dataset.streaming === '1';
}
function closeOpenAssistantResponseBlock() {
var turn = activeAssistantTurn && document.body.contains(activeAssistantTurn)
? activeAssistantTurn
: null;
var last = turn ? turn.lastElementChild : getLastAssistantResponseMessage();
if (!isAssistantResponseBlock(last) || last.dataset.streamClosed) return;
last.dataset.streamClosed = '1';
if (!last.dataset.timestamp) {
last.dataset.timestamp = formatTimestamp(new Date());
}
}
function getCurrentThinkingContent() {
var div = window.__curThinkingContent;
if (!div || !document.body.contains(div)) return null;
var block = div.closest('.thinking-block');
var turn = block ? block.closest('.assistant-turn') : null;
if (!isStreamingThinkingBlock(block)) return null;
if (turn && turn.lastElementChild !== block) return null;
return div;
}
function ensureThinkingContentBlock() {
var current = getCurrentThinkingContent();
if (current) return current;
closeOpenAssistantResponseBlock();
activeToolCallHost = null;
var details = createThinkingBlock('', true);
(ensureAssistantTurn() || allMessagesDiv).appendChild(details);
var div = details.querySelector('.thinking-content');
window.__curThinkingContent = div;
return div;
}
function ensureAssistantResponseBlock() {
if (!allMessagesDiv) return null;
var turn = ensureAssistantTurn();
var lastMsg = turn ? turn.lastElementChild : allMessagesDiv.lastElementChild;
if (isAssistantResponseBlock(lastMsg) && !lastMsg.dataset.streamClosed) {
return lastMsg;
}
closeStreamingThinkingBlock();
activeToolCallHost = null;
var msg = document.createElement('div');
msg.className = 'message assistant';
msg.dataset.raw = '';
(turn || allMessagesDiv).appendChild(msg);
return msg;
}
function createAssistantTurnElement(closed) {
var turn = document.createElement('div');
turn.className = 'assistant-turn';
if (closed) turn.dataset.streamClosed = '1';
return turn;
}
function createThinkingBlock(thinking, streaming) {
var details = document.createElement('details');
details.className = 'thinking-block';
details.open = true;
if (streaming) details.dataset.streaming = '1';
var summary = document.createElement('summary');
summary.textContent = 'Thinking...';
var content = document.createElement('div');
content.className = 'thinking-content';
content.textContent = thinking || '';
details.appendChild(summary);
details.appendChild(content);
return details;
}
function ensureAssistantTurn() {
if (!allMessagesDiv) return null;
if (activeAssistantTurn && document.body.contains(activeAssistantTurn)) return activeAssistantTurn;
var last = allMessagesDiv.lastElementChild;
if (last && last.classList.contains('assistant-turn') && !last.dataset.streamClosed) {
activeAssistantTurn = last;
return activeAssistantTurn;
}
var turn = createAssistantTurnElement(false);
allMessagesDiv.appendChild(turn);
activeAssistantTurn = turn;
return turn;
}
function appendToolNotice(status) {
var shouldAutoScroll = isAtBottom();
var toolDiv = document.createElement('div');
toolDiv.className = 'message tool-call';
toolDiv.textContent = status;
allMessagesDiv.appendChild(toolDiv);
scrollBottom(shouldAutoScroll);
}
function appendAssistantSegment(content) {
content = sanitizeAssistantDisplayText(content || '');
if (!allMessagesDiv || !content || !content.trim()) return;
removeSystemMessage('Thinking...');
var shouldAutoScroll = isAtBottom();
var msg = ensureAssistantResponseBlock();
if (!msg) return;
msg.dataset.raw = [msg.dataset.raw || '', content].filter(Boolean).join('\n\n');
msg.innerHTML = renderMarkdown(sanitizeAssistantDisplayText(msg.dataset.raw));
msg.dataset.rendered = '1';
scrollBottom(shouldAutoScroll);
}
function replaceAssistantDraft(content) {
if (!allMessagesDiv) return;
var shouldAutoScroll = isAtBottom();
var lastMsg = getLastAssistantResponseMessage();
if (!lastMsg || lastMsg.dataset.streamClosed) return;
var cleanContent = sanitizeAssistantDisplayText(content || '');
if (!cleanContent.trim()) {
lastMsg.remove();
scrollBottom(shouldAutoScroll);
return;
}
lastMsg.dataset.raw = cleanContent;
lastMsg.innerHTML = renderMarkdown(cleanContent);
lastMsg.dataset.rendered = '1';
scrollBottom(shouldAutoScroll);
}
function ensureToolCallHost() {
if (!allMessagesDiv) return null;
var turn = ensureAssistantTurn();
closeStreamingThinkingBlock();
closeOpenAssistantResponseBlock();
var msg = document.createElement('div');
msg.className = 'message assistant tool-call-host';
msg.dataset.raw = '';
msg.dataset.rendered = '1';
msg.dataset.streamClosed = '1';
msg.dataset.timestamp = formatTimestamp(new Date());
(turn || allMessagesDiv).appendChild(msg);
activeToolCallHost = msg;
return msg;
}
function createToolCallCard(id, method, path, status, parameters) {
if (!allMessagesDiv) return;
var shouldAutoScroll = isAtBottom();
var host = ensureToolCallHost();
if (!host) return;
var card = document.createElement('details');
card.className = 'tool-call-block running';
card.open = true;
card.dataset.toolCallId = id || generateId();
var summary = document.createElement('summary');
summary.innerHTML =
'<span class="tool-call-title">' + escapeHtml(method || 'TOOL') + ' ' + escapeHtml(path || '') + '</span>'
+ '<span class="tool-call-state">running</span>';
var body = document.createElement('div');
body.className = 'tool-call-body';
body.innerHTML =
'<div class="tool-call-status">' + escapeHtml(status || 'Running tool...') + '</div>'
+ '<details class="tool-call-params"><summary>Parameters</summary><pre class="tool-call-params-json"></pre></details>'
+ '<pre class="tool-call-result" style="display:none"></pre>';
body.querySelector('.tool-call-params-json').textContent = formatToolParameters(parameters || '');
card.appendChild(summary);
card.appendChild(body);
host.appendChild(card);
activeToolCalls[card.dataset.toolCallId] = card;
scrollBottom(shouldAutoScroll);
}
function updateToolCallCard(id, status, result, isError) {
if (!allMessagesDiv) return;
var card = activeToolCalls[id];
if (!card) {
createToolCallCard(id, 'TOOL', '', status || 'Tool result', '');
card = activeToolCalls[id];
}
if (!card) return;
var shouldAutoScroll = isAtBottom();
var isSkipped = String(status || '').toLowerCase().indexOf('skipped') >= 0;
card.classList.remove('running', 'success', 'error', 'skipped');
card.classList.add(isError ? 'error' : isSkipped ? 'skipped' : 'success');
card.open = !!isError || isSkipped;
var state = card.querySelector('.tool-call-state');
if (state) state.textContent = isError ? 'error' : isSkipped ? 'skipped' : 'done';
var statusEl = card.querySelector('.tool-call-status');
if (statusEl) statusEl.textContent = status || (isError ? 'Tool error' : 'Tool complete');
var resultEl = card.querySelector('.tool-call-result');
if (resultEl) {
resultEl.style.display = '';
resultEl.textContent = formatToolResultPreview(result || '');
}
delete activeToolCalls[id];
scrollBottom(shouldAutoScroll);
}
function formatToolResultPreview(result) {
var text = String(result || '');
var max = 12000;
if (text.length <= max) return text;
return text.substring(0, max) + '\n\n[Result truncated in live view; full result was provided to Kai and saved in Tool Logs.]';
}
function formatToolParameters(parameters) {
var text = String(parameters || '').trim();
if (!text) return '{}';
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch (_) {
return text;
}
}
function sanitizeAssistantDisplayText(text) {
text = String(text || '');
if (!text) return '';
var output = '';
var searchIndex = 0;
var header = findToolResultHeader(text, searchIndex);
while (header) {
output += text.substring(searchIndex, header.index);
searchIndex = findToolResultBlockEnd(text, header);
header = findToolResultHeader(text, searchIndex);
}
output += text.substring(searchIndex);
return normalizeVisibleAssistantText(output);
}
function findToolResultHeader(text, startIndex) {
var re = /^[ \t]*(?:Tool execution results?\s*\(round\s+\d+\)|PRIVATE TOOL OUTPUT[^\r\n]*)\s*:?[ \t]*(?:\r?\n|$)/gim;
re.lastIndex = startIndex || 0;
return re.exec(text);
}
function findToolResultBlockEnd(text, header) {
var bodyStart = header.index + header[0].length;
var nextHeader = findToolResultHeader(text, bodyStart);
var headerText = header[0].toLowerCase();
if (headerText.indexOf('private tool output') >= 0) {
var privateEnd = indexOfIgnoreCase(text, 'END PRIVATE TOOL OUTPUT', bodyStart);
if (privateEnd >= 0 && (!nextHeader || privateEnd < nextHeader.index)) {
return advancePastWhitespace(text, privateEnd + 'END PRIVATE TOOL OUTPUT'.length);
}
}
var instructionStart = indexOfIgnoreCase(text, 'Use this tool result before answering Raymond.', bodyStart);
if (instructionStart >= 0 && (!nextHeader || instructionStart < nextHeader.index)) {
return advancePastWhitespace(text, findToolInstructionEnd(text, instructionStart));
}
return nextHeader ? nextHeader.index : text.length;
}
function findToolInstructionEnd(text, instructionStart) {
var phrases = [
'Do not keep sampling alternatives just because more files are available. If a tool call was skipped as a repeat, use the earlier result or answer directly instead of trying the same call again.',
'If this was skipped as a repeat, use the earlier result or answer directly instead of trying the same call again.',
'If you cannot fix it, explain the problem clearly to Raymond.'
];
var bestEnd = -1;
phrases.forEach(function (phrase) {
var index = indexOfIgnoreCase(text, phrase, instructionStart);
if (index >= 0) bestEnd = Math.max(bestEnd, index + phrase.length);
});
if (bestEnd >= 0) return bestEnd;
var paragraphEnd = text.indexOf('\n\n', instructionStart);
return paragraphEnd >= 0 ? paragraphEnd : text.length;
}
function indexOfIgnoreCase(text, value, startIndex) {
return text.toLowerCase().indexOf(value.toLowerCase(), startIndex || 0);
}
function advancePastWhitespace(text, index) {
while (index < text.length && /\s/.test(text.charAt(index))) index++;
return index;
}
function normalizeVisibleAssistantText(text) {
return String(text || '')
.replace(/[ \t]+(\r?\n)/g, '$1')
.replace(/(\r?\n){3,}/g, '\n\n')
.trim();
}
function isToolResultContent(content) {
return typeof content === 'string' && content.indexOf('Tool execution results') === 0;
}
function createToolMessageElement(msg) {
var tool = msg.tool || msg.Tool || {};
var content = msg.content || msg.Content || '';
var method = tool.method || tool.Method || 'TOOL';
var path = tool.path || tool.Path || '';
var status = (tool.status || tool.Status || '').toLowerCase();
var statusLabel = tool.status || tool.Status || 'tool';
var round = tool.round || tool.Round || '?';
var bodyText = tool.body || tool.Body || '';
var resultText = tool.result || tool.Result || content || '';
var isError = !!(tool.isError || tool.IsError || status === 'error');
var isSkipped = status === 'skipped';
var stateClass = isError ? 'error' : isSkipped ? 'skipped' : 'success';
var stateLabel = isError ? 'error' : isSkipped ? 'skipped' : 'done';
var statusText = isError ? 'Tool error' : isSkipped ? 'Tool skipped' : 'Tool complete';
if (statusLabel && !/^(success|error|skipped)$/i.test(statusLabel)) {
statusText = statusLabel;
}
var host = document.createElement('div');
host.className = 'message assistant tool-call-host';
host.dataset.raw = content;
host.dataset.rendered = '1';
host.dataset.streamClosed = '1';
var details = document.createElement('details');
details.className = 'tool-call-block ' + stateClass;
details.open = isError || isSkipped;
var summary = document.createElement('summary');
summary.innerHTML =
'<span class="tool-call-title">' + escapeHtml(method) + ' ' + escapeHtml(path) + '</span>'
+ '<span class="tool-call-state">' + escapeHtml(stateLabel) + '</span>';
var body = document.createElement('div');
body.className = 'tool-call-body';
body.innerHTML =
'<div class="tool-call-status">Round ' + escapeHtml(String(round)) + ' - ' + escapeHtml(statusText) + '</div>'
+ '<details class="tool-call-params"><summary>Parameters</summary><pre class="tool-call-params-json"></pre></details>'
+ '<pre class="tool-call-result"></pre>';
body.querySelector('.tool-call-params-json').textContent = formatToolParameters(bodyText);
body.querySelector('.tool-call-result').textContent = formatToolResultPreview(resultText);
details.appendChild(summary);
details.appendChild(body);
host.appendChild(details);
return host;
}
function createToolResultHistoryElement(content) {
var host = document.createElement('div');
host.className = 'message assistant tool-call-host tool-call-history-host';
host.dataset.raw = content || '';
host.dataset.rendered = '1';
host.dataset.streamClosed = '1';
var details = document.createElement('details');
details.className = 'tool-call-block history';
details.open = false;
var summary = document.createElement('summary');
summary.innerHTML =
'<span class="tool-call-title">Tool results</span>'
+ '<span class="tool-call-state">history</span>';
var body = document.createElement('div');
body.className = 'tool-call-body';
body.innerHTML = '<pre class="tool-call-result"></pre>';
body.querySelector('.tool-call-result').textContent = formatToolResultPreview(content || '');
details.appendChild(summary);
details.appendChild(body);
host.appendChild(details);
return host;
}
function getLastAssistantMessage() {
if (!allMessagesDiv) return null;
var messages = allMessagesDiv.querySelectorAll('.message.assistant');
return messages.length ? messages[messages.length - 1] : null;
}
function getLastAssistantResponseMessage() {
if (!allMessagesDiv) return null;
var messages = allMessagesDiv.querySelectorAll('.message.assistant:not(.tool-call-host)');
return messages.length ? messages[messages.length - 1] : null;
}
function getLastOpenAssistantResponseMessage() {
var lastMsg = getLastAssistantResponseMessage();
return lastMsg && !lastMsg.dataset.streamClosed ? lastMsg : null;
}
function ensureAssistantForTranslation() {
var lastMsg = getLastOpenAssistantResponseMessage();
if (lastMsg) return lastMsg;
return ensureAssistantResponseBlock();
}
function appendUserMsg(text, msgId, savedMessage) {
var div = document.createElement('div');
div.className = 'message user';
div.textContent = text;
div.dataset.originalText = text;
div.dataset.msgId = msgId || '';
var timestamp = savedMessage ? getMessageTimestamp(savedMessage) : null;
div.dataset.timestamp = timestamp ? formatTimestamp(new Date(timestamp)) : formatTimestamp(new Date());
allMessagesDiv.appendChild(div);
addMsgActions(div, text);
forceScrollBottom();
}
function addMsgActions(div, text) {
var actions = document.createElement('div');
actions.className = 'msg-actions';
actions.innerHTML = '<button class="btn-action" onclick="editMessage(this)" title="Edit">E</button>'
+ '<button class="btn-action" onclick="retryMsg(this)" title="Resend">R</button>';
div.appendChild(actions);
}
function retryMsg(btn) {
var msgDiv = btn.closest('.message.user');
if (!msgDiv) return;
resendText(msgDiv.dataset.originalText || msgDiv.textContent, msgDiv);
}
function removeWelcome() {
var w = allMessagesDiv.querySelector('.welcome');
if (w) w.remove();
}
function formatTimestamp(date) {
var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
var d = date.getDay();
var dayNum = String(date.getDate()).padStart(2, '0');
var month = String(date.getMonth() + 1).padStart(2, '0');
var year = date.getFullYear();
var hours = date.getHours() % 12 || 12;
var hoursStr = String(hours).padStart(2, '0');
var mins = String(date.getMinutes()).padStart(2, '0');
var secs = String(date.getSeconds()).padStart(2, '0');
var ampm = date.getHours() >= 12 ? 'PM' : 'AM';
return days[d] + ' ' + dayNum + '/' + month + '/' + year + ' ' + hoursStr + ':' + mins + ':' + secs + ' ' + ampm;
}
function generateId() {
return Math.random().toString(36).substring(2, 10);
}
// --- conversation loading ---
function loadConversation(id) {
var targetUrl = getConversationUrl(id);
checkForServerUpdate(targetUrl).then(function (updateStarted) {
if (!updateStarted) loadConversationNow(id);
});
}
function loadConversationNow(id) {
if (!document.getElementById('messages')) {
window.location.href = getConversationUrl(id);
return;
}
// During initial bootstrap currentConversationId starts empty and the
// outbound queue may have already set isStreaming before we get here.
// Only bail if streaming on a different conversation (user clicked the
// sidebar mid-stream), not during the initial page load.
if (isStreaming && currentConversationId && currentConversationId !== id) return;
currentConversationId = id;
document.getElementById('currentConversationId').value = id;
document.getElementById('conversationTitle').textContent = 'Kai';
loadedMessageCount = 0;
loadedOldestMessageIndex = 0;
setInputState(true);
focusInputIfAppropriate();
fetch(getConversationApiUrl(id))
.then(function (r) {
if (r.status === 404) throw new Error('Conversation not found');
if (!r.ok) throw new Error('Could not load conversation');
return r.json();
})
.then(function (conv) {
// Only clear once the authoritative data has arrived, so
// messages rendered by the outbound queue (or from a
// previous reconnect) stay visible during the fetch.
clearMessages();
document.getElementById('conversationTitle').textContent = conv.title || 'Kai';
renderMessages(conv.messages, true);
joinCurrentConversation();
closeSidebar();
loadConversations();
})
.catch(function (err) {
if (!err || err.message !== 'Conversation not found') {
showSystemMessage('Could not load conversation. Reconnecting...');
scheduleReconnect(err && err.message ? err.message : 'Conversation load failed');
return;
}
// Conversation doesn't exist — redirect to new chat and show toast
currentConversationId = '';
document.getElementById('currentConversationId').value = '';
document.getElementById('conversationTitle').textContent = 'Kai';
clearMessages();
showWelcome();
showToast('Conversation not found — starting new chat', 3000);
history.replaceState(null, '', getConversationUrl(''));
closeSidebar();
loadConversations();
});
}
function getMessageRole(msg) {
return String(msg.role || msg.Role || '').toLowerCase();
}
function getMessageContent(msg) {
return String(msg.content || msg.Content || '');
}
function getMessageThinking(msg) {
return String(msg.thinking || msg.Thinking || '');
}
function getMessageTimestamp(msg) {
return msg.timestamp || msg.Timestamp || null;
}
function setStoredTimestamp(element, msg) {
var timestamp = getMessageTimestamp(msg);
if (timestamp) {
element.dataset.timestamp = formatTimestamp(new Date(timestamp));
}
}
function ensureStoredAssistantTurn(parent, state) {
if (!state.assistantTurn) {
state.assistantTurn = createAssistantTurnElement(true);
parent.appendChild(state.assistantTurn);
}
return state.assistantTurn;
}
function closeStoredAssistantTurn(state) {
state.assistantTurn = null;
}
function appendStoredAssistantMessage(parent, state, msg) {
var thinking = getMessageThinking(msg);
var content = sanitizeAssistantDisplayText(getMessageContent(msg));
if (!thinking.trim() && !content.trim()) return;
var turn = ensureStoredAssistantTurn(parent, state);
if (thinking.trim()) {
turn.appendChild(createThinkingBlock(thinking, false));
}
if (!content.trim()) return;
var div = document.createElement('div');
div.className = 'message assistant';
div.dataset.raw = getMessageContent(msg);
div.dataset.rendered = '1';
div.dataset.streamClosed = '1';
div.innerHTML = renderMarkdown(content);
setStoredTimestamp(div, msg);
turn.appendChild(div);
}
function appendStoredToolMessage(parent, state, msg) {
var turn = ensureStoredAssistantTurn(parent, state);
var toolMessage = createToolMessageElement(msg);
setStoredTimestamp(toolMessage, msg);
turn.appendChild(toolMessage);
}
function appendStoredToolHistory(parent, state, msg) {
var turn = ensureStoredAssistantTurn(parent, state);
var toolHistory = createToolResultHistoryElement(getMessageContent(msg));
setStoredTimestamp(toolHistory, msg);
turn.appendChild(toolHistory);
}
function createStoredStandardMessage(msg, role) {
var div = document.createElement('div');
div.className = 'message ' + role;
var content = getMessageContent(msg);
if (role === 'user') {
div.textContent = content;
div.dataset.originalText = content;
div.dataset.msgId = msg.id || msg.Id || '';
addMsgActions(div, content);
} else {
div.innerHTML = renderMarkdown(sanitizeAssistantDisplayText(content));
div.dataset.rendered = '1';
}
setStoredTimestamp(div, msg);
return div;
}
function appendStoredMessage(parent, state, msg) {
var role = getMessageRole(msg);
var content = getMessageContent(msg);
if (role === 'compaction') {
closeStoredAssistantTurn(state);
parent.appendChild(createCompactionElementFromMessage(msg, false));
return;
}
if (role === 'memory_sweep') {
closeStoredAssistantTurn(state);
parent.appendChild(createMemorySweepElementFromMessage(msg, false));
return;
}
if (role === 'system' && isToolResultContent(content)) {
appendStoredToolHistory(parent, state, msg);
return;
}
if (role === 'tool') {
appendStoredToolMessage(parent, state, msg);
return;
}
if (role === 'assistant') {
appendStoredAssistantMessage(parent, state, msg);
return;
}
closeStoredAssistantTurn(state);
parent.appendChild(createStoredStandardMessage(msg, role || 'system'));
}
function renderMessages(messages, scrollToBottom) {
var total = messages.length;
var renderedMessages = messages;
if (total > PAGE_SIZE) {
var btn = document.createElement('div');
btn.className = 'load-more';
btn.innerHTML = '<button class="btn btn-link" onclick="loadEarlierMessages()">↑ Load earlier messages</button>';
allMessagesDiv.appendChild(btn);
renderedMessages = messages.slice(-PAGE_SIZE);
}
loadedOldestMessageIndex = Math.max(0, total - renderedMessages.length);
var state = { assistantTurn: null };
renderedMessages.forEach(function (msg) {
appendStoredMessage(allMessagesDiv, state, msg);
});
loadedMessageCount = renderedMessages.length;
if (scrollToBottom) forceScrollBottom();
}
function loadEarlierMessages() {
if (!currentConversationId || loadedOldestMessageIndex <= 0) return;
fetch(getConversationApiUrl(currentConversationId))
.then(function (r) {
if (!r.ok) throw new Error('Could not load earlier messages');
return r.json();
})
.then(function (conv) {
var previousScrollHeight = allMessagesDiv.scrollHeight;
var total = conv.messages.length;
var endIdx = Math.min(loadedOldestMessageIndex, total);
var toLoad = Math.min(PAGE_SIZE, endIdx);
if (toLoad <= 0) return;
var startIdx = endIdx - toLoad;
var fragment = document.createDocumentFragment();
var btn = allMessagesDiv.querySelector('.load-more');
if (btn) btn.remove();
if (startIdx > 0) {
var lb = document.createElement('div');
lb.className = 'load-more';
lb.innerHTML = '<button class="btn btn-link" onclick="loadEarlierMessages()">↑ Load earlier messages</button>';
fragment.appendChild(lb);
}
var state = { assistantTurn: null };
conv.messages.slice(startIdx, endIdx).forEach(function (msg) {
appendStoredMessage(fragment, state, msg);
});
allMessagesDiv.insertBefore(fragment, allMessagesDiv.firstChild);
loadedMessageCount += toLoad;
loadedOldestMessageIndex = startIdx;
allMessagesDiv.scrollTop += allMessagesDiv.scrollHeight - previousScrollHeight;
})
.catch(function (err) {
showErrorMessage(err || 'Could not load earlier messages');
});
}
// --- state ---
function setInputState(enabled) {
var input = getInput();
var sendBtn = getSendBtn();
if (input) input.disabled = !enabled;
if (sendBtn) sendBtn.disabled = !enabled;
}
function applyStreamingControls(streaming) {
if (!isChatPage()) return;
var input = getInput();
var sendBtn = getSendBtn();
var stopBtn = getStopBtn();
var canSteer = streaming && !streamVisibleOutputStarted && isConnected() && !!currentConversationId;
if (input) input.disabled = false;
if (sendBtn) sendBtn.disabled = streaming && !canSteer;
if (sendBtn) sendBtn.style.display = streaming && !canSteer ? 'none' : '';
if (stopBtn) stopBtn.style.display = streaming ? '' : 'none';
}
function setStreamingState(streaming) {
isStreaming = !!streaming;
if (!isStreaming) streamVisibleOutputStarted = false;
applyStreamingControls(isStreaming);
}
function markStreamVisibleOutputStarted() {
if (!isStreaming) return;
streamVisibleOutputStarted = true;
applyStreamingControls(true);
}
function isStopButtonVisible() {
var stopBtn = getStopBtn();
return !!(stopBtn && stopBtn.style.display !== 'none' && !stopBtn.hidden);
}
function scheduleStreamingResumeCheck(reason) {
if (!isChatPage()) return;
if (streamResumeCheckTimer) clearTimeout(streamResumeCheckTimer);
applyStreamingControls(isStreaming);
streamResumeCheckTimer = setTimeout(function () {
streamResumeCheckTimer = null;
reconcileStreamingState(reason);
}, 900);
}
function reconcileStreamingState(reason) {
if (!isChatPage()) return;
applyStreamingControls(isStreaming);
if (!isConnected()) return;
if (!currentConversationId) {
if (isStreaming || isStopButtonVisible()) clearStaleStreamingUi(false);
return;
}
connection.invoke('IsConversationStreaming', currentConversationId)
.then(function (active) {
if (active) {
setStreamingState(true);
return;
}
if (isStreaming || isStopButtonVisible()) {
clearStaleStreamingUi(true);
}
})
.catch(function () {
applyStreamingControls(isStreaming);
});
}
function clearStaleStreamingUi(reloadConversation) {
setStreamingState(false);
queueProcessing = false;
removeSystemMessage('Thinking...');
closeStreamingThinkingBlock();
activeToolCallHost = null;
if (activeAssistantTurn) activeAssistantTurn.dataset.streamClosed = '1';
activeAssistantTurn = null;
window.__curThinkingContent = null;
renderLastMessageMarkdown();
if (reloadConversation && currentConversationId) {
loadConversationNow(currentConversationId);
}
processOutboundQueue();
}
function showSystemMessage(text) {
if (!allMessagesDiv) return;
removeSystemMessage(text);
var shouldAutoScroll = isAtBottom();
var div = document.createElement('div');
div.className = 'message system';
div.dataset.systemMessage = text;
div.textContent = text;
allMessagesDiv.appendChild(div);
scrollBottom(shouldAutoScroll);
}
function showErrorMessage(error) {
if (!allMessagesDiv) return;
var text = normalizeErrorMessage(error);
var systemText = 'Error: ' + text;
removeSystemMessage(systemText);
var shouldAutoScroll = isAtBottom();
var div = document.createElement('div');
div.className = 'message system error-message';
div.dataset.systemMessage = systemText;
div.setAttribute('role', 'alert');
var title = document.createElement('div');
title.className = 'error-message-title';
title.textContent = 'Error';
var body = document.createElement('div');
body.className = 'error-message-body';
body.textContent = text;
div.appendChild(title);
div.appendChild(body);
allMessagesDiv.appendChild(div);
scrollBottom(shouldAutoScroll);
}
function normalizeErrorMessage(error) {
var text = '';
if (error && error.message) {
text = error.message;
} else if (typeof error === 'string') {
text = error;
} else if (error) {
text = String(error);
}
text = (text || 'Something went wrong.').trim();
return text.replace(/^Error:\s*/i, '') || 'Something went wrong.';
}
function removeSystemMessage(text) {
if (!allMessagesDiv) return;
var msgs = allMessagesDiv.querySelectorAll('.message.system');
for (var i = 0; i < msgs.length; i++) {
if (msgs[i].dataset.systemMessage === text || msgs[i].textContent === text) msgs[i].remove();
}
}
function isConnected() {
return connection && connection.state === 'Connected';
}
function handleError(err) {
var msg = normalizeErrorMessage(err);
if (msg.indexOf('error on the server') !== -1 && connection && connection.state !== 'Connected') {
showSystemMessage('Server unreachable. Reconnecting...');
scheduleReconnect(msg);
} else {
showErrorMessage(msg);
}
setStreamingState(false);
queueProcessing = false;
processOutboundQueue();
}
function isAtBottom(margin) {
if (!allMessagesDiv) return true;
var threshold = margin === undefined ? 96 : margin;
return allMessagesDiv.scrollHeight - allMessagesDiv.scrollTop - allMessagesDiv.clientHeight < threshold;
}
function setMessagesScrollToBottom() {
if (!allMessagesDiv) return;
allMessagesDiv.scrollTop = allMessagesDiv.scrollHeight;
}
function scrollBottom(force) {
if (!allMessagesDiv) return;
if (force === false) return;
if (force === true || window.__stickToBottom || isAtBottom()) {
setMessagesScrollToBottom();
}
}
function forceScrollBottom() {
if (!allMessagesDiv) return;
var token = ++scrollPinToken;
window.__stickToBottom = true;
function runFrame(remaining) {
if (token !== scrollPinToken) return;
setMessagesScrollToBottom();
if (remaining > 0) {
requestAnimationFrame(function () { runFrame(remaining - 1); });
} else {
setTimeout(function () {
if (token !== scrollPinToken) return;
setMessagesScrollToBottom();
window.__stickToBottom = false;
}, 90);
}
}
runFrame(3);
}
function newChat() {
checkForServerUpdate('/').then(function (updateStarted) {
if (!updateStarted) window.location.href = getConversationUrl('');
});
}
function deleteConversation(id) {
clearPendingConversationOpen();
if (!confirm('Delete this conversation?')) return;
if (isConnected()) {
connection.invoke('DeleteConversation', id, activePersona).catch(function (err) {
showErrorMessage(err);
});
} else {
// Fallback for sub-pages without active SignalR
fetch(getConversationApiUrl(id), { method: 'DELETE' })
.then(function () { loadConversations(); })
.catch(function (err) {
// If fetch fails, try SignalR invoke
if (connection) connection.invoke('DeleteConversation', id, activePersona).catch(function () {});
});
}
}
function loadConversations(skipFallback) {
fetch(getConversationApiUrl())
.then(function (r) {
if (!r || !r.ok) {
throw new Error('Unable to load conversations');
}
return r.json();
})
.then(function (convs) {
var list = document.getElementById('conversationList');
if (!list) return;
if (!Array.isArray(convs)) {
convs = [];
}
var html = '';
if (convs.length === 0) {
html = '<div class="conv-loading">No conversations yet</div>';
} else {
convs.forEach(function (c) {
var active = c.id === currentConversationId ? ' active' : '';
html += '<div class="conv-item' + active + '" onclick="openConversationFromList(event, \'' + c.id + '\')">'
+ '<span class="conv-title" ondblclick="renameConversation(event, \'' + c.id + '\', this)">' + escapeHtml(c.title || 'Chat') + '</span>'
+ '<button class="conv-delete" onclick="event.stopPropagation(); deleteConversation(\'' + c.id + '\')">×</button>'
+ '</div>';
});
}
list.innerHTML = html;
})
.catch(function () {
var list = document.getElementById('conversationList');
if (list) {
list.innerHTML = '<div class="conv-loading">Unable to load conversations</div>';
}
});
}
function clearPendingConversationOpen() {
if (!conversationOpenTimer) return;
clearTimeout(conversationOpenTimer);
conversationOpenTimer = null;
}
function openConversationFromList(event, id) {
if (event) event.stopPropagation();
clearPendingConversationOpen();
conversationOpenTimer = setTimeout(function () {
conversationOpenTimer = null;
loadConversation(id);
}, 220);
}
function renameConversation(event, id, span) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
clearPendingConversationOpen();
var current = span.textContent;
var input = document.createElement('input');
input.className = 'conv-rename-input';
input.value = current;
input.setAttribute('autofocus', '');
span.parentNode.replaceChild(input, span);
input.focus();
input.select();
var finished = false;
function finish(keep) {
if (finished) return;
finished = true;
var newTitle = input.value.trim() || current;
if (keep && newTitle !== current) {
connection.invoke('RenameConversation', id, newTitle, activePersona).catch(function () {});
}
var newSpan = document.createElement('span');
newSpan.className = 'conv-title';
newSpan.textContent = newTitle;
newSpan.ondblclick = function (e) { renameConversation(e, id, newSpan); };
if (input.parentNode) input.parentNode.replaceChild(newSpan, input);
}
input.addEventListener('click', function (e) { e.stopPropagation(); });
input.addEventListener('dblclick', function (e) { e.stopPropagation(); });
input.addEventListener('blur', function () { finish(true); });
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
});
}
function goToConversation(id) {
loadConversation(id);
}
function clearMessages() { if (allMessagesDiv) allMessagesDiv.innerHTML = ''; }
function showWelcome() {
clearMessages();
var div = document.createElement('div');
div.className = 'welcome';
div.innerHTML = '<p>Hello, Raymond.</p><p class="welcome-sub">Send a message to start talking to Kai.</p>';
allMessagesDiv.appendChild(div);
}
// --- memory sweep event rendering ---
function normalizeMemorySweepEvent(evt) {
if (typeof evt === 'string') {
return {
id: activeMemorySweepId || 'memory-sweep-live',
message: evt,
report: evt
};
}
evt = evt || {};
if (!evt.id) evt.id = activeMemorySweepId || 'memory-sweep-live';
return evt;
}
function getMemorySweepMeta(msg) {
return msg.memorySweep || msg.MemorySweep || {};
}
function readMeta(meta, camelName, pascalName, fallback) {
if (!meta) return fallback;
if (meta[camelName] !== undefined) return meta[camelName];
if (meta[pascalName] !== undefined) return meta[pascalName];
return fallback;
}
function createMemorySweepElementFromMessage(msg, open) {
var meta = getMemorySweepMeta(msg);
return createMemorySweepElement({
id: msg.id || msg.Id || ('memory-sweep-' + (readMeta(meta, 'compactionNumber', 'CompactionNumber', ''))),
compactionNumber: readMeta(meta, 'compactionNumber', 'CompactionNumber', undefined),
messagesSwept: readMeta(meta, 'messagesSwept', 'MessagesSwept', undefined),
changed: readMeta(meta, 'changed', 'Changed', undefined),
success: readMeta(meta, 'success', 'Success', undefined),
operationCount: readMeta(meta, 'operationCount', 'OperationCount', undefined),
writeCount: readMeta(meta, 'writeCount', 'WriteCount', undefined),
deleteCount: readMeta(meta, 'deleteCount', 'DeleteCount', undefined),
failedCount: readMeta(meta, 'failedCount', 'FailedCount', undefined),
rawResponsePreview: readMeta(meta, 'rawResponsePreview', 'RawResponsePreview', ''),
startedAt: readMeta(meta, 'startedAt', 'StartedAt', undefined),
completedAt: readMeta(meta, 'completedAt', 'CompletedAt', getMessageTimestamp(msg)),
report: getMessageContent(msg),
message: readMeta(meta, 'status', 'Status', 'Memory sweep complete')
}, open);
}
function createMemorySweepElement(data, open) {
var details = document.createElement('details');
details.className = 'compaction-event memory-sweep-event';
details.dataset.memorySweepId = data.id || 'memory-sweep-live';
details.open = !!open;
var title = 'Memory Sweep';
if (data.compactionNumber) title += ' before compaction #' + data.compactionNumber;
details.innerHTML = '<summary>'
+ '<span class="compaction-event-title"></span>'
+ '<span class="compaction-event-counts"></span>'
+ '</summary>'
+ '<div class="compaction-event-body">'
+ '<div class="compaction-event-status"></div>'
+ '<div class="compaction-event-meta"></div>'
+ '<div class="compaction-event-grid memory-sweep-event-grid">'
+ '<section><h3>Operations</h3><pre class="memory-sweep-report-output"></pre></section>'
+ '</div>'
+ '</div>';
details.querySelector('.compaction-event-title').textContent = title;
setMemorySweepElementData(details, data);
return details;
}
function findMemorySweepElement(id) {
if (!allMessagesDiv) return null;
return allMessagesDiv.querySelector('.memory-sweep-event[data-memory-sweep-id="' + cssEscapeValue(id || 'memory-sweep-live') + '"]');
}
function ensureLiveMemorySweepElement(data) {
var id = data.id || activeMemorySweepId || 'memory-sweep-live';
var details = findMemorySweepElement(id);
if (details) return details;
data.id = id;
details = createMemorySweepElement(data, true);
allMessagesDiv.appendChild(details);
forceScrollBottom();
return details;
}
function renderLiveMemorySweepEvent(data) {
var details = ensureLiveMemorySweepElement(data);
details.open = true;
setMemorySweepElementData(details, data);
}
function updateLiveMemorySweepStatus(data) {
var details = ensureLiveMemorySweepElement(data);
if (data.message) {
var status = details.querySelector('.compaction-event-status');
if (status) status.textContent = data.message;
}
scrollBottom(isAtBottom());
}
function completeLiveMemorySweepEvent(data) {
var details = ensureLiveMemorySweepElement(data);
details.open = true;
setMemorySweepElementData(details, data);
scrollBottom(isAtBottom());
}
function setMemorySweepElementData(details, data) {
var counts = details.querySelector('.compaction-event-counts');
if (counts) {
var parts = [];
if (data.messagesSwept !== undefined) parts.push(formatCompactNumber(data.messagesSwept) + ' swept');
if (data.operationCount !== undefined) parts.push(formatCompactNumber(data.operationCount) + ' ops');
if (data.failedCount !== undefined && Number(data.failedCount) > 0) parts.push(formatCompactNumber(data.failedCount) + ' failed');
if (data.changed !== undefined) parts.push(data.changed ? 'changed' : 'noop');
counts.textContent = parts.join(' · ');
}
var status = details.querySelector('.compaction-event-status');
if (status) status.textContent = data.message || 'Memory sweep event';
var meta = details.querySelector('.compaction-event-meta');
if (meta) {
var metaParts = [];
if (data.writeCount !== undefined) metaParts.push('Writes: ' + formatCompactNumber(data.writeCount));
if (data.deleteCount !== undefined) metaParts.push('Removals: ' + formatCompactNumber(data.deleteCount));
if (data.failedCount !== undefined) metaParts.push('Failures: ' + formatCompactNumber(data.failedCount));
if (data.success !== undefined) metaParts.push('Success: ' + (data.success ? 'yes' : 'no'));
meta.textContent = metaParts.join(' | ');
}
var report = details.querySelector('.memory-sweep-report-output');
if (report) report.textContent = data.report || data.message || '';
}
// --- compaction event rendering ---
function normalizeCompactionEvent(evt) {
if (typeof evt === 'string') {
return {
id: activeCompactionId || 'compaction-live',
message: evt,
text: evt
};
}
evt = evt || {};
if (!evt.id) evt.id = activeCompactionId || 'compaction-live';
return evt;
}
function createCompactionElementFromMessage(msg, open) {
var meta = msg.compaction || {};
return createCompactionElement({
id: msg.id || ('compaction-' + (meta.compactionNumber || '')),
compactionNumber: meta.compactionNumber,
archivePath: meta.archivePath,
previousArchivePath: meta.previousArchivePath,
messagesToCompact: meta.messagesToCompact,
messagesToKeep: meta.messagesToKeep,
chunkCount: meta.chunkCount,
estimatedApiTokens: meta.estimatedApiTokens,
estimatedPostCompactionApiTokens: meta.estimatedPostCompactionApiTokens,
startedAt: meta.startedAt,
completedAt: meta.completedAt || msg.timestamp,
summary: msg.content || '',
thinking: msg.thinking || '',
message: 'Compaction complete'
}, open);
}
function createCompactionElement(data, open) {
var details = document.createElement('details');
details.className = 'compaction-event';
details.dataset.compactionId = data.id || 'compaction-live';
details.open = !!open;
var title = 'Compaction';
if (data.compactionNumber) title += ' #' + data.compactionNumber;
details.innerHTML = '<summary>'
+ '<span class="compaction-event-title"></span>'
+ '<span class="compaction-event-counts"></span>'
+ '</summary>'
+ '<div class="compaction-event-body">'
+ '<div class="compaction-event-status"></div>'
+ '<div class="compaction-event-meta"></div>'
+ '<div class="compaction-event-grid">'
+ '<section><h3>Thinking</h3><pre class="compaction-thinking-output"></pre></section>'
+ '<section><h3>Summary</h3><pre class="compaction-summary-output"></pre></section>'
+ '</div>'
+ '</div>';
details.querySelector('.compaction-event-title').textContent = title;
setCompactionElementData(details, data);
return details;
}
function findCompactionElement(id) {
if (!allMessagesDiv) return null;
return allMessagesDiv.querySelector('.compaction-event[data-compaction-id="' + cssEscapeValue(id || 'compaction-live') + '"]');
}
function ensureLiveCompactionElement(data) {
var id = data.id || activeCompactionId || 'compaction-live';
var details = findCompactionElement(id);
if (details) return details;
data.id = id;
details = createCompactionElement(data, true);
allMessagesDiv.appendChild(details);
forceScrollBottom();
return details;
}
function renderLiveCompactionEvent(data) {
var details = ensureLiveCompactionElement(data);
details.open = true;
setCompactionElementData(details, data);
}
function updateLiveCompactionStatus(data) {
var details = ensureLiveCompactionElement(data);
if (data.message) {
var status = details.querySelector('.compaction-event-status');
if (status) status.textContent = data.message;
}
scrollBottom(isAtBottom());
}
function appendLiveCompactionText(data, target) {
var details = ensureLiveCompactionElement(data);
var selector = target === 'thinking' ? '.compaction-thinking-output' : '.compaction-summary-output';
var output = details.querySelector(selector);
if (output && data.text) output.textContent += data.text;
if (data.message) updateLiveCompactionStatus(data);
scrollBottom(isAtBottom());
}
function completeLiveCompactionEvent(data) {
var details = ensureLiveCompactionElement(data);
details.open = true;
setCompactionElementData(details, data);
if (data.thinking !== undefined) {
details.querySelector('.compaction-thinking-output').textContent = data.thinking || '';
}
if (data.summary !== undefined) {
details.querySelector('.compaction-summary-output').textContent = data.summary || '';
}
scrollBottom(isAtBottom());
}
function setCompactionElementData(details, data) {
var counts = details.querySelector('.compaction-event-counts');
if (counts) {
var parts = [];
if (data.messagesToCompact !== undefined) parts.push(formatCompactNumber(data.messagesToCompact) + ' archived');
if (data.messagesToKeep !== undefined) parts.push(formatCompactNumber(data.messagesToKeep) + ' kept');
if (data.chunkCount !== undefined) parts.push(formatCompactNumber(data.chunkCount) + ' chunks');
counts.textContent = parts.join(' · ');
}
var status = details.querySelector('.compaction-event-status');
if (status) status.textContent = data.message || 'Compaction event';
var meta = details.querySelector('.compaction-event-meta');
if (meta) {
var metaParts = [];
if (data.estimatedApiTokens !== undefined) metaParts.push('Before: ' + formatCompactNumber(data.estimatedApiTokens));
if (data.estimatedPostCompactionApiTokens !== undefined) metaParts.push('After: ' + formatCompactNumber(data.estimatedPostCompactionApiTokens));
if (data.archivePath) metaParts.push('Archive: ' + data.archivePath);
if (data.previousArchivePath) metaParts.push('Previous: ' + data.previousArchivePath);
meta.textContent = metaParts.join(' | ');
}
if (data.thinking !== undefined) {
details.querySelector('.compaction-thinking-output').textContent = data.thinking || '';
}
if (data.summary !== undefined) {
details.querySelector('.compaction-summary-output').textContent = data.summary || '';
}
}
function formatCompactNumber(value) {
if (value === null || value === undefined || value === '') return '-';
return Number(value).toLocaleString();
}
function cssEscapeValue(value) {
if (window.CSS && CSS.escape) return CSS.escape(value);
return String(value).replace(/["\\]/g, '\\$&');
}
function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); }
function closeSidebar() { document.getElementById('sidebar').classList.remove('open'); }
function escapeHtml(text) {
var d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
// --- Browser notifications ---
function setupKaiNotifications() {
updateAllKaiNotificationButtons();
if (isKaiNotificationsEnabled()) {
ensureKaiNotificationRegistration().then(updateAllKaiNotificationButtons).catch(function () {
updateAllKaiNotificationButtons();
});
}
}
function isKaiLoopbackHost(hostname) {
var host = String(hostname || '').toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
}
function getKaiNotificationSupport() {
if (!('Notification' in window)) {
return {
supported: false,
reason: 'Notifications are not available in this browser.',
className: 'blocked'
};
}
if (!window.isSecureContext) {
var reason = isKaiLoopbackHost(location.hostname)
? 'Firefox requires localhost or HTTPS before it can request notifications.'
: 'Firefox requires HTTPS for LAN notifications. Use the HTTPS LAN URL, then check again.';
return {
supported: false,
reason: reason,
className: 'blocked'
};
}
if (!('serviceWorker' in navigator)) {
return {
supported: true,
reason: 'Permission can be requested, but background delivery may be limited because service workers are unavailable.',
className: 'waiting'
};
}
return {
supported: true,
reason: 'Ready to request notification permission.',
className: 'waiting'
};
}
function isKaiNotificationSupported() {
return getKaiNotificationSupport().supported;
}
function isKaiNotificationsEnabled() {
return isKaiNotificationSupported() && Notification.permission === 'granted';
}
function enableKaiNotifications() {
var support = getKaiNotificationSupport();
if (!support.supported) {
showToast(support.reason, 5000);
updateAllKaiNotificationButtons();
return Promise.resolve(false);
}
return Notification.requestPermission().then(function (permission) {
if (permission !== 'granted') {
try { localStorage.removeItem(KAI_NOTIFICATIONS_KEY); } catch (e) {}
showToast('Notifications not enabled', 3000);
updateAllKaiNotificationButtons();
return false;
}
try { localStorage.setItem(KAI_NOTIFICATIONS_KEY, '1'); } catch (e) {}
return ensureKaiNotificationRegistration().then(function () {
showToast('KaiChat notifications enabled', 2500);
updateAllKaiNotificationButtons();
return true;
});
}).catch(function (error) {
showToast((error && error.message) || 'Could not enable notifications', 4000);
updateAllKaiNotificationButtons();
return false;
});
}
function ensureKaiNotificationRegistration() {
if (!('serviceWorker' in navigator)) {
return Promise.resolve(null);
}
if (kaiNotificationRegistration) {
return Promise.resolve(kaiNotificationRegistration);
}
return navigator.serviceWorker.register('/kai-notifications-sw.js').then(function (registration) {
kaiNotificationRegistration = registration;
return registration;
});
}
function updateAllKaiNotificationButtons() {
updateKaiNotificationButton(document.getElementById('notificationEnableBtn'));
updateKaiNotificationButton(document.getElementById('timerNotificationBtn'));
updateKaiNotificationButton(document.getElementById('settingsNotificationBtn'));
updateKaiNotificationStatusText();
}
function updateKaiNotificationButton(button) {
if (!button) return;
var support = getKaiNotificationSupport();
button.title = support.reason;
if (!support.supported) {
button.hidden = button.id !== 'settingsNotificationBtn';
button.textContent = 'Notifications unavailable';
button.disabled = true;
return;
}
button.hidden = false;
if (Notification.permission === 'granted' && isKaiNotificationsEnabled()) {
button.textContent = 'Notifications on';
button.disabled = true;
button.title = 'Notifications are allowed for this browser.';
} else if (Notification.permission === 'denied') {
button.textContent = 'Notifications blocked';
button.disabled = false;
button.title = 'Firefox has blocked notifications for this site. Change the site permission, then check again.';
} else {
button.textContent = 'Enable notifications';
button.disabled = false;
}
}
function updateKaiNotificationStatusText() {
var status = document.getElementById('settingsNotificationStatus');
if (!status) return;
var support = getKaiNotificationSupport();
if (!support.supported) {
status.textContent = support.reason;
status.className = 'settings-notification-status ' + support.className;
return;
}
if (Notification.permission === 'granted' && isKaiNotificationsEnabled()) {
status.textContent = 'Allowed';
status.className = 'settings-notification-status allowed';
} else if (Notification.permission === 'denied') {
status.textContent = 'Blocked by browser permission. Change Firefox site permissions, then check again.';
status.className = 'settings-notification-status blocked';
} else {
status.textContent = support.reason;
status.className = 'settings-notification-status waiting';
}
}
window.updateKaiNotificationButton = updateKaiNotificationButton;
window.enableKaiNotifications = enableKaiNotifications;
window.getKaiNotificationSupport = getKaiNotificationSupport;
function notifyKaiMessage(conversationId, msg, source) {
if (!isKaiNotificationsEnabled()) return;
shouldSuppressKaiNotification().then(function (suppress) {
if (suppress) return;
showKaiNotification(conversationId, msg, source);
});
}
function showKaiNotification(conversationId, msg, source) {
if (!isKaiNotificationsEnabled()) return;
if (isAnyKaiClientForeground()) return;
var content = sanitizeAssistantDisplayText(getMessageContent(msg)).replace(/\s+/g, ' ').trim();
if (!content) return;
var body = content.length > 180 ? content.slice(0, 177) + '...' : content;
var messageId = (msg && (msg.id || msg.Id)) || generateId();
var url = getConversationUrl(conversationId || currentConversationId || '');
var title = source === 'kaitimer_milestone' ? 'KaiTimer milestone' : 'Kai';
var options = {
body: body,
tag: 'kaichat-' + (conversationId || 'kai') + '-' + messageId,
data: { url: url },
requireInteraction: source === 'kaitimer_milestone'
};
ensureKaiNotificationRegistration().then(function (registration) {
if (isAnyKaiClientForeground()) return;
if (registration && registration.showNotification) {
return registration.showNotification(title, options);
}
if (isAnyKaiClientForeground()) return;
var notification = new Notification(title, options);
notification.onclick = function () {
window.focus();
if (conversationId) loadConversation(conversationId);
notification.close();
};
}).catch(function () {
if (isAnyKaiClientForeground()) return;
try {
var notification = new Notification(title, options);
notification.onclick = function () {
window.focus();
if (conversationId) loadConversation(conversationId);
notification.close();
};
} catch (e) {}
});
}
// --- Toast notifications ---
function showToast(message, durationMs) {
var existing = document.querySelector('.toast');
if (existing) existing.remove();
var toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
document.body.appendChild(toast);
if (durationMs > 0) {
setTimeout(function () {
toast.classList.add('toast-hide');
setTimeout(function () { toast.remove(); }, 300);
}, durationMs);
}
// Click to dismiss
toast.addEventListener('click', function () { toast.remove(); });
}
// --- SVG rendering helpers ---
function decodeHtmlEntities(text) {
var textarea = document.createElement('textarea');
textarea.innerHTML = String(text || '');
return textarea.value;
}
function isSvgCodeBlock(lang, code) {
var language = String(lang || '').trim().toLowerCase();
var raw = String(code || '').trim();
return language === 'svg' || (!language && /^<svg[\s>]/i.test(raw));
}
function sanitizeSvgSource(source) {
var raw = String(source || '').trim();
if (!raw) return null;
var doc = new DOMParser().parseFromString(raw, 'image/svg+xml');
if (doc.querySelector('parsererror')) return null;
var svg = doc.documentElement;
if (!svg || svg.nodeName.toLowerCase() !== 'svg') return null;
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
var blocked = doc.querySelectorAll('script, foreignObject, iframe, object, embed, link, meta, base');
blocked.forEach(function (node) { node.remove(); });
doc.querySelectorAll('*').forEach(function (node) {
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
var name = attr.name.toLowerCase();
var value = String(attr.value || '').trim();
var valueLower = value.toLowerCase();
if (name.indexOf('on') === 0 || valueLower.indexOf('javascript:') >= 0) {
node.removeAttribute(attr.name);
return;
}
if ((name === 'href' || name === 'xlink:href')
&& /^(https?:|file:|data:text\/html)/i.test(value)) {
node.removeAttribute(attr.name);
}
});
});
return new XMLSerializer().serializeToString(svg);
}
function svgToDataUrl(svgSource) {
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgSource);
}
function registerSvgSource(svgSource) {
var id = 'svg-' + (++svgRenderCounter).toString(36);
svgRenderSources[id] = svgSource;
return id;
}
function renderSvgCodeBlock(svgSource) {
var sanitized = sanitizeSvgSource(svgSource);
if (!sanitized) return null;
var id = registerSvgSource(sanitized);
var sourceId = 'svg-source-' + id;
var dataUrl = svgToDataUrl(sanitized);
return '<figure class="svg-render-block" data-svg-id="' + id + '">'
+ '<div class="svg-render-toolbar">'
+ '<span class="svg-render-label">SVG</span>'
+ '<button type="button" class="svg-render-btn" data-svg-source-button-id="' + id + '" onclick="toggleSvgSource(\'' + id + '\')">Source</button>'
+ '<button type="button" class="svg-render-btn" onclick="openSvgViewer(\'' + id + '\')">Full size</button>'
+ '<button type="button" class="svg-render-btn" onclick="downloadSvgAsPng(\'' + id + '\')">PNG</button>'
+ '</div>'
+ '<button type="button" class="svg-preview-shell" onclick="openSvgViewer(\'' + id + '\')" title="Open full size SVG preview">'
+ '<img class="svg-preview-img" alt="SVG preview" src="' + escapeHtml(dataUrl) + '">'
+ '</button>'
+ '<pre class="svg-source-panel" id="' + sourceId + '" hidden><code>' + escapeHtml(sanitized) + '</code></pre>'
+ '</figure>';
}
function toggleSvgSource(id) {
var panel = document.getElementById('svg-source-' + id);
if (!panel) return;
panel.hidden = !panel.hidden;
var button = document.querySelector('[data-svg-source-button-id="' + cssEscapeValue(id) + '"]');
if (button) button.textContent = panel.hidden ? 'Source' : 'Hide source';
}
function ensureSvgLightbox() {
var existing = document.getElementById('svgLightbox');
if (existing) return existing;
var lightbox = document.createElement('div');
lightbox.id = 'svgLightbox';
lightbox.className = 'svg-lightbox';
lightbox.hidden = true;
var close = document.createElement('button');
close.type = 'button';
close.className = 'svg-lightbox-close';
close.textContent = 'Close';
close.addEventListener('click', closeSvgViewer);
var img = document.createElement('img');
img.className = 'svg-lightbox-img';
img.alt = 'Full size SVG preview';
lightbox.appendChild(close);
lightbox.appendChild(img);
lightbox.addEventListener('click', function (event) {
if (event.target === lightbox) closeSvgViewer();
});
document.body.appendChild(lightbox);
return lightbox;
}
function openSvgViewer(id) {
var source = svgRenderSources[id];
if (!source) {
showToast('SVG source not found', 2500);
return;
}
var lightbox = ensureSvgLightbox();
var img = lightbox.querySelector('.svg-lightbox-img');
img.src = svgToDataUrl(source);
lightbox.hidden = false;
document.body.classList.add('svg-lightbox-open');
if (!svgLightboxKeyHandler) {
svgLightboxKeyHandler = function (event) {
if (event.key === 'Escape') closeSvgViewer();
};
document.addEventListener('keydown', svgLightboxKeyHandler);
}
}
function closeSvgViewer() {
var lightbox = document.getElementById('svgLightbox');
if (lightbox) {
lightbox.hidden = true;
var img = lightbox.querySelector('.svg-lightbox-img');
if (img) img.removeAttribute('src');
}
document.body.classList.remove('svg-lightbox-open');
if (svgLightboxKeyHandler) {
document.removeEventListener('keydown', svgLightboxKeyHandler);
svgLightboxKeyHandler = null;
}
}
function getSvgRasterSize(svgSource) {
var fallback = { width: 1200, height: 800 };
var doc = new DOMParser().parseFromString(svgSource, 'image/svg+xml');
var svg = doc.documentElement;
if (!svg || svg.nodeName.toLowerCase() !== 'svg') return fallback;
var width = parseSvgLength(svg.getAttribute('width'));
var height = parseSvgLength(svg.getAttribute('height'));
var viewBox = parseSvgViewBox(svg.getAttribute('viewBox'));
if ((!width || !height) && viewBox) {
width = width || viewBox.width;
height = height || viewBox.height;
}
if (!width || !height) return fallback;
return {
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height))
};
}
function parseSvgLength(value) {
if (!value) return 0;
var match = String(value).trim().match(/^([0-9]+(?:\.[0-9]+)?)/);
return match ? parseFloat(match[1]) : 0;
}
function parseSvgViewBox(value) {
if (!value) return null;
var parts = String(value).trim().split(/[\s,]+/).map(parseFloat);
if (parts.length !== 4 || parts.some(function (part) { return !isFinite(part); })) return null;
return { width: Math.abs(parts[2]), height: Math.abs(parts[3]) };
}
function downloadSvgAsPng(id) {
var source = svgRenderSources[id];
if (!source) {
showToast('SVG source not found', 2500);
return;
}
var size = getSvgRasterSize(source);
var maxSide = 4096;
var scale = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
if (Math.max(size.width, size.height) * scale > maxSide) {
scale = maxSide / Math.max(size.width, size.height);
}
var canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(size.width * scale));
canvas.height = Math.max(1, Math.round(size.height * scale));
var img = new Image();
var objectUrl = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml;charset=utf-8' }));
img.onload = function () {
try {
var ctx = canvas.getContext('2d');
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.drawImage(img, 0, 0, size.width, size.height);
var link = document.createElement('a');
link.download = 'kaichat-svg-' + id + '.png';
link.href = canvas.toDataURL('image/png');
document.body.appendChild(link);
link.click();
link.remove();
} catch (error) {
showToast('Could not export SVG as PNG', 3000);
} finally {
URL.revokeObjectURL(objectUrl);
}
};
img.onerror = function () {
URL.revokeObjectURL(objectUrl);
showToast('Could not load SVG for PNG export', 3000);
};
img.src = objectUrl;
}
// --- Markdown Renderer ---
function renderLastMessageMarkdown() {
// Render every assistant message that hasn't been rendered yet.
// During tool-call follow-ups the DOM may have multiple assistant messages,
// and only the last one would be picked up by a "lastElementChild" approach.
var rendered = allMessagesDiv.querySelectorAll('.message.assistant[data-rendered]');
var all = allMessagesDiv.querySelectorAll('.message.assistant');
for (var i = rendered.length; i < all.length; i++) {
var msg = all[i];
if (msg.dataset.rendered) continue;
// data-translated has highest priority (completed or streaming translation).
// data-raw is the pre-translation raw text fallback.
// textContent is the last resort.
var raw = sanitizeAssistantDisplayText(msg.dataset.translated || msg.dataset.raw || msg.textContent);
var shouldAutoScroll = isAtBottom();
msg.innerHTML = renderMarkdown(raw);
msg.dataset.rendered = '1';
scrollBottom(shouldAutoScroll);
}
}
function renderTranslationProgress(translated, original, consumedSymbols) {
var tail = sliceSymbols(original || '', consumedSymbols || 0);
var marker = '\x00TRANSLATION_TAIL\x00';
var html = renderMarkdown((translated || '') + marker);
var tailHtml = tail
? '<span class="translation-original-tail">' + escapeHtml(tail) + '</span>'
: '';
return html.replace(marker, tailHtml);
}
function countSymbols(text) {
return Array.from(text || '').length;
}
function sliceSymbols(text, start) {
return Array.from(text || '').slice(start || 0).join('');
}
function renderBlockquotes(escaped) {
return escaped.replace(/(^|\n)((?:>[^\n]*(?:\n|$))+)/g, function (_match, prefix, block) {
var paragraphs = [];
var current = [];
var lines = block.split('\n');
lines.forEach(function (line) {
line = line.replace(/\r$/, '');
if (!line) return;
var content = line.replace(/^>[ \t]?/, '');
if (!content.trim()) {
if (current.length) {
paragraphs.push(current.join('<br>'));
current = [];
}
return;
}
current.push(content);
});
if (current.length) paragraphs.push(current.join('<br>'));
if (!paragraphs.length) return prefix;
var quote = '<blockquote>' + paragraphs.map(function (paragraph) {
return '<p>' + paragraph + '</p>';
}).join('') + '</blockquote>';
return prefix + quote + (/\r?\n$/.test(block) ? '\n' : '');
});
}
function renderMarkdown(text) {
// Tool call markers — extract and replace with placeholders before HTML escaping
// so they survive escaping and code-block processing intact.
// Format: [TOOL_CALL: GET|POST /path, {json body...}]
var toolCalls = [];
text = text.replace(/\[TOOL_CALL:\s*(GET|POST|PUT|PATCH|DELETE)\s+(\/[^,]+?),\s*(\{[\s\S]*?\})\s*\]/g, function(m) {
toolCalls.push(m);
return '\x00TC' + (toolCalls.length - 1) + '\x00';
});
// Escape HTML first to prevent XSS
var escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
// Restore tool call placeholders as formatted action-line blocks
for (var i = 0; i < toolCalls.length; i++) {
var ph = '\x00TC' + i + '\x00';
var raw = toolCalls[i];
var mm = raw.match(/\[TOOL_CALL:\s*(GET|POST|PUT|PATCH|DELETE)\s+(\/[^,]+?),\s*(\{[\s\S]*?\})\s*\]/);
var label = '';
if (mm) {
var path = mm[2];
try {
var parsed = JSON.parse(mm[3]);
if (path.indexOf('/api/files/read') >= 0 && parsed.path) {
var fn = parsed.path;
var ls = Math.max(fn.lastIndexOf('/'), fn.lastIndexOf('\\'));
if (ls >= 0) fn = fn.substring(ls + 1);
label = 'reading ' + fn;
} else if (path.indexOf('/api/search') >= 0 && parsed.q) {
label = 'searching: ' + parsed.q;
} else if (path.indexOf('/api/memory') >= 0) {
label = 'updating memory';
} else if (path.indexOf('/api/bible') >= 0) {
label = 'updating bible';
} else if (path.indexOf('/api/git') >= 0) {
label = 'committing changes';
} else if (path.indexOf('/api/instructions') >= 0) {
label = 'rewriting instructions';
}
} catch (e) {}
if (!label) label = 'tool: ' + path.split('/').pop();
}
escaped = escaped.replace(ph, '<div class="msg-tool-call">~ ' + label + ' ~</div>');
}
// Code blocks (fenced) — must be processed first
escaped = escaped.replace(/```([\w-]*)\n([\s\S]*?)```/g, function(m, lang, code) {
var rawCode = decodeHtmlEntities(code);
if (isSvgCodeBlock(lang, rawCode)) {
var svgBlock = renderSvgCodeBlock(rawCode);
if (svgBlock) return svgBlock;
}
var safeLang = String(lang || '').replace(/[^\w-]/g, '');
return '<pre><code class="lang-' + safeLang + '">' + code.replace(/</g, '<').replace(/>/g, '>') + '</code></pre>';
});
// Inline code
escaped = escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
// Headers
escaped = escaped.replace(/^### (.+)$/gm, '<h4>$1</h4>');
escaped = escaped.replace(/^## (.+)$/gm, '<h3>$1</h3>');
escaped = escaped.replace(/^# (.+)$/gm, '<h2>$1</h2>');
// Bold and italic
escaped = escaped.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
escaped = escaped.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
escaped = escaped.replace(/\*(.+?)\*/g, '<em>$1</em>');
escaped = escaped.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>');
escaped = escaped.replace(/__(.+?)__/g, '<strong>$1</strong>');
escaped = escaped.replace(/_(.+?)_/g, '<em>$1</em>');
// Strikethrough
escaped = escaped.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Horizontal rules
escaped = escaped.replace(/^---$/gm, '<hr>');
// Blockquotes
escaped = renderBlockquotes(escaped);
// Unordered lists
escaped = escaped.replace(/^[\*\-] (.+)$/gm, '<li>$1</li>');
escaped = escaped.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
// Ordered lists
escaped = escaped.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
escaped = escaped.replace(/(<li>.*<\/li>\n?)+/g, function(m) {
return m.indexOf('<ol>') === -1 ? '<ol>' + m + '</ol>' : m;
});
// Line breaks and paragraphs
escaped = escaped.replace(/\n\n/g, '</p><p>');
escaped = escaped.replace(/\n/g, '<br>');
escaped = '<p>' + escaped + '</p>';
escaped = escaped.replace(/<p><blockquote>/g, '<blockquote>');
escaped = escaped.replace(/<\/blockquote><\/p>/g, '</blockquote>');
escaped = escaped.replace(/<p><figure class="svg-render-block"/g, '<figure class="svg-render-block"');
escaped = escaped.replace(/<\/figure><\/p>/g, '</figure>');
// Clean empty paragraphs
escaped = escaped.replace(/<p><\/p>/g, '');
return escaped;
}