/** * Main application wiring and state management. * Credentials remain strictly in browser memory and are never persisted or logged. */ import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials } from './api.js'; import { MarkdownRenderer, exportToMarkdown, renderWarnings } from './render.js'; import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js'; import { mountThinkingOrb } from './orb.js'; import { initBookLogo } from './logo.js'; // Application memory state let committedCredentials = null; // { url: string, pat: string } | null let draftCredentials = { url: '', pat: '' }; let activeAbortController = null; let currentRequestGeneration = 0; let activeVerifyAbortController = null; let currentVerifyGeneration = 0; let currentResult = null; let lastSubmittedPrompt = ''; // DOM Elements let viewPrompt, viewLoading, viewResult; let promptInput, submitBtn, promptError; let keyBtn, credIndicator, credStatusSr; let modalBackdrop, modalDialog, modalCloseBtn, credentialsForm; let credUrlInput, credPatInput, togglePatBtn, modalFeedback; let btnTestCred, btnSaveCred, btnCancelCred, btnClearCred; let cancelBtn, backBtn, exportBtn; let outputContent, sectionNav, resultWarnings; let artifactsSection, artifactsList, artifactsError; let historySection, historyToggleBtn, historyToggleTitle, historyContent; let pagesAccessedList, toolHistoryList; let markdownRenderer = null; /** * Initializes DOM element references and event listeners. */ document.addEventListener('DOMContentLoaded', () => { // Views viewPrompt = document.getElementById('view-prompt'); viewLoading = document.getElementById('view-loading'); viewResult = document.getElementById('view-result'); // Decorative animations (page is a fixed light theme, so the orb ink is pinned to light) mountThinkingOrb(document.getElementById('thinking-orb'), { state: 'solving', size: 64, theme: 'light' }); initBookLogo(document.getElementById('app-brand'), document.getElementById('brand-logo')); // Prompt View promptInput = document.getElementById('prompt-input'); submitBtn = document.getElementById('submit-btn'); promptError = document.getElementById('prompt-error'); // Header / Credentials keyBtn = document.getElementById('key-btn'); credIndicator = document.getElementById('cred-indicator'); credStatusSr = document.getElementById('cred-status-sr'); // Modal modalBackdrop = document.getElementById('modal-backdrop'); modalDialog = document.getElementById('modal-dialog'); modalCloseBtn = document.getElementById('modal-close-btn'); credentialsForm = document.getElementById('credentials-form'); credUrlInput = document.getElementById('cred-url'); credPatInput = document.getElementById('cred-pat'); togglePatBtn = document.getElementById('toggle-pat-btn'); modalFeedback = document.getElementById('modal-feedback'); btnTestCred = document.getElementById('btn-test-cred'); btnSaveCred = document.getElementById('btn-save-cred'); btnCancelCred = document.getElementById('btn-cancel-cred'); btnClearCred = document.getElementById('btn-clear-cred'); // Loading & Result Controls cancelBtn = document.getElementById('cancel-btn'); backBtn = document.getElementById('back-btn'); exportBtn = document.getElementById('export-btn'); // Result Area outputContent = document.getElementById('output-content'); sectionNav = document.getElementById('section-nav'); resultWarnings = document.getElementById('result-warnings'); // Artifacts Area artifactsSection = document.getElementById('artifacts-section'); artifactsList = document.getElementById('artifacts-list'); artifactsError = document.getElementById('artifacts-error'); // History Area historySection = document.getElementById('history-section'); historyToggleBtn = document.getElementById('history-toggle-btn'); historyToggleTitle = document.getElementById('history-toggle-title'); historyContent = document.getElementById('history-content'); pagesAccessedList = document.getElementById('pages-accessed-list'); toolHistoryList = document.getElementById('tool-history-list'); // Markdown renderer markdownRenderer = new MarkdownRenderer(outputContent, sectionNav); // Wire events setupPromptEvents(); setupModalEvents(); setupResultEvents(); updateCredentialIndicator(); }); /** * Updates textarea height dynamically up to 280px. */ function autoResizeTextarea() { if (!promptInput) return; promptInput.style.height = 'auto'; const newHeight = Math.min(promptInput.scrollHeight, 280); promptInput.style.height = `${newHeight}px`; } /** * Sets up prompt input and submission listeners. */ function setupPromptEvents() { promptInput.addEventListener('input', autoResizeTextarea); // Enter to submit (outside IME), Shift+Enter for newline promptInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { if (e.isComposing || e.keyCode === 229) { return; // IME composition in progress } if (!e.shiftKey) { e.preventDefault(); handleSubmitQuery(); } } }); submitBtn.addEventListener('click', (e) => { e.preventDefault(); handleSubmitQuery(); }); } /** * Handles query submission. */ async function handleSubmitQuery() { const prompt = promptInput.value; if (!prompt || !prompt.trim()) { showPromptError('Please enter a research prompt.'); return; } // Ensure credentials are configured if (!committedCredentials || !committedCredentials.url || !committedCredentials.pat) { showPromptError('Confluence credentials required. Click the key icon to configure URL and PAT.'); openCredentialsModal(); return; } hidePromptError(); lastSubmittedPrompt = prompt; // Transition to loading view switchView('loading'); const generation = ++currentRequestGeneration; activeAbortController = new AbortController(); try { const result = await submitQuery( { prompt, credentials: committedCredentials }, { signal: activeAbortController.signal } ); // Stale check if (generation !== currentRequestGeneration) { return; } currentResult = result; renderResultView(result); switchView('result'); } catch (err) { // Stale check if (generation !== currentRequestGeneration) { return; } if (err.name === 'AbortError') { // User cancelled, smoothly return to prompt view switchView('prompt'); return; } // Switch back to prompt view and display error switchView('prompt'); if (err.code === 'confluence_auth_failed') { showPromptError('Confluence authentication failed. Please verify your URL and PAT in the credentials modal.'); } else if (err.code === 'origin_denied') { showPromptError('Request forbidden: Origin denied by server policy.'); } else if (err.code === 'destination_denied') { showPromptError('Request forbidden: Confluence destination URL is not permitted by server policy.'); } else if (err.code === 'busy') { showPromptError('The server is currently busy processing another query or cleaning up. Please try again shortly.'); } else if (err.code === 'query_timeout') { showPromptError('The query timed out. The operation exceeded the allowed execution time.'); } else if (err.code === 'request_too_large') { showPromptError('The request exceeds the maximum allowed payload size.'); } else { showPromptError(`Query failed [${err.code || 'error'}]: ${err.message || 'An unexpected error occurred.'}`); } } finally { if (generation === currentRequestGeneration) { activeAbortController = null; } } } /** * Sets up credentials modal and actions. */ function setupModalEvents() { keyBtn.addEventListener('click', openCredentialsModal); modalCloseBtn.addEventListener('click', closeCredentialsModal); btnCancelCred.addEventListener('click', closeCredentialsModal); // Close modal on Escape window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !modalBackdrop.classList.contains('hidden')) { closeCredentialsModal(); } }); // Close on backdrop click outside dialog modalBackdrop.addEventListener('click', (e) => { if (e.target === modalBackdrop) { closeCredentialsModal(); } }); // Focus trap inside modal dialog modalDialog.addEventListener('keydown', (e) => { if (e.key !== 'Tab') return; const focusable = modalDialog.querySelectorAll( 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])' ); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }); // Password visibility toggle togglePatBtn.addEventListener('click', () => { const isPassword = credPatInput.type === 'password'; credPatInput.type = isPassword ? 'text' : 'password'; togglePatBtn.setAttribute('aria-label', isPassword ? 'Hide token' : 'Show token'); }); // Typing in inputs invalidates any in-flight test connection credUrlInput.addEventListener('input', () => { abortActiveVerify(); btnTestCred.disabled = false; }); credPatInput.addEventListener('input', () => { abortActiveVerify(); btnTestCred.disabled = false; }); // Test connection button (draft credentials only, does NOT commit; guarded against staleness) btnTestCred.addEventListener('click', async () => { draftCredentials.url = credUrlInput.value.trim(); draftCredentials.pat = credPatInput.value.trim(); try { validateCredentials(draftCredentials); } catch (err) { showModalFeedback(err.message, 'error'); return; } const verifyGen = ++currentVerifyGeneration; if (activeVerifyAbortController) { activeVerifyAbortController.abort(); } activeVerifyAbortController = new AbortController(); btnTestCred.disabled = true; showModalFeedback('Testing connection...', 'info'); try { await verifyCredentials(draftCredentials, { signal: activeVerifyAbortController.signal }); if (verifyGen !== currentVerifyGeneration) return; showModalFeedback('Connection successful! Credentials are valid.', 'success'); } catch (err) { if (verifyGen !== currentVerifyGeneration) return; if (err.name === 'AbortError') return; showModalFeedback(`Connection failed [${err.code || 'error'}]: ${err.message}`, 'error'); } finally { if (verifyGen === currentVerifyGeneration) { btnTestCred.disabled = false; activeVerifyAbortController = null; } } }); // Save & Close form submit credentialsForm.addEventListener('submit', (e) => { e.preventDefault(); abortActiveVerify(); draftCredentials.url = credUrlInput.value.trim(); draftCredentials.pat = credPatInput.value.trim(); try { validateCredentials(draftCredentials); } catch (err) { showModalFeedback(err.message, 'error'); return; } // Commit credentials to memory committedCredentials = { url: draftCredentials.url, pat: draftCredentials.pat }; updateCredentialIndicator(); closeCredentialsModal(); hidePromptError(); }); // Clear credentials btnClearCred.addEventListener('click', () => { abortActiveVerify(); // If a query is active, abort it if (activeAbortController) { currentRequestGeneration++; activeAbortController.abort(); activeAbortController = null; switchView('prompt'); } committedCredentials = null; draftCredentials = { url: '', pat: '' }; credUrlInput.value = ''; credPatInput.value = ''; updateCredentialIndicator(); showModalFeedback('Credentials cleared from browser memory.', 'info'); }); } /** * Aborts any pending verify connection request and increments generation counter. */ function abortActiveVerify() { currentVerifyGeneration++; if (activeVerifyAbortController) { activeVerifyAbortController.abort(); activeVerifyAbortController = null; } } /** * Opens credentials modal and restores draft from committed state. */ function openCredentialsModal() { modalFeedback.className = 'modal-feedback hidden'; modalFeedback.textContent = ''; if (committedCredentials) { credUrlInput.value = committedCredentials.url; credPatInput.value = committedCredentials.pat; } else { credUrlInput.value = draftCredentials.url || ''; credPatInput.value = draftCredentials.pat || ''; } modalBackdrop.classList.remove('hidden'); credUrlInput.focus(); } /** * Closes credentials modal and restores focus to key button. */ function closeCredentialsModal() { abortActiveVerify(); modalBackdrop.classList.add('hidden'); keyBtn.focus(); } /** * Displays modal feedback messages. * @param {string} msg * @param {'error'|'success'|'info'} type */ function showModalFeedback(msg, type) { modalFeedback.className = `modal-feedback ${type === 'error' ? 'error-box' : type === 'success' ? 'success-box' : 'warning-box'}`; modalFeedback.textContent = msg; } /** * Updates the key icon indicator state. */ function updateCredentialIndicator() { if (committedCredentials && committedCredentials.url && committedCredentials.pat) { credIndicator.classList.add('active'); credStatusSr.textContent = 'Credentials active in memory'; keyBtn.title = 'Credentials active (Click to edit)'; } else { credIndicator.classList.remove('active'); credStatusSr.textContent = 'Credentials not set'; keyBtn.title = 'Configure Confluence Credentials'; } } /** * Sets up result view buttons and interactions. */ function setupResultEvents() { // Cancel button during loading cancelBtn.addEventListener('click', () => { if (activeAbortController) { currentRequestGeneration++; activeAbortController.abort(); activeAbortController = null; } switchView('prompt'); }); // Back to prompt button backBtn.addEventListener('click', () => { switchView('prompt'); promptInput.focus(); }); // Export to MD button exportBtn.addEventListener('click', () => { if (currentResult && typeof currentResult.markdown === 'string') { exportToMarkdown(currentResult.markdown, lastSubmittedPrompt); } }); // History toggle button historyToggleBtn.addEventListener('click', () => { const expanded = historyToggleBtn.getAttribute('aria-expanded') === 'true'; historyToggleBtn.setAttribute('aria-expanded', String(!expanded)); if (expanded) { historyContent.classList.add('hidden'); } else { historyContent.classList.remove('hidden'); } }); } /** * Renders the query result view. * @param {object} result */ function renderResultView(result) { // 1. Warnings renderWarnings(resultWarnings, result.warnings || []); // 2. Markdown output (with bounded section renderer) markdownRenderer.load(result.markdown || ''); // 3. Artifacts artifactsError.classList.add('hidden'); artifactsError.textContent = ''; if (Array.isArray(result.artifacts) && result.artifacts.length > 0) { artifactsSection.classList.remove('hidden'); renderArtifacts(artifactsList, result.artifacts, async (id, name) => { try { artifactsError.classList.add('hidden'); const { blob, filename } = await downloadArtifact(id); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename || name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { artifactsError.classList.remove('hidden'); artifactsError.textContent = `Download failed [${err.code || 'error'}]: ${err.message || 'Artifact not found or expired.'}`; } }); } else { artifactsSection.classList.add('hidden'); artifactsList.replaceChildren(); } // 4. Sources and Tool History const pages = result.pages_accessed || []; historyToggleTitle.textContent = `Sources & Request History (${pages.length} page${pages.length === 1 ? '' : 's'} accessed)`; renderPagesAccessed(pagesAccessedList, pages); renderToolHistory(toolHistoryList, result.tool_history || []); // Collapse history by default historyToggleBtn.setAttribute('aria-expanded', 'false'); historyContent.classList.add('hidden'); } /** * Switches the active view. * @param {'prompt'|'loading'|'result'} viewName */ function switchView(viewName) { viewPrompt.classList.add('hidden'); viewLoading.classList.add('hidden'); viewResult.classList.add('hidden'); if (viewName === 'prompt') { viewPrompt.classList.remove('hidden'); } else if (viewName === 'loading') { viewLoading.classList.remove('hidden'); } else if (viewName === 'result') { viewResult.classList.remove('hidden'); } } /** * Shows prompt error message. * @param {string} msg */ function showPromptError(msg) { promptError.textContent = msg; promptError.classList.remove('hidden'); } /** * Hides prompt error message. */ function hidePromptError() { promptError.textContent = ''; promptError.classList.add('hidden'); }