mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 16:49:34 +00:00
* feat: add inbound attachment support to WASM channel system Add attachment record to WIT interface and implement inbound media parsing across all four channel implementations (Telegram, Slack, WhatsApp, Discord). Attachments flow from WASM channels through EmittedMessage to IncomingMessage with validation (size limits, MIME allowlist, count caps) at the host boundary. - Add `attachment` record to `emitted-message` in wit/channel.wit - Add `IncomingAttachment` struct to channel.rs and re-export - Add host-side validation (20MB total, 10 max, MIME allowlist) - Telegram: parse photo, document, audio, video, voice, sticker - Slack: parse file attachments with url_private - WhatsApp: parse image, audio, video, document with captions - Discord: backward-compatible empty attachments - Update FEATURE_PARITY.md section 7 - Add fixture-based tests per channel and host integration tests [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: integrate outbound attachment support and reconcile WIT types (#409) Reconcile PR #409's outbound attachment work with our inbound attachment support into a unified design: WIT type split: - `inbound-attachment` in channel-host: metadata-only (id, mime_type, filename, size_bytes, source_url, storage_key, extracted_text) - `attachment` in channel: raw bytes (filename, mime_type, data) on agent-response for outbound sending Outbound features (from PR #409): - `on-broadcast` WIT export for proactive messages without prior inbound - Telegram: multipart sendPhoto/sendDocument with auto photo→document fallback for files >10MB - wrapper.rs: `call_on_broadcast`, `read_attachments` from disk, attachment params threaded through `call_on_respond` - HTTP tool: `save_to` param for binary downloads to /tmp/ (50MB limit, path traversal protection, SSRF-safe redirect following) - Message tool: allow /tmp/ paths for attachments alongside base_dir - Credential env var fallback in inject_channel_credentials Channel updates: - All 4 channels implement on_broadcast (Telegram full, others stub) - Telegram: polling_enabled config, adjusted poll timeout - Inbound attachment types renamed to InboundAttachment in all channels Tests: 1965 passing (9 new), 0 clippy warnings [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add audio transcription pipeline and extensible WIT attachment design Add host-side transcription middleware (OpenAI Whisper) that detects audio attachments with inline data on incoming messages and transcribes them automatically. Refactor WIT inbound-attachment to use extras-json and a store-attachment-data host function instead of typed fields, so future attachment properties (dimensions, codec, etc.) don't require WIT changes that invalidate all channel plugins. - Add src/transcription/ module: TranscriptionProvider trait, TranscriptionMiddleware, AudioFormat enum, OpenAI Whisper provider - Add src/config/transcription.rs: TRANSCRIPTION_ENABLED/MODEL/BASE_URL - Wire middleware into agent message loop via AgentDeps - WIT: replace data + duration-secs with extras-json + store-attachment-data - Host: parse extras-json for well-known keys, merge stored binary data - Telegram: download voice files via store-attachment-data, add duration to extras-json, add /file/bot to HTTP allowlist, voice-only placeholder - Add reqwest multipart feature for Whisper API uploads - 5 regression tests for transcription middleware Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: wire attachment processing into LLM pipeline with multimodal image support Attachments on incoming messages are now augmented into user text via XML tags before entering the turn system, and images with data are passed as multimodal content parts (base64 data URIs) to LLM providers. This enables audio transcripts, document text, and image content to reach the LLM without changes to ChatMessage serialization or provider interfaces. - Add src/agent/attachments.rs with augment_with_attachments() and 9 unit tests - Add ContentPart/ImageUrl types to llm::provider with OpenAI-compatible serde - Carry image_content_parts transiently on Turn (skipped in serialization) - Update nearai_chat and rig_adapter to serialize multimodal content - Add 3 e2e tests verifying attachments flow through the full agent loop Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: CI failures — formatting, version bumps, and Telegram voice test - Fix cargo fmt formatting in attachments.rs, nearai_chat.rs, rig_adapter.rs, e2e_attachments.rs - Bump channel registry versions 0.1.0 → 0.2.0 (discord, slack, telegram, whatsapp) to satisfy version-bump CI check - Fix Telegram test_extract_attachments_voice: add missing required `duration` field to voice fixture JSON Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: bump WIT channel version to 0.3.0, fix Telegram voice test, add pre-commit hook - Bump wit/channel.wit package version 0.2.0 → 0.3.0 (interface changed with store-attachment-data) - Update WIT_CHANNEL_VERSION constant and registry wit_version fields to match - Fix Telegram test_extract_attachments_voice: gate voice download behind #[cfg(target_arch = "wasm32")] so host functions aren't called in native tests, update assertions for generated filename and extras_json duration - Add @0.3.0 linker stubs in wit_compat.rs - Add .githooks/pre-commit hook that runs scripts/check-version-bumps.sh when WIT or extension sources are staged - Symlink commit-msg regression hook into .githooks/ [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: extract voice download from extract_attachments into handle_message Move download_voice_file + store_attachment_data calls out of extract_attachments into a separate download_and_store_voice function called from handle_message. This keeps extract_attachments as a pure data-mapping function with no host calls, making it fully testable in native unit tests without #[cfg(target_arch)] gates. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review comments — security, correctness, and code quality Security fixes: - Add path validation to read_attachments (restrict to /tmp/) preventing arbitrary file reads from compromised tools - Escape XML special characters in attachment filenames, MIME types, and extracted text to prevent prompt injection via tag spoofing - Percent-encode file_id in Telegram getFile URL to prevent query injection - Clone SecretString directly instead of expose_secret().to_string() Correctness fixes: - Fix store_attachment_data overwrite accounting: subtract old entry size before adding new to prevent inflated totals and false rejections - Use max(reported, stored_size) for attachment size accounting to prevent WASM channels from under-reporting size_bytes to bypass limits - Add application/octet-stream to MIME allowlist (channels default unknown types to this) Code quality: - Extract send_response helper in Telegram, deduplicating on_respond and on_broadcast - Rename misleading Discord test to test_parse_slash_command_interaction - Fix .githooks/commit-msg to use relative symlink (portable across machines) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add tool_upgrade command + fix TOCTOU in save_to path validation Add `tool_upgrade` — a new extension management tool that automatically detects and reinstalls WASM extensions with outdated WIT versions. Preserves authentication secrets during upgrade. Supports upgrading a single extension by name or all installed WASM tools/channels at once. Fix TOCTOU in `validate_save_to_path`: validate the path *before* creating parent directories, so traversal paths like `/tmp/../../etc/` cannot cause filesystem mutations outside /tmp before being rejected. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: unify WIT package version to 0.3.0 across tool.wit and all capabilities tool.wit and channel.wit share the `near:agent` package namespace, so they must declare the same version. Bumps tool.wit from 0.2.0 to 0.3.0 and updates all capabilities files and registry entries to match. Fixes `cargo component build` failure: "package identifier near:[email protected] does not match previous package name of near:[email protected]" [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: move WIT file comments after package declaration WIT treats `//` comments before `package` as doc comments. When both tool.wit and channel.wit had header comments, the parser rejected them as "doc comments on multiple 'package' items". Move comments after the package declaration in both files. Also bumps tool registry versions to 0.2.0 to match the WIT 0.3.0 bump. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: display extension versions in gateway Extensions tab Add version field to InstalledExtension and RegistryEntry types, pipe through the web API (ExtensionInfo, RegistryEntryInfo), and render as a badge in the gateway UI for both installed and available extensions. For installed WASM extensions, version is read from the capabilities file with a fallback to the registry entry when the local file has no version (old installations). Bump all extension Cargo.toml and registry JSON versions from 0.1.0 to 0.2.0 to keep them in sync. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add document text extraction middleware for PDF, Office, and text files Extract text from document attachments (PDF, DOCX, PPTX, XLSX, RTF, plain text, code files) so the LLM can reason about uploaded documents. Uses pdf-extract for PDFs, zip+XML parsing for Office XML formats, and UTF-8 decode for text files. Wired into the agent loop after transcription middleware. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: download document files in Telegram channel for text extraction The DocumentExtractionMiddleware needs file bytes in the attachment `data` field, but only voice files were being downloaded. Document attachments (PDFs, DOCX, etc.) had empty `data` and a source_url with a credential placeholder that only works inside the WASM host's http_request. Add `download_and_store_documents()` that downloads non-voice, non-image, non-audio attachments via the existing two-step getFile→download flow and stores bytes via `store_attachment_data` for host-side extraction. Also rename `download_voice_file` → `download_telegram_file` since it's generic for any file_id. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: allow Office MIME types and increase file download limit for Telegram Two issues preventing document extraction from Telegram: 1. PPTX/DOCX/XLSX MIME types (application/vnd.*) were dropped by the WASM host attachment allowlist — add application/vnd., application/msword, and application/rtf prefixes. 2. Telegram file downloads over 10 MB failed with "Response body too large" — set max_response_bytes to 20 MB in Telegram capabilities. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: report document extraction errors back to user instead of silently skipping - Bump max_response_bytes to 50 MB for Telegram file downloads - When document extraction fails (too large, download error, parse error), set extracted_text to a user-friendly error message instead of leaving it None. This ensures the LLM tells the user what went wrong. - On Telegram download failure, set extracted_text with the error so the user sees feedback even when the file never reaches the extraction middleware. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: store extracted document text in workspace memory for search/recall After document extraction succeeds, write the extracted text to workspace memory at `documents/{date}/{filename}`. This enables: - Full-text and semantic search over past uploaded documents - Cross-conversation recall ("what did that PDF say?") - Automatic chunking and embedding via the workspace pipeline Documents are stored with metadata header (uploader, channel, date, MIME type). Error messages (extraction failures) are not stored — only successful extractions. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: CI failures — formatting, unused assignment warning - Run cargo fmt on document_extraction and agent_loop modules - Suppress unused_assignments warning on trace_llm_ref (used only behind #[cfg(feature = "libsql")]) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review comments — security, correctness, and code quality Security fixes: - Remove SSRF-prone download() from DocumentExtractionMiddleware (#13) - Sanitize filenames in workspace path to prevent directory traversal (#11) - Pre-check file size before reading in WASM wrapper to prevent OOM (#2) - Percent-encode file_id in Telegram source URLs (#7) Correctness fixes: - Clear image_content_parts on turn end to prevent memory leak (#1) - Find first *successful* transcription instead of first overall (#3) - Enforce data.len() size limit in document extraction (#10) - Use UTF-8 safe truncation with char_indices() (#12) Robustness & code quality: - Add 120s timeout to OpenAI Whisper HTTP client (#5) - Trim trailing slash from Whisper base_url (#6) - Allow ~/.ironclaw/ paths in WASM wrapper (#8) - Return error from on_broadcast in Slack/Discord/WhatsApp (#9) - Fix doc comment in HTTP tool (#4) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: formatting — cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address latest PR review — doc comments, error messages, version bumps - Fix DocumentExtractionMiddleware doc comment (no longer downloads from source_url) - Fix error message: "no inline data" instead of "no download URL" - Log error + fallback instead of silent unwrap_or_default on Whisper HTTP client - Bump all capabilities.json versions from 0.1.0 to 0.2.0 to match Cargo.toml Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: remove unsupported profile: minimal from CI workflows [skip-regression-check] dtolnay/rust-toolchain@stable does not accept the 'profile' input (it was a parameter for the deprecated actions-rs/toolchain action). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: merge with latest main — resolve compilation errors and PR review nits - Add version: None to RegistryEntry/InstalledExtension test constructors - Fix MessageContent type mismatches in nearai_chat tests (String → MessageContent::Text) - Fix .contains() calls on MessageContent — use .as_text().unwrap() - Remove redundant trace_llm_ref = None assignment in test_rig - Check data size before clone in document extraction to avoid unnecessary allocation [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
3921 lines
130 KiB
JavaScript
3921 lines
130 KiB
JavaScript
// IronClaw Web Gateway - Client
|
|
|
|
let token = '';
|
|
let eventSource = null;
|
|
let logEventSource = null;
|
|
let currentTab = 'chat';
|
|
let currentThreadId = null;
|
|
let assistantThreadId = null;
|
|
let hasMore = false;
|
|
let oldestTimestamp = null;
|
|
let loadingOlder = false;
|
|
let sseHasConnectedBefore = false;
|
|
let jobEvents = new Map(); // job_id -> Array of events
|
|
let jobListRefreshTimer = null;
|
|
let pairingPollInterval = null;
|
|
const JOB_EVENTS_CAP = 500;
|
|
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
|
|
|
|
// --- Slash Commands ---
|
|
|
|
const SLASH_COMMANDS = [
|
|
{ cmd: '/status', desc: 'Show all jobs, or /status <id> for one job' },
|
|
{ cmd: '/list', desc: 'List all jobs' },
|
|
{ cmd: '/cancel', desc: '/cancel <job-id> — cancel a running job' },
|
|
{ cmd: '/undo', desc: 'Revert the last turn' },
|
|
{ cmd: '/redo', desc: 'Re-apply an undone turn' },
|
|
{ cmd: '/compact', desc: 'Compress the context window' },
|
|
{ cmd: '/clear', desc: 'Clear thread and start fresh' },
|
|
{ cmd: '/interrupt', desc: 'Stop the current turn' },
|
|
{ cmd: '/heartbeat', desc: 'Trigger manual heartbeat check' },
|
|
{ cmd: '/summarize', desc: 'Summarize the current thread' },
|
|
{ cmd: '/suggest', desc: 'Suggest next steps' },
|
|
{ cmd: '/help', desc: 'Show help' },
|
|
{ cmd: '/version', desc: 'Show version info' },
|
|
{ cmd: '/tools', desc: 'List available tools' },
|
|
{ cmd: '/skills', desc: 'List installed skills' },
|
|
{ cmd: '/model', desc: 'Show or switch the LLM model' },
|
|
{ cmd: '/thread new', desc: 'Create a new conversation thread' },
|
|
];
|
|
|
|
let _slashSelected = -1;
|
|
let _slashMatches = [];
|
|
|
|
// --- Tool Activity State ---
|
|
let _activeGroup = null;
|
|
let _activeToolCards = {};
|
|
let _activityThinking = null;
|
|
|
|
// --- Auth ---
|
|
|
|
function authenticate() {
|
|
token = document.getElementById('token-input').value.trim();
|
|
if (!token) {
|
|
document.getElementById('auth-error').textContent = 'Token required';
|
|
return;
|
|
}
|
|
|
|
// Test the token against the health-ish endpoint (chat/threads requires auth)
|
|
apiFetch('/api/chat/threads')
|
|
.then(() => {
|
|
sessionStorage.setItem('ironclaw_token', token);
|
|
document.getElementById('auth-screen').style.display = 'none';
|
|
document.getElementById('app').style.display = 'flex';
|
|
// Strip token and log_level from URL so they're not visible in the address bar
|
|
const cleaned = new URL(window.location);
|
|
const urlLogLevel = cleaned.searchParams.get('log_level');
|
|
cleaned.searchParams.delete('token');
|
|
cleaned.searchParams.delete('log_level');
|
|
window.history.replaceState({}, '', cleaned.pathname + cleaned.search);
|
|
connectSSE();
|
|
connectLogSSE();
|
|
startGatewayStatusPolling();
|
|
checkTeeStatus();
|
|
loadThreads();
|
|
loadMemoryTree();
|
|
loadJobs();
|
|
// Apply URL log_level param if present, otherwise just sync the dropdown
|
|
if (urlLogLevel) {
|
|
setServerLogLevel(urlLogLevel);
|
|
} else {
|
|
loadServerLogLevel();
|
|
}
|
|
})
|
|
.catch(() => {
|
|
sessionStorage.removeItem('ironclaw_token');
|
|
document.getElementById('auth-screen').style.display = '';
|
|
document.getElementById('app').style.display = 'none';
|
|
document.getElementById('auth-error').textContent = 'Invalid token';
|
|
});
|
|
}
|
|
|
|
document.getElementById('token-input').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') authenticate();
|
|
});
|
|
|
|
// Auto-authenticate from URL param or saved session
|
|
(function autoAuth() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const urlToken = params.get('token');
|
|
if (urlToken) {
|
|
document.getElementById('token-input').value = urlToken;
|
|
authenticate();
|
|
return;
|
|
}
|
|
const saved = sessionStorage.getItem('ironclaw_token');
|
|
if (saved) {
|
|
document.getElementById('token-input').value = saved;
|
|
// Hide auth screen immediately to prevent flash, authenticate() will
|
|
// restore it if the token turns out to be invalid.
|
|
document.getElementById('auth-screen').style.display = 'none';
|
|
document.getElementById('app').style.display = 'flex';
|
|
authenticate();
|
|
}
|
|
})();
|
|
|
|
// --- API helper ---
|
|
|
|
function apiFetch(path, options) {
|
|
const opts = options || {};
|
|
opts.headers = opts.headers || {};
|
|
opts.headers['Authorization'] = 'Bearer ' + token;
|
|
if (opts.body && typeof opts.body === 'object') {
|
|
opts.headers['Content-Type'] = 'application/json';
|
|
opts.body = JSON.stringify(opts.body);
|
|
}
|
|
return fetch(path, opts).then((res) => {
|
|
if (!res.ok) {
|
|
return res.text().then(function(body) {
|
|
throw new Error(body || (res.status + ' ' + res.statusText));
|
|
});
|
|
}
|
|
return res.json();
|
|
});
|
|
}
|
|
|
|
// --- Restart Feature ---
|
|
|
|
let isRestarting = false; // Track if we're currently restarting
|
|
let restartEnabled = false; // Track if restart is available in this deployment
|
|
|
|
function triggerRestart() {
|
|
if (!currentThreadId) {
|
|
alert('Please start a conversation first');
|
|
return;
|
|
}
|
|
|
|
// Show the confirmation modal
|
|
const confirmModal = document.getElementById('restart-confirm-modal');
|
|
confirmModal.style.display = 'flex';
|
|
}
|
|
|
|
function confirmRestart() {
|
|
if (!currentThreadId) {
|
|
alert('Please start a conversation first');
|
|
return;
|
|
}
|
|
|
|
// Hide confirmation modal
|
|
const confirmModal = document.getElementById('restart-confirm-modal');
|
|
confirmModal.style.display = 'none';
|
|
|
|
const restartBtn = document.getElementById('restart-btn');
|
|
const restartIcon = document.getElementById('restart-icon');
|
|
|
|
// Mark as restarting
|
|
isRestarting = true;
|
|
restartBtn.disabled = true;
|
|
if (restartIcon) restartIcon.classList.add('spinning');
|
|
|
|
// Show progress modal
|
|
const loaderEl = document.getElementById('restart-loader');
|
|
loaderEl.style.display = 'flex';
|
|
|
|
// Send restart command via chat
|
|
console.log('[confirmRestart] Sending /restart command to server');
|
|
apiFetch('/api/chat/send', {
|
|
method: 'POST',
|
|
body: {
|
|
content: '/restart',
|
|
thread_id: currentThreadId,
|
|
},
|
|
})
|
|
.then((response) => {
|
|
console.log('[confirmRestart] API call succeeded, response:', response);
|
|
})
|
|
.catch((err) => {
|
|
console.error('[confirmRestart] Restart request failed:', err);
|
|
addMessage('system', 'Restart failed: ' + err.message);
|
|
isRestarting = false;
|
|
restartBtn.disabled = false;
|
|
if (restartIcon) restartIcon.classList.remove('spinning');
|
|
loaderEl.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
function cancelRestart() {
|
|
const confirmModal = document.getElementById('restart-confirm-modal');
|
|
confirmModal.style.display = 'none';
|
|
}
|
|
|
|
function tryShowRestartModal() {
|
|
// Defensive callback for when restart is detected in messages.
|
|
if (!isRestarting) {
|
|
isRestarting = true;
|
|
const restartBtn = document.getElementById('restart-btn');
|
|
const restartIcon = document.getElementById('restart-icon');
|
|
restartBtn.disabled = true;
|
|
if (restartIcon) restartIcon.classList.add('spinning');
|
|
|
|
// Show progress modal
|
|
const loaderEl = document.getElementById('restart-loader');
|
|
loaderEl.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
function updateRestartButtonVisibility() {
|
|
const restartBtn = document.getElementById('restart-btn');
|
|
if (restartBtn) {
|
|
restartBtn.style.display = restartEnabled ? 'block' : 'none';
|
|
}
|
|
}
|
|
|
|
function startGatewayStatusPolling() {
|
|
fetchGatewayStatus();
|
|
// Poll every 5 seconds
|
|
setInterval(fetchGatewayStatus, 5000);
|
|
}
|
|
|
|
function fetchGatewayStatus() {
|
|
apiFetch('/api/gateway/status')
|
|
.then((data) => {
|
|
restartEnabled = data.restart_enabled || false;
|
|
updateRestartButtonVisibility();
|
|
})
|
|
.catch((err) => {
|
|
console.warn('[gateway status] Failed to fetch:', err);
|
|
});
|
|
}
|
|
|
|
// --- SSE ---
|
|
|
|
function connectSSE() {
|
|
if (eventSource) eventSource.close();
|
|
|
|
eventSource = new EventSource('/api/chat/events?token=' + encodeURIComponent(token));
|
|
|
|
eventSource.onopen = () => {
|
|
document.getElementById('sse-dot').classList.remove('disconnected');
|
|
document.getElementById('sse-status').textContent = 'Connected';
|
|
|
|
// If we were restarting, close the modal and reset button now that server is back
|
|
if (isRestarting) {
|
|
const loaderEl = document.getElementById('restart-loader');
|
|
if (loaderEl) loaderEl.style.display = 'none';
|
|
const restartBtn = document.getElementById('restart-btn');
|
|
const restartIcon = document.getElementById('restart-icon');
|
|
if (restartBtn) restartBtn.disabled = false;
|
|
if (restartIcon) restartIcon.classList.remove('spinning');
|
|
isRestarting = false;
|
|
}
|
|
|
|
if (sseHasConnectedBefore && currentThreadId) {
|
|
finalizeActivityGroup();
|
|
loadHistory();
|
|
}
|
|
sseHasConnectedBefore = true;
|
|
};
|
|
|
|
eventSource.onerror = () => {
|
|
document.getElementById('sse-dot').classList.add('disconnected');
|
|
document.getElementById('sse-status').textContent = 'Reconnecting...';
|
|
};
|
|
|
|
eventSource.addEventListener('response', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
finalizeActivityGroup();
|
|
addMessage('assistant', data.content);
|
|
enableChatInput();
|
|
// Refresh thread list so new titles appear after first message
|
|
loadThreads();
|
|
|
|
// Show restart modal if the response indicates restart was initiated
|
|
if (data.content && data.content.toLowerCase().includes('restart initiated')) {
|
|
setTimeout(() => tryShowRestartModal(), 500);
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('thinking', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
showActivityThinking(data.message);
|
|
});
|
|
|
|
eventSource.addEventListener('tool_started', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
addToolCard(data.name);
|
|
});
|
|
|
|
eventSource.addEventListener('tool_completed', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
completeToolCard(data.name, data.success, data.error, data.parameters);
|
|
|
|
// Show restart modal only when the restart tool succeeds
|
|
if (data.name.toLowerCase() === 'restart' && data.success) {
|
|
setTimeout(() => tryShowRestartModal(), 500);
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('tool_result', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
setToolCardOutput(data.name, data.preview);
|
|
});
|
|
|
|
eventSource.addEventListener('stream_chunk', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
finalizeActivityGroup();
|
|
appendToLastAssistant(data.content);
|
|
});
|
|
|
|
eventSource.addEventListener('status', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
// "Done" and "Awaiting approval" are terminal signals from the agent:
|
|
// the agentic loop finished, so re-enable input as a safety net in case
|
|
// the response SSE event is empty or lost.
|
|
// Status text is not displayed — inline activity cards handle visual feedback.
|
|
if (data.message === 'Done' || data.message === 'Awaiting approval') {
|
|
finalizeActivityGroup();
|
|
enableChatInput();
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('job_started', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
showJobCard(data);
|
|
});
|
|
|
|
eventSource.addEventListener('approval_needed', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
showApproval(data);
|
|
});
|
|
|
|
eventSource.addEventListener('auth_required', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (data.auth_url) {
|
|
// OAuth flow: show the auth card with an OAuth button + optional token paste field.
|
|
showAuthCard(data);
|
|
} else {
|
|
// Setup flow: fetch the extension's credential schema and show the multi-field
|
|
// configure modal (the same UI used by the Extensions tab "Setup" button).
|
|
showConfigureModal(data.extension_name);
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('auth_completed', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
// Dismiss whichever UI path was active: auth card (OAuth) or configure modal (setup).
|
|
removeAuthCard(data.extension_name);
|
|
closeConfigureModal();
|
|
showToast(data.message, data.success ? 'success' : 'error');
|
|
// Refresh extensions list so status indicators update
|
|
if (currentTab === 'extensions') loadExtensions();
|
|
enableChatInput();
|
|
});
|
|
|
|
eventSource.addEventListener('extension_status', (e) => {
|
|
if (currentTab === 'extensions') loadExtensions();
|
|
});
|
|
|
|
eventSource.addEventListener('error', (e) => {
|
|
if (e.data) {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
finalizeActivityGroup();
|
|
addMessage('system', 'Error: ' + data.message);
|
|
enableChatInput();
|
|
}
|
|
});
|
|
|
|
// Job event listeners (activity stream for all sandbox jobs)
|
|
const jobEventTypes = [
|
|
'job_message', 'job_tool_use', 'job_tool_result',
|
|
'job_status', 'job_result'
|
|
];
|
|
for (const evtType of jobEventTypes) {
|
|
eventSource.addEventListener(evtType, (e) => {
|
|
const data = JSON.parse(e.data);
|
|
const jobId = data.job_id;
|
|
if (!jobId) return;
|
|
if (!jobEvents.has(jobId)) jobEvents.set(jobId, []);
|
|
const events = jobEvents.get(jobId);
|
|
events.push({ type: evtType, data: data, ts: Date.now() });
|
|
// Cap per-job events to prevent memory leak
|
|
while (events.length > JOB_EVENTS_CAP) events.shift();
|
|
// If the Activity tab is currently visible for this job, refresh it
|
|
refreshActivityTab(jobId);
|
|
// Auto-refresh job list when on jobs tab (debounced)
|
|
if ((evtType === 'job_result' || evtType === 'job_status') && currentTab === 'jobs' && !currentJobId) {
|
|
clearTimeout(jobListRefreshTimer);
|
|
jobListRefreshTimer = setTimeout(loadJobs, 200);
|
|
}
|
|
// Clean up finished job events after a viewing window
|
|
if (evtType === 'job_result') {
|
|
setTimeout(() => jobEvents.delete(jobId), 60000);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check if an SSE event belongs to the currently viewed thread.
|
|
// Events without a thread_id (legacy) are always shown.
|
|
function isCurrentThread(threadId) {
|
|
if (!threadId) return true;
|
|
if (!currentThreadId) return true;
|
|
return threadId === currentThreadId;
|
|
}
|
|
|
|
// --- Chat ---
|
|
|
|
function sendMessage() {
|
|
const input = document.getElementById('chat-input');
|
|
if (!currentThreadId) {
|
|
console.warn('sendMessage: no thread selected, ignoring');
|
|
return;
|
|
}
|
|
const content = input.value.trim();
|
|
if (!content) return;
|
|
|
|
addMessage('user', content);
|
|
input.value = '';
|
|
autoResizeTextarea(input);
|
|
input.focus();
|
|
|
|
apiFetch('/api/chat/send', {
|
|
method: 'POST',
|
|
body: { content, thread_id: currentThreadId || undefined },
|
|
}).catch((err) => {
|
|
addMessage('system', 'Failed to send: ' + err.message);
|
|
});
|
|
}
|
|
|
|
function enableChatInput() {
|
|
// no-op: input and send button are always enabled
|
|
}
|
|
|
|
// --- Slash Autocomplete ---
|
|
|
|
function showSlashAutocomplete(matches) {
|
|
const el = document.getElementById('slash-autocomplete');
|
|
if (!el || matches.length === 0) { hideSlashAutocomplete(); return; }
|
|
_slashMatches = matches;
|
|
_slashSelected = -1;
|
|
el.innerHTML = '';
|
|
matches.forEach((item, i) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'slash-ac-item';
|
|
row.dataset.index = i;
|
|
var cmdSpan = document.createElement('span');
|
|
cmdSpan.className = 'slash-ac-cmd';
|
|
cmdSpan.textContent = item.cmd;
|
|
var descSpan = document.createElement('span');
|
|
descSpan.className = 'slash-ac-desc';
|
|
descSpan.textContent = item.desc;
|
|
row.appendChild(cmdSpan);
|
|
row.appendChild(descSpan);
|
|
row.addEventListener('mousedown', (e) => {
|
|
e.preventDefault(); // prevent blur
|
|
selectSlashItem(item.cmd);
|
|
});
|
|
el.appendChild(row);
|
|
});
|
|
el.style.display = 'block';
|
|
}
|
|
|
|
function hideSlashAutocomplete() {
|
|
const el = document.getElementById('slash-autocomplete');
|
|
if (el) el.style.display = 'none';
|
|
_slashSelected = -1;
|
|
_slashMatches = [];
|
|
}
|
|
|
|
function selectSlashItem(cmd) {
|
|
const input = document.getElementById('chat-input');
|
|
input.value = cmd + ' ';
|
|
input.focus();
|
|
hideSlashAutocomplete();
|
|
autoResizeTextarea(input);
|
|
}
|
|
|
|
function updateSlashHighlight() {
|
|
const items = document.querySelectorAll('#slash-autocomplete .slash-ac-item');
|
|
items.forEach((el, i) => el.classList.toggle('selected', i === _slashSelected));
|
|
if (_slashSelected >= 0 && items[_slashSelected]) {
|
|
items[_slashSelected].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}
|
|
|
|
function filterSlashCommands(value) {
|
|
if (!value.startsWith('/')) { hideSlashAutocomplete(); return; }
|
|
// Only show autocomplete when the input is just a slash command prefix (no spaces except /thread new)
|
|
const lower = value.toLowerCase();
|
|
const matches = SLASH_COMMANDS.filter((c) => c.cmd.startsWith(lower));
|
|
if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === lower.trimEnd())) {
|
|
hideSlashAutocomplete();
|
|
} else {
|
|
showSlashAutocomplete(matches);
|
|
}
|
|
}
|
|
|
|
function sendApprovalAction(requestId, action) {
|
|
apiFetch('/api/chat/approval', {
|
|
method: 'POST',
|
|
body: { request_id: requestId, action: action, thread_id: currentThreadId },
|
|
}).catch((err) => {
|
|
addMessage('system', 'Failed to send approval: ' + err.message);
|
|
});
|
|
|
|
// Disable buttons and show confirmation on the card
|
|
const card = document.querySelector('.approval-card[data-request-id="' + requestId + '"]');
|
|
if (card) {
|
|
const buttons = card.querySelectorAll('.approval-actions button');
|
|
buttons.forEach((btn) => {
|
|
btn.disabled = true;
|
|
});
|
|
const actions = card.querySelector('.approval-actions');
|
|
const label = document.createElement('span');
|
|
label.className = 'approval-resolved';
|
|
const labelText = action === 'approve' ? 'Approved' : action === 'always' ? 'Always approved' : 'Denied';
|
|
label.textContent = labelText;
|
|
actions.appendChild(label);
|
|
// Remove the card after showing the confirmation briefly
|
|
setTimeout(() => { card.remove(); }, 1500);
|
|
}
|
|
}
|
|
|
|
function renderMarkdown(text) {
|
|
if (typeof marked !== 'undefined') {
|
|
let html = marked.parse(text);
|
|
// Sanitize HTML output to prevent XSS from tool output or LLM responses.
|
|
html = sanitizeRenderedHtml(html);
|
|
// Inject copy buttons into <pre> blocks
|
|
html = html.replace(/<pre>/g, '<pre class="code-block-wrapper"><button class="copy-btn" onclick="copyCodeBlock(this)">Copy</button>');
|
|
return html;
|
|
}
|
|
return escapeHtml(text);
|
|
}
|
|
|
|
// Strip dangerous HTML elements and attributes from rendered markdown.
|
|
// This prevents XSS from tool output or prompt injection in LLM responses.
|
|
function sanitizeRenderedHtml(html) {
|
|
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
|
html = html.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, '');
|
|
html = html.replace(/<object\b[^>]*>[\s\S]*?<\/object>/gi, '');
|
|
html = html.replace(/<embed\b[^>]*\/?>/gi, '');
|
|
html = html.replace(/<form\b[^>]*>[\s\S]*?<\/form>/gi, '');
|
|
html = html.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
html = html.replace(/<link\b[^>]*\/?>/gi, '');
|
|
html = html.replace(/<base\b[^>]*\/?>/gi, '');
|
|
html = html.replace(/<meta\b[^>]*\/?>/gi, '');
|
|
// Remove event handler attributes (onclick, onerror, onload, etc.)
|
|
html = html.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, '');
|
|
html = html.replace(/\s+on\w+\s*=\s*'[^']*'/gi, '');
|
|
html = html.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '');
|
|
// Remove javascript: and data: URLs in href/src attributes
|
|
html = html.replace(/(href|src|action)\s*=\s*["']?\s*javascript\s*:/gi, '$1="');
|
|
html = html.replace(/(href|src|action)\s*=\s*["']?\s*data\s*:/gi, '$1="');
|
|
return html;
|
|
}
|
|
|
|
function copyCodeBlock(btn) {
|
|
const pre = btn.parentElement;
|
|
const code = pre.querySelector('code');
|
|
const text = code ? code.textContent : pre.textContent;
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
btn.textContent = 'Copied!';
|
|
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
|
});
|
|
}
|
|
|
|
function addMessage(role, content) {
|
|
const container = document.getElementById('chat-messages');
|
|
const div = document.createElement('div');
|
|
div.className = 'message ' + role;
|
|
if (role === 'user') {
|
|
div.textContent = content;
|
|
} else {
|
|
div.setAttribute('data-raw', content);
|
|
div.innerHTML = renderMarkdown(content);
|
|
}
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function appendToLastAssistant(chunk) {
|
|
const container = document.getElementById('chat-messages');
|
|
const messages = container.querySelectorAll('.message.assistant');
|
|
if (messages.length > 0) {
|
|
const last = messages[messages.length - 1];
|
|
const raw = (last.getAttribute('data-raw') || '') + chunk;
|
|
last.setAttribute('data-raw', raw);
|
|
last.innerHTML = renderMarkdown(raw);
|
|
container.scrollTop = container.scrollHeight;
|
|
} else {
|
|
addMessage('assistant', chunk);
|
|
}
|
|
}
|
|
|
|
// --- Inline Tool Activity Cards ---
|
|
|
|
function getOrCreateActivityGroup() {
|
|
if (_activeGroup) return _activeGroup;
|
|
const container = document.getElementById('chat-messages');
|
|
const group = document.createElement('div');
|
|
group.className = 'activity-group';
|
|
container.appendChild(group);
|
|
container.scrollTop = container.scrollHeight;
|
|
_activeGroup = group;
|
|
_activeToolCards = {};
|
|
return group;
|
|
}
|
|
|
|
function showActivityThinking(message) {
|
|
const group = getOrCreateActivityGroup();
|
|
if (_activityThinking) {
|
|
// Already exists — just update text and un-hide
|
|
_activityThinking.style.display = '';
|
|
_activityThinking.querySelector('.activity-thinking-text').textContent = message;
|
|
} else {
|
|
_activityThinking = document.createElement('div');
|
|
_activityThinking.className = 'activity-thinking';
|
|
_activityThinking.innerHTML =
|
|
'<span class="activity-thinking-dots">'
|
|
+ '<span class="activity-thinking-dot"></span>'
|
|
+ '<span class="activity-thinking-dot"></span>'
|
|
+ '<span class="activity-thinking-dot"></span>'
|
|
+ '</span>'
|
|
+ '<span class="activity-thinking-text"></span>';
|
|
group.appendChild(_activityThinking);
|
|
_activityThinking.querySelector('.activity-thinking-text').textContent = message;
|
|
}
|
|
const container = document.getElementById('chat-messages');
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function removeActivityThinking() {
|
|
if (_activityThinking) {
|
|
_activityThinking.remove();
|
|
_activityThinking = null;
|
|
}
|
|
}
|
|
|
|
function addToolCard(name) {
|
|
// Hide thinking instead of destroying — it may reappear between tool rounds
|
|
if (_activityThinking) _activityThinking.style.display = 'none';
|
|
const group = getOrCreateActivityGroup();
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'activity-tool-card';
|
|
card.setAttribute('data-tool-name', name);
|
|
card.setAttribute('data-status', 'running');
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'activity-tool-header';
|
|
|
|
const icon = document.createElement('span');
|
|
icon.className = 'activity-tool-icon';
|
|
icon.innerHTML = '<div class="spinner"></div>';
|
|
|
|
const toolName = document.createElement('span');
|
|
toolName.className = 'activity-tool-name';
|
|
toolName.textContent = name;
|
|
|
|
const duration = document.createElement('span');
|
|
duration.className = 'activity-tool-duration';
|
|
duration.textContent = '';
|
|
|
|
const chevron = document.createElement('span');
|
|
chevron.className = 'activity-tool-chevron';
|
|
chevron.innerHTML = '▸';
|
|
|
|
header.appendChild(icon);
|
|
header.appendChild(toolName);
|
|
header.appendChild(duration);
|
|
header.appendChild(chevron);
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'activity-tool-body';
|
|
body.style.display = 'none';
|
|
|
|
const output = document.createElement('pre');
|
|
output.className = 'activity-tool-output';
|
|
body.appendChild(output);
|
|
|
|
header.addEventListener('click', () => {
|
|
const isOpen = body.style.display !== 'none';
|
|
body.style.display = isOpen ? 'none' : 'block';
|
|
chevron.classList.toggle('expanded', !isOpen);
|
|
});
|
|
|
|
card.appendChild(header);
|
|
card.appendChild(body);
|
|
group.appendChild(card);
|
|
|
|
const startTime = Date.now();
|
|
const timerInterval = setInterval(() => {
|
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
if (elapsed > 300) { clearInterval(timerInterval); return; }
|
|
duration.textContent = elapsed < 10 ? elapsed.toFixed(1) + 's' : Math.floor(elapsed) + 's';
|
|
}, 100);
|
|
|
|
if (!_activeToolCards[name]) _activeToolCards[name] = [];
|
|
_activeToolCards[name].push({ card, startTime, timer: timerInterval, duration, icon, finalDuration: null });
|
|
|
|
const container = document.getElementById('chat-messages');
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function completeToolCard(name, success, error, parameters) {
|
|
const entries = _activeToolCards[name];
|
|
if (!entries || entries.length === 0) return;
|
|
// Find first running card
|
|
let entry = null;
|
|
for (let i = 0; i < entries.length; i++) {
|
|
if (entries[i].card.getAttribute('data-status') === 'running') {
|
|
entry = entries[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!entry) entry = entries[entries.length - 1];
|
|
|
|
clearInterval(entry.timer);
|
|
const elapsed = (Date.now() - entry.startTime) / 1000;
|
|
entry.finalDuration = elapsed;
|
|
entry.duration.textContent = elapsed < 10 ? elapsed.toFixed(1) + 's' : Math.floor(elapsed) + 's';
|
|
entry.icon.innerHTML = success
|
|
? '<span class="activity-icon-success">✓</span>'
|
|
: '<span class="activity-icon-fail">✗</span>';
|
|
entry.card.setAttribute('data-status', success ? 'success' : 'fail');
|
|
|
|
// For failed tools, populate the body with error details and auto-expand
|
|
if (!success && (error || parameters)) {
|
|
const output = entry.card.querySelector('.activity-tool-output');
|
|
if (output) {
|
|
let detail = '';
|
|
if (parameters) {
|
|
detail += 'Input:\n' + parameters + '\n\n';
|
|
}
|
|
if (error) {
|
|
detail += 'Error:\n' + error;
|
|
}
|
|
output.textContent = detail;
|
|
|
|
// Auto-expand so the error is immediately visible
|
|
const body = entry.card.querySelector('.activity-tool-body');
|
|
const chevron = entry.card.querySelector('.activity-tool-chevron');
|
|
if (body) body.style.display = 'block';
|
|
if (chevron) chevron.classList.add('expanded');
|
|
}
|
|
}
|
|
}
|
|
|
|
function setToolCardOutput(name, preview) {
|
|
const entries = _activeToolCards[name];
|
|
if (!entries || entries.length === 0) return;
|
|
// Find first card with empty output
|
|
let entry = null;
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const out = entries[i].card.querySelector('.activity-tool-output');
|
|
if (out && !out.textContent) {
|
|
entry = entries[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!entry) entry = entries[entries.length - 1];
|
|
|
|
const output = entry.card.querySelector('.activity-tool-output');
|
|
if (output) {
|
|
const truncated = preview.length > 2000 ? preview.substring(0, 2000) + '\n... (truncated)' : preview;
|
|
output.textContent = truncated;
|
|
}
|
|
}
|
|
|
|
function finalizeActivityGroup() {
|
|
removeActivityThinking();
|
|
if (!_activeGroup) return;
|
|
|
|
// Stop all timers
|
|
for (const name in _activeToolCards) {
|
|
const entries = _activeToolCards[name];
|
|
for (let i = 0; i < entries.length; i++) {
|
|
clearInterval(entries[i].timer);
|
|
}
|
|
}
|
|
|
|
// Count tools and total duration
|
|
let toolCount = 0;
|
|
let totalDuration = 0;
|
|
for (const tname in _activeToolCards) {
|
|
const tentries = _activeToolCards[tname];
|
|
for (let j = 0; j < tentries.length; j++) {
|
|
const entry = tentries[j];
|
|
toolCount++;
|
|
if (entry.finalDuration !== null) {
|
|
totalDuration += entry.finalDuration;
|
|
} else {
|
|
// Tool was still running when finalized
|
|
totalDuration += (Date.now() - entry.startTime) / 1000;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (toolCount === 0) {
|
|
// No tools were used — remove the empty group
|
|
_activeGroup.remove();
|
|
_activeGroup = null;
|
|
_activeToolCards = {};
|
|
return;
|
|
}
|
|
|
|
// Wrap existing cards into a hidden container
|
|
const cardsContainer = document.createElement('div');
|
|
cardsContainer.className = 'activity-cards-container';
|
|
cardsContainer.style.display = 'none';
|
|
|
|
const cards = _activeGroup.querySelectorAll('.activity-tool-card');
|
|
for (let k = 0; k < cards.length; k++) {
|
|
cardsContainer.appendChild(cards[k]);
|
|
}
|
|
|
|
// Build summary line
|
|
const durationStr = totalDuration < 10 ? totalDuration.toFixed(1) + 's' : Math.floor(totalDuration) + 's';
|
|
const toolWord = toolCount === 1 ? 'tool' : 'tools';
|
|
const summary = document.createElement('div');
|
|
summary.className = 'activity-summary';
|
|
summary.innerHTML = '<span class="activity-summary-chevron">▸</span>'
|
|
+ '<span class="activity-summary-text">Used ' + toolCount + ' ' + toolWord + '</span>'
|
|
+ '<span class="activity-summary-duration">(' + durationStr + ')</span>';
|
|
|
|
summary.addEventListener('click', () => {
|
|
const isOpen = cardsContainer.style.display !== 'none';
|
|
cardsContainer.style.display = isOpen ? 'none' : 'block';
|
|
summary.querySelector('.activity-summary-chevron').classList.toggle('expanded', !isOpen);
|
|
});
|
|
|
|
// Clear group and add summary + hidden cards
|
|
_activeGroup.innerHTML = '';
|
|
_activeGroup.classList.add('collapsed');
|
|
_activeGroup.appendChild(summary);
|
|
_activeGroup.appendChild(cardsContainer);
|
|
|
|
_activeGroup = null;
|
|
_activeToolCards = {};
|
|
}
|
|
|
|
function showApproval(data) {
|
|
const container = document.getElementById('chat-messages');
|
|
const card = document.createElement('div');
|
|
card.className = 'approval-card';
|
|
card.setAttribute('data-request-id', data.request_id);
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'approval-header';
|
|
header.textContent = 'Tool requires approval';
|
|
card.appendChild(header);
|
|
|
|
const toolName = document.createElement('div');
|
|
toolName.className = 'approval-tool-name';
|
|
toolName.textContent = data.tool_name;
|
|
card.appendChild(toolName);
|
|
|
|
if (data.description) {
|
|
const desc = document.createElement('div');
|
|
desc.className = 'approval-description';
|
|
desc.textContent = data.description;
|
|
card.appendChild(desc);
|
|
}
|
|
|
|
if (data.parameters) {
|
|
const paramsToggle = document.createElement('button');
|
|
paramsToggle.className = 'approval-params-toggle';
|
|
paramsToggle.textContent = 'Show parameters';
|
|
const paramsBlock = document.createElement('pre');
|
|
paramsBlock.className = 'approval-params';
|
|
paramsBlock.textContent = data.parameters;
|
|
paramsBlock.style.display = 'none';
|
|
paramsToggle.addEventListener('click', () => {
|
|
const visible = paramsBlock.style.display !== 'none';
|
|
paramsBlock.style.display = visible ? 'none' : 'block';
|
|
paramsToggle.textContent = visible ? 'Show parameters' : 'Hide parameters';
|
|
});
|
|
card.appendChild(paramsToggle);
|
|
card.appendChild(paramsBlock);
|
|
}
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'approval-actions';
|
|
|
|
const approveBtn = document.createElement('button');
|
|
approveBtn.className = 'approve';
|
|
approveBtn.textContent = 'Approve';
|
|
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
|
|
|
|
const alwaysBtn = document.createElement('button');
|
|
alwaysBtn.className = 'always';
|
|
alwaysBtn.textContent = 'Always';
|
|
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
|
|
|
|
const denyBtn = document.createElement('button');
|
|
denyBtn.className = 'deny';
|
|
denyBtn.textContent = 'Deny';
|
|
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
|
|
|
|
actions.appendChild(approveBtn);
|
|
actions.appendChild(alwaysBtn);
|
|
actions.appendChild(denyBtn);
|
|
card.appendChild(actions);
|
|
|
|
container.appendChild(card);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function showJobCard(data) {
|
|
const container = document.getElementById('chat-messages');
|
|
const card = document.createElement('div');
|
|
card.className = 'job-card';
|
|
|
|
const icon = document.createElement('span');
|
|
icon.className = 'job-card-icon';
|
|
icon.textContent = '\u2692';
|
|
card.appendChild(icon);
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'job-card-info';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'job-card-title';
|
|
title.textContent = data.title || 'Sandbox Job';
|
|
info.appendChild(title);
|
|
|
|
const id = document.createElement('div');
|
|
id.className = 'job-card-id';
|
|
id.textContent = (data.job_id || '').substring(0, 8);
|
|
info.appendChild(id);
|
|
|
|
card.appendChild(info);
|
|
|
|
const viewBtn = document.createElement('button');
|
|
viewBtn.className = 'job-card-view';
|
|
viewBtn.textContent = 'View Job';
|
|
viewBtn.addEventListener('click', () => {
|
|
switchTab('jobs');
|
|
openJobDetail(data.job_id);
|
|
});
|
|
card.appendChild(viewBtn);
|
|
|
|
if (data.browse_url) {
|
|
const browseBtn = document.createElement('a');
|
|
browseBtn.className = 'job-card-browse';
|
|
browseBtn.href = data.browse_url;
|
|
browseBtn.target = '_blank';
|
|
browseBtn.textContent = 'Browse';
|
|
card.appendChild(browseBtn);
|
|
}
|
|
|
|
container.appendChild(card);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
// --- Auth card ---
|
|
|
|
function showAuthCard(data) {
|
|
// Remove any existing card for this extension first
|
|
removeAuthCard(data.extension_name);
|
|
|
|
const container = document.getElementById('chat-messages');
|
|
const card = document.createElement('div');
|
|
card.className = 'auth-card';
|
|
card.setAttribute('data-extension-name', data.extension_name);
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'auth-header';
|
|
header.textContent = 'Authentication required for ' + data.extension_name;
|
|
card.appendChild(header);
|
|
|
|
if (data.instructions) {
|
|
const instr = document.createElement('div');
|
|
instr.className = 'auth-instructions';
|
|
instr.textContent = data.instructions;
|
|
card.appendChild(instr);
|
|
}
|
|
|
|
const links = document.createElement('div');
|
|
links.className = 'auth-links';
|
|
|
|
if (data.auth_url) {
|
|
const oauthBtn = document.createElement('button');
|
|
oauthBtn.className = 'auth-oauth';
|
|
oauthBtn.textContent = 'Authenticate with ' + data.extension_name;
|
|
oauthBtn.addEventListener('click', () => {
|
|
openOAuthUrl(data.auth_url);
|
|
});
|
|
links.appendChild(oauthBtn);
|
|
}
|
|
|
|
if (data.setup_url) {
|
|
const setupLink = document.createElement('a');
|
|
setupLink.href = data.setup_url;
|
|
setupLink.target = '_blank';
|
|
setupLink.textContent = 'Get your token';
|
|
links.appendChild(setupLink);
|
|
}
|
|
|
|
if (links.children.length > 0) {
|
|
card.appendChild(links);
|
|
}
|
|
|
|
// Token input
|
|
const tokenRow = document.createElement('div');
|
|
tokenRow.className = 'auth-token-input';
|
|
|
|
const tokenInput = document.createElement('input');
|
|
tokenInput.type = 'password';
|
|
tokenInput.placeholder = data.instructions || 'Paste your API key or token';
|
|
tokenInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
|
|
});
|
|
tokenRow.appendChild(tokenInput);
|
|
card.appendChild(tokenRow);
|
|
|
|
// Error display (hidden initially)
|
|
const errorEl = document.createElement('div');
|
|
errorEl.className = 'auth-error';
|
|
errorEl.style.display = 'none';
|
|
card.appendChild(errorEl);
|
|
|
|
// Action buttons
|
|
const actions = document.createElement('div');
|
|
actions.className = 'auth-actions';
|
|
|
|
const submitBtn = document.createElement('button');
|
|
submitBtn.className = 'auth-submit';
|
|
submitBtn.textContent = 'Submit';
|
|
submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value));
|
|
|
|
const cancelBtn = document.createElement('button');
|
|
cancelBtn.className = 'auth-cancel';
|
|
cancelBtn.textContent = 'Cancel';
|
|
cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name));
|
|
|
|
actions.appendChild(submitBtn);
|
|
actions.appendChild(cancelBtn);
|
|
card.appendChild(actions);
|
|
|
|
container.appendChild(card);
|
|
container.scrollTop = container.scrollHeight;
|
|
tokenInput.focus();
|
|
}
|
|
|
|
function removeAuthCard(extensionName) {
|
|
const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
|
|
if (card) card.remove();
|
|
}
|
|
|
|
function submitAuthToken(extensionName, tokenValue) {
|
|
if (!tokenValue || !tokenValue.trim()) return;
|
|
|
|
// Disable submit button while in flight
|
|
const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
|
|
if (card) {
|
|
const btns = card.querySelectorAll('button');
|
|
btns.forEach((b) => { b.disabled = true; });
|
|
}
|
|
|
|
apiFetch('/api/chat/auth-token', {
|
|
method: 'POST',
|
|
body: { extension_name: extensionName, token: tokenValue.trim() },
|
|
}).then((result) => {
|
|
if (result.success) {
|
|
removeAuthCard(extensionName);
|
|
addMessage('system', result.message);
|
|
} else {
|
|
showAuthCardError(extensionName, result.message);
|
|
}
|
|
}).catch((err) => {
|
|
showAuthCardError(extensionName, 'Failed: ' + err.message);
|
|
});
|
|
}
|
|
|
|
function cancelAuth(extensionName) {
|
|
apiFetch('/api/chat/auth-cancel', {
|
|
method: 'POST',
|
|
body: { extension_name: extensionName },
|
|
}).catch(() => {});
|
|
removeAuthCard(extensionName);
|
|
enableChatInput();
|
|
}
|
|
|
|
function showAuthCardError(extensionName, message) {
|
|
const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
|
|
if (!card) return;
|
|
// Re-enable buttons
|
|
const btns = card.querySelectorAll('button');
|
|
btns.forEach((b) => { b.disabled = false; });
|
|
// Show error
|
|
const errorEl = card.querySelector('.auth-error');
|
|
if (errorEl) {
|
|
errorEl.textContent = message;
|
|
errorEl.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function loadHistory(before) {
|
|
let historyUrl = '/api/chat/history?limit=50';
|
|
if (currentThreadId) {
|
|
historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId);
|
|
}
|
|
if (before) {
|
|
historyUrl += '&before=' + encodeURIComponent(before);
|
|
}
|
|
|
|
const isPaginating = !!before;
|
|
if (isPaginating) loadingOlder = true;
|
|
|
|
apiFetch(historyUrl).then((data) => {
|
|
const container = document.getElementById('chat-messages');
|
|
|
|
if (!isPaginating) {
|
|
// Fresh load: clear and render
|
|
container.innerHTML = '';
|
|
for (const turn of data.turns) {
|
|
addMessage('user', turn.user_input);
|
|
if (turn.tool_calls && turn.tool_calls.length > 0) {
|
|
addToolCallsSummary(turn.tool_calls);
|
|
}
|
|
if (turn.response) {
|
|
addMessage('assistant', turn.response);
|
|
}
|
|
}
|
|
// Show processing indicator if the last turn is still in-progress
|
|
var lastTurn = data.turns.length > 0 ? data.turns[data.turns.length - 1] : null;
|
|
if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') {
|
|
showActivityThinking('Processing...');
|
|
}
|
|
// Re-render pending approval card if the thread is awaiting approval
|
|
if (data.pending_approval) {
|
|
showApproval(data.pending_approval);
|
|
}
|
|
} else {
|
|
// Pagination: prepend older messages
|
|
const savedHeight = container.scrollHeight;
|
|
const fragment = document.createDocumentFragment();
|
|
for (const turn of data.turns) {
|
|
const userDiv = createMessageElement('user', turn.user_input);
|
|
fragment.appendChild(userDiv);
|
|
if (turn.tool_calls && turn.tool_calls.length > 0) {
|
|
fragment.appendChild(createToolCallsSummaryElement(turn.tool_calls));
|
|
}
|
|
if (turn.response) {
|
|
const assistantDiv = createMessageElement('assistant', turn.response);
|
|
fragment.appendChild(assistantDiv);
|
|
}
|
|
}
|
|
container.insertBefore(fragment, container.firstChild);
|
|
// Restore scroll position so the user doesn't jump
|
|
container.scrollTop = container.scrollHeight - savedHeight;
|
|
}
|
|
|
|
hasMore = data.has_more || false;
|
|
oldestTimestamp = data.oldest_timestamp || null;
|
|
}).catch(() => {
|
|
// No history or no active thread
|
|
}).finally(() => {
|
|
loadingOlder = false;
|
|
removeScrollSpinner();
|
|
});
|
|
}
|
|
|
|
// Create a message DOM element without appending it (for prepend operations)
|
|
function createMessageElement(role, content) {
|
|
const div = document.createElement('div');
|
|
div.className = 'message ' + role;
|
|
if (role === 'user') {
|
|
div.textContent = content;
|
|
} else {
|
|
div.setAttribute('data-raw', content);
|
|
div.innerHTML = renderMarkdown(content);
|
|
}
|
|
return div;
|
|
}
|
|
|
|
function addToolCallsSummary(toolCalls) {
|
|
const container = document.getElementById('chat-messages');
|
|
container.appendChild(createToolCallsSummaryElement(toolCalls));
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function createToolCallsSummaryElement(toolCalls) {
|
|
const div = document.createElement('div');
|
|
div.className = 'tool-calls-summary';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'tool-calls-header';
|
|
header.textContent = toolCalls.length + ' tool' + (toolCalls.length !== 1 ? 's' : '') + ' used';
|
|
div.appendChild(header);
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'tool-calls-list';
|
|
|
|
for (const tc of toolCalls) {
|
|
const item = document.createElement('div');
|
|
item.className = 'tool-call-item' + (tc.has_error ? ' tool-error' : '');
|
|
|
|
const icon = tc.has_error ? '\u2717' : '\u2713';
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'tool-call-name';
|
|
nameSpan.textContent = icon + ' ' + tc.name;
|
|
item.appendChild(nameSpan);
|
|
|
|
if (tc.result_preview) {
|
|
const preview = document.createElement('div');
|
|
preview.className = 'tool-call-preview';
|
|
preview.textContent = tc.result_preview;
|
|
item.appendChild(preview);
|
|
}
|
|
if (tc.error) {
|
|
const errDiv = document.createElement('div');
|
|
errDiv.className = 'tool-call-error-text';
|
|
errDiv.textContent = tc.error;
|
|
item.appendChild(errDiv);
|
|
}
|
|
|
|
list.appendChild(item);
|
|
}
|
|
|
|
div.appendChild(list);
|
|
|
|
header.style.cursor = 'pointer';
|
|
header.addEventListener('click', () => {
|
|
list.classList.toggle('expanded');
|
|
header.classList.toggle('expanded');
|
|
});
|
|
|
|
return div;
|
|
}
|
|
|
|
function removeScrollSpinner() {
|
|
const spinner = document.getElementById('scroll-load-spinner');
|
|
if (spinner) spinner.remove();
|
|
}
|
|
|
|
// --- Threads ---
|
|
|
|
function loadThreads() {
|
|
apiFetch('/api/chat/threads').then((data) => {
|
|
// Pinned assistant thread
|
|
if (data.assistant_thread) {
|
|
assistantThreadId = data.assistant_thread.id;
|
|
const el = document.getElementById('assistant-thread');
|
|
const isActive = currentThreadId === assistantThreadId;
|
|
el.className = 'assistant-item' + (isActive ? ' active' : '');
|
|
const meta = document.getElementById('assistant-meta');
|
|
const count = data.assistant_thread.turn_count || 0;
|
|
meta.textContent = count > 0 ? count + ' turns' : '';
|
|
}
|
|
|
|
// Regular threads
|
|
const list = document.getElementById('thread-list');
|
|
list.innerHTML = '';
|
|
const threads = data.threads || [];
|
|
for (const thread of threads) {
|
|
const item = document.createElement('div');
|
|
item.className = 'thread-item' + (thread.id === currentThreadId ? ' active' : '');
|
|
const label = document.createElement('span');
|
|
label.className = 'thread-label';
|
|
label.textContent = thread.title || thread.id.substring(0, 8);
|
|
label.title = thread.title ? thread.title + ' (' + thread.id + ')' : thread.id;
|
|
item.appendChild(label);
|
|
const meta = document.createElement('span');
|
|
meta.className = 'thread-meta';
|
|
meta.textContent = (thread.turn_count || 0) + ' turns';
|
|
item.appendChild(meta);
|
|
item.addEventListener('click', () => switchThread(thread.id));
|
|
list.appendChild(item);
|
|
}
|
|
|
|
// Default to assistant thread on first load if no thread selected
|
|
if (!currentThreadId && assistantThreadId) {
|
|
switchToAssistant();
|
|
}
|
|
|
|
// Enable chat input once a thread is available
|
|
if (currentThreadId) {
|
|
enableChatInput();
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function switchToAssistant() {
|
|
if (!assistantThreadId) return;
|
|
finalizeActivityGroup();
|
|
currentThreadId = assistantThreadId;
|
|
hasMore = false;
|
|
oldestTimestamp = null;
|
|
loadHistory();
|
|
loadThreads();
|
|
}
|
|
|
|
function switchThread(threadId) {
|
|
finalizeActivityGroup();
|
|
currentThreadId = threadId;
|
|
hasMore = false;
|
|
oldestTimestamp = null;
|
|
loadHistory();
|
|
loadThreads();
|
|
}
|
|
|
|
function createNewThread() {
|
|
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
|
|
currentThreadId = data.id || null;
|
|
document.getElementById('chat-messages').innerHTML = '';
|
|
loadThreads();
|
|
}).catch((err) => {
|
|
showToast('Failed to create thread: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function toggleThreadSidebar() {
|
|
const sidebar = document.getElementById('thread-sidebar');
|
|
sidebar.classList.toggle('collapsed');
|
|
const btn = document.getElementById('thread-toggle-btn');
|
|
btn.innerHTML = sidebar.classList.contains('collapsed') ? '»' : '«';
|
|
}
|
|
|
|
// Chat input auto-resize and keyboard handling
|
|
const chatInput = document.getElementById('chat-input');
|
|
chatInput.addEventListener('keydown', (e) => {
|
|
const acEl = document.getElementById('slash-autocomplete');
|
|
const acVisible = acEl && acEl.style.display !== 'none';
|
|
|
|
if (acVisible) {
|
|
const items = acEl.querySelectorAll('.slash-ac-item');
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
_slashSelected = Math.min(_slashSelected + 1, items.length - 1);
|
|
updateSlashHighlight();
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
_slashSelected = Math.max(_slashSelected - 1, -1);
|
|
updateSlashHighlight();
|
|
return;
|
|
}
|
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const pick = _slashSelected >= 0 ? _slashMatches[_slashSelected] : _slashMatches[0];
|
|
if (pick) selectSlashItem(pick.cmd);
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
hideSlashAutocomplete();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
hideSlashAutocomplete();
|
|
sendMessage();
|
|
}
|
|
});
|
|
chatInput.addEventListener('input', () => {
|
|
autoResizeTextarea(chatInput);
|
|
filterSlashCommands(chatInput.value);
|
|
});
|
|
chatInput.addEventListener('blur', () => {
|
|
// Small delay so mousedown on autocomplete item fires first
|
|
setTimeout(hideSlashAutocomplete, 150);
|
|
});
|
|
|
|
// Infinite scroll: load older messages when scrolled near the top
|
|
document.getElementById('chat-messages').addEventListener('scroll', function () {
|
|
if (this.scrollTop < 100 && hasMore && !loadingOlder) {
|
|
loadingOlder = true;
|
|
// Show spinner at top
|
|
const spinner = document.createElement('div');
|
|
spinner.id = 'scroll-load-spinner';
|
|
spinner.className = 'scroll-load-spinner';
|
|
spinner.innerHTML = '<div class="spinner"></div> Loading older messages...';
|
|
this.insertBefore(spinner, this.firstChild);
|
|
loadHistory(oldestTimestamp);
|
|
}
|
|
});
|
|
|
|
function autoResizeTextarea(el) {
|
|
el.style.height = 'auto';
|
|
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
|
}
|
|
|
|
// --- Tabs ---
|
|
|
|
document.querySelectorAll('.tab-bar button[data-tab]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const tab = btn.getAttribute('data-tab');
|
|
switchTab(tab);
|
|
});
|
|
});
|
|
|
|
function switchTab(tab) {
|
|
currentTab = tab;
|
|
document.querySelectorAll('.tab-bar button[data-tab]').forEach((b) => {
|
|
b.classList.toggle('active', b.getAttribute('data-tab') === tab);
|
|
});
|
|
document.querySelectorAll('.tab-panel').forEach((p) => {
|
|
p.classList.toggle('active', p.id === 'tab-' + tab);
|
|
});
|
|
|
|
if (tab === 'memory') loadMemoryTree();
|
|
if (tab === 'jobs') loadJobs();
|
|
if (tab === 'routines') loadRoutines();
|
|
if (tab === 'logs') applyLogFilters();
|
|
if (tab === 'extensions') {
|
|
loadExtensions();
|
|
startPairingPoll();
|
|
} else {
|
|
stopPairingPoll();
|
|
}
|
|
if (tab === 'skills') loadSkills();
|
|
}
|
|
|
|
// --- Memory (filesystem tree) ---
|
|
|
|
let memorySearchTimeout = null;
|
|
let currentMemoryPath = null;
|
|
let currentMemoryContent = null;
|
|
// Tree state: nested nodes persisted across renders
|
|
// { name, path, is_dir, children: [] | null, expanded: bool, loaded: bool }
|
|
let memoryTreeState = null;
|
|
|
|
document.getElementById('memory-search').addEventListener('input', (e) => {
|
|
clearTimeout(memorySearchTimeout);
|
|
const query = e.target.value.trim();
|
|
if (!query) {
|
|
loadMemoryTree();
|
|
return;
|
|
}
|
|
memorySearchTimeout = setTimeout(() => searchMemory(query), 300);
|
|
});
|
|
|
|
function loadMemoryTree() {
|
|
// Only load top-level on first load (or refresh)
|
|
apiFetch('/api/memory/list?path=').then((data) => {
|
|
memoryTreeState = data.entries.map((e) => ({
|
|
name: e.name,
|
|
path: e.path,
|
|
is_dir: e.is_dir,
|
|
children: e.is_dir ? null : undefined,
|
|
expanded: false,
|
|
loaded: false,
|
|
}));
|
|
renderTree();
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function renderTree() {
|
|
const container = document.getElementById('memory-tree');
|
|
container.innerHTML = '';
|
|
if (!memoryTreeState || memoryTreeState.length === 0) {
|
|
container.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No files in workspace</div>';
|
|
return;
|
|
}
|
|
renderNodes(memoryTreeState, container, 0);
|
|
}
|
|
|
|
function renderNodes(nodes, container, depth) {
|
|
for (const node of nodes) {
|
|
const row = document.createElement('div');
|
|
row.className = 'tree-row';
|
|
row.style.paddingLeft = (depth * 16 + 8) + 'px';
|
|
|
|
if (node.is_dir) {
|
|
const arrow = document.createElement('span');
|
|
arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : '');
|
|
arrow.textContent = '\u25B6';
|
|
arrow.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
toggleExpand(node);
|
|
});
|
|
row.appendChild(arrow);
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'tree-label dir';
|
|
label.textContent = node.name;
|
|
label.addEventListener('click', () => toggleExpand(node));
|
|
row.appendChild(label);
|
|
} else {
|
|
const spacer = document.createElement('span');
|
|
spacer.className = 'expand-arrow-spacer';
|
|
row.appendChild(spacer);
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'tree-label file';
|
|
label.textContent = node.name;
|
|
label.addEventListener('click', () => readMemoryFile(node.path));
|
|
row.appendChild(label);
|
|
}
|
|
|
|
container.appendChild(row);
|
|
|
|
if (node.is_dir && node.expanded && node.children) {
|
|
const childContainer = document.createElement('div');
|
|
childContainer.className = 'tree-children';
|
|
renderNodes(node.children, childContainer, depth + 1);
|
|
container.appendChild(childContainer);
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleExpand(node) {
|
|
if (node.expanded) {
|
|
node.expanded = false;
|
|
renderTree();
|
|
return;
|
|
}
|
|
|
|
if (node.loaded) {
|
|
node.expanded = true;
|
|
renderTree();
|
|
return;
|
|
}
|
|
|
|
// Lazy-load children
|
|
apiFetch('/api/memory/list?path=' + encodeURIComponent(node.path)).then((data) => {
|
|
node.children = data.entries.map((e) => ({
|
|
name: e.name,
|
|
path: e.path,
|
|
is_dir: e.is_dir,
|
|
children: e.is_dir ? null : undefined,
|
|
expanded: false,
|
|
loaded: false,
|
|
}));
|
|
node.loaded = true;
|
|
node.expanded = true;
|
|
renderTree();
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function readMemoryFile(path) {
|
|
currentMemoryPath = path;
|
|
// Update breadcrumb
|
|
document.getElementById('memory-breadcrumb-path').innerHTML = buildBreadcrumb(path);
|
|
document.getElementById('memory-edit-btn').style.display = 'inline-block';
|
|
|
|
// Exit edit mode if active
|
|
cancelMemoryEdit();
|
|
|
|
apiFetch('/api/memory/read?path=' + encodeURIComponent(path)).then((data) => {
|
|
currentMemoryContent = data.content;
|
|
const viewer = document.getElementById('memory-viewer');
|
|
// Render markdown if it's a .md file
|
|
if (path.endsWith('.md')) {
|
|
viewer.innerHTML = '<div class="memory-rendered">' + renderMarkdown(data.content) + '</div>';
|
|
viewer.classList.add('rendered');
|
|
} else {
|
|
viewer.textContent = data.content;
|
|
viewer.classList.remove('rendered');
|
|
}
|
|
}).catch((err) => {
|
|
currentMemoryContent = null;
|
|
document.getElementById('memory-viewer').innerHTML = '<div class="empty">Error: ' + escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function startMemoryEdit() {
|
|
if (!currentMemoryPath || currentMemoryContent === null) return;
|
|
document.getElementById('memory-viewer').style.display = 'none';
|
|
const editor = document.getElementById('memory-editor');
|
|
editor.style.display = 'flex';
|
|
const textarea = document.getElementById('memory-edit-textarea');
|
|
textarea.value = currentMemoryContent;
|
|
textarea.focus();
|
|
}
|
|
|
|
function cancelMemoryEdit() {
|
|
document.getElementById('memory-viewer').style.display = '';
|
|
document.getElementById('memory-editor').style.display = 'none';
|
|
}
|
|
|
|
function saveMemoryEdit() {
|
|
if (!currentMemoryPath) return;
|
|
const content = document.getElementById('memory-edit-textarea').value;
|
|
apiFetch('/api/memory/write', {
|
|
method: 'POST',
|
|
body: { path: currentMemoryPath, content: content },
|
|
}).then(() => {
|
|
showToast('Saved ' + currentMemoryPath, 'success');
|
|
cancelMemoryEdit();
|
|
readMemoryFile(currentMemoryPath);
|
|
}).catch((err) => {
|
|
showToast('Save failed: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function buildBreadcrumb(path) {
|
|
const parts = path.split('/');
|
|
let html = '<a onclick="loadMemoryTree()">workspace</a>';
|
|
let current = '';
|
|
for (const part of parts) {
|
|
current += (current ? '/' : '') + part;
|
|
// Store the path in data-path (HTML-escaped) and read it back via this.dataset.path
|
|
// to avoid single-quote injection in inline JS string literals.
|
|
html += ' / <a onclick="readMemoryFile(this.dataset.path)" data-path="' + escapeHtml(current) + '">' + escapeHtml(part) + '</a>';
|
|
}
|
|
return html;
|
|
}
|
|
|
|
function searchMemory(query) {
|
|
const normalizedQuery = normalizeSearchQuery(query);
|
|
if (!normalizedQuery) return;
|
|
|
|
apiFetch('/api/memory/search', {
|
|
method: 'POST',
|
|
body: { query: normalizedQuery, limit: 20 },
|
|
}).then((data) => {
|
|
const tree = document.getElementById('memory-tree');
|
|
tree.innerHTML = '';
|
|
if (data.results.length === 0) {
|
|
tree.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No results</div>';
|
|
return;
|
|
}
|
|
for (const result of data.results) {
|
|
const item = document.createElement('div');
|
|
item.className = 'search-result';
|
|
const snippet = snippetAround(result.content, normalizedQuery, 120);
|
|
item.innerHTML = '<div class="path">' + escapeHtml(result.path) + '</div>'
|
|
+ '<div class="snippet">' + highlightQuery(snippet, normalizedQuery) + '</div>';
|
|
item.addEventListener('click', () => readMemoryFile(result.path));
|
|
tree.appendChild(item);
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function normalizeSearchQuery(query) {
|
|
return (typeof query === 'string' ? query : '').slice(0, MEMORY_SEARCH_QUERY_MAX_LENGTH);
|
|
}
|
|
|
|
function snippetAround(text, query, len) {
|
|
const normalizedQuery = normalizeSearchQuery(query);
|
|
const lower = text.toLowerCase();
|
|
const idx = lower.indexOf(normalizedQuery.toLowerCase());
|
|
if (idx < 0) return text.substring(0, len);
|
|
const start = Math.max(0, idx - Math.floor(len / 2));
|
|
const end = Math.min(text.length, start + len);
|
|
let s = text.substring(start, end);
|
|
if (start > 0) s = '...' + s;
|
|
if (end < text.length) s = s + '...';
|
|
return s;
|
|
}
|
|
|
|
function highlightQuery(text, query) {
|
|
if (!query) return escapeHtml(text);
|
|
const escaped = escapeHtml(text);
|
|
const normalizedQuery = normalizeSearchQuery(query);
|
|
const queryEscaped = normalizedQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp('(' + queryEscaped + ')', 'gi');
|
|
return escaped.replace(re, '<mark>$1</mark>');
|
|
}
|
|
// --- Logs ---
|
|
|
|
const LOG_MAX_ENTRIES = 2000;
|
|
let logsPaused = false;
|
|
let logBuffer = []; // buffer while paused
|
|
|
|
function connectLogSSE() {
|
|
if (logEventSource) logEventSource.close();
|
|
|
|
logEventSource = new EventSource('/api/logs/events?token=' + encodeURIComponent(token));
|
|
|
|
logEventSource.addEventListener('log', (e) => {
|
|
const entry = JSON.parse(e.data);
|
|
if (logsPaused) {
|
|
logBuffer.push(entry);
|
|
return;
|
|
}
|
|
prependLogEntry(entry);
|
|
});
|
|
|
|
logEventSource.onerror = () => {
|
|
// Silent reconnect
|
|
};
|
|
}
|
|
|
|
function prependLogEntry(entry) {
|
|
const output = document.getElementById('logs-output');
|
|
|
|
// Level filter
|
|
const levelFilter = document.getElementById('logs-level-filter').value;
|
|
const targetFilter = document.getElementById('logs-target-filter').value.trim().toLowerCase();
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'log-entry level-' + entry.level;
|
|
div.setAttribute('data-level', entry.level);
|
|
div.setAttribute('data-target', entry.target);
|
|
|
|
const ts = document.createElement('span');
|
|
ts.className = 'log-ts';
|
|
ts.textContent = entry.timestamp.substring(11, 23);
|
|
div.appendChild(ts);
|
|
|
|
const lvl = document.createElement('span');
|
|
lvl.className = 'log-level';
|
|
lvl.textContent = entry.level.padEnd(5);
|
|
div.appendChild(lvl);
|
|
|
|
const tgt = document.createElement('span');
|
|
tgt.className = 'log-target';
|
|
tgt.textContent = entry.target;
|
|
div.appendChild(tgt);
|
|
|
|
const msg = document.createElement('span');
|
|
msg.className = 'log-msg';
|
|
msg.textContent = entry.message;
|
|
div.appendChild(msg);
|
|
|
|
div.addEventListener('click', () => div.classList.toggle('expanded'));
|
|
|
|
// Apply current filters as visibility
|
|
const matchesLevel = levelFilter === 'all' || entry.level === levelFilter;
|
|
const matchesTarget = !targetFilter || entry.target.toLowerCase().includes(targetFilter);
|
|
if (!matchesLevel || !matchesTarget) {
|
|
div.style.display = 'none';
|
|
}
|
|
|
|
output.prepend(div);
|
|
|
|
// Cap entries (remove oldest at the bottom)
|
|
while (output.children.length > LOG_MAX_ENTRIES) {
|
|
output.removeChild(output.lastChild);
|
|
}
|
|
|
|
// Auto-scroll to top (newest entries are at the top)
|
|
if (document.getElementById('logs-autoscroll').checked) {
|
|
output.scrollTop = 0;
|
|
}
|
|
}
|
|
|
|
function toggleLogsPause() {
|
|
logsPaused = !logsPaused;
|
|
const btn = document.getElementById('logs-pause-btn');
|
|
btn.textContent = logsPaused ? 'Resume' : 'Pause';
|
|
|
|
if (!logsPaused) {
|
|
// Flush buffer: oldest-first + prepend naturally puts newest at top
|
|
for (const entry of logBuffer) {
|
|
prependLogEntry(entry);
|
|
}
|
|
logBuffer = [];
|
|
}
|
|
}
|
|
|
|
function clearLogs() {
|
|
if (!confirm('Clear all logs?')) return;
|
|
document.getElementById('logs-output').innerHTML = '';
|
|
logBuffer = [];
|
|
}
|
|
|
|
// Re-apply filters when level or target changes
|
|
document.getElementById('logs-level-filter').addEventListener('change', applyLogFilters);
|
|
document.getElementById('logs-target-filter').addEventListener('input', applyLogFilters);
|
|
|
|
function applyLogFilters() {
|
|
const levelFilter = document.getElementById('logs-level-filter').value;
|
|
const targetFilter = document.getElementById('logs-target-filter').value.trim().toLowerCase();
|
|
const entries = document.querySelectorAll('#logs-output .log-entry');
|
|
for (const el of entries) {
|
|
const matchesLevel = levelFilter === 'all' || el.getAttribute('data-level') === levelFilter;
|
|
const matchesTarget = !targetFilter || el.getAttribute('data-target').toLowerCase().includes(targetFilter);
|
|
el.style.display = (matchesLevel && matchesTarget) ? '' : 'none';
|
|
}
|
|
}
|
|
|
|
// --- Server-side log level control ---
|
|
|
|
function setServerLogLevel(level) {
|
|
apiFetch('/api/logs/level', {
|
|
method: 'PUT',
|
|
body: { level },
|
|
})
|
|
.then(data => {
|
|
document.getElementById('logs-server-level').value = data.level;
|
|
})
|
|
.catch(err => console.error('Failed to set server log level:', err));
|
|
}
|
|
|
|
function loadServerLogLevel() {
|
|
apiFetch('/api/logs/level')
|
|
.then(data => {
|
|
document.getElementById('logs-server-level').value = data.level;
|
|
})
|
|
.catch(() => {}); // ignore if not available
|
|
}
|
|
|
|
// --- Extensions ---
|
|
|
|
var kindLabels = { 'wasm_channel': 'Channel', 'wasm_tool': 'Tool', 'mcp_server': 'MCP' };
|
|
|
|
function loadExtensions() {
|
|
const extList = document.getElementById('extensions-list');
|
|
const wasmList = document.getElementById('available-wasm-list');
|
|
const mcpList = document.getElementById('mcp-servers-list');
|
|
const toolsTbody = document.getElementById('tools-tbody');
|
|
const toolsEmpty = document.getElementById('tools-empty');
|
|
|
|
// Fetch all three in parallel
|
|
Promise.all([
|
|
apiFetch('/api/extensions').catch(() => ({ extensions: [] })),
|
|
apiFetch('/api/extensions/tools').catch(() => ({ tools: [] })),
|
|
apiFetch('/api/extensions/registry').catch(function(err) { console.warn('registry fetch failed:', err); return { entries: [] }; }),
|
|
]).then(([extData, toolData, registryData]) => {
|
|
// Render installed extensions
|
|
if (extData.extensions.length === 0) {
|
|
extList.innerHTML = '<div class="empty-state">No extensions installed</div>';
|
|
} else {
|
|
extList.innerHTML = '';
|
|
for (const ext of extData.extensions) {
|
|
extList.appendChild(renderExtensionCard(ext));
|
|
}
|
|
}
|
|
|
|
// Split registry entries by kind
|
|
var wasmEntries = registryData.entries.filter(function(e) { return e.kind !== 'mcp_server' && !e.installed; });
|
|
var mcpEntries = registryData.entries.filter(function(e) { return e.kind === 'mcp_server'; });
|
|
|
|
// Available WASM extensions
|
|
if (wasmEntries.length === 0) {
|
|
wasmList.innerHTML = '<div class="empty-state">No additional WASM extensions available</div>';
|
|
} else {
|
|
wasmList.innerHTML = '';
|
|
for (const entry of wasmEntries) {
|
|
wasmList.appendChild(renderAvailableExtensionCard(entry));
|
|
}
|
|
}
|
|
|
|
// MCP servers (show both installed and uninstalled)
|
|
if (mcpEntries.length === 0) {
|
|
mcpList.innerHTML = '<div class="empty-state">No MCP servers available</div>';
|
|
} else {
|
|
mcpList.innerHTML = '';
|
|
for (const entry of mcpEntries) {
|
|
var installedExt = extData.extensions.find(function(e) { return e.name === entry.name; });
|
|
mcpList.appendChild(renderMcpServerCard(entry, installedExt));
|
|
}
|
|
}
|
|
|
|
// Render tools
|
|
if (toolData.tools.length === 0) {
|
|
toolsTbody.innerHTML = '';
|
|
toolsEmpty.style.display = 'block';
|
|
} else {
|
|
toolsEmpty.style.display = 'none';
|
|
toolsTbody.innerHTML = toolData.tools.map((t) =>
|
|
'<tr><td>' + escapeHtml(t.name) + '</td><td>' + escapeHtml(t.description) + '</td></tr>'
|
|
).join('');
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderAvailableExtensionCard(entry) {
|
|
const card = document.createElement('div');
|
|
card.className = 'ext-card ext-available';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'ext-header';
|
|
|
|
const name = document.createElement('span');
|
|
name.className = 'ext-name';
|
|
name.textContent = entry.display_name;
|
|
header.appendChild(name);
|
|
|
|
const kind = document.createElement('span');
|
|
kind.className = 'ext-kind kind-' + entry.kind;
|
|
kind.textContent = kindLabels[entry.kind] || entry.kind;
|
|
header.appendChild(kind);
|
|
|
|
if (entry.version) {
|
|
const ver = document.createElement('span');
|
|
ver.className = 'ext-version';
|
|
ver.textContent = 'v' + entry.version;
|
|
header.appendChild(ver);
|
|
}
|
|
|
|
card.appendChild(header);
|
|
|
|
const desc = document.createElement('div');
|
|
desc.className = 'ext-desc';
|
|
desc.textContent = entry.description;
|
|
card.appendChild(desc);
|
|
|
|
if (entry.keywords && entry.keywords.length > 0) {
|
|
const kw = document.createElement('div');
|
|
kw.className = 'ext-keywords';
|
|
kw.textContent = entry.keywords.join(', ');
|
|
card.appendChild(kw);
|
|
}
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'ext-actions';
|
|
|
|
const installBtn = document.createElement('button');
|
|
installBtn.className = 'btn-ext install';
|
|
installBtn.textContent = 'Install';
|
|
installBtn.addEventListener('click', function() {
|
|
installBtn.disabled = true;
|
|
installBtn.textContent = 'Installing...';
|
|
apiFetch('/api/extensions/install', {
|
|
method: 'POST',
|
|
body: { name: entry.name, kind: entry.kind },
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast('Installed ' + entry.display_name, 'success');
|
|
// OAuth popup if auth started during install (builtin creds)
|
|
if (res.auth_url) {
|
|
showToast('Opening authentication for ' + entry.display_name, 'info');
|
|
openOAuthUrl(res.auth_url);
|
|
}
|
|
loadExtensions();
|
|
// Auto-open configure for WASM channels
|
|
if (entry.kind === 'wasm_channel') {
|
|
showConfigureModal(entry.name);
|
|
}
|
|
} else {
|
|
showToast('Install: ' + (res.message || 'unknown error'), 'error');
|
|
loadExtensions();
|
|
}
|
|
}).catch(function(err) {
|
|
showToast('Install failed: ' + err.message, 'error');
|
|
loadExtensions();
|
|
});
|
|
});
|
|
actions.appendChild(installBtn);
|
|
|
|
card.appendChild(actions);
|
|
return card;
|
|
}
|
|
|
|
function renderMcpServerCard(entry, installedExt) {
|
|
var card = document.createElement('div');
|
|
card.className = 'ext-card' + (installedExt ? '' : ' ext-available');
|
|
|
|
var header = document.createElement('div');
|
|
header.className = 'ext-header';
|
|
|
|
var name = document.createElement('span');
|
|
name.className = 'ext-name';
|
|
name.textContent = entry.display_name;
|
|
header.appendChild(name);
|
|
|
|
var kind = document.createElement('span');
|
|
kind.className = 'ext-kind kind-mcp_server';
|
|
kind.textContent = kindLabels['mcp_server'] || 'mcp_server';
|
|
header.appendChild(kind);
|
|
|
|
if (installedExt) {
|
|
var authDot = document.createElement('span');
|
|
authDot.className = 'ext-auth-dot ' + (installedExt.authenticated ? 'authed' : 'unauthed');
|
|
authDot.title = installedExt.authenticated ? 'Authenticated' : 'Not authenticated';
|
|
header.appendChild(authDot);
|
|
}
|
|
|
|
card.appendChild(header);
|
|
|
|
var desc = document.createElement('div');
|
|
desc.className = 'ext-desc';
|
|
desc.textContent = entry.description;
|
|
card.appendChild(desc);
|
|
|
|
var actions = document.createElement('div');
|
|
actions.className = 'ext-actions';
|
|
|
|
if (installedExt) {
|
|
if (!installedExt.active) {
|
|
var activateBtn = document.createElement('button');
|
|
activateBtn.className = 'btn-ext activate';
|
|
activateBtn.textContent = 'Activate';
|
|
activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); });
|
|
actions.appendChild(activateBtn);
|
|
} else {
|
|
var activeLabel = document.createElement('span');
|
|
activeLabel.className = 'ext-active-label';
|
|
activeLabel.textContent = 'Active';
|
|
actions.appendChild(activeLabel);
|
|
}
|
|
var removeBtn = document.createElement('button');
|
|
removeBtn.className = 'btn-ext remove';
|
|
removeBtn.textContent = 'Remove';
|
|
removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); });
|
|
actions.appendChild(removeBtn);
|
|
} else {
|
|
var installBtn = document.createElement('button');
|
|
installBtn.className = 'btn-ext install';
|
|
installBtn.textContent = 'Install';
|
|
installBtn.addEventListener('click', function() {
|
|
installBtn.disabled = true;
|
|
installBtn.textContent = 'Installing...';
|
|
apiFetch('/api/extensions/install', {
|
|
method: 'POST',
|
|
body: { name: entry.name, kind: entry.kind },
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast('Installed ' + entry.display_name, 'success');
|
|
} else {
|
|
showToast('Install: ' + (res.message || 'unknown error'), 'error');
|
|
}
|
|
loadExtensions();
|
|
}).catch(function(err) {
|
|
showToast('Install failed: ' + err.message, 'error');
|
|
loadExtensions();
|
|
});
|
|
});
|
|
actions.appendChild(installBtn);
|
|
}
|
|
|
|
card.appendChild(actions);
|
|
return card;
|
|
}
|
|
|
|
function createReconfigureButton(extName) {
|
|
var btn = document.createElement('button');
|
|
btn.className = 'btn-ext configure';
|
|
btn.textContent = 'Reconfigure';
|
|
btn.addEventListener('click', function() { showConfigureModal(extName); });
|
|
return btn;
|
|
}
|
|
|
|
function renderExtensionCard(ext) {
|
|
const card = document.createElement('div');
|
|
card.className = 'ext-card';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'ext-header';
|
|
|
|
const name = document.createElement('span');
|
|
name.className = 'ext-name';
|
|
name.textContent = ext.display_name || ext.name;
|
|
header.appendChild(name);
|
|
|
|
const kind = document.createElement('span');
|
|
kind.className = 'ext-kind kind-' + ext.kind;
|
|
kind.textContent = kindLabels[ext.kind] || ext.kind;
|
|
header.appendChild(kind);
|
|
|
|
if (ext.version) {
|
|
const ver = document.createElement('span');
|
|
ver.className = 'ext-version';
|
|
ver.textContent = 'v' + ext.version;
|
|
header.appendChild(ver);
|
|
}
|
|
|
|
// Auth dot only for non-WASM-channel extensions (channels use the stepper instead)
|
|
if (ext.kind !== 'wasm_channel') {
|
|
const authDot = document.createElement('span');
|
|
authDot.className = 'ext-auth-dot ' + (ext.authenticated ? 'authed' : 'unauthed');
|
|
authDot.title = ext.authenticated ? 'Authenticated' : 'Not authenticated';
|
|
header.appendChild(authDot);
|
|
}
|
|
|
|
card.appendChild(header);
|
|
|
|
// WASM channels get a progress stepper
|
|
if (ext.kind === 'wasm_channel') {
|
|
card.appendChild(renderWasmChannelStepper(ext));
|
|
}
|
|
|
|
if (ext.description) {
|
|
const desc = document.createElement('div');
|
|
desc.className = 'ext-desc';
|
|
desc.textContent = ext.description;
|
|
card.appendChild(desc);
|
|
}
|
|
|
|
if (ext.url) {
|
|
const url = document.createElement('div');
|
|
url.className = 'ext-url';
|
|
url.textContent = ext.url;
|
|
url.title = ext.url;
|
|
card.appendChild(url);
|
|
}
|
|
|
|
if (ext.tools && ext.tools.length > 0) {
|
|
const tools = document.createElement('div');
|
|
tools.className = 'ext-tools';
|
|
tools.textContent = 'Tools: ' + ext.tools.join(', ');
|
|
card.appendChild(tools);
|
|
}
|
|
|
|
// Show activation error for WASM channels
|
|
if (ext.kind === 'wasm_channel' && ext.activation_error) {
|
|
const errorDiv = document.createElement('div');
|
|
errorDiv.className = 'ext-error';
|
|
errorDiv.textContent = ext.activation_error;
|
|
card.appendChild(errorDiv);
|
|
}
|
|
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'ext-actions';
|
|
|
|
if (ext.kind === 'wasm_channel') {
|
|
// WASM channels: state-based buttons (no generic Activate)
|
|
var status = ext.activation_status || 'installed';
|
|
if (status === 'active') {
|
|
var activeLabel = document.createElement('span');
|
|
activeLabel.className = 'ext-active-label';
|
|
activeLabel.textContent = 'Active';
|
|
actions.appendChild(activeLabel);
|
|
actions.appendChild(createReconfigureButton(ext.name));
|
|
} else if (status === 'pairing') {
|
|
var pairingLabel = document.createElement('span');
|
|
pairingLabel.className = 'ext-pairing-label';
|
|
pairingLabel.textContent = 'Awaiting Pairing';
|
|
actions.appendChild(pairingLabel);
|
|
actions.appendChild(createReconfigureButton(ext.name));
|
|
} else if (status === 'failed') {
|
|
actions.appendChild(createReconfigureButton(ext.name));
|
|
} else {
|
|
// installed or configured: show Setup button
|
|
var setupBtn = document.createElement('button');
|
|
setupBtn.className = 'btn-ext configure';
|
|
setupBtn.textContent = 'Setup';
|
|
setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); });
|
|
actions.appendChild(setupBtn);
|
|
}
|
|
} else {
|
|
// WASM tools / MCP servers
|
|
const activeLabel = document.createElement('span');
|
|
activeLabel.className = 'ext-active-label';
|
|
activeLabel.textContent = ext.active ? 'Active' : 'Installed';
|
|
actions.appendChild(activeLabel);
|
|
|
|
// MCP servers may be installed but inactive — show Activate button
|
|
if (ext.kind === 'mcp_server' && !ext.active) {
|
|
const activateBtn = document.createElement('button');
|
|
activateBtn.className = 'btn-ext activate';
|
|
activateBtn.textContent = 'Activate';
|
|
activateBtn.addEventListener('click', () => activateExtension(ext.name));
|
|
actions.appendChild(activateBtn);
|
|
}
|
|
|
|
// Show Configure/Reconfigure button when there are secrets to enter.
|
|
// Skip when has_auth is true but needs_setup is false and not yet authenticated —
|
|
// this means OAuth credentials resolve automatically (builtin/env) and the user
|
|
// just needs to complete the OAuth flow, not fill in a config form.
|
|
if (ext.needs_setup || (ext.has_auth && ext.authenticated)) {
|
|
const configBtn = document.createElement('button');
|
|
configBtn.className = 'btn-ext configure';
|
|
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
|
|
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
|
|
actions.appendChild(configBtn);
|
|
}
|
|
}
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.className = 'btn-ext remove';
|
|
removeBtn.textContent = 'Remove';
|
|
removeBtn.addEventListener('click', () => removeExtension(ext.name));
|
|
actions.appendChild(removeBtn);
|
|
|
|
card.appendChild(actions);
|
|
|
|
// For WASM channels, check for pending pairing requests.
|
|
if (ext.kind === 'wasm_channel') {
|
|
const pairingSection = document.createElement('div');
|
|
pairingSection.className = 'ext-pairing';
|
|
pairingSection.setAttribute('data-channel', ext.name);
|
|
card.appendChild(pairingSection);
|
|
loadPairingRequests(ext.name, pairingSection);
|
|
}
|
|
|
|
return card;
|
|
}
|
|
|
|
function activateExtension(name) {
|
|
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' })
|
|
.then((res) => {
|
|
if (res.success) {
|
|
// Even on success, the tool may need OAuth (e.g., WASM loaded but no token yet)
|
|
if (res.auth_url) {
|
|
showToast('Opening authentication for ' + name, 'info');
|
|
openOAuthUrl(res.auth_url);
|
|
}
|
|
loadExtensions();
|
|
return;
|
|
}
|
|
|
|
if (res.auth_url) {
|
|
showToast('Opening authentication for ' + name, 'info');
|
|
openOAuthUrl(res.auth_url);
|
|
} else if (res.awaiting_token) {
|
|
showConfigureModal(name);
|
|
} else {
|
|
showToast('Activate failed: ' + res.message, 'error');
|
|
}
|
|
loadExtensions();
|
|
})
|
|
.catch((err) => showToast('Activate failed: ' + err.message, 'error'));
|
|
}
|
|
|
|
function removeExtension(name) {
|
|
if (!confirm('Remove extension "' + name + '"?')) return;
|
|
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
|
|
.then((res) => {
|
|
if (!res.success) {
|
|
showToast('Remove failed: ' + res.message, 'error');
|
|
} else {
|
|
showToast('Removed ' + name, 'success');
|
|
}
|
|
loadExtensions();
|
|
})
|
|
.catch((err) => showToast('Remove failed: ' + err.message, 'error'));
|
|
}
|
|
|
|
function showConfigureModal(name) {
|
|
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup')
|
|
.then((setup) => {
|
|
if (!setup.secrets || setup.secrets.length === 0) {
|
|
showToast('No configuration needed for ' + name, 'info');
|
|
return;
|
|
}
|
|
renderConfigureModal(name, setup.secrets);
|
|
})
|
|
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
|
|
}
|
|
|
|
function renderConfigureModal(name, secrets) {
|
|
closeConfigureModal();
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'configure-overlay';
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) closeConfigureModal();
|
|
});
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'configure-modal';
|
|
|
|
const header = document.createElement('h3');
|
|
header.textContent = 'Configure ' + name;
|
|
modal.appendChild(header);
|
|
|
|
const form = document.createElement('div');
|
|
form.className = 'configure-form';
|
|
|
|
const fields = [];
|
|
for (const secret of secrets) {
|
|
const field = document.createElement('div');
|
|
field.className = 'configure-field';
|
|
|
|
const label = document.createElement('label');
|
|
label.textContent = secret.prompt;
|
|
if (secret.optional) {
|
|
const opt = document.createElement('span');
|
|
opt.className = 'field-optional';
|
|
opt.textContent = ' (optional)';
|
|
label.appendChild(opt);
|
|
}
|
|
field.appendChild(label);
|
|
|
|
const inputRow = document.createElement('div');
|
|
inputRow.className = 'configure-input-row';
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'password';
|
|
input.name = secret.name;
|
|
input.placeholder = secret.provided ? '(already set — leave empty to keep)' : '';
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') submitConfigureModal(name, fields);
|
|
});
|
|
inputRow.appendChild(input);
|
|
|
|
if (secret.provided) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'field-provided';
|
|
badge.textContent = '\u2713';
|
|
badge.title = 'Already configured';
|
|
inputRow.appendChild(badge);
|
|
}
|
|
if (secret.auto_generate && !secret.provided) {
|
|
const hint = document.createElement('span');
|
|
hint.className = 'field-autogen';
|
|
hint.textContent = 'Auto-generated if empty';
|
|
inputRow.appendChild(hint);
|
|
}
|
|
|
|
field.appendChild(inputRow);
|
|
form.appendChild(field);
|
|
fields.push({ name: secret.name, input: input });
|
|
}
|
|
|
|
modal.appendChild(form);
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'configure-actions';
|
|
|
|
const submitBtn = document.createElement('button');
|
|
submitBtn.className = 'btn-ext activate';
|
|
submitBtn.textContent = 'Save';
|
|
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
|
|
actions.appendChild(submitBtn);
|
|
|
|
const cancelBtn = document.createElement('button');
|
|
cancelBtn.className = 'btn-ext remove';
|
|
cancelBtn.textContent = 'Cancel';
|
|
cancelBtn.addEventListener('click', closeConfigureModal);
|
|
actions.appendChild(cancelBtn);
|
|
|
|
modal.appendChild(actions);
|
|
overlay.appendChild(modal);
|
|
document.body.appendChild(overlay);
|
|
|
|
if (fields.length > 0) fields[0].input.focus();
|
|
}
|
|
|
|
function submitConfigureModal(name, fields) {
|
|
const secrets = {};
|
|
for (const f of fields) {
|
|
if (f.input.value.trim()) {
|
|
secrets[f.name] = f.input.value.trim();
|
|
}
|
|
}
|
|
|
|
// Disable buttons to prevent double-submit
|
|
var btns = document.querySelectorAll('.configure-actions button');
|
|
btns.forEach(function(b) { b.disabled = true; });
|
|
|
|
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', {
|
|
method: 'POST',
|
|
body: { secrets },
|
|
})
|
|
.then((res) => {
|
|
if (res.success) {
|
|
closeConfigureModal();
|
|
if (res.auth_url) {
|
|
// OAuth flow started — open consent popup. The auth_completed SSE will
|
|
// not arrive immediately (it fires after OAuth callback), so show a toast now.
|
|
showToast('Opening OAuth authorization for ' + name, 'info');
|
|
openOAuthUrl(res.auth_url);
|
|
loadExtensions();
|
|
}
|
|
// For non-OAuth success: the server always broadcasts auth_completed SSE,
|
|
// which will show the toast and refresh extensions — no need to do it here too.
|
|
} else {
|
|
// Keep modal open so the user can correct their input and retry.
|
|
btns.forEach(function(b) { b.disabled = false; });
|
|
showToast(res.message || 'Configuration failed', 'error');
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
btns.forEach(function(b) { b.disabled = false; });
|
|
showToast('Configuration failed: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function closeConfigureModal() {
|
|
const existing = document.querySelector('.configure-overlay');
|
|
if (existing) existing.remove();
|
|
}
|
|
|
|
// Validate that a server-supplied OAuth URL is HTTPS before opening a popup.
|
|
// Rejects javascript:, data:, and other non-HTTPS schemes to prevent URL-injection.
|
|
// Uses the URL constructor to safely parse and validate the scheme, which also
|
|
// handles non-string values (objects, null, etc.) that would throw on .startsWith().
|
|
function openOAuthUrl(url) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(url);
|
|
if (parsed.protocol !== 'https:') {
|
|
throw new Error('non-HTTPS protocol: ' + parsed.protocol);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Blocked invalid/non-HTTPS OAuth URL:', url, e.message);
|
|
showToast('Invalid OAuth URL returned by server', 'error');
|
|
return;
|
|
}
|
|
window.open(parsed.href, '_blank', 'width=600,height=700');
|
|
}
|
|
|
|
// --- Pairing ---
|
|
|
|
function loadPairingRequests(channel, container) {
|
|
apiFetch('/api/pairing/' + encodeURIComponent(channel))
|
|
.then(data => {
|
|
container.innerHTML = '';
|
|
if (!data.requests || data.requests.length === 0) return;
|
|
|
|
const heading = document.createElement('div');
|
|
heading.className = 'pairing-heading';
|
|
heading.textContent = 'Pending pairing requests';
|
|
container.appendChild(heading);
|
|
|
|
data.requests.forEach(req => {
|
|
const row = document.createElement('div');
|
|
row.className = 'pairing-row';
|
|
|
|
const code = document.createElement('span');
|
|
code.className = 'pairing-code';
|
|
code.textContent = req.code;
|
|
row.appendChild(code);
|
|
|
|
const sender = document.createElement('span');
|
|
sender.className = 'pairing-sender';
|
|
sender.textContent = 'from ' + req.sender_id;
|
|
row.appendChild(sender);
|
|
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn-ext activate';
|
|
btn.textContent = 'Approve';
|
|
btn.addEventListener('click', () => approvePairing(channel, req.code, container));
|
|
row.appendChild(btn);
|
|
|
|
container.appendChild(row);
|
|
});
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function approvePairing(channel, code, container) {
|
|
apiFetch('/api/pairing/' + encodeURIComponent(channel) + '/approve', {
|
|
method: 'POST',
|
|
body: { code },
|
|
}).then(res => {
|
|
if (res.success) {
|
|
showToast('Pairing approved', 'success');
|
|
loadExtensions();
|
|
} else {
|
|
showToast(res.message || 'Approve failed', 'error');
|
|
}
|
|
}).catch(err => showToast('Error: ' + err.message, 'error'));
|
|
}
|
|
|
|
function startPairingPoll() {
|
|
stopPairingPoll();
|
|
pairingPollInterval = setInterval(function() {
|
|
document.querySelectorAll('.ext-pairing[data-channel]').forEach(function(el) {
|
|
loadPairingRequests(el.getAttribute('data-channel'), el);
|
|
});
|
|
}, 10000);
|
|
}
|
|
|
|
function stopPairingPoll() {
|
|
if (pairingPollInterval) {
|
|
clearInterval(pairingPollInterval);
|
|
pairingPollInterval = null;
|
|
}
|
|
}
|
|
|
|
// --- WASM channel stepper ---
|
|
|
|
function renderWasmChannelStepper(ext) {
|
|
var stepper = document.createElement('div');
|
|
stepper.className = 'ext-stepper';
|
|
|
|
var status = ext.activation_status || 'installed';
|
|
|
|
var steps = [
|
|
{ label: 'Installed', key: 'installed' },
|
|
{ label: 'Configured', key: 'configured' },
|
|
{ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' },
|
|
];
|
|
|
|
var reachedIdx;
|
|
if (status === 'active') reachedIdx = 2;
|
|
else if (status === 'pairing') reachedIdx = 2;
|
|
else if (status === 'failed') reachedIdx = 2;
|
|
else if (status === 'configured') reachedIdx = 1;
|
|
else reachedIdx = 0;
|
|
|
|
for (var i = 0; i < steps.length; i++) {
|
|
if (i > 0) {
|
|
var connector = document.createElement('div');
|
|
connector.className = 'stepper-connector' + (i <= reachedIdx ? ' completed' : '');
|
|
stepper.appendChild(connector);
|
|
}
|
|
|
|
var step = document.createElement('div');
|
|
var stepState;
|
|
if (i < reachedIdx) {
|
|
stepState = 'completed';
|
|
} else if (i === reachedIdx) {
|
|
if (status === 'failed') {
|
|
stepState = 'failed';
|
|
} else if (status === 'pairing') {
|
|
stepState = 'in-progress';
|
|
} else if (status === 'active' || status === 'configured' || status === 'installed') {
|
|
stepState = 'completed';
|
|
} else {
|
|
stepState = 'pending';
|
|
}
|
|
} else {
|
|
stepState = 'pending';
|
|
}
|
|
step.className = 'stepper-step ' + stepState;
|
|
|
|
var circle = document.createElement('span');
|
|
circle.className = 'stepper-circle';
|
|
if (stepState === 'completed') circle.textContent = '\u2713';
|
|
else if (stepState === 'failed') circle.textContent = '\u2717';
|
|
step.appendChild(circle);
|
|
|
|
var label = document.createElement('span');
|
|
label.className = 'stepper-label';
|
|
label.textContent = steps[i].label;
|
|
step.appendChild(label);
|
|
|
|
stepper.appendChild(step);
|
|
}
|
|
|
|
return stepper;
|
|
}
|
|
|
|
// --- Jobs ---
|
|
|
|
let currentJobId = null;
|
|
let currentJobSubTab = 'overview';
|
|
let jobFilesTreeState = null;
|
|
|
|
function loadJobs() {
|
|
currentJobId = null;
|
|
jobFilesTreeState = null;
|
|
|
|
// Rebuild DOM if renderJobDetail() destroyed it (it wipes .jobs-container innerHTML).
|
|
const container = document.querySelector('.jobs-container');
|
|
if (!document.getElementById('jobs-summary')) {
|
|
container.innerHTML =
|
|
'<div class="jobs-summary" id="jobs-summary"></div>'
|
|
+ '<table class="jobs-table" id="jobs-table"><thead><tr>'
|
|
+ '<th>ID</th><th>Title</th><th>Status</th><th>Created</th><th>Actions</th>'
|
|
+ '</tr></thead><tbody id="jobs-tbody"></tbody></table>'
|
|
+ '<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>';
|
|
}
|
|
|
|
Promise.all([
|
|
apiFetch('/api/jobs/summary'),
|
|
apiFetch('/api/jobs'),
|
|
]).then(([summary, jobList]) => {
|
|
renderJobsSummary(summary);
|
|
renderJobsList(jobList.jobs);
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function renderJobsSummary(s) {
|
|
document.getElementById('jobs-summary').innerHTML = ''
|
|
+ summaryCard('Total', s.total, '')
|
|
+ summaryCard('In Progress', s.in_progress, 'active')
|
|
+ summaryCard('Completed', s.completed, 'completed')
|
|
+ summaryCard('Failed', s.failed, 'failed')
|
|
+ summaryCard('Stuck', s.stuck, 'stuck');
|
|
}
|
|
|
|
function summaryCard(label, count, cls) {
|
|
return '<div class="summary-card ' + cls + '">'
|
|
+ '<div class="count">' + count + '</div>'
|
|
+ '<div class="label">' + label + '</div>'
|
|
+ '</div>';
|
|
}
|
|
|
|
function renderJobsList(jobs) {
|
|
const tbody = document.getElementById('jobs-tbody');
|
|
const empty = document.getElementById('jobs-empty');
|
|
|
|
if (jobs.length === 0) {
|
|
tbody.innerHTML = '';
|
|
empty.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
empty.style.display = 'none';
|
|
tbody.innerHTML = jobs.map((job) => {
|
|
const shortId = job.id.substring(0, 8);
|
|
const stateClass = job.state.replace(' ', '_');
|
|
|
|
let actionBtns = '';
|
|
if (job.state === 'pending' || job.state === 'in_progress') {
|
|
actionBtns = '<button class="btn-cancel" onclick="event.stopPropagation(); cancelJob(\'' + job.id + '\')">Cancel</button>';
|
|
}
|
|
// Retry is only shown in the detail view where can_restart is available.
|
|
|
|
return '<tr class="job-row" onclick="openJobDetail(\'' + job.id + '\')">'
|
|
+ '<td title="' + escapeHtml(job.id) + '">' + shortId + '</td>'
|
|
+ '<td>' + escapeHtml(job.title) + '</td>'
|
|
+ '<td><span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span></td>'
|
|
+ '<td>' + formatDate(job.created_at) + '</td>'
|
|
+ '<td>' + actionBtns + '</td>'
|
|
+ '</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
function cancelJob(jobId) {
|
|
if (!confirm('Cancel this job?')) return;
|
|
apiFetch('/api/jobs/' + jobId + '/cancel', { method: 'POST' })
|
|
.then(() => {
|
|
showToast('Job cancelled', 'success');
|
|
if (currentJobId) openJobDetail(currentJobId);
|
|
else loadJobs();
|
|
})
|
|
.catch((err) => {
|
|
showToast('Failed to cancel job: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function restartJob(jobId) {
|
|
apiFetch('/api/jobs/' + jobId + '/restart', { method: 'POST' })
|
|
.then((res) => {
|
|
showToast('Job restarted as ' + (res.new_job_id || '').substring(0, 8), 'success');
|
|
})
|
|
.catch((err) => {
|
|
showToast('Failed to restart job: ' + err.message, 'error');
|
|
})
|
|
.finally(() => {
|
|
loadJobs();
|
|
});
|
|
}
|
|
|
|
function openJobDetail(jobId) {
|
|
currentJobId = jobId;
|
|
currentJobSubTab = 'activity';
|
|
apiFetch('/api/jobs/' + jobId).then((job) => {
|
|
renderJobDetail(job);
|
|
}).catch((err) => {
|
|
addMessage('system', 'Failed to load job: ' + err.message);
|
|
closeJobDetail();
|
|
});
|
|
}
|
|
|
|
function closeJobDetail() {
|
|
currentJobId = null;
|
|
jobFilesTreeState = null;
|
|
loadJobs();
|
|
}
|
|
|
|
function renderJobDetail(job) {
|
|
const container = document.querySelector('.jobs-container');
|
|
const stateClass = job.state.replace(' ', '_');
|
|
|
|
container.innerHTML = '';
|
|
|
|
// Header
|
|
const header = document.createElement('div');
|
|
header.className = 'job-detail-header';
|
|
|
|
let headerHtml = '<button class="btn-back" onclick="closeJobDetail()">← Back</button>'
|
|
+ '<h2>' + escapeHtml(job.title) + '</h2>'
|
|
+ '<span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span>';
|
|
|
|
if ((job.state === 'failed' || job.state === 'interrupted') && job.can_restart === true) {
|
|
headerHtml += '<button class="btn-restart" onclick="restartJob(\'' + job.id + '\')">Retry</button>';
|
|
}
|
|
if (job.browse_url) {
|
|
headerHtml += '<a class="btn-browse" href="' + escapeHtml(job.browse_url) + '" target="_blank">Browse Files</a>';
|
|
}
|
|
|
|
header.innerHTML = headerHtml;
|
|
container.appendChild(header);
|
|
|
|
// Sub-tab bar
|
|
const tabs = document.createElement('div');
|
|
tabs.className = 'job-detail-tabs';
|
|
const subtabs = ['overview', 'activity', 'files'];
|
|
for (const st of subtabs) {
|
|
const btn = document.createElement('button');
|
|
btn.textContent = st.charAt(0).toUpperCase() + st.slice(1);
|
|
btn.className = st === currentJobSubTab ? 'active' : '';
|
|
btn.addEventListener('click', () => {
|
|
currentJobSubTab = st;
|
|
renderJobDetail(job);
|
|
});
|
|
tabs.appendChild(btn);
|
|
}
|
|
container.appendChild(tabs);
|
|
|
|
// Content
|
|
const content = document.createElement('div');
|
|
content.className = 'job-detail-content';
|
|
container.appendChild(content);
|
|
|
|
switch (currentJobSubTab) {
|
|
case 'overview': renderJobOverview(content, job); break;
|
|
case 'files': renderJobFiles(content, job); break;
|
|
case 'activity': renderJobActivity(content, job); break;
|
|
}
|
|
}
|
|
|
|
function metaItem(label, value) {
|
|
return '<div class="meta-item"><div class="meta-label">' + escapeHtml(label)
|
|
+ '</div><div class="meta-value">' + escapeHtml(String(value != null ? value : '-'))
|
|
+ '</div></div>';
|
|
}
|
|
|
|
function formatDuration(secs) {
|
|
if (secs == null) return '-';
|
|
if (secs < 60) return secs + 's';
|
|
const m = Math.floor(secs / 60);
|
|
const s = secs % 60;
|
|
if (m < 60) return m + 'm ' + s + 's';
|
|
const h = Math.floor(m / 60);
|
|
return h + 'h ' + (m % 60) + 'm';
|
|
}
|
|
|
|
function renderJobOverview(container, job) {
|
|
// Metadata grid
|
|
const grid = document.createElement('div');
|
|
grid.className = 'job-meta-grid';
|
|
grid.innerHTML = metaItem('Job ID', job.id)
|
|
+ metaItem('State', job.state)
|
|
+ metaItem('Created', formatDate(job.created_at))
|
|
+ metaItem('Started', formatDate(job.started_at))
|
|
+ metaItem('Completed', formatDate(job.completed_at))
|
|
+ metaItem('Duration', formatDuration(job.elapsed_secs))
|
|
+ (job.job_mode ? metaItem('Mode', job.job_mode) : '');
|
|
container.appendChild(grid);
|
|
|
|
// Description
|
|
if (job.description) {
|
|
const descSection = document.createElement('div');
|
|
descSection.className = 'job-description';
|
|
const descHeader = document.createElement('h3');
|
|
descHeader.textContent = 'Description';
|
|
descSection.appendChild(descHeader);
|
|
const descBody = document.createElement('div');
|
|
descBody.className = 'job-description-body';
|
|
descBody.innerHTML = renderMarkdown(job.description);
|
|
descSection.appendChild(descBody);
|
|
container.appendChild(descSection);
|
|
}
|
|
|
|
// State transitions timeline
|
|
if (job.transitions.length > 0) {
|
|
const timelineSection = document.createElement('div');
|
|
timelineSection.className = 'job-timeline-section';
|
|
const tlHeader = document.createElement('h3');
|
|
tlHeader.textContent = 'State Transitions';
|
|
timelineSection.appendChild(tlHeader);
|
|
|
|
const timeline = document.createElement('div');
|
|
timeline.className = 'timeline';
|
|
for (const t of job.transitions) {
|
|
const entry = document.createElement('div');
|
|
entry.className = 'timeline-entry';
|
|
const dot = document.createElement('div');
|
|
dot.className = 'timeline-dot';
|
|
entry.appendChild(dot);
|
|
const info = document.createElement('div');
|
|
info.className = 'timeline-info';
|
|
info.innerHTML = '<span class="badge ' + t.from.replace(' ', '_') + '">' + escapeHtml(t.from) + '</span>'
|
|
+ ' → '
|
|
+ '<span class="badge ' + t.to.replace(' ', '_') + '">' + escapeHtml(t.to) + '</span>'
|
|
+ '<span class="timeline-time">' + formatDate(t.timestamp) + '</span>'
|
|
+ (t.reason ? '<div class="timeline-reason">' + escapeHtml(t.reason) + '</div>' : '');
|
|
entry.appendChild(info);
|
|
timeline.appendChild(entry);
|
|
}
|
|
timelineSection.appendChild(timeline);
|
|
container.appendChild(timelineSection);
|
|
}
|
|
}
|
|
|
|
function renderJobFiles(container, job) {
|
|
container.innerHTML = '<div class="job-files">'
|
|
+ '<div class="job-files-sidebar"><div class="job-files-tree"></div></div>'
|
|
+ '<div class="job-files-viewer"><div class="empty-state">Select a file to view</div></div>'
|
|
+ '</div>';
|
|
|
|
container._jobId = job ? job.id : null;
|
|
|
|
apiFetch('/api/jobs/' + job.id + '/files/list?path=').then((data) => {
|
|
jobFilesTreeState = data.entries.map((e) => ({
|
|
name: e.name,
|
|
path: e.path,
|
|
is_dir: e.is_dir,
|
|
children: e.is_dir ? null : undefined,
|
|
expanded: false,
|
|
loaded: false,
|
|
}));
|
|
renderJobFilesTree();
|
|
}).catch(() => {
|
|
const treeContainer = document.querySelector('.job-files-tree');
|
|
if (treeContainer) {
|
|
treeContainer.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No project files</div>';
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderJobFilesTree() {
|
|
const treeContainer = document.querySelector('.job-files-tree');
|
|
if (!treeContainer) return;
|
|
treeContainer.innerHTML = '';
|
|
if (!jobFilesTreeState || jobFilesTreeState.length === 0) {
|
|
treeContainer.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No files in workspace</div>';
|
|
return;
|
|
}
|
|
renderJobFileNodes(jobFilesTreeState, treeContainer, 0);
|
|
}
|
|
|
|
function renderJobFileNodes(nodes, container, depth) {
|
|
for (const node of nodes) {
|
|
const row = document.createElement('div');
|
|
row.className = 'tree-row';
|
|
row.style.paddingLeft = (depth * 16 + 8) + 'px';
|
|
|
|
if (node.is_dir) {
|
|
const arrow = document.createElement('span');
|
|
arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : '');
|
|
arrow.textContent = '\u25B6';
|
|
arrow.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
toggleJobFileExpand(node);
|
|
});
|
|
row.appendChild(arrow);
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'tree-label dir';
|
|
label.textContent = node.name;
|
|
label.addEventListener('click', () => toggleJobFileExpand(node));
|
|
row.appendChild(label);
|
|
} else {
|
|
const spacer = document.createElement('span');
|
|
spacer.className = 'expand-arrow-spacer';
|
|
row.appendChild(spacer);
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'tree-label file';
|
|
label.textContent = node.name;
|
|
label.addEventListener('click', () => readJobFile(node.path));
|
|
row.appendChild(label);
|
|
}
|
|
|
|
container.appendChild(row);
|
|
|
|
if (node.is_dir && node.expanded && node.children) {
|
|
const childContainer = document.createElement('div');
|
|
childContainer.className = 'tree-children';
|
|
renderJobFileNodes(node.children, childContainer, depth + 1);
|
|
container.appendChild(childContainer);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getJobId() {
|
|
const container = document.querySelector('.job-detail-content');
|
|
return (container && container._jobId) || null;
|
|
}
|
|
|
|
function toggleJobFileExpand(node) {
|
|
if (node.expanded) {
|
|
node.expanded = false;
|
|
renderJobFilesTree();
|
|
return;
|
|
}
|
|
if (node.loaded) {
|
|
node.expanded = true;
|
|
renderJobFilesTree();
|
|
return;
|
|
}
|
|
const jobId = getJobId();
|
|
apiFetch('/api/jobs/' + jobId + '/files/list?path=' + encodeURIComponent(node.path)).then((data) => {
|
|
node.children = data.entries.map((e) => ({
|
|
name: e.name,
|
|
path: e.path,
|
|
is_dir: e.is_dir,
|
|
children: e.is_dir ? null : undefined,
|
|
expanded: false,
|
|
loaded: false,
|
|
}));
|
|
node.loaded = true;
|
|
node.expanded = true;
|
|
renderJobFilesTree();
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function readJobFile(path) {
|
|
const viewer = document.querySelector('.job-files-viewer');
|
|
if (!viewer) return;
|
|
const jobId = getJobId();
|
|
apiFetch('/api/jobs/' + jobId + '/files/read?path=' + encodeURIComponent(path)).then((data) => {
|
|
viewer.innerHTML = '<div class="job-files-path">' + escapeHtml(path) + '</div>'
|
|
+ '<pre class="job-files-content">' + escapeHtml(data.content) + '</pre>';
|
|
}).catch((err) => {
|
|
viewer.innerHTML = '<div class="empty-state">Error: ' + escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
// --- Activity tab (unified for all sandbox jobs) ---
|
|
|
|
let activityCurrentJobId = null;
|
|
// Track how many live SSE events we've already rendered so refreshActivityTab
|
|
// only appends new ones (avoids duplicates on each SSE tick).
|
|
let activityRenderedLiveIndex = 0;
|
|
|
|
function renderJobActivity(container, job) {
|
|
activityCurrentJobId = job ? job.id : null;
|
|
activityRenderedLiveIndex = 0;
|
|
|
|
let html = '<div class="activity-toolbar">'
|
|
+ '<select id="activity-type-filter">'
|
|
+ '<option value="all">All Events</option>'
|
|
+ '<option value="message">Messages</option>'
|
|
+ '<option value="tool_use">Tool Calls</option>'
|
|
+ '<option value="tool_result">Results</option>'
|
|
+ '</select>'
|
|
+ '<label class="logs-checkbox"><input type="checkbox" id="activity-autoscroll" checked> Auto-scroll</label>'
|
|
+ '</div>'
|
|
+ '<div class="activity-terminal" id="activity-terminal"></div>';
|
|
|
|
if (job && job.can_prompt === true) {
|
|
html += '<div class="activity-input-bar" id="activity-input-bar">'
|
|
+ '<input type="text" id="activity-prompt-input" placeholder="Send follow-up prompt..." />'
|
|
+ '<button id="activity-send-btn">Send</button>'
|
|
+ '<button id="activity-done-btn" title="Signal done">Done</button>'
|
|
+ '</div>';
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
|
|
document.getElementById('activity-type-filter').addEventListener('change', applyActivityFilter);
|
|
|
|
const terminal = document.getElementById('activity-terminal');
|
|
const input = document.getElementById('activity-prompt-input');
|
|
const sendBtn = document.getElementById('activity-send-btn');
|
|
const doneBtn = document.getElementById('activity-done-btn');
|
|
|
|
if (sendBtn) sendBtn.addEventListener('click', () => sendJobPrompt(job.id, false));
|
|
if (doneBtn) doneBtn.addEventListener('click', () => sendJobPrompt(job.id, true));
|
|
if (input) input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') sendJobPrompt(job.id, false);
|
|
});
|
|
|
|
// Load persisted events from DB, then catch up with any live SSE events
|
|
apiFetch('/api/jobs/' + job.id + '/events').then((data) => {
|
|
if (data.events && data.events.length > 0) {
|
|
for (const evt of data.events) {
|
|
appendActivityEvent(terminal, evt.event_type, evt.data);
|
|
}
|
|
}
|
|
appendNewLiveEvents(terminal, job.id);
|
|
}).catch(() => {
|
|
appendNewLiveEvents(terminal, job.id);
|
|
});
|
|
}
|
|
|
|
function appendNewLiveEvents(terminal, jobId) {
|
|
const live = jobEvents.get(jobId) || [];
|
|
for (let i = activityRenderedLiveIndex; i < live.length; i++) {
|
|
const evt = live[i];
|
|
appendActivityEvent(terminal, evt.type.replace('job_', ''), evt.data);
|
|
}
|
|
activityRenderedLiveIndex = live.length;
|
|
const autoScroll = document.getElementById('activity-autoscroll');
|
|
if (!autoScroll || autoScroll.checked) {
|
|
terminal.scrollTop = terminal.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function applyActivityFilter() {
|
|
const filter = document.getElementById('activity-type-filter').value;
|
|
const events = document.querySelectorAll('#activity-terminal .activity-event');
|
|
for (const el of events) {
|
|
if (filter === 'all') {
|
|
el.style.display = '';
|
|
} else {
|
|
el.style.display = el.getAttribute('data-event-type') === filter ? '' : 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
function appendActivityEvent(terminal, eventType, data) {
|
|
if (!terminal) return;
|
|
const el = document.createElement('div');
|
|
el.className = 'activity-event activity-event-' + eventType;
|
|
el.setAttribute('data-event-type', eventType);
|
|
|
|
// Respect current filter
|
|
const filterEl = document.getElementById('activity-type-filter');
|
|
if (filterEl && filterEl.value !== 'all' && filterEl.value !== eventType) {
|
|
el.style.display = 'none';
|
|
}
|
|
|
|
switch (eventType) {
|
|
case 'message':
|
|
el.innerHTML = '<span class="activity-role">' + escapeHtml(data.role || 'assistant') + '</span> '
|
|
+ '<span class="activity-content">' + escapeHtml(data.content || '') + '</span>';
|
|
break;
|
|
case 'tool_use':
|
|
el.innerHTML = '<details class="activity-tool-block"><summary>'
|
|
+ '<span class="activity-tool-icon">⚙</span> '
|
|
+ escapeHtml(data.tool_name || 'tool')
|
|
+ '</summary><pre class="activity-tool-input">'
|
|
+ escapeHtml(typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2))
|
|
+ '</pre></details>';
|
|
break;
|
|
case 'tool_result': {
|
|
const trSuccess = data.success !== false;
|
|
const trIcon = trSuccess ? '✓' : '✗';
|
|
const trOutput = data.output || data.error || '';
|
|
const trClass = 'activity-tool-block activity-tool-result'
|
|
+ (trSuccess ? '' : ' activity-tool-error');
|
|
el.innerHTML = '<details class="' + trClass + '"><summary>'
|
|
+ '<span class="activity-tool-icon">' + trIcon + '</span> '
|
|
+ escapeHtml(data.tool_name || 'result')
|
|
+ '</summary><pre class="activity-tool-output">'
|
|
+ escapeHtml(trOutput)
|
|
+ '</pre></details>';
|
|
break;
|
|
}
|
|
case 'status':
|
|
el.innerHTML = '<span class="activity-status">' + escapeHtml(data.message || '') + '</span>';
|
|
break;
|
|
case 'result':
|
|
el.className += ' activity-final';
|
|
const success = data.success !== false;
|
|
el.innerHTML = '<span class="activity-result-status" data-success="' + success + '">'
|
|
+ escapeHtml(data.message || data.error || data.status || 'done') + '</span>';
|
|
if (data.session_id) {
|
|
el.innerHTML += ' <span class="activity-session-id">session: ' + escapeHtml(data.session_id) + '</span>';
|
|
}
|
|
break;
|
|
default:
|
|
el.innerHTML = '<span class="activity-status">' + escapeHtml(JSON.stringify(data)) + '</span>';
|
|
}
|
|
|
|
terminal.appendChild(el);
|
|
}
|
|
|
|
function refreshActivityTab(jobId) {
|
|
if (activityCurrentJobId !== jobId) return;
|
|
if (currentJobSubTab !== 'activity') return;
|
|
const terminal = document.getElementById('activity-terminal');
|
|
if (!terminal) return;
|
|
appendNewLiveEvents(terminal, jobId);
|
|
}
|
|
|
|
function sendJobPrompt(jobId, done) {
|
|
const input = document.getElementById('activity-prompt-input');
|
|
const content = input ? input.value.trim() : '';
|
|
if (!content && !done) return;
|
|
|
|
apiFetch('/api/jobs/' + jobId + '/prompt', {
|
|
method: 'POST',
|
|
body: { content: content || '(done)', done: done },
|
|
}).then(() => {
|
|
if (input) input.value = '';
|
|
if (done) {
|
|
const bar = document.getElementById('activity-input-bar');
|
|
if (bar) bar.innerHTML = '<span class="activity-status">Done signal sent</span>';
|
|
}
|
|
}).catch((err) => {
|
|
const terminal = document.getElementById('activity-terminal');
|
|
if (terminal) {
|
|
appendActivityEvent(terminal, 'status', { message: 'Failed to send: ' + err.message });
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Routines ---
|
|
|
|
let currentRoutineId = null;
|
|
|
|
function loadRoutines() {
|
|
currentRoutineId = null;
|
|
|
|
// Restore list view if detail was open
|
|
const detail = document.getElementById('routine-detail');
|
|
if (detail) detail.style.display = 'none';
|
|
const table = document.getElementById('routines-table');
|
|
if (table) table.style.display = '';
|
|
|
|
Promise.all([
|
|
apiFetch('/api/routines/summary'),
|
|
apiFetch('/api/routines'),
|
|
]).then(([summary, listData]) => {
|
|
renderRoutinesSummary(summary);
|
|
renderRoutinesList(listData.routines);
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function renderRoutinesSummary(s) {
|
|
document.getElementById('routines-summary').innerHTML = ''
|
|
+ summaryCard('Total', s.total, '')
|
|
+ summaryCard('Enabled', s.enabled, 'active')
|
|
+ summaryCard('Disabled', s.disabled, '')
|
|
+ summaryCard('Failing', s.failing, 'failed')
|
|
+ summaryCard('Runs Today', s.runs_today, 'completed');
|
|
}
|
|
|
|
function renderRoutinesList(routines) {
|
|
const tbody = document.getElementById('routines-tbody');
|
|
const empty = document.getElementById('routines-empty');
|
|
|
|
if (!routines || routines.length === 0) {
|
|
tbody.innerHTML = '';
|
|
empty.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
empty.style.display = 'none';
|
|
tbody.innerHTML = routines.map((r) => {
|
|
const statusClass = r.status === 'active' ? 'completed'
|
|
: r.status === 'failing' ? 'failed'
|
|
: 'pending';
|
|
|
|
const toggleLabel = r.enabled ? 'Disable' : 'Enable';
|
|
const toggleClass = r.enabled ? 'btn-cancel' : 'btn-restart';
|
|
|
|
return '<tr class="routine-row" onclick="openRoutineDetail(\'' + r.id + '\')">'
|
|
+ '<td>' + escapeHtml(r.name) + '</td>'
|
|
+ '<td>' + escapeHtml(r.trigger_summary) + '</td>'
|
|
+ '<td>' + escapeHtml(r.action_type) + '</td>'
|
|
+ '<td>' + formatRelativeTime(r.last_run_at) + '</td>'
|
|
+ '<td>' + formatRelativeTime(r.next_fire_at) + '</td>'
|
|
+ '<td>' + r.run_count + '</td>'
|
|
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>'
|
|
+ '<td>'
|
|
+ '<button class="' + toggleClass + '" onclick="event.stopPropagation(); toggleRoutine(\'' + r.id + '\')">' + toggleLabel + '</button> '
|
|
+ '<button class="btn-restart" onclick="event.stopPropagation(); triggerRoutine(\'' + r.id + '\')">Run</button> '
|
|
+ '<button class="btn-cancel" onclick="event.stopPropagation(); deleteRoutine(\'' + r.id + '\', \'' + escapeHtml(r.name) + '\')">Delete</button>'
|
|
+ '</td>'
|
|
+ '</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
function openRoutineDetail(id) {
|
|
currentRoutineId = id;
|
|
apiFetch('/api/routines/' + id).then((routine) => {
|
|
renderRoutineDetail(routine);
|
|
}).catch((err) => {
|
|
showToast('Failed to load routine: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function closeRoutineDetail() {
|
|
currentRoutineId = null;
|
|
loadRoutines();
|
|
}
|
|
|
|
function renderRoutineDetail(routine) {
|
|
const table = document.getElementById('routines-table');
|
|
if (table) table.style.display = 'none';
|
|
document.getElementById('routines-empty').style.display = 'none';
|
|
|
|
const detail = document.getElementById('routine-detail');
|
|
detail.style.display = 'block';
|
|
|
|
const statusClass = !routine.enabled ? 'pending'
|
|
: routine.consecutive_failures > 0 ? 'failed'
|
|
: 'completed';
|
|
const statusLabel = !routine.enabled ? 'disabled'
|
|
: routine.consecutive_failures > 0 ? 'failing'
|
|
: 'active';
|
|
|
|
let html = '<div class="job-detail-header">'
|
|
+ '<button class="btn-back" onclick="closeRoutineDetail()">← Back</button>'
|
|
+ '<h2>' + escapeHtml(routine.name) + '</h2>'
|
|
+ '<span class="badge ' + statusClass + '">' + escapeHtml(statusLabel) + '</span>'
|
|
+ '</div>';
|
|
|
|
// Metadata grid
|
|
html += '<div class="job-meta-grid">'
|
|
+ metaItem('Routine ID', routine.id)
|
|
+ metaItem('Enabled', routine.enabled ? 'Yes' : 'No')
|
|
+ metaItem('Run Count', routine.run_count)
|
|
+ metaItem('Failures', routine.consecutive_failures)
|
|
+ metaItem('Last Run', formatDate(routine.last_run_at))
|
|
+ metaItem('Next Fire', formatDate(routine.next_fire_at))
|
|
+ metaItem('Created', formatDate(routine.created_at))
|
|
+ '</div>';
|
|
|
|
// Description
|
|
if (routine.description) {
|
|
html += '<div class="job-description"><h3>Description</h3>'
|
|
+ '<div class="job-description-body">' + escapeHtml(routine.description) + '</div></div>';
|
|
}
|
|
|
|
// Trigger config
|
|
html += '<div class="job-description"><h3>Trigger</h3>'
|
|
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '</pre></div>';
|
|
|
|
// Action config
|
|
html += '<div class="job-description"><h3>Action</h3>'
|
|
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.action, null, 2)) + '</pre></div>';
|
|
|
|
// Recent runs
|
|
if (routine.recent_runs && routine.recent_runs.length > 0) {
|
|
html += '<div class="job-timeline-section"><h3>Recent Runs</h3>'
|
|
+ '<table class="routines-table"><thead><tr>'
|
|
+ '<th>Trigger</th><th>Started</th><th>Completed</th><th>Status</th><th>Summary</th><th>Tokens</th>'
|
|
+ '</tr></thead><tbody>';
|
|
for (const run of routine.recent_runs) {
|
|
const runStatusClass = run.status === 'Ok' ? 'completed'
|
|
: run.status === 'Failed' ? 'failed'
|
|
: run.status === 'Attention' ? 'stuck'
|
|
: 'in_progress';
|
|
html += '<tr>'
|
|
+ '<td>' + escapeHtml(run.trigger_type) + '</td>'
|
|
+ '<td>' + formatDate(run.started_at) + '</td>'
|
|
+ '<td>' + formatDate(run.completed_at) + '</td>'
|
|
+ '<td><span class="badge ' + runStatusClass + '">' + escapeHtml(run.status) + '</span></td>'
|
|
+ '<td>' + escapeHtml(run.result_summary || '-')
|
|
+ (run.job_id ? ' <a href="#" onclick="event.preventDefault(); switchTab(\'jobs\'); openJobDetail(\'' + run.job_id + '\')">[view job]</a>' : '')
|
|
+ '</td>'
|
|
+ '<td>' + (run.tokens_used != null ? run.tokens_used : '-') + '</td>'
|
|
+ '</tr>';
|
|
}
|
|
html += '</tbody></table></div>';
|
|
}
|
|
|
|
detail.innerHTML = html;
|
|
}
|
|
|
|
function triggerRoutine(id) {
|
|
apiFetch('/api/routines/' + id + '/trigger', { method: 'POST' })
|
|
.then(() => {
|
|
showToast('Routine triggered', 'success');
|
|
if (currentRoutineId === id) openRoutineDetail(id);
|
|
else loadRoutines();
|
|
})
|
|
.catch((err) => showToast('Trigger failed: ' + err.message, 'error'));
|
|
}
|
|
|
|
function toggleRoutine(id) {
|
|
apiFetch('/api/routines/' + id + '/toggle', { method: 'POST' })
|
|
.then((res) => {
|
|
showToast('Routine ' + (res.status || 'toggled'), 'success');
|
|
if (currentRoutineId) openRoutineDetail(currentRoutineId);
|
|
else loadRoutines();
|
|
})
|
|
.catch((err) => showToast('Toggle failed: ' + err.message, 'error'));
|
|
}
|
|
|
|
function deleteRoutine(id, name) {
|
|
if (!confirm('Delete routine "' + name + '"?')) return;
|
|
apiFetch('/api/routines/' + id, { method: 'DELETE' })
|
|
.then(() => {
|
|
showToast('Routine deleted', 'success');
|
|
if (currentRoutineId === id) closeRoutineDetail();
|
|
else loadRoutines();
|
|
})
|
|
.catch((err) => showToast('Delete failed: ' + err.message, 'error'));
|
|
}
|
|
|
|
function formatRelativeTime(isoString) {
|
|
if (!isoString) return '-';
|
|
const d = new Date(isoString);
|
|
const now = Date.now();
|
|
const diffMs = now - d.getTime();
|
|
const absDiff = Math.abs(diffMs);
|
|
const future = diffMs < 0;
|
|
|
|
if (absDiff < 60000) return future ? 'in <1m' : '<1m ago';
|
|
if (absDiff < 3600000) {
|
|
const m = Math.floor(absDiff / 60000);
|
|
return future ? 'in ' + m + 'm' : m + 'm ago';
|
|
}
|
|
if (absDiff < 86400000) {
|
|
const h = Math.floor(absDiff / 3600000);
|
|
return future ? 'in ' + h + 'h' : h + 'h ago';
|
|
}
|
|
const days = Math.floor(absDiff / 86400000);
|
|
return future ? 'in ' + days + 'd' : days + 'd ago';
|
|
}
|
|
|
|
// --- Gateway status widget ---
|
|
|
|
let gatewayStatusInterval = null;
|
|
|
|
function startGatewayStatusPolling() {
|
|
fetchGatewayStatus();
|
|
gatewayStatusInterval = setInterval(fetchGatewayStatus, 30000);
|
|
}
|
|
|
|
function formatTokenCount(n) {
|
|
if (n == null || n === 0) return '0';
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
|
return '' + n;
|
|
}
|
|
|
|
function formatCost(costStr) {
|
|
if (!costStr) return '$0.00';
|
|
var n = parseFloat(costStr);
|
|
if (n < 0.01) return '$' + n.toFixed(4);
|
|
return '$' + n.toFixed(2);
|
|
}
|
|
|
|
function shortModelName(model) {
|
|
// Strip provider prefix and shorten common model names
|
|
var m = model.indexOf('/') >= 0 ? model.split('/').pop() : model;
|
|
// Shorten dated suffixes
|
|
m = m.replace(/-20\d{6}$/, '');
|
|
return m;
|
|
}
|
|
|
|
function fetchGatewayStatus() {
|
|
apiFetch('/api/gateway/status').then(function(data) {
|
|
var popover = document.getElementById('gateway-popover');
|
|
var html = '';
|
|
|
|
// Version
|
|
if (data.version) {
|
|
html += '<div class="gw-section-label">IronClaw v' + escapeHtml(data.version) + '</div>';
|
|
html += '<div class="gw-divider"></div>';
|
|
}
|
|
|
|
// Connection info
|
|
html += '<div class="gw-section-label">Connections</div>';
|
|
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
|
|
html += '<div class="gw-stat"><span>WebSocket</span><span>' + (data.ws_connections || 0) + '</span></div>';
|
|
html += '<div class="gw-stat"><span>Uptime</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
|
|
|
|
// Cost tracker
|
|
if (data.daily_cost != null) {
|
|
html += '<div class="gw-divider"></div>';
|
|
html += '<div class="gw-section-label">Cost Today</div>';
|
|
html += '<div class="gw-stat"><span>Spent</span><span>' + formatCost(data.daily_cost) + '</span></div>';
|
|
if (data.actions_this_hour != null) {
|
|
html += '<div class="gw-stat"><span>Actions/hr</span><span>' + data.actions_this_hour + '</span></div>';
|
|
}
|
|
}
|
|
|
|
// Per-model token usage
|
|
if (data.model_usage && data.model_usage.length > 0) {
|
|
html += '<div class="gw-divider"></div>';
|
|
html += '<div class="gw-section-label">Token Usage</div>';
|
|
data.model_usage.sort(function(a, b) {
|
|
return (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens);
|
|
});
|
|
for (var i = 0; i < data.model_usage.length; i++) {
|
|
var m = data.model_usage[i];
|
|
var name = escapeHtml(shortModelName(m.model));
|
|
html += '<div class="gw-model-row">'
|
|
+ '<span class="gw-model-name">' + name + '</span>'
|
|
+ '<span class="gw-model-cost">' + escapeHtml(formatCost(m.cost)) + '</span>'
|
|
+ '</div>';
|
|
html += '<div class="gw-token-detail">'
|
|
+ '<span>in: ' + formatTokenCount(m.input_tokens) + '</span>'
|
|
+ '<span>out: ' + formatTokenCount(m.output_tokens) + '</span>'
|
|
+ '</div>';
|
|
}
|
|
}
|
|
|
|
popover.innerHTML = html;
|
|
}).catch(function() {});
|
|
}
|
|
|
|
// Show/hide popover on hover
|
|
document.getElementById('gateway-status-trigger').addEventListener('mouseenter', () => {
|
|
document.getElementById('gateway-popover').classList.add('visible');
|
|
});
|
|
document.getElementById('gateway-status-trigger').addEventListener('mouseleave', () => {
|
|
document.getElementById('gateway-popover').classList.remove('visible');
|
|
});
|
|
|
|
// --- TEE attestation ---
|
|
|
|
let teeInfo = null;
|
|
let teeReportCache = null;
|
|
let teeReportLoading = false;
|
|
|
|
function teeApiBase() {
|
|
var parts = window.location.hostname.split('.');
|
|
if (parts.length < 2) return null;
|
|
var domain = parts.slice(1).join('.');
|
|
return window.location.protocol + '//api.' + domain;
|
|
}
|
|
|
|
function teeInstanceName() {
|
|
return window.location.hostname.split('.')[0];
|
|
}
|
|
|
|
function checkTeeStatus() {
|
|
var base = teeApiBase();
|
|
if (!base) return;
|
|
var name = teeInstanceName();
|
|
fetch(base + '/instances/' + encodeURIComponent(name) + '/attestation').then(function(res) {
|
|
if (!res.ok) throw new Error(res.status);
|
|
return res.json();
|
|
}).then(function(data) {
|
|
teeInfo = data;
|
|
document.getElementById('tee-shield').style.display = 'flex';
|
|
}).catch(function() {});
|
|
}
|
|
|
|
function fetchTeeReport() {
|
|
if (teeReportCache) {
|
|
renderTeePopover(teeReportCache);
|
|
return;
|
|
}
|
|
if (teeReportLoading) return;
|
|
teeReportLoading = true;
|
|
var base = teeApiBase();
|
|
if (!base) return;
|
|
var popover = document.getElementById('tee-popover');
|
|
popover.innerHTML = '<div class="tee-popover-loading">Loading attestation report...</div>';
|
|
fetch(base + '/attestation/report').then(function(res) {
|
|
if (!res.ok) throw new Error(res.status);
|
|
return res.json();
|
|
}).then(function(data) {
|
|
teeReportCache = data;
|
|
renderTeePopover(data);
|
|
}).catch(function() {
|
|
popover.innerHTML = '<div class="tee-popover-loading">Could not load attestation report</div>';
|
|
}).finally(function() {
|
|
teeReportLoading = false;
|
|
});
|
|
}
|
|
|
|
function renderTeePopover(report) {
|
|
var popover = document.getElementById('tee-popover');
|
|
var digest = (teeInfo && teeInfo.image_digest) || 'N/A';
|
|
var fingerprint = report.tls_certificate_fingerprint || 'N/A';
|
|
var reportData = report.report_data || '';
|
|
var vmConfig = report.vm_config || 'N/A';
|
|
var truncated = reportData.length > 32 ? reportData.slice(0, 32) + '...' : reportData;
|
|
popover.innerHTML = '<div class="tee-popover-title">'
|
|
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
|
|
+ 'TEE Attestation</div>'
|
|
+ '<div class="tee-field"><div class="tee-field-label">Image Digest</div>'
|
|
+ '<div class="tee-field-value">' + escapeHtml(digest) + '</div></div>'
|
|
+ '<div class="tee-field"><div class="tee-field-label">TLS Certificate Fingerprint</div>'
|
|
+ '<div class="tee-field-value">' + escapeHtml(fingerprint) + '</div></div>'
|
|
+ '<div class="tee-field"><div class="tee-field-label">Report Data</div>'
|
|
+ '<div class="tee-field-value">' + escapeHtml(truncated) + '</div></div>'
|
|
+ '<div class="tee-field"><div class="tee-field-label">VM Config</div>'
|
|
+ '<div class="tee-field-value">' + escapeHtml(vmConfig) + '</div></div>'
|
|
+ '<div class="tee-popover-actions">'
|
|
+ '<button class="tee-btn-copy" onclick="copyTeeReport()">Copy Full Report</button></div>';
|
|
}
|
|
|
|
function copyTeeReport() {
|
|
if (!teeReportCache) return;
|
|
var combined = Object.assign({}, teeReportCache, teeInfo || {});
|
|
navigator.clipboard.writeText(JSON.stringify(combined, null, 2)).then(function() {
|
|
showToast('Attestation report copied', 'success');
|
|
}).catch(function() {
|
|
showToast('Failed to copy report', 'error');
|
|
});
|
|
}
|
|
|
|
document.getElementById('tee-shield').addEventListener('mouseenter', function() {
|
|
fetchTeeReport();
|
|
document.getElementById('tee-popover').classList.add('visible');
|
|
});
|
|
document.getElementById('tee-shield').addEventListener('mouseleave', function() {
|
|
document.getElementById('tee-popover').classList.remove('visible');
|
|
});
|
|
|
|
// --- Extension install ---
|
|
|
|
function installWasmExtension() {
|
|
var name = document.getElementById('wasm-install-name').value.trim();
|
|
if (!name) {
|
|
showToast('Extension name is required', 'error');
|
|
return;
|
|
}
|
|
var url = document.getElementById('wasm-install-url').value.trim();
|
|
if (!url) {
|
|
showToast('URL to .tar.gz bundle is required', 'error');
|
|
return;
|
|
}
|
|
|
|
apiFetch('/api/extensions/install', {
|
|
method: 'POST',
|
|
body: { name: name, url: url, kind: 'wasm_tool' },
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast('Installed ' + name, 'success');
|
|
document.getElementById('wasm-install-name').value = '';
|
|
document.getElementById('wasm-install-url').value = '';
|
|
loadExtensions();
|
|
} else {
|
|
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
|
|
}
|
|
}).catch(function(err) {
|
|
showToast('Install failed: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function addMcpServer() {
|
|
var name = document.getElementById('mcp-install-name').value.trim();
|
|
if (!name) {
|
|
showToast('Server name is required', 'error');
|
|
return;
|
|
}
|
|
var url = document.getElementById('mcp-install-url').value.trim();
|
|
if (!url) {
|
|
showToast('MCP server URL is required', 'error');
|
|
return;
|
|
}
|
|
|
|
apiFetch('/api/extensions/install', {
|
|
method: 'POST',
|
|
body: { name: name, url: url, kind: 'mcp_server' },
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast('Added MCP server ' + name, 'success');
|
|
document.getElementById('mcp-install-name').value = '';
|
|
document.getElementById('mcp-install-url').value = '';
|
|
loadExtensions();
|
|
} else {
|
|
showToast('Failed to add MCP server: ' + (res.message || 'unknown error'), 'error');
|
|
}
|
|
}).catch(function(err) {
|
|
showToast('Failed to add MCP server: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
// --- Skills ---
|
|
|
|
function loadSkills() {
|
|
var skillsList = document.getElementById('skills-list');
|
|
apiFetch('/api/skills').then(function(data) {
|
|
if (!data.skills || data.skills.length === 0) {
|
|
skillsList.innerHTML = '<div class="empty-state">No skills installed</div>';
|
|
return;
|
|
}
|
|
skillsList.innerHTML = '';
|
|
for (var i = 0; i < data.skills.length; i++) {
|
|
skillsList.appendChild(renderSkillCard(data.skills[i]));
|
|
}
|
|
}).catch(function(err) {
|
|
skillsList.innerHTML = '<div class="empty-state">Failed to load skills: ' + escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function renderSkillCard(skill) {
|
|
var card = document.createElement('div');
|
|
card.className = 'ext-card';
|
|
|
|
var header = document.createElement('div');
|
|
header.className = 'ext-header';
|
|
|
|
var name = document.createElement('span');
|
|
name.className = 'ext-name';
|
|
name.textContent = skill.name;
|
|
header.appendChild(name);
|
|
|
|
var trust = document.createElement('span');
|
|
var trustClass = skill.trust.toLowerCase() === 'trusted' ? 'trust-trusted' : 'trust-installed';
|
|
trust.className = 'skill-trust ' + trustClass;
|
|
trust.textContent = skill.trust;
|
|
header.appendChild(trust);
|
|
|
|
var version = document.createElement('span');
|
|
version.className = 'skill-version';
|
|
version.textContent = 'v' + skill.version;
|
|
header.appendChild(version);
|
|
|
|
card.appendChild(header);
|
|
|
|
var desc = document.createElement('div');
|
|
desc.className = 'ext-desc';
|
|
desc.textContent = skill.description;
|
|
card.appendChild(desc);
|
|
|
|
if (skill.keywords && skill.keywords.length > 0) {
|
|
var kw = document.createElement('div');
|
|
kw.className = 'ext-keywords';
|
|
kw.textContent = 'Activates on: ' + skill.keywords.join(', ');
|
|
card.appendChild(kw);
|
|
}
|
|
|
|
var actions = document.createElement('div');
|
|
actions.className = 'ext-actions';
|
|
|
|
// Only show Remove for registry-installed skills, not user-placed trusted skills
|
|
if (skill.trust.toLowerCase() !== 'trusted') {
|
|
var removeBtn = document.createElement('button');
|
|
removeBtn.className = 'btn-ext remove';
|
|
removeBtn.textContent = 'Remove';
|
|
removeBtn.addEventListener('click', function() { removeSkill(skill.name); });
|
|
actions.appendChild(removeBtn);
|
|
}
|
|
|
|
card.appendChild(actions);
|
|
return card;
|
|
}
|
|
|
|
function searchClawHub() {
|
|
var input = document.getElementById('skill-search-input');
|
|
var query = input.value.trim();
|
|
if (!query) return;
|
|
|
|
var resultsDiv = document.getElementById('skill-search-results');
|
|
resultsDiv.innerHTML = '<div class="empty-state">Searching...</div>';
|
|
|
|
apiFetch('/api/skills/search', {
|
|
method: 'POST',
|
|
body: { query: query },
|
|
}).then(function(data) {
|
|
resultsDiv.innerHTML = '';
|
|
|
|
// Show registry error as a warning banner if present
|
|
if (data.catalog_error) {
|
|
var warning = document.createElement('div');
|
|
warning.className = 'empty-state';
|
|
warning.style.color = '#f0ad4e';
|
|
warning.style.borderLeft = '3px solid #f0ad4e';
|
|
warning.style.paddingLeft = '12px';
|
|
warning.style.marginBottom = '16px';
|
|
warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error;
|
|
resultsDiv.appendChild(warning);
|
|
}
|
|
|
|
// Show catalog results
|
|
if (data.catalog && data.catalog.length > 0) {
|
|
// Build a set of installed skill names for quick lookup
|
|
var installedNames = {};
|
|
if (data.installed) {
|
|
for (var j = 0; j < data.installed.length; j++) {
|
|
installedNames[data.installed[j].name] = true;
|
|
}
|
|
}
|
|
|
|
for (var i = 0; i < data.catalog.length; i++) {
|
|
var card = renderCatalogSkillCard(data.catalog[i], installedNames);
|
|
card.style.animationDelay = (i * 0.06) + 's';
|
|
resultsDiv.appendChild(card);
|
|
}
|
|
}
|
|
|
|
// Show matching installed skills too
|
|
if (data.installed && data.installed.length > 0) {
|
|
for (var k = 0; k < data.installed.length; k++) {
|
|
var installedCard = renderSkillCard(data.installed[k]);
|
|
installedCard.style.animationDelay = ((data.catalog ? data.catalog.length : 0) + k) * 0.06 + 's';
|
|
installedCard.classList.add('skill-search-result');
|
|
resultsDiv.appendChild(installedCard);
|
|
}
|
|
}
|
|
|
|
if (resultsDiv.children.length === 0) {
|
|
resultsDiv.innerHTML = '<div class="empty-state">No skills found for "' + escapeHtml(query) + '"</div>';
|
|
}
|
|
}).catch(function(err) {
|
|
resultsDiv.innerHTML = '<div class="empty-state">Search failed: ' + escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function renderCatalogSkillCard(entry, installedNames) {
|
|
var card = document.createElement('div');
|
|
card.className = 'ext-card ext-available skill-search-result';
|
|
|
|
var header = document.createElement('div');
|
|
header.className = 'ext-header';
|
|
|
|
var name = document.createElement('a');
|
|
name.className = 'ext-name';
|
|
name.textContent = entry.name || entry.slug;
|
|
name.href = 'https://clawhub.ai/skills/' + encodeURIComponent(entry.slug);
|
|
name.target = '_blank';
|
|
name.rel = 'noopener';
|
|
name.style.textDecoration = 'none';
|
|
name.style.color = 'inherit';
|
|
name.title = 'View on ClawHub';
|
|
header.appendChild(name);
|
|
|
|
if (entry.version) {
|
|
var version = document.createElement('span');
|
|
version.className = 'skill-version';
|
|
version.textContent = 'v' + entry.version;
|
|
header.appendChild(version);
|
|
}
|
|
|
|
card.appendChild(header);
|
|
|
|
if (entry.description) {
|
|
var desc = document.createElement('div');
|
|
desc.className = 'ext-desc';
|
|
desc.textContent = entry.description;
|
|
card.appendChild(desc);
|
|
}
|
|
|
|
// Metadata row: owner, stars, downloads, recency
|
|
var meta = document.createElement('div');
|
|
meta.className = 'ext-meta';
|
|
meta.style.fontSize = '11px';
|
|
meta.style.color = '#888';
|
|
meta.style.marginTop = '6px';
|
|
|
|
function addMetaSep() {
|
|
if (meta.children.length > 0) {
|
|
meta.appendChild(document.createTextNode(' \u00b7 '));
|
|
}
|
|
}
|
|
|
|
if (entry.owner) {
|
|
var ownerSpan = document.createElement('span');
|
|
ownerSpan.textContent = 'by ' + entry.owner;
|
|
meta.appendChild(ownerSpan);
|
|
}
|
|
|
|
if (entry.stars != null) {
|
|
addMetaSep();
|
|
var starsSpan = document.createElement('span');
|
|
starsSpan.textContent = entry.stars + ' stars';
|
|
meta.appendChild(starsSpan);
|
|
}
|
|
|
|
if (entry.downloads != null) {
|
|
addMetaSep();
|
|
var dlSpan = document.createElement('span');
|
|
dlSpan.textContent = formatCompactNumber(entry.downloads) + ' downloads';
|
|
meta.appendChild(dlSpan);
|
|
}
|
|
|
|
if (entry.updatedAt) {
|
|
var ago = formatTimeAgo(entry.updatedAt);
|
|
if (ago) {
|
|
addMetaSep();
|
|
var updatedSpan = document.createElement('span');
|
|
updatedSpan.textContent = 'updated ' + ago;
|
|
meta.appendChild(updatedSpan);
|
|
}
|
|
}
|
|
|
|
if (meta.children.length > 0) {
|
|
card.appendChild(meta);
|
|
}
|
|
|
|
var actions = document.createElement('div');
|
|
actions.className = 'ext-actions';
|
|
|
|
var slug = entry.slug || entry.name;
|
|
var isInstalled = installedNames[entry.name] || installedNames[slug];
|
|
|
|
if (isInstalled) {
|
|
var label = document.createElement('span');
|
|
label.className = 'ext-active-label';
|
|
label.textContent = 'Installed';
|
|
actions.appendChild(label);
|
|
} else {
|
|
var installBtn = document.createElement('button');
|
|
installBtn.className = 'btn-ext install';
|
|
installBtn.textContent = 'Install';
|
|
installBtn.addEventListener('click', (function(s, btn) {
|
|
return function() {
|
|
if (!confirm('Install skill "' + s + '" from ClawHub?')) return;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Installing...';
|
|
installSkill(s, null, btn);
|
|
};
|
|
})(slug, installBtn));
|
|
actions.appendChild(installBtn);
|
|
}
|
|
|
|
card.appendChild(actions);
|
|
return card;
|
|
}
|
|
|
|
function formatCompactNumber(n) {
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
return '' + n;
|
|
}
|
|
|
|
function formatTimeAgo(epochMs) {
|
|
var now = Date.now();
|
|
var diff = now - epochMs;
|
|
if (diff < 0) return null;
|
|
var minutes = Math.floor(diff / 60000);
|
|
if (minutes < 60) return minutes <= 1 ? 'just now' : minutes + 'm ago';
|
|
var hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return hours + 'h ago';
|
|
var days = Math.floor(hours / 24);
|
|
if (days < 30) return days + 'd ago';
|
|
var months = Math.floor(days / 30);
|
|
if (months < 12) return months + 'mo ago';
|
|
return Math.floor(months / 12) + 'y ago';
|
|
}
|
|
|
|
function installSkill(nameOrSlug, url, btn) {
|
|
var body = { name: nameOrSlug, slug: nameOrSlug };
|
|
if (url) body.url = url;
|
|
|
|
apiFetch('/api/skills/install', {
|
|
method: 'POST',
|
|
headers: { 'X-Confirm-Action': 'true' },
|
|
body: body,
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast('Installed skill "' + nameOrSlug + '"', 'success');
|
|
} else {
|
|
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
|
|
}
|
|
loadSkills();
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Install'; }
|
|
}).catch(function(err) {
|
|
showToast('Install failed: ' + err.message, 'error');
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Install'; }
|
|
});
|
|
}
|
|
|
|
function removeSkill(name) {
|
|
if (!confirm('Remove skill "' + name + '"?')) return;
|
|
apiFetch('/api/skills/' + encodeURIComponent(name), {
|
|
method: 'DELETE',
|
|
headers: { 'X-Confirm-Action': 'true' },
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast('Removed skill "' + name + '"', 'success');
|
|
} else {
|
|
showToast('Remove failed: ' + (res.message || 'unknown error'), 'error');
|
|
}
|
|
loadSkills();
|
|
}).catch(function(err) {
|
|
showToast('Remove failed: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function installSkillFromForm() {
|
|
var name = document.getElementById('skill-install-name').value.trim();
|
|
if (!name) { showToast('Skill name is required', 'error'); return; }
|
|
var url = document.getElementById('skill-install-url').value.trim() || null;
|
|
if (url && !url.startsWith('https://')) {
|
|
showToast('URL must use HTTPS', 'error');
|
|
return;
|
|
}
|
|
if (!confirm('Install skill "' + name + '"?')) return;
|
|
installSkill(name, url, null);
|
|
document.getElementById('skill-install-name').value = '';
|
|
document.getElementById('skill-install-url').value = '';
|
|
}
|
|
|
|
// Wire up Enter key on search input
|
|
document.getElementById('skill-search-input').addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') searchClawHub();
|
|
});
|
|
|
|
// --- Keyboard shortcuts ---
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
const mod = e.metaKey || e.ctrlKey;
|
|
const tag = (e.target.tagName || '').toLowerCase();
|
|
const inInput = tag === 'input' || tag === 'textarea';
|
|
|
|
// Mod+1-6: switch tabs
|
|
if (mod && e.key >= '1' && e.key <= '6') {
|
|
e.preventDefault();
|
|
const tabs = ['chat', 'memory', 'jobs', 'routines', 'extensions', 'skills'];
|
|
const idx = parseInt(e.key) - 1;
|
|
if (tabs[idx]) switchTab(tabs[idx]);
|
|
return;
|
|
}
|
|
|
|
// Mod+K: focus chat input or memory search
|
|
if (mod && e.key === 'k') {
|
|
e.preventDefault();
|
|
if (currentTab === 'memory') {
|
|
document.getElementById('memory-search').focus();
|
|
} else {
|
|
document.getElementById('chat-input').focus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Mod+N: new thread
|
|
if (mod && e.key === 'n' && currentTab === 'chat') {
|
|
e.preventDefault();
|
|
createNewThread();
|
|
return;
|
|
}
|
|
|
|
// Escape: close autocomplete, job detail, or blur input
|
|
if (e.key === 'Escape') {
|
|
const acEl = document.getElementById('slash-autocomplete');
|
|
if (acEl && acEl.style.display !== 'none') {
|
|
hideSlashAutocomplete();
|
|
return;
|
|
}
|
|
if (currentJobId) {
|
|
closeJobDetail();
|
|
} else if (inInput) {
|
|
e.target.blur();
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
|
|
// --- Toasts ---
|
|
|
|
function showToast(message, type) {
|
|
const container = document.getElementById('toasts');
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast toast-' + (type || 'info');
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
// Trigger slide-in
|
|
requestAnimationFrame(() => toast.classList.add('visible'));
|
|
setTimeout(() => {
|
|
toast.classList.remove('visible');
|
|
toast.addEventListener('transitionend', () => toast.remove());
|
|
}, 4000);
|
|
}
|
|
|
|
// --- Utilities ---
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatDate(isoString) {
|
|
if (!isoString) return '-';
|
|
const d = new Date(isoString);
|
|
return d.toLocaleString();
|
|
}
|