📜
chat-core.js
Back
📝 Javascript ⚡ Executable Ctrl+S: Save • Ctrl+R: Run • Ctrl+F: Find
// chat-core.js - Core chat functionality and DOM elements (function() { 'use strict'; // Global elements object window.els = { // dynamic settings (bound when overlay opens) model: null, maxTokens: null, maxTokensVal: document.getElementById('maxTokensVal'), temperature: null, tempVal: document.getElementById('tempVal'), forceTemperature: null, system: null, includeArtifacts: null, jsonFormat: null, clearChat: null, // chat 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 chrome overlay: document.getElementById('settingsOverlay'), openSettings: document.getElementById('openSettings'), closeSettings: document.getElementById('closeSettings'), overlayBackdrop: document.getElementById('overlayBackdrop'), // stitcher stitcherOverlay: document.getElementById('stitcherOverlay'), openStitcher: document.getElementById('openStitcher'), closeStitcher: document.getElementById('closeStitcher'), stitcherBackdrop: document.getElementById('stitcherBackdrop'), stitcherContent: document.getElementById('stitcherContent'), stitcherEmpty: document.getElementById('stitcherEmpty'), stitcherCount: document.getElementById('stitcherCount'), clearStitcher: document.getElementById('clearStitcher'), copyStitched: document.getElementById('copyStitched'), downloadStitched: document.getElementById('downloadStitched'), stitcherFilename: document.getElementById('stitcherFilename'), }; // --- Local persistence --- const STORAGE_KEY = 'unified-chat-state-v2'; const defaultState = () => ({ settings: { model: 'deepseek-chat', maxTokens: 800, temperature: 0.7, forceTemperature: false, includeArtifacts: false, jsonFormat: false, system: 'You are a helpful, accurate assistant. Be concise and clear. Use markdown when it helps readability.', codeStyle: 'default', fullCodePrompt: 'When providing code examples, always include complete, runnable code with all necessary imports, setup, and context. Provide full file contents rather than partial snippets.', snippetsPrompt: 'Focus on providing concise code snippets that show only the relevant changes or key parts. Explain what each snippet does and where it should be used.', chunkedPrompt: 'When providing large code files or long responses, break them into logical chunks. End each chunk with a clear indication of what comes next. If approaching token limits, stop at a logical break point and indicate there\'s more to follow.' }, messages: [], stitcher: { chunks: [], isOpen: false } }); function loadState(){ try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || defaultState(); } catch { return defaultState(); } } function saveState(s){ localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } // Make state globally available window.chatState = loadState(); window.saveState = saveState; window.STORAGE_KEY = STORAGE_KEY; // --- Improved code detection and rendering --- function detectLanguage(code) { // Simple language detection based on patterns if (code.includes('<!doctype html>') || code.includes('<html')) return 'html'; if (code.includes('<?php') || code.includes('$_')) 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'; if (code.includes('class ') && code.includes('public')) return 'java'; if (code.includes('#include') || code.includes('int main')) return 'c'; if (code.includes('console.log') || code.includes('document.')) return 'javascript'; return 'text'; } function isLikelyCode(text) { const codeMarkers = /(<!doctype html>|<html\b|<script\b|<\?php|^\s*#include\b|^\s*import\b|^\s*from\b|function\s+\w+\s*\(|class\s+\w+|console\.log\(|=>|^\s*const\s|^\s*let\s|^\s*var\s|document\.querySelector|React\.createElement)/mi; const lines = (text || '').split(/\n/); const codeish = lines.filter(l => /[;{}=<>()$]/.test(l) || codeMarkers.test(l)).length; return codeish >= Math.max(3, Math.ceil(lines.length * 0.35)); } function createCodeBlock(code, language = 'text') { const container = document.createElement('div'); container.className = 'code-container'; const header = document.createElement('div'); header.className = 'code-header'; const headerLeft = document.createElement('div'); headerLeft.className = 'code-header-left'; const collapseIcon = document.createElement('span'); collapseIcon.className = 'collapse-icon'; collapseIcon.textContent = '▼'; const langLabel = document.createElement('span'); langLabel.textContent = language.toUpperCase(); headerLeft.appendChild(collapseIcon); headerLeft.appendChild(langLabel); const headerRight = document.createElement('div'); headerRight.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 = 'Add to Stitcher'; headerRight.appendChild(copyBtn); headerRight.appendChild(stitchBtn); header.appendChild(headerLeft); header.appendChild(headerRight); const content = document.createElement('pre'); content.className = 'code-content'; content.textContent = code; container.appendChild(header); container.appendChild(content); // Add event listeners after elements are created and added to DOM setTimeout(() => { // Copy button functionality copyBtn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(code).then(() => { copyBtn.textContent = 'Copied!'; setTimeout(() => copyBtn.textContent = 'Copy', 2000); }).catch(() => { fallbackCopyTextToClipboard(code, copyBtn); }); } else { fallbackCopyTextToClipboard(code, copyBtn); } }); // Stitch button functionality stitchBtn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); const chunkId = Date.now() + Math.random(); const isAlreadyAdded = window.chatState.stitcher.chunks.some(chunk => chunk.code === code); if (isAlreadyAdded) { // Remove from stitcher window.chatState.stitcher.chunks = window.chatState.stitcher.chunks.filter(chunk => chunk.code !== code); stitchBtn.textContent = 'Add to Stitcher'; stitchBtn.classList.remove('added'); } else { // Add to stitcher window.chatState.stitcher.chunks.push({ id: chunkId, code: code, language: language, timestamp: Date.now() }); stitchBtn.textContent = 'Remove from Stitcher'; stitchBtn.classList.add('added'); } window.saveState(window.chatState); if (window.updateStitcherUI) window.updateStitcherUI(); }); // Check if this code is already in stitcher const isInStitcher = window.chatState.stitcher.chunks.some(chunk => chunk.code === code); if (isInStitcher) { stitchBtn.textContent = 'Remove from Stitcher'; stitchBtn.classList.add('added'); } // Collapse functionality - only on the header left side headerLeft.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); const isCollapsed = content.classList.contains('collapsed'); if (isCollapsed) { content.classList.remove('collapsed'); collapseIcon.classList.remove('collapsed'); collapseIcon.textContent = '▼'; } else { content.classList.add('collapsed'); collapseIcon.classList.add('collapsed'); collapseIcon.textContent = '►'; } }); }, 0); return container; } // Fallback copy function for older browsers function fallbackCopyTextToClipboard(text, button) { const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { document.execCommand('copy'); button.textContent = 'Copied!'; setTimeout(() => button.textContent = 'Copy', 2000); } catch (err) { button.textContent = 'Failed'; setTimeout(() => button.textContent = 'Copy', 2000); } document.body.removeChild(textArea); } function renderMessage(msg, index) { const wrapper = document.createElement('div'); const isUser = msg.role === 'user'; wrapper.className = `message-wrapper flex gap-2 sm:gap-3 ${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'}`; // Add delete button for all messages (both user and assistant) const deleteBtn = document.createElement('button'); deleteBtn.className = 'delete-btn message-actions'; deleteBtn.textContent = '×'; deleteBtn.title = 'Delete message'; deleteBtn.onclick = (e) => { e.stopPropagation(); if (confirm('Delete this message?')) { window.chatState.messages.splice(index, 1); window.saveState(window.chatState); renderTranscript(); } }; wrapper.appendChild(deleteBtn); const header = document.createElement('div'); header.className = 'text-xs opacity-70 mb-1'; const dt = new Date(msg.ts || Date.now()); header.textContent = `${isUser ? 'You' : 'Assistant'} • ${dt.toLocaleTimeString()}`; bubble.appendChild(header); const body = document.createElement('div'); if (!isUser) { try { let content = msg.content || ''; // 1) Get raw HTML from marked const raw = marked.parse(content); // 2) Sanitize the HTML string const safeHtml = DOMPurify.sanitize(raw, { ADD_TAGS: ['div', 'pre', 'button', 'span'], ADD_ATTR: ['class', 'onclick'] }); // 3) Build a working DOM from the sanitized HTML const tempDiv = document.createElement('div'); tempDiv.innerHTML = safeHtml; // 4) Now replace <pre><code> with interactive blocks (adds listeners) const codeBlocks = tempDiv.querySelectorAll('pre code'); codeBlocks.forEach(codeEl => { const code = codeEl.textContent; let language = 'text'; for (const cls of codeEl.classList) { if (cls.startsWith('language-')) { language = cls.slice(9); break; } } if (language === 'text') language = detectLanguage(code); const codeBlock = createCodeBlock(code, language); codeEl.closest('pre').replaceWith(codeBlock); }); // 5) Finally inject the DOM (not innerHTML) so listeners stay intact body.replaceChildren(...tempDiv.childNodes); } catch (e) { console.error('Error rendering message:', e); body.textContent = msg.content; } } else { body.textContent = msg.content; } body.className = 'prose prose-zinc dark:prose-invert max-w-none text-sm mobile-text'; bubble.appendChild(body); // Add continue/next chunk button for assistant messages if (!isUser) { const actionBtn = document.createElement('button'); const isChunkedMode = window.chatState.settings.codeStyle === 'chunked'; actionBtn.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 transition-colors'; actionBtn.textContent = isChunkedMode ? 'Next Chunk' : 'Continue'; actionBtn.onclick = () => { if (isChunkedMode) { // Find the last code block in this message const codeContainers = bubble.querySelectorAll('.code-container .code-content'); let lastCode = ''; if (codeContainers.length > 0) { // Get the last code block const lastCodeContainer = codeContainers[codeContainers.length - 1]; lastCode = lastCodeContainer.textContent; // Take the last 8-10 lines for context in chunked mode const lines = lastCode.split('\n'); const contextLines = lines.slice(-8); // Last 8 lines for chunked mode const contextCode = contextLines.join('\n'); // Detect language from the code container const codeContainer = lastCodeContainer.closest('.code-container'); const langLabel = codeContainer.querySelector('.code-header span'); const language = langLabel ? langLabel.textContent.toLowerCase() : 'code'; // Create the next chunk message const nextChunkMessage = `Continue with the next chunk from this point:\n\n\`\`\`${language}\n${contextCode}\n\`\`\`\n\nProvide the next logical chunk/section.`; window.els.question.value = nextChunkMessage; } else { // No code found, ask for next chunk of the explanation/content window.els.question.value = 'Please provide the next chunk/section.'; } } else { // Regular continue mode const codeContainers = bubble.querySelectorAll('.code-container .code-content'); let lastCode = ''; if (codeContainers.length > 0) { const lastCodeContainer = codeContainers[codeContainers.length - 1]; lastCode = lastCodeContainer.textContent; const lines = lastCode.split('\n'); const contextLines = lines.slice(-15); // More context for regular continue const contextCode = contextLines.join('\n'); const codeContainer = lastCodeContainer.closest('.code-container'); const langLabel = codeContainer.querySelector('.code-header span'); const language = langLabel ? langLabel.textContent.toLowerCase() : 'code'; const continueMessage = `Continue from this point:\n\n\`\`\`${language}\n${contextCode}\n\`\`\`\n\nContinue from here.`; window.els.question.value = continueMessage; } else { window.els.question.value = 'Continue from where you left off.'; } } window.els.question.focus(); window.els.question.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; bubble.appendChild(actionBtn); } wrapper.appendChild(bubble); return wrapper; } function renderTranscript() { window.els.transcript.innerHTML = ''; window.chatState.messages.forEach((m, index) => window.els.transcript.appendChild(renderMessage(m, index))); window.els.transcript.scrollTop = window.els.transcript.scrollHeight; } // --- Mobile-friendly submit behavior --- window.els.send.addEventListener('click', async () => { await submitMessage(); }); // Enter key handling - submit on desktop, new line on mobile window.els.question.addEventListener('keydown', (e) => { if (e.key === 'Enter') { // On mobile (detected by touch capability), allow Enter for new lines if ('ontouchstart' in window || navigator.maxTouchPoints > 0) { // Allow default behavior (new line) on mobile return; } else { // On desktop, submit with Enter (unless Shift is held) if (!e.shiftKey) { e.preventDefault(); submitMessage(); } } } }); async function submitMessage() { const question = window.els.question.value.trim(); if (!question) return; // Add cache busting to API requests const timestamp = Date.now(); // Build system prompt based on code style let systemPrompt = window.chatState.settings.system || ''; if (window.chatState.settings.codeStyle === 'fullCode' && window.chatState.settings.fullCodePrompt) { systemPrompt += '\n\nCODE RESPONSE STYLE: ' + window.chatState.settings.fullCodePrompt; } else if (window.chatState.settings.codeStyle === 'snippets' && window.chatState.settings.snippetsPrompt) { systemPrompt += '\n\nCODE RESPONSE STYLE: ' + window.chatState.settings.snippetsPrompt; } else if (window.chatState.settings.codeStyle === 'chunked' && window.chatState.settings.chunkedPrompt) { systemPrompt += '\n\nCODE RESPONSE STYLE: ' + window.chatState.settings.chunkedPrompt; } const payload = { question, model: window.chatState.settings.model, maxTokens: window.chatState.settings.maxTokens, temperature: window.chatState.settings.temperature, system: systemPrompt || undefined, includeArtifacts: window.chatState.settings.includeArtifacts, _t: timestamp // Cache buster }; if (window.chatState.settings.forceTemperature) payload.forceTemperature = true; if (window.chatState.settings.jsonFormat) payload.response_format = { type: 'json_object' }; const userMsg = { role: 'user', content: question, ts: Date.now() }; window.chatState.messages.push(userMsg); window.saveState(window.chatState); renderTranscript(); window.els.question.value = ''; window.els.send.disabled = true; window.els.status.textContent = 'Thinking…'; let resJSON = null; try { const res = await fetch(`api.php?_t=${timestamp}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }, body: JSON.stringify(payload), }); resJSON = await res.json(); } catch (err) { resJSON = { error: 'Network error', debug: String(err) }; } window.els.send.disabled = false; window.els.debugWrap.classList.remove('hidden'); window.els.debugArea.textContent = JSON.stringify({ request: payload, response: resJSON }, null, 2); if (!resJSON || resJSON.error) { const msg = resJSON?.error || 'Unknown error'; const dbg = resJSON?.debug ? `\n\nDebug: ${JSON.stringify(resJSON.debug)}` : ''; window.chatState.messages.push({ role: 'assistant', content: `❌ ${msg}${dbg}`, ts: Date.now() }); window.saveState(window.chatState); renderTranscript(); window.els.status.textContent = 'Error'; return; } const { answer, usage, model, provider, warning } = resJSON; let content = answer || '(no content)'; if (warning) content = `> ⚠️ ${warning}\n\n` + content; const meta = []; if (provider) meta.push(`provider: ${provider}`); if (model) meta.push(`model: ${model}`); if (usage) meta.push(`tokens – prompt: ${usage.prompt_tokens ?? 0}, completion: ${usage.completion_tokens ?? 0}, total: ${usage.total_tokens ?? 0}`); if (meta.length) content += `\n\n---\n*${meta.join(' · ')}*`; window.chatState.messages.push({ role: 'assistant', content, ts: Date.now() }); window.saveState(window.chatState); renderTranscript(); window.els.status.textContent = 'Done'; setTimeout(() => window.els.status.textContent = '', 1200); } // Handle viewport changes for mobile function handleViewportChange() { // Adjust textarea height on mobile for better UX if (window.innerWidth < 640) { window.els.question.rows = 2; // Ensure mobile-friendly max width document.body.style.maxWidth = '100vw'; } else { window.els.question.rows = 3; document.body.style.maxWidth = ''; } } window.addEventListener('resize', handleViewportChange); handleViewportChange(); // Call on load // Make functions globally available window.renderTranscript = renderTranscript; window.createCodeBlock = createCodeBlock; window.detectLanguage = detectLanguage; window.isLikelyCode = isLikelyCode; window.fallbackCopyTextToClipboard = fallbackCopyTextToClipboard; // Initial render renderTranscript(); })();