Artur Mukhamadiev 3ef50c4448 frontend: thinking-orbs loading indicator and CSS book logo
Replace the gear spinner with the thinking-orbs canvas engine (vendored
framework-free build, driven by js/orb.js in the "solving" state) and the
inline SVG brand icon with a pure CSS book whose cover flips open on page
load and while the brand is hovered (js/logo.js).
2026-09-15 12:00:26 +03:00

545 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';
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');
}