Static frontend with vendored marked/DOMPurify, bounded Markdown pipeline, same-origin mock server with scenario selection, unit, contract and CDP end-to-end tests under frontend/**.
539 lines
17 KiB
JavaScript
539 lines
17 KiB
JavaScript
/**
|
|
* 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';
|
|
|
|
// 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');
|
|
|
|
// 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');
|
|
}
|