📜
chat.js
Back
📝 Javascript ⚡ Executable Ctrl+S: Save • Ctrl+R: Run • Ctrl+F: Find
// chat.js - Unified Chat Application (function() { 'use strict'; const els = { transcript: document.getElementById('transcript'), question: document.getElementById('question'), send: document.getElementById('send'), status: document.getElementById('status'), debugWrap: document.getElementById('debugWrap'), debugArea: document.getElementById('debugArea'), overlay: document.getElementById('settingsOverlay'), openSettings: document.getElementById('openSettings'), closeSettings: document.getElementById('closeSettings'), overlayBackdrop: document.getElementById('overlayBackdrop'), stitcherOverlay: document.getElementById('stitcherOverlay'), openStitcher: document.getElementById('openStitcher'), closeStitcher: document.getElementById('closeStitcher'), stitcherBackdrop: document.getElementById('stitcherBackdrop'), stitcherContent: document.getElementById('stitcherContent'), stitcherCount: document.getElementById('stitcherCount'), clearStitcher: document.getElementById('clearStitcher'), copyStitched: document.getElementById('copyStitched'), downloadStitched: document.getElementById('downloadStitched'), stitcherFilename: document.getElementById('stitcherFilename'), }; const STORAGE_KEY = 'unified-chat-state-v2'; const defaultState = () => ({ settings: { model: 'deepseek-chat', maxTokens: 800, temperature: 0.7, system: 'You are a helpful, accurate assistant. Be concise and clear. Use markdown when it helps readability.' }, messages: [], stitcher: { chunks: [] } }); function loadState() { try { const loaded = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (!loaded) return defaultState(); if (!loaded.stitcher) loaded.stitcher = { chunks: [] }; if (!loaded.stitcher.chunks) loaded.stitcher.chunks = []; return loaded; } catch { return defaultState(); } } function saveState(s) { if (!s.stitcher) s.stitcher = { chunks: [] }; if (!s.stitcher.chunks) s.stitcher.chunks = []; localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } const state = loadState(); function detectLanguage(code) { if (code.includes('<!doctype html>') || code.includes('<html')) return 'html'; if (code.includes('<?php')) return 'php'; if (code.includes('function') && code.includes('{')) return 'javascript'; if (code.includes('import ') && code.includes('from ')) return 'javascript'; if (code.includes('def ') && code.includes(':')) return 'python'; return 'text'; } function fallbackCopy(text, btn) { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-999999px'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 2000); } catch { btn.textContent = 'Failed'; setTimeout(() => btn.textContent = 'Copy', 2000); } document.body.removeChild(ta); } function createCodeBlock(code, lang = 'text') { const cont = document.createElement('div'); cont.className = 'code-container'; const hdr = document.createElement('div'); hdr.className = 'code-header'; const left = document.createElement('div'); left.className = 'code-header-left'; const icon = document.createElement('span'); icon.className = 'collapse-icon'; icon.textContent = '▼'; const label = document.createElement('span'); label.textContent = lang.toUpperCase(); left.appendChild(icon); left.appendChild(label); const right = document.createElement('div'); right.className = 'code-header-right'; const copyBtn = document.createElement('button'); copyBtn.className = 'copy-btn'; copyBtn.textContent = 'Copy'; const stitchBtn = document.createElement('button'); stitchBtn.className = 'stitch-btn'; stitchBtn.textContent = '+ Stitch'; right.appendChild(copyBtn); right.appendChild(stitchBtn); hdr.appendChild(left); hdr.appendChild(right); const content = document.createElement('pre'); content.className = 'code-content'; content.textContent = code; cont.appendChild(hdr); cont.appendChild(content); setTimeout(() => { copyBtn.onclick = (e) => { e.stopPropagation(); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(code).then(() => { copyBtn.textContent = 'Copied!'; setTimeout(() => copyBtn.textContent = 'Copy', 2000); }).catch(() => fallbackCopy(code, copyBtn)); } else { fallbackCopy(code, copyBtn); } }; stitchBtn.onclick = (e) => { e.stopPropagation(); const isIn = state.stitcher.chunks.some(c => c.code === code); if (isIn) { state.stitcher.chunks = state.stitcher.chunks.filter(c => c.code !== code); stitchBtn.textContent = '+ Stitch'; stitchBtn.classList.remove('added'); } else { state.stitcher.chunks.push({ id: Date.now(), code, language: lang, timestamp: Date.now() }); stitchBtn.textContent = '- Remove'; stitchBtn.classList.add('added'); } saveState(state); updateStitcherUI(); }; if (state.stitcher.chunks.some(c => c.code === code)) { stitchBtn.textContent = '- Remove'; stitchBtn.classList.add('added'); } left.onclick = (e) => { e.stopPropagation(); const collapsed = content.classList.contains('collapsed'); if (collapsed) { content.classList.remove('collapsed'); icon.classList.remove('collapsed'); icon.textContent = '▼'; } else { content.classList.add('collapsed'); icon.classList.add('collapsed'); icon.textContent = '►'; } }; }, 0); return cont; } function renderMessage(msg, idx) { const wrap = document.createElement('div'); const isUser = msg.role === 'user'; wrap.className = `message-wrapper flex gap-2 ${isUser ? 'justify-end' : ''} relative`; const bubble = document.createElement('div'); bubble.className = `max-w-[95%] sm:max-w-[85%] rounded-2xl px-2 sm:px-4 py-2 sm:py-3 shadow-sm border ${isUser ? 'bg-indigo-600 text-white border-indigo-700' : 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800'}`; const delBtn = document.createElement('button'); delBtn.className = 'delete-btn message-actions'; delBtn.textContent = '×'; delBtn.onclick = (e) => { e.stopPropagation(); if (confirm('Delete this message?')) { state.messages.splice(idx, 1); saveState(state); renderTranscript(); } }; wrap.appendChild(delBtn); const hdr = document.createElement('div'); hdr.className = 'text-xs opacity-70 mb-1'; const dt = new Date(msg.ts || Date.now()); hdr.textContent = `${isUser ? 'You' : 'Assistant'} • ${dt.toLocaleTimeString()}`; bubble.appendChild(hdr); const body = document.createElement('div'); if (!isUser) { try { const raw = marked.parse(msg.content || ''); const safe = DOMPurify.sanitize(raw, { ADD_TAGS: ['div', 'pre', 'button', 'span'], ADD_ATTR: ['class'] }); const temp = document.createElement('div'); temp.innerHTML = safe; temp.querySelectorAll('pre code').forEach(codeEl => { const code = codeEl.textContent; let lang = 'text'; for (const cls of codeEl.classList) { if (cls.startsWith('language-')) { lang = cls.slice(9); break; } } if (lang === 'text') lang = detectLanguage(code); const cb = createCodeBlock(code, lang); codeEl.closest('pre').replaceWith(cb); }); body.replaceChildren(...temp.childNodes); } catch (e) { body.textContent = msg.content; } } else { body.textContent = msg.content; } body.className = 'prose prose-zinc dark:prose-invert max-w-none text-sm'; bubble.appendChild(body); if (!isUser) { const contBtn = document.createElement('button'); contBtn.className = 'mt-2 px-3 py-1 text-xs rounded-lg border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-700'; contBtn.textContent = 'Continue'; contBtn.onclick = () => { els.question.value = 'Continue from where you left off.'; els.question.focus(); }; bubble.appendChild(contBtn); } wrap.appendChild(bubble); return wrap; } function renderTranscript() { els.transcript.innerHTML = ''; state.messages.forEach((m, i) => els.transcript.appendChild(renderMessage(m, i))); els.transcript.scrollTop = els.transcript.scrollHeight; } async function submitMessage() { const q = els.question.value.trim(); if (!q) return; const ts = Date.now(); const payload = { question: q, model: state.settings.model, maxTokens: state.settings.maxTokens, temperature: state.settings.temperature, system: state.settings.system, _t: ts }; state.messages.push({ role: 'user', content: q, ts: Date.now() }); saveState(state); renderTranscript(); els.question.value = ''; els.send.disabled = true; els.status.textContent = 'Thinking…'; let res = null; try { const r = await fetch(`api.php?_t=${ts}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); res = await r.json(); } catch (err) { res = { error: 'Network error', debug: String(err) }; } els.send.disabled = false; els.debugWrap.classList.remove('hidden'); els.debugArea.textContent = JSON.stringify({ request: payload, response: res }, null, 2); if (!res || res.error) { const msg = res?.error || 'Unknown error'; state.messages.push({ role: 'assistant', content: `❌ ${msg}`, ts: Date.now() }); saveState(state); renderTranscript(); els.status.textContent = 'Error'; return; } let content = res.answer || '(no content)'; if (res.warning) content = `> ⚠️ ${res.warning}\n\n` + content; const meta = []; if (res.provider) meta.push(`provider: ${res.provider}`); if (res.model) meta.push(`model: ${res.model}`); if (res.usage) meta.push(`tokens – prompt: ${res.usage.prompt_tokens ?? 0}, completion: ${res.usage.completion_tokens ?? 0}, total: ${res.usage.total_tokens ?? 0}`); if (meta.length) content += `\n\n---\n*${meta.join(' · ')}*`; state.messages.push({ role: 'assistant', content, ts: Date.now() }); saveState(state); renderTranscript(); els.status.textContent = 'Done'; setTimeout(() => els.status.textContent = '', 1200); } els.send.onclick = submitMessage; els.question.onkeydown = (e) => { if (e.key === 'Enter' && !('ontouchstart' in window) && !e.shiftKey) { e.preventDefault(); submitMessage(); } }; // Settings function openSettings() { els.overlay.classList.remove('hidden'); els.model = document.getElementById('model'); els.maxTokens = document.getElementById('maxTokens'); els.temperature = document.getElementById('temperature'); els.system = document.getElementById('system'); els.clearChat = document.getElementById('clearChat'); els.model.value = state.settings.model; els.maxTokens.value = state.settings.maxTokens; document.getElementById('maxTokensVal').textContent = state.settings.maxTokens; els.temperature.value = state.settings.temperature; document.getElementById('tempVal').textContent = state.settings.temperature; els.system.value = state.settings.system || ''; els.maxTokens.oninput = () => { document.getElementById('maxTokensVal').textContent = els.maxTokens.value; state.settings.maxTokens = parseInt(els.maxTokens.value, 10); saveState(state); }; els.temperature.oninput = () => { document.getElementById('tempVal').textContent = els.temperature.value; state.settings.temperature = parseFloat(els.temperature.value); saveState(state); }; els.model.onchange = () => { state.settings.model = els.model.value; saveState(state); }; els.system.oninput = () => { state.settings.system = els.system.value; saveState(state); }; els.clearChat.onclick = () => { state.messages = []; saveState(state); renderTranscript(); els.status.textContent = 'Cleared'; setTimeout(() => els.status.textContent = '', 1500); closeSettings(); }; } function closeSettings() { els.overlay.classList.add('hidden'); } els.openSettings.onclick = openSettings; document.getElementById('closeSettings').onclick = closeSettings; els.overlayBackdrop.onclick = closeSettings; // Stitcher function updateStitcherUI() { if (!state.stitcher || !state.stitcher.chunks) { state.stitcher = { chunks: [] }; saveState(state); } const count = state.stitcher.chunks.length; if (count > 0) { els.stitcherCount.textContent = count; els.stitcherCount.classList.remove('hidden'); } else { els.stitcherCount.classList.add('hidden'); } if (count === 0) { els.stitcherContent.innerHTML = '<div class="text-center text-zinc-500 text-sm">No code chunks yet</div>'; } else { els.stitcherContent.innerHTML = ''; state.stitcher.chunks.forEach((chunk, idx) => { const div = document.createElement('div'); div.className = 'border border-zinc-200 dark:border-zinc-800 rounded-lg p-3 bg-zinc-50 dark:bg-zinc-800'; const hdr = document.createElement('div'); hdr.className = 'flex items-center justify-between mb-2 gap-2 flex-wrap'; const title = document.createElement('div'); title.className = 'text-sm font-medium'; title.textContent = `Chunk ${idx + 1} (${chunk.language.toUpperCase()})`; const actions = document.createElement('div'); actions.className = 'flex gap-2 flex-shrink-0'; const up = document.createElement('button'); up.className = 'text-xs px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700'; up.textContent = '↑'; up.disabled = idx === 0; if (up.disabled) up.className += ' opacity-50'; const down = document.createElement('button'); down.className = 'text-xs px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700'; down.textContent = '↓'; down.disabled = idx === count - 1; if (down.disabled) down.className += ' opacity-50'; const del = document.createElement('button'); del.className = 'text-xs px-2 py-1 rounded bg-red-600 text-white'; del.textContent = '×'; actions.appendChild(up); actions.appendChild(down); actions.appendChild(del); hdr.appendChild(title); hdr.appendChild(actions); const pre = document.createElement('pre'); pre.className = 'text-xs bg-zinc-900 text-zinc-100 p-2 rounded overflow-x-auto max-h-32'; pre.textContent = chunk.code.substring(0, 500) + (chunk.code.length > 500 ? '...' : ''); div.appendChild(hdr); div.appendChild(pre); els.stitcherContent.appendChild(div); up.onclick = () => { if (idx > 0) { [state.stitcher.chunks[idx], state.stitcher.chunks[idx - 1]] = [state.stitcher.chunks[idx - 1], state.stitcher.chunks[idx]]; saveState(state); updateStitcherUI(); } }; down.onclick = () => { if (idx < count - 1) { [state.stitcher.chunks[idx], state.stitcher.chunks[idx + 1]] = [state.stitcher.chunks[idx + 1], state.stitcher.chunks[idx]]; saveState(state); updateStitcherUI(); } }; del.onclick = () => { state.stitcher.chunks.splice(idx, 1); saveState(state); updateStitcherUI(); renderTranscript(); }; }); } } function getStitched() { return state.stitcher.chunks.map(c => c.code).join('\n\n'); } function downloadStitched() { const code = getStitched(); const name = els.stitcherFilename.value || 'stitched-code.txt'; const blob = new Blob([code], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function copyStitched() { const code = getStitched(); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(code).then(() => { els.copyStitched.textContent = 'Copied!'; setTimeout(() => els.copyStitched.textContent = 'Copy', 2000); }); } else { fallbackCopy(code, els.copyStitched); } } function openStitcher() { els.stitcherOverlay.classList.remove('hidden'); updateStitcherUI(); } function closeStitcher() { els.stitcherOverlay.classList.add('hidden'); } els.openStitcher.onclick = openStitcher; els.closeStitcher.onclick = closeStitcher; els.stitcherBackdrop.onclick = closeStitcher; els.clearStitcher.onclick = () => { if (confirm('Clear all chunks?')) { state.stitcher.chunks = []; saveState(state); updateStitcherUI(); renderTranscript(); } }; els.copyStitched.onclick = copyStitched; els.downloadStitched.onclick = downloadStitched; window.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeSettings(); closeStitcher(); } }); renderTranscript(); updateStitcherUI(); })();