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