Spaces:
Sleeping
Sleeping
Download front_end/app.js from toasted1920/GitRead: direct link, hf CLI and curl.
- Browser
- Download file 34.8 kB
-
https://huggingface.co/spaces/toasted1920/GitRead/resolve/main/front_end/app.js
- Command line
-
hf download hf://spaces/toasted1920/GitRead/front_end/app.js
-
curl -L -o app.js https://huggingface.co/spaces/toasted1920/GitRead/resolve/main/front_end/app.js
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, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |