/* global chrome, loadConfig, saveConfig, baseUrlFrom, ensureHostPermission,
loginAndValidate, validateTokenSession, fetchCatalogCached, invalidateCatalogCache, streamTest,
cancelByConversationId, extractConversationId, AGENT_MODES, toPrompt,
defaultInstruction, markdownToHtml, CSAI_LIMITS, isExtensionContextError */
let config = {};
let entries = [];
let runs = [];
let selectedEntryId = null;
let selectedRunId = null;
let inspectedTabId = null;
let token = '';
let tokenExpiresAt = '';
let bgPort = null;
let activeAbort = null;
let activeRunId = null;
let validateAbort = null;
let validating = false;
let captureEnabled = true;
let searchDebounceTimer = null;
let connectRetryTimer = null;
let tokenCheckTimer = null;
let probeInFlight = null;
let lastProbeAt = 0;
const TOKEN_TICK_INTERVAL_MS = 30 * 1000;
const PROBE_MIN_GAP_MS = 5000;
let outputFlushScheduled = false;
let pendingProgressRunId = null;
let pendingFinalRunId = null;
let finalRenderedLen = 0;
let markdownRenderToken = 0;
const $ = (id) => document.getElementById(id);
function extensionAlive() {
try {
return !!(typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.id);
} catch (_) {
return false;
}
}
function stopOnContextLoss() {
if (tokenCheckTimer) {
clearInterval(tokenCheckTimer);
tokenCheckTimer = null;
}
if (connectRetryTimer) {
clearTimeout(connectRetryTimer);
connectRetryTimer = null;
}
bgPort = null;
showContextBanner(true);
}
function onContextLoss(err) {
if (!isExtensionContextError(err)) return false;
stopOnContextLoss();
return true;
}
function showContextBanner(show) {
const el = $('ctx-banner');
if (el) el.classList.toggle('hidden', !show);
}
function scheduleConnect(delayMs) {
if (connectRetryTimer) clearTimeout(connectRetryTimer);
connectRetryTimer = setTimeout(connectBackground, delayMs);
}
function runtimeSendMessage(msg) {
if (!extensionAlive()) {
showContextBanner(true);
return Promise.reject(new Error('Extension context lost'));
}
return chrome.runtime.sendMessage(msg);
}
function setStatus(text, kind) {
const el = $('status');
el.textContent = text;
el.className = 'status' + (kind ? ' status-' + kind : '');
if (kind !== 'error') updateConnSummary();
}
function connectionEndpointLabel() {
const scheme = $('https').checked ? 'https' : 'http';
const host = ($('host').value || '').trim() || '127.0.0.1';
const port = ($('port').value || '').trim() || '8080';
return `${scheme}://${host}:${port}`;
}
function setConnExpanded(expanded) {
const toolbar = $('toolbar');
if (!toolbar) return;
toolbar.classList.toggle('toolbar--conn-collapsed', !expanded);
const btn = $('btn-conn-toggle');
if (btn) {
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
btn.textContent = expanded ? 'Collapse' : 'Connection';
}
}
function updateConnSummary() {
const summary = $('conn-summary');
if (!summary) return;
summary.textContent = connectionEndpointLabel();
summary.classList.toggle('conn-summary--ok', !!token && !isTokenExpiredByTime(tokenExpiresAt));
}
function refreshAuthStatus() {
if (!token) {
setStatus('Not validated', '');
updateConnSummary();
return;
}
if (isTokenExpiredByTime(tokenExpiresAt)) {
setStatus('Session expired — please Validate again', 'error');
updateConnSummary();
return;
}
const hint = formatTokenExpiryHint(tokenExpiresAt);
const kind = tokenExpiresWithin(tokenExpiresAt, TOKEN_WARN_BEFORE_MS) ? 'warn' : 'ok';
setStatus(hint, kind);
}
async function clearAuthSession(message) {
token = '';
tokenExpiresAt = '';
try {
await saveConfig({ token: '', tokenExpiresAt: '' });
} catch (err) {
if (onContextLoss(err)) return;
throw err;
}
invalidateCatalogCache();
setStatus(message || 'Not validated', message ? 'error' : '');
setConnExpanded(true);
updateConnSummary();
updateSendButtons();
}
async function handleAuthFailure(err, fallbackMessage) {
if (err && isAuthHttpStatus(err.status)) {
await clearAuthSession(fallbackMessage || 'Token invalid or expired — please Validate again');
return true;
}
return false;
}
async function ensureAuthReady() {
if (!token) {
setStatus('Validate first', 'warn');
setConnExpanded(true);
return false;
}
if (isTokenExpiredByTime(tokenExpiresAt)) {
await clearAuthSession('Session expired — please Validate again');
return false;
}
try {
await probeTokenOnServer(true);
return !!token;
} catch (_) {
return false;
}
}
function startTokenWatch() {
if (tokenCheckTimer) clearInterval(tokenCheckTimer);
tokenCheckTimer = setInterval(async () => {
if (!extensionAlive()) {
stopOnContextLoss();
return;
}
if (!token) return;
if (isTokenExpiredByTime(tokenExpiresAt)) {
await clearAuthSession('Session expired — please Validate again');
return;
}
await probeTokenOnServer();
}, TOKEN_TICK_INTERVAL_MS);
}
async function probeTokenOnServer(force) {
if (!extensionAlive()) {
stopOnContextLoss();
return;
}
if (!token || isTokenExpiredByTime(tokenExpiresAt)) return;
const now = Date.now();
if (!force && now - lastProbeAt < PROBE_MIN_GAP_MS) return;
if (probeInFlight) return probeInFlight;
probeInFlight = (async () => {
try {
const baseUrl = baseUrlFrom(await loadConfig());
await validateTokenSession(baseUrl, token);
lastProbeAt = Date.now();
refreshAuthStatus();
} catch (err) {
if (onContextLoss(err)) return;
lastProbeAt = Date.now();
if (await handleAuthFailure(err, 'Server restarted or token invalid — please Validate again')) {
return;
}
if (isNetworkFetchError(err)) {
setStatus('Cannot reach server — confirm CyberStrikeAI is running', 'warn');
updateConnSummary();
return;
}
setStatus('Validation failed: ' + err.message, 'warn');
updateConnSummary();
} finally {
probeInFlight = null;
}
})();
return probeInFlight;
}
function setupAuthProbeHooks() {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && token) {
probeTokenOnServer(true).catch(() => {});
}
});
window.addEventListener('focus', () => {
if (token) probeTokenOnServer(true).catch(() => {});
});
}
function onConnToggle() {
const toolbar = $('toolbar');
const collapsed = toolbar && toolbar.classList.contains('toolbar--conn-collapsed');
setConnExpanded(!!collapsed);
}
function updateCaptureToggleUi() {
const btn = $('btn-capture-toggle');
if (btn) {
btn.classList.toggle('btn--capture-on', captureEnabled);
btn.classList.toggle('btn--capture-off', !captureEnabled);
btn.setAttribute('aria-pressed', captureEnabled ? 'true' : 'false');
btn.textContent = captureEnabled ? '● Capturing' : '○ Paused';
}
const sidebar = $('sidebar');
if (sidebar) sidebar.classList.toggle('capture-paused', !captureEnabled);
const hint = $('capture-paused-hint');
if (hint) hint.classList.toggle('hidden', captureEnabled);
}
async function syncCaptureEnabled() {
try {
await saveConfig({ captureEnabled });
} catch (err) {
if (onContextLoss(err)) return;
throw err;
}
try {
await runtimeSendMessage({ type: 'set-capture-enabled', enabled: captureEnabled });
} catch (_) {}
updateCaptureToggleUi();
}
async function onCaptureToggle() {
captureEnabled = !captureEnabled;
await syncCaptureEnabled();
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(//g, '>');
}
function truncateOutput(s, max) {
if (!s || s.length <= max) return s || '';
return s.slice(0, max) + '\n\n… [output truncated at ' + max + ' chars]';
}
function selectedEntry() {
return entries.find((e) => e.id === selectedEntryId) || null;
}
function selectedRun() {
return runs.find((r) => r.id === selectedRunId) || null;
}
function statusDotClass(status) {
switch (status) {
case 'running': return 'dot-running';
case 'done': return 'dot-done';
case 'error': return 'dot-error';
case 'cancelled':
case 'cancelling': return 'dot-cancelled';
default: return '';
}
}
function createRun(entry, meta) {
const run = {
id: 'run_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
title: entry ? entry.title : 'Unknown',
status: 'running',
meta: meta || '',
progress: '',
final: '',
entry: entry ? { ...entry } : null,
conversationId: '',
};
runs.unshift(run);
if (runs.length > CSAI_LIMITS.MAX_RUNS) runs.length = CSAI_LIMITS.MAX_RUNS;
selectedRunId = run.id;
return run;
}
function buildRunLi(r) {
const li = document.createElement('li');
li.dataset.id = r.id;
if (r.id === selectedRunId) li.classList.add('selected');
li.innerHTML =
`` +
`
` +
`
${escapeHtml(r.title)}
` +
`
${escapeHtml(r.meta)} · ${escapeHtml(r.status)}
` +
`
`;
li.addEventListener('click', () => selectRun(r.id));
return li;
}
function renderRunList() {
const ul = $('run-list');
ul.innerHTML = '';
for (const r of runs) {
ul.appendChild(buildRunLi(r));
}
}
function prependRunItem(run) {
const ul = $('run-list');
ul.insertBefore(buildRunLi(run), ul.firstChild);
while (ul.children.length > runs.length) {
ul.removeChild(ul.lastChild);
}
}
function updateRunListItem(runId) {
const r = runs.find((x) => x.id === runId);
const ul = $('run-list');
const li = ul.querySelector(`li[data-id="${CSS.escape(runId)}"]`);
if (!r || !li) {
renderRunList();
return;
}
const selected = r.id === selectedRunId;
li.className = selected ? 'selected' : '';
li.innerHTML =
`` +
`` +
`
${escapeHtml(r.title)}
` +
`
${escapeHtml(r.meta)} · ${escapeHtml(r.status)}
` +
`
`;
li.onclick = () => selectRun(r.id);
}
function currentSearchQuery() {
return ($('search').value || '').trim().toLowerCase();
}
function entryMatchesSearch(e, q) {
return !q || (e.url || '').toLowerCase().includes(q) || (e.title || '').toLowerCase().includes(q);
}
function buildRequestLi(e) {
const li = document.createElement('li');
li.dataset.id = e.id;
if (e.id === selectedEntryId) li.classList.add('selected');
const st = e.responseStatus || 0;
const stClass = st >= 200 && st < 400 ? 'status-ok' : 'status-err';
li.innerHTML =
`${escapeHtml(e.method)}` +
`${st || '—'}` +
`${escapeHtml(e.title || e.url)}`;
li.addEventListener('click', () => selectEntry(e.id));
return li;
}
function renderRequestList() {
const q = currentSearchQuery();
const ul = $('request-list');
ul.innerHTML = '';
const filtered = entries.filter((e) => entryMatchesSearch(e, q));
for (const e of filtered) {
ul.appendChild(buildRequestLi(e));
}
updateSendButtons();
}
function prependRequestEntry(entry) {
const q = currentSearchQuery();
if (!entryMatchesSearch(entry, q)) return;
const ul = $('request-list');
ul.insertBefore(buildRequestLi(entry), ul.firstChild);
while (ul.children.length > CSAI_LIMITS.MAX_CAPTURED) {
ul.removeChild(ul.lastChild);
}
updateSendButtons();
}
function updateRequestSelection() {
$('request-list').querySelectorAll('li').forEach((li) => {
li.classList.toggle('selected', li.dataset.id === selectedEntryId);
});
}
function updateSendButtons() {
const ok = !!token;
$('btn-send').disabled = !selectedEntryId || !ok;
$('btn-latest-xhr').disabled = !ok;
}
function selectEntry(id) {
selectedEntryId = id;
const e = selectedEntry();
updateRequestSelection();
if (!e) return;
$('view-request').textContent = formatRawRequest(e);
$('view-response').textContent = formatRawResponse(e);
}
function updateRunSelection(prevId, nextId) {
const ul = $('run-list');
if (prevId && prevId !== nextId) {
const prevLi = ul.querySelector(`li[data-id="${CSS.escape(prevId)}"]`);
if (prevLi) prevLi.classList.remove('selected');
}
if (nextId) {
const nextLi = ul.querySelector(`li[data-id="${CSS.escape(nextId)}"]`);
if (nextLi) nextLi.classList.add('selected');
}
}
function trimInactiveRunFinals(activeId) {
const max = CSAI_LIMITS.MAX_FINAL_ARCHIVE_CHARS;
for (const r of runs) {
if (r.id === activeId || r.status === 'running') continue;
if (r.final && r.final.length > max) {
r.final = r.final.slice(0, max) + '\n\n… [inactive run truncated at ' + max + ' chars]';
}
}
}
function selectRun(id) {
const prevId = selectedRunId;
selectedRunId = id;
if (prevId !== id) {
updateRunSelection(prevId, id);
trimInactiveRunFinals(id);
}
const r = selectedRun();
if (!r) return;
flushOutputViewNow();
refreshFinalView(r.final, { streaming: r.status === 'running' && activeRunId === r.id });
if (r.entry) {
$('view-request').textContent = formatRawRequest(r.entry);
$('view-response').textContent = formatRawResponse(r.entry);
}
}
function formatRawRequest(e) {
return formatRequestDisplay(e);
}
function formatRawResponse(e) {
return formatResponseDisplay(e);
}
function resetFinalDom(finalText) {
const raw = $('final-raw');
raw.textContent = finalText || '';
finalRenderedLen = (finalText || '').length;
}
function appendFinalDom(delta) {
if (!delta) return;
const raw = $('final-raw');
raw.append(document.createTextNode(delta));
finalRenderedLen += delta.length;
raw.scrollTop = raw.scrollHeight;
}
function refreshFinalView(finalText, options) {
const opts = options || {};
const streaming = !!opts.streaming;
const text = finalText || '';
const raw = $('final-raw');
const md = $('final-md');
const wantMd =
$('render-md').checked &&
text.trim() &&
text.length <= CSAI_LIMITS.MAX_MARKDOWN_CHARS;
if (streaming) {
md.classList.add('hidden');
raw.classList.remove('hidden');
if (text.length < finalRenderedLen) {
resetFinalDom(text);
} else if (text.length > finalRenderedLen) {
appendFinalDom(text.slice(finalRenderedLen));
}
return;
}
resetFinalDom(text);
markdownRenderToken += 1;
const token = markdownRenderToken;
if (wantMd) {
const renderMd = () => {
if (token !== markdownRenderToken) return;
md.classList.remove('hidden');
raw.classList.add('hidden');
md.srcdoc = markdownToHtml(text);
};
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(renderMd, { timeout: 500 });
} else {
setTimeout(renderMd, 0);
}
return;
}
md.classList.add('hidden');
raw.classList.remove('hidden');
if (text.length > CSAI_LIMITS.MAX_MARKDOWN_CHARS && $('render-md').checked) {
raw.textContent =
text + '\n\n… [Markdown disabled: content exceeds ' + CSAI_LIMITS.MAX_MARKDOWN_CHARS + ' chars]';
}
}
function finalizeFinalView(finalText) {
flushOutputViewNow();
refreshFinalView(finalText, { streaming: false });
}
function scheduleOutputFlush() {
if (outputFlushScheduled) return;
outputFlushScheduled = true;
requestAnimationFrame(flushOutputView);
}
function flushOutputView() {
outputFlushScheduled = false;
if (pendingProgressRunId != null) {
const rid = pendingProgressRunId;
pendingProgressRunId = null;
if (rid === selectedRunId) {
const r = runs.find((x) => x.id === rid);
if (r) {
const el = $('progress');
el.textContent = r.progress;
el.scrollTop = el.scrollHeight;
}
}
}
if (pendingFinalRunId != null) {
const rid = pendingFinalRunId;
pendingFinalRunId = null;
if (rid === selectedRunId) {
const r = runs.find((x) => x.id === rid);
if (r) {
refreshFinalView(r.final, { streaming: isRunStreaming(rid) });
}
}
}
}
function flushOutputViewNow() {
outputFlushScheduled = false;
if (pendingProgressRunId != null || pendingFinalRunId != null) {
const p = pendingProgressRunId;
const f = pendingFinalRunId;
pendingProgressRunId = null;
pendingFinalRunId = null;
if (p === selectedRunId) {
const r = runs.find((x) => x.id === p);
if (r) {
$('progress').textContent = r.progress;
$('progress').scrollTop = $('progress').scrollHeight;
}
}
if (f === selectedRunId) {
const r = runs.find((x) => x.id === f);
if (r) {
refreshFinalView(r.final, { streaming: isRunStreaming(f) });
}
}
}
}
function isRunStreaming(runId) {
if (runId !== activeRunId) return false;
const r = runs.find((x) => x.id === runId);
return !!(r && r.status === 'running');
}
function appendToRun(runId, field, text) {
const r = runs.find((x) => x.id === runId);
if (!r || !text) return;
if (field === 'progress') {
r.progress = truncateOutput(r.progress + text, CSAI_LIMITS.MAX_PROGRESS_CHARS);
} else {
r[field] = (r[field] || '') + text;
}
if (runId === selectedRunId) {
if (field === 'progress') {
pendingProgressRunId = runId;
scheduleOutputFlush();
} else if (field === 'final') {
pendingFinalRunId = runId;
scheduleOutputFlush();
}
}
}
function setRunStatus(runId, status) {
const r = runs.find((x) => x.id === runId);
if (!r) return;
if (r.status === status) return;
r.status = status;
updateRunListItem(runId);
if (runId === selectedRunId && status !== 'running') {
finalizeFinalView(r ? r.final : '');
}
}
function setRunConversationId(runId, cid) {
const r = runs.find((x) => x.id === runId);
if (r && cid) r.conversationId = cid;
}
async function initConfig() {
try {
config = await loadConfig();
} catch (err) {
if (onContextLoss(err)) return;
throw err;
}
token = config.token || '';
tokenExpiresAt = config.tokenExpiresAt || '';
$('host').value = config.host;
$('port').value = config.port;
$('https').checked = config.https;
$('filter-api').checked = config.filterApiOnly;
captureEnabled = config.captureEnabled !== false;
$('render-md').checked = config.renderMarkdown;
$('debug-events').checked = config.showDebugEvents;
if (token && isTokenExpiredByTime(tokenExpiresAt)) {
await clearAuthSession('Session expired — please Validate again');
} else {
refreshAuthStatus();
if (token) {
startTokenWatch();
probeTokenOnServer().catch(() => {});
}
}
setConnExpanded(!token);
updateCaptureToggleUi();
syncCaptureEnabled().catch(() => {});
updateSendButtons();
}
async function persistConnection() {
try {
await saveConfig(connectionConfigFromForm());
} catch (err) {
onContextLoss(err);
}
}
function connectionConfigFromForm() {
return {
host: $('host').value.trim(),
port: $('port').value.trim(),
https: $('https').checked,
filterApiOnly: $('filter-api').checked,
renderMarkdown: $('render-md').checked,
showDebugEvents: $('debug-events').checked,
};
}
async function onValidate() {
if (validating) {
validateAbort?.abort();
validating = false;
$('btn-validate').textContent = 'Validate';
setStatus('Cancelled', 'warn');
return;
}
validating = true;
validateAbort = new AbortController();
$('btn-validate').textContent = 'Cancel';
setStatus('Validating...', 'pending');
const nextConfig = connectionConfigFromForm();
const baseUrl = baseUrlFrom(nextConfig);
const password = $('password').value;
let hostPermission;
try {
// Invoke before the first await so Chrome still associates the optional
// permission prompt with the Validate button's user gesture.
hostPermission = ensureHostPermission(baseUrl);
} catch (err) {
hostPermission = Promise.reject(err);
}
try {
await hostPermission;
await saveConfig(nextConfig);
config = { ...config, ...nextConfig };
const auth = await loginAndValidate(baseUrl, password, validateAbort.signal);
token = auth.token;
tokenExpiresAt = auth.expiresAt || '';
await saveConfig({ token, tokenExpiresAt });
invalidateCatalogCache();
refreshAuthStatus();
startTokenWatch();
setConnExpanded(false);
updateSendButtons();
} catch (err) {
if (onContextLoss(err)) return;
if (err.name === 'AbortError') {
setStatus('Cancelled', 'warn');
} else {
token = '';
tokenExpiresAt = '';
try {
await saveConfig({ token: '', tokenExpiresAt: '' });
} catch (saveErr) {
onContextLoss(saveErr);
}
setStatus('Failed: ' + err.message, 'error');
setConnExpanded(true);
}
updateSendButtons();
} finally {
validating = false;
validateAbort = null;
$('btn-validate').textContent = 'Validate';
}
}
function fillAgentSelect(sel, selected) {
fillSelect(sel, AGENT_MODES.map((m) => ({ id: m.id, label: m.label })), selected || 'eino_single');
}
function csSelectWrap(input) {
return input && input.closest ? input.closest('.cs-select') : null;
}
function closeAllCsSelects(except) {
document.querySelectorAll('.cs-select.open').forEach((wrap) => {
if (except && wrap === except) return;
wrap.classList.remove('open');
const trigger = wrap.querySelector('.cs-select-trigger');
const menu = wrap.querySelector('.cs-select-menu');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
if (menu) menu.hidden = true;
});
}
function fillSelect(sel, items, value) {
const wrap = csSelectWrap(sel);
const has = items.some((i) => i.id === value);
const resolved = has ? value : (items[0] && items[0].id) || '';
const selectedItem = items.find((i) => i.id === resolved) || items[0];
sel.value = resolved;
if (!wrap) {
// Fallback for plain