mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-10 22:33:48 +02:00
1147 lines
32 KiB
JavaScript
1147 lines
32 KiB
JavaScript
/* 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, '<')
|
|
.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 =
|
|
`<span class="dot ${statusDotClass(r.status)}"></span>` +
|
|
`<div class="run-body">` +
|
|
`<div class="run-title">${escapeHtml(r.title)}</div>` +
|
|
`<div class="meta">${escapeHtml(r.meta)} · ${escapeHtml(r.status)}</div>` +
|
|
`</div>`;
|
|
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 =
|
|
`<span class="dot ${statusDotClass(r.status)}"></span>` +
|
|
`<div class="run-body">` +
|
|
`<div class="run-title">${escapeHtml(r.title)}</div>` +
|
|
`<div class="meta">${escapeHtml(r.meta)} · ${escapeHtml(r.status)}</div>` +
|
|
`</div>`;
|
|
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 =
|
|
`<span class="method">${escapeHtml(e.method)}</span>` +
|
|
`<span class="${stClass}">${st || '—'}</span>` +
|
|
`<span class="req-path">${escapeHtml(e.title || e.url)}</span>`;
|
|
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({
|
|
host: $('host').value.trim(),
|
|
port: $('port').value.trim(),
|
|
https: $('https').checked,
|
|
filterApiOnly: $('filter-api').checked,
|
|
renderMarkdown: $('render-md').checked,
|
|
showDebugEvents: $('debug-events').checked,
|
|
});
|
|
} catch (err) {
|
|
onContextLoss(err);
|
|
}
|
|
}
|
|
|
|
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');
|
|
await persistConnection();
|
|
try {
|
|
config = await loadConfig();
|
|
} catch (err) {
|
|
if (onContextLoss(err)) return;
|
|
throw err;
|
|
}
|
|
const baseUrl = baseUrlFrom(config);
|
|
const password = $('password').value;
|
|
try {
|
|
await ensureHostPermission(baseUrl);
|
|
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) {
|
|
sel.innerHTML = '';
|
|
for (const m of AGENT_MODES) {
|
|
const opt = document.createElement('option');
|
|
opt.value = m.id;
|
|
opt.textContent = m.label;
|
|
sel.appendChild(opt);
|
|
}
|
|
sel.value = selected || 'eino_single';
|
|
}
|
|
|
|
function fillSelect(sel, items, value) {
|
|
sel.innerHTML = '';
|
|
for (const item of items) {
|
|
const opt = document.createElement('option');
|
|
opt.value = item.id;
|
|
opt.textContent = item.label;
|
|
sel.appendChild(opt);
|
|
}
|
|
const has = items.some((i) => i.id === value);
|
|
sel.value = has ? value : (items[0] && items[0].id) || '';
|
|
}
|
|
|
|
async function openSendDialog(entryOverride) {
|
|
const e = entryOverride || selectedEntry();
|
|
if (!e) return;
|
|
if (!(await ensureAuthReady())) return;
|
|
const dlg = $('send-dialog');
|
|
config = await loadConfig();
|
|
const baseUrl = baseUrlFrom(config);
|
|
|
|
$('dlg-instruction').value = config.lastInstruction || defaultInstruction();
|
|
fillAgentSelect($('dlg-agent'), config.lastAgentMode);
|
|
$('dlg-project').innerHTML = '<option>Loading…</option>';
|
|
$('dlg-role').innerHTML = '<option>Loading…</option>';
|
|
dlg.showModal();
|
|
|
|
try {
|
|
const { projects, roles } = await fetchCatalogCached(baseUrl, token);
|
|
fillSelect($('dlg-project'), projects, config.lastProjectId);
|
|
fillSelect($('dlg-role'), roles, config.lastRole);
|
|
} catch (err) {
|
|
if (await handleAuthFailure(err, 'Token invalid or expired — please Validate again')) {
|
|
dlg.close();
|
|
return;
|
|
}
|
|
fillSelect($('dlg-project'), [{ id: '', label: '(load failed)' }], '');
|
|
fillSelect($('dlg-role'), [{ id: '', label: 'Default' }], '');
|
|
}
|
|
|
|
dlg.dataset.entryId = e.id;
|
|
}
|
|
|
|
async function onSendConfirmed() {
|
|
const entryId = $('send-dialog').dataset.entryId;
|
|
const e = entries.find((x) => x.id === entryId) || selectedEntry();
|
|
if (!e || !(await ensureAuthReady())) return;
|
|
|
|
const instruction = $('dlg-instruction').value.trim() || defaultInstruction();
|
|
const projectId = $('dlg-project').value || '';
|
|
const role = $('dlg-role').value || '';
|
|
const agentMode = $('dlg-agent').value || 'eino_single';
|
|
|
|
try {
|
|
await saveConfig({
|
|
lastInstruction: instruction,
|
|
lastProjectId: projectId,
|
|
lastRole: role,
|
|
lastAgentMode: agentMode,
|
|
});
|
|
} catch (err) {
|
|
if (onContextLoss(err)) return;
|
|
throw err;
|
|
}
|
|
|
|
const modeLabel = (AGENT_MODES.find((m) => m.id === agentMode) || {}).label || agentMode;
|
|
const roleLabel = role || 'Default';
|
|
const run = createRun(e, modeLabel + ' · ' + roleLabel);
|
|
activeRunId = run.id;
|
|
prependRunItem(run);
|
|
finalRenderedLen = 0;
|
|
markdownRenderToken += 1;
|
|
selectRun(run.id);
|
|
|
|
const baseUrl = baseUrlFrom(await loadConfig());
|
|
const prompt = toPrompt(e, instruction);
|
|
appendToRun(run.id, 'progress', `[*] ${e.title}\n[server] ${baseUrl}\n[mode] ${modeLabel}\n`);
|
|
if (projectId) appendToRun(run.id, 'progress', `[project] ${projectId}\n`);
|
|
appendToRun(run.id, 'progress', '\n');
|
|
|
|
try {
|
|
await streamTest(
|
|
baseUrl,
|
|
token,
|
|
{ message: prompt, role, projectId, agentMode },
|
|
{
|
|
setAbortController(ctrl) {
|
|
activeAbort = ctrl;
|
|
},
|
|
onEvent(type, message, ev) {
|
|
handleStreamEvent(run.id, type, message, ev);
|
|
},
|
|
onDone() {
|
|
appendToRun(run.id, 'progress', '\n[done]\n');
|
|
setRunStatus(run.id, 'done');
|
|
activeAbort = null;
|
|
activeRunId = null;
|
|
},
|
|
}
|
|
);
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') {
|
|
appendToRun(run.id, 'progress', '\n[info] Local stream stopped.\n');
|
|
setRunStatus(run.id, 'cancelled');
|
|
} else {
|
|
await handleAuthFailure(err, 'Token invalid or expired — please Validate again');
|
|
appendToRun(run.id, 'progress', '\n[error] ' + err.message + '\n');
|
|
setRunStatus(run.id, 'error');
|
|
}
|
|
activeAbort = null;
|
|
activeRunId = null;
|
|
}
|
|
}
|
|
|
|
function handleStreamEvent(runId, type, message, ev) {
|
|
const debug = $('debug-events').checked;
|
|
switch (type) {
|
|
case 'response_start':
|
|
appendToRun(runId, 'progress', '\n\n[Main reply]\n');
|
|
break;
|
|
case 'response_delta':
|
|
if (message) appendToRun(runId, 'final', message);
|
|
break;
|
|
case 'response':
|
|
if (message) appendToRun(runId, 'final', message);
|
|
break;
|
|
case 'eino_agent_reply_stream_start':
|
|
appendToRun(runId, 'progress', '\n\n[Sub-agent reply]\n');
|
|
break;
|
|
case 'eino_agent_reply_stream_delta':
|
|
if (message) appendToRun(runId, 'progress', message);
|
|
break;
|
|
case 'progress':
|
|
appendToRun(runId, 'progress', '\n[progress] ' + message + '\n');
|
|
setRunStatus(runId, 'running');
|
|
break;
|
|
case 'error':
|
|
appendToRun(runId, 'progress', '\n[error] ' + message + '\n');
|
|
setRunStatus(runId, 'error');
|
|
break;
|
|
case 'cancelled':
|
|
appendToRun(runId, 'progress', '\n[cancelled] ' + message + '\n');
|
|
setRunStatus(runId, 'cancelled');
|
|
break;
|
|
case 'conversation':
|
|
case 'message_saved': {
|
|
const cid = extractConversationId(ev);
|
|
if (cid) setRunConversationId(runId, cid);
|
|
if (type === 'conversation' && debug && message) {
|
|
appendToRun(runId, 'progress', '\n[conversation] ' + message + '\n');
|
|
}
|
|
break;
|
|
}
|
|
case 'tool_call':
|
|
case 'tool_result':
|
|
case 'tool_result_delta':
|
|
if (debug && message) appendToRun(runId, 'progress', '\n[' + type + '] ' + message + '\n');
|
|
break;
|
|
default:
|
|
if (debug && message && !type.endsWith('_stream_delta') && !type.endsWith('_stream_start')) {
|
|
appendToRun(runId, 'progress', '\n[' + type + '] ' + message + '\n');
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
async function onStop() {
|
|
const runId = activeRunId || selectedRunId;
|
|
const run = runs.find((x) => x.id === runId);
|
|
if (!run) return;
|
|
|
|
if (activeAbort) {
|
|
activeAbort.abort();
|
|
activeAbort = null;
|
|
}
|
|
|
|
setRunStatus(run.id, 'cancelling');
|
|
appendToRun(run.id, 'progress', '\n[info] Stop requested (local stream + server cancel).\n');
|
|
|
|
const cid = run.conversationId;
|
|
if (!cid || !token) {
|
|
if (!cid) {
|
|
appendToRun(run.id, 'progress', '[info] conversationId not ready yet; only local stream was stopped.\n');
|
|
}
|
|
setRunStatus(run.id, 'cancelled');
|
|
activeRunId = null;
|
|
return;
|
|
}
|
|
|
|
const baseUrl = baseUrlFrom(await loadConfig());
|
|
try {
|
|
await cancelByConversationId(baseUrl, token, cid);
|
|
appendToRun(run.id, 'progress', '[info] Server cancel acknowledged.\n');
|
|
setRunStatus(run.id, 'cancelled');
|
|
} catch (err) {
|
|
appendToRun(run.id, 'progress', '[error] Server cancel failed: ' + err.message + '\n');
|
|
setRunStatus(run.id, 'cancelled');
|
|
}
|
|
activeRunId = null;
|
|
}
|
|
|
|
function clearCurrentRunOutput() {
|
|
pendingProgressRunId = null;
|
|
pendingFinalRunId = null;
|
|
outputFlushScheduled = false;
|
|
finalRenderedLen = 0;
|
|
markdownRenderToken += 1;
|
|
const r = selectedRun();
|
|
if (r) {
|
|
r.progress = '';
|
|
r.final = '';
|
|
$('progress').textContent = '';
|
|
refreshFinalView('');
|
|
} else {
|
|
$('progress').textContent = '';
|
|
refreshFinalView('');
|
|
}
|
|
}
|
|
|
|
function clearAllRuns() {
|
|
runs = [];
|
|
selectedRunId = null;
|
|
pendingProgressRunId = null;
|
|
pendingFinalRunId = null;
|
|
outputFlushScheduled = false;
|
|
finalRenderedLen = 0;
|
|
markdownRenderToken += 1;
|
|
renderRunList();
|
|
$('progress').textContent = '';
|
|
refreshFinalView('');
|
|
}
|
|
|
|
function connectBackground() {
|
|
connectRetryTimer = null;
|
|
|
|
if (!extensionAlive()) {
|
|
showContextBanner(true);
|
|
bgPort = null;
|
|
return;
|
|
}
|
|
|
|
showContextBanner(false);
|
|
|
|
try {
|
|
if (!chrome.devtools || !chrome.devtools.inspectedWindow) {
|
|
scheduleConnect(1000);
|
|
return;
|
|
}
|
|
inspectedTabId = chrome.devtools.inspectedWindow.tabId;
|
|
bgPort = chrome.runtime.connect({ name: 'cyberstrike-panel' });
|
|
} catch (_) {
|
|
if (!extensionAlive()) {
|
|
showContextBanner(true);
|
|
bgPort = null;
|
|
return;
|
|
}
|
|
scheduleConnect(1000);
|
|
return;
|
|
}
|
|
|
|
bgPort.postMessage({
|
|
type: 'subscribe',
|
|
tabId: inspectedTabId,
|
|
filterApiOnly: $('filter-api').checked,
|
|
});
|
|
bgPort.onMessage.addListener((msg) => {
|
|
if (msg.type === 'list') {
|
|
entries = msg.entries || [];
|
|
renderRequestList();
|
|
} else if (msg.type === 'entry') {
|
|
entries.unshift(msg.entry);
|
|
if (entries.length > CSAI_LIMITS.MAX_CAPTURED) entries.length = CSAI_LIMITS.MAX_CAPTURED;
|
|
prependRequestEntry(msg.entry);
|
|
}
|
|
});
|
|
bgPort.onDisconnect.addListener(() => {
|
|
bgPort = null;
|
|
if (!extensionAlive()) {
|
|
showContextBanner(true);
|
|
return;
|
|
}
|
|
scheduleConnect(1000);
|
|
});
|
|
}
|
|
|
|
async function onFilterApiChange() {
|
|
await persistConnection();
|
|
if (bgPort && inspectedTabId != null) {
|
|
bgPort.postMessage({
|
|
type: 'subscribe',
|
|
tabId: inspectedTabId,
|
|
filterApiOnly: $('filter-api').checked,
|
|
});
|
|
runtimeSendMessage({
|
|
type: 'set-filter-api',
|
|
tabId: inspectedTabId,
|
|
filterApiOnly: $('filter-api').checked,
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function clearCaptures() {
|
|
if (inspectedTabId == null) return;
|
|
await runtimeSendMessage({ type: 'clear-captures', tabId: inspectedTabId });
|
|
entries = [];
|
|
selectedEntryId = null;
|
|
renderRequestList();
|
|
}
|
|
|
|
async function sendLatestXhr() {
|
|
if (inspectedTabId == null) return;
|
|
if (!(await ensureAuthReady())) return;
|
|
const resp = await runtimeSendMessage({
|
|
type: 'get-latest-api',
|
|
tabId: inspectedTabId,
|
|
});
|
|
if (resp && resp.entry) {
|
|
selectedEntryId = resp.entry.id;
|
|
selectEntry(resp.entry.id);
|
|
openSendDialog(resp.entry);
|
|
}
|
|
}
|
|
|
|
function onSearchInput() {
|
|
clearTimeout(searchDebounceTimer);
|
|
searchDebounceTimer = setTimeout(renderRequestList, 150);
|
|
}
|
|
|
|
function onCopyOutput() {
|
|
const activeTab = document.querySelector('.tab.active');
|
|
const tabName = activeTab ? activeTab.dataset.tab : 'output';
|
|
let text = '';
|
|
if (tabName === 'request') text = $('view-request').textContent;
|
|
else if (tabName === 'response') text = $('view-response').textContent;
|
|
else {
|
|
const r = selectedRun();
|
|
text = (r && r.final) ? r.final : $('final-raw').textContent;
|
|
}
|
|
navigator.clipboard.writeText(text || '').catch(() => {});
|
|
}
|
|
|
|
function setupTabs() {
|
|
document.querySelectorAll('.tab').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab').forEach((b) => b.classList.remove('active'));
|
|
document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
|
|
btn.classList.add('active');
|
|
$('tab-' + btn.dataset.tab).classList.remove('hidden');
|
|
});
|
|
});
|
|
}
|
|
|
|
$('btn-validate').addEventListener('click', onValidate);
|
|
$('btn-conn-toggle').addEventListener('click', onConnToggle);
|
|
$('host').addEventListener('input', updateConnSummary);
|
|
$('port').addEventListener('input', updateConnSummary);
|
|
$('https').addEventListener('change', updateConnSummary);
|
|
$('btn-send').addEventListener('click', () => openSendDialog());
|
|
$('btn-latest-xhr').addEventListener('click', sendLatestXhr);
|
|
$('btn-stop').addEventListener('click', onStop);
|
|
$('btn-copy').addEventListener('click', onCopyOutput);
|
|
$('btn-clear').addEventListener('click', clearCurrentRunOutput);
|
|
$('btn-clear-runs').addEventListener('click', clearAllRuns);
|
|
$('btn-clear-captures').addEventListener('click', clearCaptures);
|
|
$('search').addEventListener('input', onSearchInput);
|
|
$('btn-capture-toggle').addEventListener('click', onCaptureToggle);
|
|
$('filter-api').addEventListener('change', onFilterApiChange);
|
|
$('render-md').addEventListener('change', () => {
|
|
const r = selectedRun();
|
|
const text = r ? r.final : $('final-raw').textContent;
|
|
const streaming = r && r.status === 'running' && activeRunId === r.id;
|
|
refreshFinalView(text, { streaming });
|
|
persistConnection();
|
|
});
|
|
$('debug-events').addEventListener('change', persistConnection);
|
|
$('send-form').addEventListener('submit', (ev) => {
|
|
ev.preventDefault();
|
|
$('send-dialog').close();
|
|
onSendConfirmed();
|
|
});
|
|
$('dlg-cancel').addEventListener('click', () => $('send-dialog').close());
|
|
|
|
setupTabs();
|
|
setupAuthProbeHooks();
|
|
initConfig().then(() => {
|
|
if (extensionAlive()) connectBackground();
|
|
else showContextBanner(true);
|
|
});
|