// 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();
})();