window.App = window.App || {};
(() => {
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'),
};
// Utilities
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 detectLanguage(code) {
if (code.includes('<!doctype html>') || code.includes('<html')) return 'html';
if (code.includes('<?php') || code.includes('$_')) return 'php';
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';
if (code.includes('import ') && code.includes('from ')) return 'javascript';
return 'text';
}
function fallbackCopy(text, button) {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; ta.style.top = '-9999px';
document.body.appendChild(ta); ta.focus(); ta.select();
try { document.execCommand('copy'); button.textContent = 'Copied!'; setTimeout(() => button.textContent = 'Copy', 2000); }
catch { button.textContent = 'Failed'; setTimeout(() => button.textContent = 'Copy', 2000); }
document.body.removeChild(ta);
}
function createCodeBlock(code, language = 'text', isUserMessage = false) {
const container = document.createElement('div');
container.className = isUserMessage ? 'code-container user-code' : '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' + (isUserMessage ? ' collapsed' : '');
collapseIcon.textContent = isUserMessage ? '►' : '▼';
const langLabel = document.createElement('span'); langLabel.textContent = (language || 'text').toUpperCase();
headerLeft.append(collapseIcon, langLabel);
const headerRight = document.createElement('div'); headerRight.className = 'code-header-right';
const copyBtn = document.createElement('button'); copyBtn.className = 'copy-btn'; copyBtn.textContent = 'Copy';
if (!isUserMessage) {
const stitchBtn = document.createElement('button'); stitchBtn.className = 'stitch-btn'; stitchBtn.textContent = 'Add to Stitcher';
headerRight.append(copyBtn, stitchBtn);
} else {
headerRight.append(copyBtn);
}
header.append(headerLeft, headerRight);
const content = document.createElement('pre');
content.className = 'code-content' + (isUserMessage ? ' collapsed' : '');
content.textContent = code;
container.append(header, content);
// Listeners
setTimeout(() => {
copyBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.preventDefault();
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(code).then(() => {
copyBtn.textContent = 'Copied!'; setTimeout(() => copyBtn.textContent = 'Copy', 2000);
}).catch(() => fallbackCopy(code, copyBtn));
} else { fallbackCopy(code, copyBtn); }
});
if (!isUserMessage) {
const stitchBtn = headerRight.querySelector('.stitch-btn');
stitchBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.preventDefault();
const added = App.addToStitcher(code, language);
stitchBtn.textContent = added ? 'Remove from Stitcher' : 'Add to Stitcher';
stitchBtn.classList.toggle('added', added);
});
// Set initial stitch state
const isIn = App.state.stitcher.chunks.some(c => c.code === code);
if (isIn) { stitchBtn.textContent = 'Remove from Stitcher'; stitchBtn.classList.add('added'); }
}
headerLeft.addEventListener('click', (e) => {
e.stopPropagation(); e.preventDefault();
const collapsed = content.classList.contains('collapsed');
content.classList.toggle('collapsed', !collapsed);
collapseIcon.classList.toggle('collapsed', !collapsed);
collapseIcon.textContent = collapsed ? '▼' : '►';
});
}, 0);
return container;
}
function parseUserMessageForCodeBlocks(text) {
// Match code blocks in markdown format
const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = codeBlockRegex.exec(text)) !== null) {
// Add text before code block
if (match.index > lastIndex) {
parts.push({
type: 'text',
content: text.slice(lastIndex, match.index)
});
}
// Add code block
parts.push({
type: 'code',
language: match[1] || 'text',
content: match[2]
});
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push({
type: 'text',
content: text.slice(lastIndex)
});
}
return parts.length > 0 ? parts : [{ type: 'text', content: text }];
}
function renderMessage(msg, index) {
const wrapper = document.createElement('div');
const isUser = msg.role === 'user';
wrapper.className = `message-wrapper ${isUser ? 'justify-end' : ''}`;
const bubble = document.createElement('div');
bubble.className = `bubble ${isUser ? 'bg-indigo-600 text-white border-indigo-700' : 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800'}`;
// delete button
const del = document.createElement('button');
del.className = 'delete-btn message-actions'; del.textContent = '×'; del.title = 'Delete message';
del.onclick = (e) => {
e.stopPropagation();
if (confirm('Delete this message?')) {
App.state.messages.splice(index, 1);
App.saveState(App.state);
App.renderTranscript();
}
};
wrapper.appendChild(del);
// header
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);
// body
const body = document.createElement('div');
body.className = 'prose prose-zinc dark:prose-invert max-w-none text-sm mobile-text';
if (!isUser) {
// Assistant messages - render markdown with code blocks
try {
const raw = marked.parse(msg.content || '');
const safeHtml = DOMPurify.sanitize(raw, { ADD_TAGS: ['div','pre','button','span'], ADD_ATTR: ['class','onclick'] });
const tempDiv = document.createElement('div'); tempDiv.innerHTML = safeHtml;
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, false);
codeEl.closest('pre').replaceWith(codeBlock);
});
body.replaceChildren(...tempDiv.childNodes);
} catch (e) {
console.error('render error', e); body.textContent = msg.content || '';
}
} else {
// User messages - parse for code blocks
const parts = parseUserMessageForCodeBlocks(msg.content || '');
parts.forEach(part => {
if (part.type === 'text') {
const textNode = document.createElement('div');
textNode.style.whiteSpace = 'pre-wrap';
textNode.textContent = part.content;
body.appendChild(textNode);
} else if (part.type === 'code') {
const codeBlock = createCodeBlock(part.content, part.language, true);
body.appendChild(codeBlock);
}
});
}
bubble.appendChild(body);
if (!isUser) {
const action = document.createElement('button');
const chunked = App.state.settings.codeStyle === 'chunked';
action.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';
action.textContent = chunked ? 'Next Chunk' : 'Continue';
action.onclick = () => {
const codes = bubble.querySelectorAll('.code-container .code-content');
if (codes.length > 0) {
const last = codes[codes.length - 1].textContent.split('\n');
const context = last.slice(chunked ? -8 : -15).join('\n');
const langLabel = codes[codes.length - 1].closest('.code-container').querySelector('.code-header span');
const language = langLabel ? langLabel.textContent.toLowerCase() : 'code';
els.question.value = `${chunked ? 'Continue with the next chunk from this point:' : 'Continue from this point:'}\n\n\`\`\`${language}\n${context}\n\`\`\`\n\n${chunked ? 'Provide the next logical chunk/section.' : 'Continue from here.'}`;
} else {
els.question.value = chunked ? 'Please provide the next chunk/section.' : 'Continue from where you left off.';
}
els.question.focus();
els.question.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
bubble.appendChild(action);
}
wrapper.appendChild(bubble);
return wrapper;
}
function renderTranscript() {
els.transcript.innerHTML = '';
App.state.messages.forEach((m, i) => els.transcript.appendChild(renderMessage(m, i)));
els.transcript.scrollTop = els.transcript.scrollHeight;
}
App.renderTranscript = renderTranscript;
async function submitMessage() {
const question = els.question.value.trim();
if (!question) return;
const timestamp = Date.now();
// Build system prompt based on code style
const s = App.state.settings;
let systemPrompt = s.system || '';
if (s.codeStyle === 'fullCode' && s.fullCodePrompt) systemPrompt += '\n\nCODE RESPONSE STYLE: ' + s.fullCodePrompt;
else if (s.codeStyle === 'snippets' && s.snippetsPrompt) systemPrompt += '\n\nCODE RESPONSE STYLE: ' + s.snippetsPrompt;
else if (s.codeStyle === 'chunked' && s.chunkedPrompt) systemPrompt += '\n\nCODE RESPONSE STYLE: ' + s.chunkedPrompt;
const payload = {
question,
model: s.model,
maxTokens: s.maxTokens,
temperature: s.temperature,
system: systemPrompt || undefined,
includeArtifacts: s.includeArtifacts,
conversation: filteredHistory, // <-- ADD THIS
_t: timestamp
};
if (s.forceTemperature) payload.forceTemperature = true;
if (s.jsonFormat) payload.response_format = { type: 'json_object' };
const userMsg = { role: 'user', content: question, ts: Date.now() };
App.state.messages.push(userMsg); App.saveState(App.state); renderTranscript();
els.question.value = '';
els.send.disabled = true;
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) };
}
els.send.disabled = false;
els.debugWrap.classList.remove('hidden');
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)}` : '';
App.state.messages.push({ role: 'assistant', content: `❌ ${msg}${dbg}`, ts: Date.now() });
App.saveState(App.state); renderTranscript(); 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(' · ')}*`;
// Track usage stats
if (usage && model) {
console.log('Tracking usage:', model, usage);
if (typeof App.trackUsage === 'function') {
App.trackUsage(model, usage);
} else {
console.warn('App.trackUsage not available yet');
}
}
App.state.messages.push({ role: 'assistant', content, ts: Date.now() });
App.saveState(App.state); renderTranscript(); els.status.textContent = 'Done';
setTimeout(() => els.status.textContent = '', 1200);
}
// Wire send + keyboard behavior
els.send.addEventListener('click', submitMessage);
els.question.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) return; // mobile: allow newline
if (!e.shiftKey) { e.preventDefault(); submitMessage(); }
}
});
// Initial render + stitcher badge sync (stitcher.js will call render too)
renderTranscript();
})();