GitRead / front_end /app.js
toasted1920's picture
Claude Opus 5.5
Add bring-your-own-key LLMs, ONNX code embeddings and security hardening
5e9ddc1
Raw History Blame Contribute Delete
34.8 kB
// ─────────────────────────────────────────────
// DOM REFS
// ─────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const els = {
repoInput: $('repoInput'),
analyseBtn: $('analyseBtn'),
landingPage: $('landingPage'),
processingPage: $('processingPage'),
chatPage: $('chatPage'),
stepsContainer: $('stepsContainer'),
procSubtitle: $('procSubtitle'),
footer: $('footer'),
chatInput: $('chatInput'),
sendBtn: $('sendBtn'),
chatMessages: $('chatMessages'),
warningBanner: $('warningBanner'),
dismissWarning: $('dismissWarning'),
statusDot: $('statusDot'),
statusLabel: $('statusLabel'),
settingsBtn: $('settingsBtn'),
settingsLabel: $('settingsLabel'),
settingsModal: $('settingsModal'),
settingsForm: $('settingsForm'),
settingsClose: $('settingsClose'),
settingsError: $('settingsError'),
providerSelect: $('providerSelect'),
modelInput: $('modelInput'),
apiKeyInput: $('apiKeyInput'),
rememberKey: $('rememberKey'),
clearKeyBtn: $('clearKeyBtn'),
keyHelpLink: $('keyHelpLink'),
};
let IS_BIG_REPO = false;
let activeStepBox = null;
let hadError = false;
let hadWarning = false;
// ─────────────────────────────────────────────
// MARKED.JS SETUP
// ─────────────────────────────────────────────
if (typeof marked !== 'undefined') {
marked.setOptions({ breaks: true, gfm: true });
}
// LLM output can be steered by whatever is inside the analysed repo (prompt
// injection), so rendered markdown is ALWAYS sanitized before hitting the DOM.
if (typeof DOMPurify !== 'undefined') {
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
}
function safeMarkdown(mdText) {
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') return null;
return DOMPurify.sanitize(marked.parse(mdText), {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select', 'iframe', 'object', 'embed', 'img', 'svg', 'math'],
FORBID_ATTR: ['style'],
});
}
// ─────────────────────────────────────────────
// SAFE STORAGE
// Browser storage can throw or be blocked (private mode, or third-party
// iframes β€” Hugging Face shows the app inside one), so everything is mirrored
// in memory and the app keeps working for the current page view.
// ─────────────────────────────────────────────
const memoryStore = {};
const store = {
get(area, key) {
try {
const v = window[area].getItem(key);
if (v !== null) return v;
} catch { /* fall through */ }
return memoryStore[area + ':' + key] ?? null;
},
set(area, key, val) {
memoryStore[area + ':' + key] = val;
try { window[area].setItem(key, val); } catch { /* memory only */ }
},
del(area, key) {
delete memoryStore[area + ':' + key];
try { window[area].removeItem(key); } catch { /* ignore */ }
},
};
// ─────────────────────────────────────────────
// LLM SETTINGS (bring your own key)
// ─────────────────────────────────────────────
const KEY_HELP = {
google: 'https://aistudio.google.com/app/apikey',
openai: 'https://platform.openai.com/api-keys',
anthropic: 'https://console.anthropic.com/settings/keys',
};
const KEY_PATTERN = /^[A-Za-z0-9_\-.]{20,300}$/;
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_\-.:/]{0,99}$/;
let PROVIDER_INFO = {};
let SERVER_KEY_AVAILABLE = false;
function loadLlmSettings() {
const fromLocal = store.get('localStorage', 'gitread.llm');
const raw = fromLocal || store.get('sessionStorage', 'gitread.llm');
let cfg = {};
try { cfg = raw ? JSON.parse(raw) : {}; } catch { cfg = {}; }
return {
provider: cfg.provider || 'google',
model: cfg.model || '',
apiKey: cfg.apiKey || '',
remember: Boolean(fromLocal),
};
}
function saveLlmSettings(cfg) {
const data = JSON.stringify({ provider: cfg.provider, model: cfg.model, apiKey: cfg.apiKey });
store.del('localStorage', 'gitread.llm');
store.del('sessionStorage', 'gitread.llm');
store.set(cfg.remember ? 'localStorage' : 'sessionStorage', 'gitread.llm', data);
refreshSettingsButton();
}
function refreshSettingsButton() {
const cfg = loadLlmSettings();
const hasKey = Boolean(cfg.apiKey);
els.settingsBtn.classList.toggle('needs-key', !hasKey && !SERVER_KEY_AVAILABLE);
const label = (PROVIDER_INFO[cfg.provider] && PROVIDER_INFO[cfg.provider].label) || cfg.provider;
els.settingsLabel.textContent = hasKey ? label : 'Add API key';
}
function updateProviderHints() {
const p = els.providerSelect.value;
els.keyHelpLink.href = KEY_HELP[p] || '#';
const def = PROVIDER_INFO[p] && PROVIDER_INFO[p].default_model;
els.modelInput.placeholder = def ? `Default: ${def}` : '';
}
function showSettingsError(message) {
els.settingsError.textContent = message || '';
els.settingsError.classList.toggle('hidden', !message);
}
function openSettings(message) {
const cfg = loadLlmSettings();
els.providerSelect.value = cfg.provider;
els.modelInput.value = cfg.model;
els.apiKeyInput.value = cfg.apiKey;
els.rememberKey.checked = cfg.remember;
updateProviderHints();
showSettingsError(message);
els.settingsModal.classList.remove('hidden');
(cfg.apiKey ? els.providerSelect : els.apiKeyInput).focus();
}
function closeSettings() {
els.settingsModal.classList.add('hidden');
els.apiKeyInput.value = '';
}
els.settingsBtn.addEventListener('click', () => openSettings());
els.settingsClose.addEventListener('click', closeSettings);
els.settingsModal.addEventListener('click', (e) => { if (e.target === els.settingsModal) closeSettings(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !els.settingsModal.classList.contains('hidden')) closeSettings();
});
els.providerSelect.addEventListener('change', updateProviderHints);
els.settingsForm.addEventListener('submit', (e) => {
e.preventDefault();
const apiKey = els.apiKeyInput.value.trim();
const model = els.modelInput.value.trim();
if (apiKey && !KEY_PATTERN.test(apiKey)) return showSettingsError("That API key doesn't look valid.");
if (model && !MODEL_PATTERN.test(model)) return showSettingsError('Invalid model name.');
saveLlmSettings({ provider: els.providerSelect.value, model, apiKey, remember: els.rememberKey.checked });
closeSettings();
});
els.clearKeyBtn.addEventListener('click', () => {
store.del('localStorage', 'gitread.llm');
store.del('sessionStorage', 'gitread.llm');
els.apiKeyInput.value = '';
els.modelInput.value = '';
els.rememberKey.checked = false;
refreshSettingsButton();
});
async function loadProviders() {
try {
const res = await fetch('/api/providers');
if (res.ok) {
const data = await res.json();
PROVIDER_INFO = data.providers || {};
SERVER_KEY_AVAILABLE = Boolean(data.server_key_available);
}
} catch { /* offline β€” keep defaults */ }
refreshSettingsButton();
}
loadProviders();
// ─────────────────────────────────────────────
// SESSION + API HELPERS
// Each visitor gets their own server-side session (own repo, own chat).
// ─────────────────────────────────────────────
async function errorDetail(res) {
try {
const data = await res.json();
if (data && typeof data.detail === 'string') return data.detail;
} catch { /* not JSON */ }
return `Request failed (HTTP ${res.status})`;
}
async function newSession() {
const res = await fetch('/api/session', { method: 'POST' });
if (!res.ok) throw new Error(await errorDetail(res));
const { token } = await res.json();
store.set('sessionStorage', 'gitread.session', token);
return token;
}
async function getSessionToken() {
return store.get('sessionStorage', 'gitread.session') || newSession();
}
async function apiPost(path, body, { retryOnExpired = true } = {}) {
const token = await getSessionToken();
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(body),
});
if (res.status === 401) {
store.del('sessionStorage', 'gitread.session');
if (retryOnExpired) {
await newSession();
return apiPost(path, body, { retryOnExpired: false });
}
}
return res;
}
// ─────────────────────────────────────────────
// UTILITIES
// ─────────────────────────────────────────────
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
function setStatus(mode, label) {
els.statusDot.className = 'status-dot ' + mode;
els.statusLabel.textContent = label;
}
function showPage(pageEl) {
['landingPage', 'processingPage', 'chatPage'].forEach((id) => {
$(id).classList.remove('active');
});
pageEl.classList.add('active');
}
function formatTime(d) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// ─────────────────────────────────────────────
// AUTO-GROW TEXTAREA
// ─────────────────────────────────────────────
els.chatInput.addEventListener('input', () => {
els.chatInput.style.height = 'auto';
els.chatInput.style.height = Math.min(els.chatInput.scrollHeight, 160) + 'px';
});
// ─────────────────────────────────────────────
// COLLAPSE A COMPLETED STEP BOX
// ─────────────────────────────────────────────
function collapseBox(box, status) {
return new Promise((resolve) => {
// Lock height at current value so we can animate down
const currentH = box.offsetHeight;
box.style.height = currentH + 'px';
box.style.overflow = 'hidden';
// Badge text
const badge = document.createElement('span');
badge.className = 'step-collapsed-badge';
badge.textContent = status === 'ERROR' ? 'Β· Error' : status === 'WARNING' ? 'Β· Warning' : 'Β· Done';
box.querySelector('.step-title').appendChild(badge);
// Hide sub-items right away so they don't flash during resize
const subItems = box.querySelector('.sub-items');
if (subItems) subItems.style.opacity = '0';
// Animate to collapsed height next frame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
box.style.height = '46px';
box.style.paddingTop = '0';
box.style.paddingBottom = '0';
});
});
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
box.removeEventListener('transitionend', handler);
// Reveal the badge now that height is settled
const b = box.querySelector('.step-collapsed-badge');
if (b) b.classList.add('visible');
resolve();
};
function handler(e) {
if (e.propertyName === 'height') finish();
}
box.addEventListener('transitionend', handler);
// Background tabs don't run transitions, so never wait on transitionend alone
// (otherwise the progress UI freezes while the user is in another tab)
setTimeout(finish, 800);
});
}
// ─────────────────────────────────────────────
// LANDING β†’ PROCESSING
// ─────────────────────────────────────────────
els.analyseBtn.addEventListener('click', startAnalysis);
els.repoInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') startAnalysis();
});
// Welcome chip clicks
document.querySelectorAll('.welcome-chip').forEach((chip) => {
chip.addEventListener('click', () => {
els.chatInput.value = chip.textContent;
els.chatInput.focus();
});
});
function startAnalysis() {
const repoUrl = els.repoInput.value.trim();
if (!repoUrl) return;
setStatus('active', 'analysing');
showPage(els.processingPage);
els.stepsContainer.innerHTML = '';
activeStepBox = null;
IS_BIG_REPO = false;
hadError = false;
hadWarning = false;
connectToBackend(repoUrl);
}
// ─────────────────────────────────────────────
// BACKEND: INIT-REPO STREAM
// ─────────────────────────────────────────────
async function connectToBackend(repoUrl) {
try {
const response = await apiPost('/api/init-repo', { url: repoUrl });
if (!response.ok) throw new Error(await errorDetail(response));
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop();
for (const event of lines) {
if (!event.trim()) continue;
const dataLine = event.split('\n')[0];
if (!dataLine.startsWith('data: ')) continue;
try {
const data = JSON.parse(dataLine.slice(6).trim());
await processBackendEvent(data);
} catch (e) {
console.error('Parse error:', e);
}
}
}
} catch (error) {
console.error('Fetch error:', error);
if (activeStepBox) {
await finishActiveBox(activeStepBox, 'ERROR');
} else {
const errorBox = createNewStepBox(`Error: ${error.message}`);
await finishActiveBox(errorBox, 'ERROR');
}
showProcessingAction('error');
}
}
async function processBackendEvent(data) {
const { status, task } = data;
if (status === 'FINISHED') {
if (activeStepBox) await finishActiveBox(activeStepBox, 'SUCCESS');
if (hadError) {
showProcessingAction('error');
} else if (hadWarning) {
showProcessingAction('warning');
} else {
await delay(700);
setStatus('chat', 'ready');
transitionToChat();
}
return;
}
// Live progress for long steps (e.g. embedding): update the current box in place
if (status === 'PROGRESS') {
if (activeStepBox) {
const titleText = activeStepBox.querySelector('.title-text');
if (titleText) titleText.textContent = task;
}
if (els.procSubtitle) els.procSubtitle.textContent = task;
return;
}
if (status === 'START') {
if (activeStepBox) await finishActiveBox(activeStepBox, 'SUCCESS');
activeStepBox = createNewStepBox(task);
if (els.procSubtitle) els.procSubtitle.textContent = task;
}
if (status === 'WARNING' && task && task.includes('Repo is large')) {
IS_BIG_REPO = true;
}
if (status === 'WARNING') hadWarning = true;
if (status === 'ERROR') hadError = true;
if (els.procSubtitle && task && status !== 'START') els.procSubtitle.textContent = task;
if (status === 'SUCCESS' || status === 'WARNING' || status === 'ERROR') {
if (activeStepBox) {
const titleText = activeStepBox.querySelector('.title-text');
if (titleText) titleText.textContent = task;
await finishActiveBox(activeStepBox, status);
activeStepBox = null;
} else {
const box = createNewStepBox(task);
await finishActiveBox(box, status);
}
}
}
// ─────────────────────────────────────────────
// PROCESSING ACTION BUTTON (error / warning gate)
// ─────────────────────────────────────────────
function showProcessingAction(type) {
// Remove any existing action card
const existing = els.stepsContainer.querySelector('.proc-action-card');
if (existing) existing.remove();
const card = document.createElement('div');
card.className = `proc-action-card ${type === 'error' ? 'proc-action-error' : 'proc-action-warning'}`;
if (type === 'error') {
card.innerHTML = `
<div class="proc-action-icon">βœ•</div>
<div class="proc-action-body">
<p class="proc-action-title">Analysis failed</p>
<p class="proc-action-desc">An error occurred while analysing this repository. Please check the URL and try again.</p>
<button class="proc-action-btn proc-btn-error" id="procBackBtn">← Try a Different Repo</button>
</div>`;
} else {
card.innerHTML = `
<div class="proc-action-icon">⚠</div>
<div class="proc-action-body">
<p class="proc-action-title">Analysis complete with warnings</p>
<p class="proc-action-desc">Some steps raised warnings. Results may be partial, but you can still explore the repository.</p>
<button class="proc-action-btn proc-btn-warning" id="procContinueBtn">Continue to Chat β†’</button>
</div>`;
}
els.stepsContainer.appendChild(card);
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (type === 'error') {
document.getElementById('procBackBtn').addEventListener('click', () => {
showPage(els.landingPage);
setStatus('', 'idle');
});
} else {
document.getElementById('procContinueBtn').addEventListener('click', () => {
setStatus('chat', 'ready');
transitionToChat();
});
}
}
// ─────────────────────────────────────────────
// STEP BOX HELPERS
// ─────────────────────────────────────────────
function createNewStepBox(message) {
const box = document.createElement('div');
box.className = 'step-box';
box.innerHTML = `
<div class="step-title">
<span class="step-icon-wrap">
<span class="step-ring-spinner"></span>
</span>
<span class="title-text">${escapeHtml(message)}</span>
</div>
<div class="sub-items"></div>`;
els.stepsContainer.appendChild(box);
box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
return box;
}
async function finishActiveBox(box, status) {
// Swap spinner for status icon
const iconWrap = box.querySelector('.step-icon-wrap');
if (iconWrap) {
const spinner = iconWrap.querySelector('.step-ring-spinner');
if (spinner) spinner.remove();
const icon = document.createElement('span');
icon.className = 'step-status-icon';
if (status === 'WARNING') {
icon.textContent = '⚠';
icon.classList.add('warn');
} else if (status === 'ERROR') {
icon.textContent = 'βœ•';
icon.classList.add('err');
} else {
icon.textContent = 'βœ“';
icon.classList.add('green');
}
iconWrap.appendChild(icon);
}
// Apply status colour class
if (status === 'WARNING') {
box.classList.add('warn-yellow');
} else if (status === 'ERROR') {
box.classList.add('err-red');
} else {
box.classList.add('done-green');
}
// Brief pause so the colour is visible, then animate collapsed
await delay(320);
await collapseBox(box, status);
}
// ─────────────────────────────────────────────
// PROCESSING β†’ CHAT
// ─────────────────────────────────────────────
function transitionToChat() {
if (IS_BIG_REPO) els.warningBanner.classList.remove('hidden');
showPage(els.chatPage);
renderWelcome();
els.footer.classList.remove('hidden');
els.chatInput.focus();
}
els.dismissWarning.addEventListener('click', () => {
els.warningBanner.classList.add('hidden');
});
// ─────────────────────────────────────────────
// WELCOME TO CHAT STATE
// ─────────────────────────────────────────────
function renderWelcome() {
els.chatMessages.innerHTML = '';
const welcome = document.createElement('div');
welcome.className = 'chat-welcome';
welcome.innerHTML = `
<div class="welcome-icon">βŽ”</div>
<div class="welcome-title">Where do you want to start?</div>
<div class="welcome-sub">Ask anything about this repository</div>
<div class="welcome-chips">
<span class="welcome-chip">Give me an overview of this repo</span>
<span class="welcome-chip">What are the main dependencies?</span>
<span class="welcome-chip">How do I run this locally?</span>
<span class="welcome-chip">What design patterns are used?</span>
</div>`;
els.chatMessages.appendChild(welcome);
welcome.querySelectorAll('.welcome-chip').forEach((chip) => {
chip.addEventListener('click', () => {
els.chatInput.value = chip.dataset.q || chip.textContent;
els.chatInput.focus();
sendMessage();
});
});
}
// ─────────────────────────────────────────────
// CHAT: SEND
// ─────────────────────────────────────────────
els.sendBtn.addEventListener('click', sendMessage);
els.chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
async function sendMessage() {
const text = els.chatInput.value.trim();
if (!text) return;
const llm = loadLlmSettings();
if (!llm.apiKey && !SERVER_KEY_AVAILABLE) {
openSettings('Add your API key to start chatting.');
return;
}
// Remove welcome block if present
const welcome = els.chatMessages.querySelector('.chat-welcome');
if (welcome) welcome.remove();
els.chatInput.value = '';
els.chatInput.style.height = 'auto';
// User bubble
appendUserMessage(text);
// Bot group container
const botGroup = document.createElement('div');
botGroup.className = 'msg-group bot';
els.chatMessages.appendChild(botGroup);
// Thinking UI (collapsible)
let thinkingWrap = null;
let stepsInner = null;
let lastStepEl = null;
let stepCount = 0;
let finalBubble = null;
let fullMarkdown = '';
try {
// No silent retry here: a new session would have no repo loaded
const response = await apiPost('/api/chat', {
message: text,
provider: llm.provider,
model: llm.model || null,
api_key: llm.apiKey || null,
}, { retryOnExpired: false });
if (response.status === 401) {
throw new Error('Your session expired. Please reload the page and analyse the repo again.');
}
if (!response.ok) throw new Error(await errorDetail(response));
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (value) {
buffer += decoder.decode(value, { stream: !done });
const lines = buffer.split('\n\n');
buffer = lines.pop();
for (const event of lines) {
if (!event.trim()) continue;
const dataLine = event.split('\n')[0];
if (!dataLine.startsWith('data: ')) continue;
try {
const data = JSON.parse(dataLine.slice(6).trim());
if (data.type === 'tool' || data.type === 'thinking') {
// Build thinking UI on first thought
if (!thinkingWrap) {
const ui = createThinkingUI(botGroup);
thinkingWrap = ui.wrap;
stepsInner = ui.inner;
}
lastStepEl = addThinkingStep(stepsInner, data.text, lastStepEl);
stepCount++;
updateThinkingToggleLabel(thinkingWrap, stepCount, false);
}
else if (data.type === 'error') {
if (thinkingWrap) {
updateThinkingToggleLabel(thinkingWrap, stepCount, true);
closeThinkingPanel(thinkingWrap);
}
appendErrorBubble(botGroup, data.text);
}
else if (data.type === 'message') {
// Mark last step done
if (lastStepEl) lastStepEl.classList.add('done-step');
// Finalise thinking toggle
if (thinkingWrap) {
updateThinkingToggleLabel(thinkingWrap, stepCount, true);
closeThinkingPanel(thinkingWrap);
}
// Accumulate markdown text
fullMarkdown += data.text;
// Create or update the answer bubble
if (!finalBubble) {
finalBubble = createBotBubble(botGroup);
}
renderMarkdownToBubble(finalBubble, fullMarkdown);
finalBubble.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
} catch (e) {
console.error('Parse error:', e);
}
}
}
if (done) break;
}
// Add copy-response button after streaming is done
if (finalBubble && fullMarkdown) {
const copyBtn = buildCopyResponseBtn(fullMarkdown, finalBubble);
botGroup.appendChild(copyBtn);
}
// Add timestamp after response is complete
const meta = document.createElement('div');
meta.className = 'msg-meta';
meta.textContent = 'WHATREPO Β· ' + formatTime(new Date());
botGroup.appendChild(meta);
} catch (error) {
appendErrorBubble(botGroup, 'Error: ' + error.message);
}
}
function appendErrorBubble(container, text) {
const bubble = createBotBubble(container);
const span = document.createElement('span');
span.style.color = 'var(--red)';
span.textContent = text;
bubble.appendChild(span);
}
// ─────────────────────────────────────────────
// MARKDOWN RENDERING
// ─────────────────────────────────────────────
function renderMarkdownToBubble(bubble, mdText) {
const html = safeMarkdown(mdText);
if (html === null) {
// Fallback (libraries failed to load): plain text only, never raw HTML
bubble.textContent = mdText;
return;
}
bubble.innerHTML = html;
// Post-process: wrap <pre> blocks with header + copy button
bubble.querySelectorAll('pre').forEach((pre) => {
if (pre.parentElement && pre.parentElement.classList.contains('code-block-wrap')) return; // already wrapped
const codeEl = pre.querySelector('code');
const rawLang = codeEl ? codeEl.className.replace(/^language-/, '').trim() : '';
const lang = rawLang || 'code';
const wrap = document.createElement('div');
wrap.className = 'code-block-wrap';
const header = document.createElement('div');
header.className = 'code-block-header';
const langLabel = document.createElement('span');
langLabel.className = 'code-lang-label';
langLabel.textContent = lang;
const copyBtn = document.createElement('button');
copyBtn.className = 'code-copy-btn';
copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 11V3h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Copy</span>`;
copyBtn.addEventListener('click', () => {
const content = codeEl ? codeEl.textContent : pre.textContent;
navigator.clipboard.writeText(content).then(() => {
copyBtn.querySelector('span').textContent = 'Copied!';
setTimeout(() => { copyBtn.querySelector('span').textContent = 'Copy'; }, 1800);
}).catch(() => {});
});
header.appendChild(langLabel);
header.appendChild(copyBtn);
pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(header);
wrap.appendChild(pre);
});
}
function buildCopyResponseBtn(markdownText, bubble) {
const btn = document.createElement('button');
btn.className = 'msg-copy-btn';
btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 11V3h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
btn.addEventListener('click', () => {
// Copy plain text from the rendered bubble
navigator.clipboard.writeText(bubble.innerText).then(() => {
btn.querySelector('span').textContent = 'Copied!';
setTimeout(() => { btn.querySelector('span').textContent = 'Copy response'; }, 1800);
}).catch(() => {});
});
return btn;
}
// ─────────────────────────────────────────────
// MESSAGE UI HELPERS
// ─────────────────────────────────────────────
function appendUserMessage(text) {
const group = document.createElement('div');
group.className = 'msg-group user';
group.innerHTML = `
<div class="msg-sender">You</div>
<div class="msg-bubble user">${escapeHtml(text)}</div>
<div class="msg-meta">${formatTime(new Date())}</div>`;
els.chatMessages.appendChild(group);
group.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
function createBotBubble(container) {
const bubble = document.createElement('div');
bubble.className = 'msg-bubble bot';
container.appendChild(bubble);
return bubble;
}
// ─────────────────────────────────────────────
// THINKING UI β€” Collapsible (Gemini-style)
// ─────────────────────────────────────────────
function createThinkingUI(container) {
const wrap = document.createElement('div');
wrap.className = 'thinking-wrap';
wrap.innerHTML = `
<button class="thinking-toggle active open" aria-expanded="true">
<span class="thinking-spinner">
<svg width="12" height="12" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="20" fill="none" stroke="var(--accent)" stroke-width="5"
stroke-dasharray="80 45" stroke-linecap="round"/>
</svg>
</span>
<span class="thinking-label">Thinking…</span>
<span class="thinking-chevron">β–Ύ</span>
</button>
<div class="thinking-steps-panel open">
<div class="thinking-steps-inner"></div>
</div>`;
container.appendChild(wrap);
// Toggle click
const toggle = wrap.querySelector('.thinking-toggle');
const panel = wrap.querySelector('.thinking-steps-panel');
toggle.addEventListener('click', () => {
const isOpen = panel.classList.contains('open');
panel.classList.toggle('open', !isOpen);
toggle.classList.toggle('open', !isOpen);
toggle.setAttribute('aria-expanded', String(!isOpen));
});
return {
wrap,
toggle,
panel,
inner: wrap.querySelector('.thinking-steps-inner'),
};
}
function addThinkingStep(inner, text, previousStepEl) {
// Mark previous as done
if (previousStepEl) {
previousStepEl.classList.remove('current');
previousStepEl.classList.add('done-step');
}
const step = document.createElement('div');
step.className = 'thinking-step current';
// Preserve newlines from backend
const formatted = escapeHtml(text).replace(/\n/g, '<br>');
step.innerHTML = `<span class="thinking-step-dot"></span><span class="thinking-step-text">${formatted}</span>`;
inner.appendChild(step);
// Scroll the panel
inner.scrollTop = inner.scrollHeight;
return step;
}
function updateThinkingToggleLabel(wrap, count, isDone) {
const label = wrap.querySelector('.thinking-label');
const spinner = wrap.querySelector('.thinking-spinner');
const toggle = wrap.querySelector('.thinking-toggle');
if (isDone) {
label.textContent = `Thought for ${count} step${count !== 1 ? 's' : ''}`;
spinner.classList.add('done');
spinner.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4" fill="var(--green)"/></svg>`;
toggle.classList.remove('active');
} else {
label.textContent = `Thinking… (${count} step${count !== 1 ? 's' : ''})`;
}
}
function closeThinkingPanel(wrap) {
const panel = wrap.querySelector('.thinking-steps-panel');
const toggle = wrap.querySelector('.thinking-toggle');
// Auto-collapse once done so the focus is on the answer
panel.classList.remove('open');
toggle.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
}
// ─────────────────────────────────────────────
// ESCAPE
// ─────────────────────────────────────────────
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}