<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>Unified Chat (DeepSeek · Grok · OpenAI)</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/
[email protected]/dist/purify.min.js"></script>
<script>
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', prefersDark);
</script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.scrollbar-thin::-webkit-scrollbar { height: 8px; width: 8px; }
.scrollbar-thin::-webkit-scrollbar-thumb { background: #9ca3af; border-radius: 8px; }
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
/* Mobile improvements */
@media (max-width: 640px) {
.mobile-padding { padding-left: 0.75rem; padding-right: 0.75rem; }
.mobile-text { font-size: 0.875rem; }
.mobile-compact { gap: 0.5rem; }
}
/* Code block improvements */
.code-container {
position: relative;
background: #1f2937;
border-radius: 0.75rem;
overflow: hidden;
margin: 1rem 0;
}
.code-header {
background: #374151;
padding: 0.5rem 1rem;
font-size: 0.75rem;
color: #d1d5db;
border-bottom: 1px solid #4b5563;
display: flex;
justify-content: space-between;
align-items: center;
}
.copy-btn {
background: #6b7280;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
cursor: pointer;
transition: background-color 0.2s;
}
.copy-btn:hover {
background: #9ca3af;
}
.code-content {
padding: 1rem;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
color: #e5e7eb;
}
</style>
</head>
<body class="bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<div class="min-h-screen grid grid-rows-[auto,1fr]">
<!-- Header -->
<header class="border-b border-zinc-200/80 dark:border-zinc-800/80 bg-white/80 dark:bg-zinc-950/80 backdrop-blur sticky top-0 z-10">
<div class="max-w-5xl mx-auto mobile-padding px-4 py-3 flex items-center justify-between mobile-compact gap-3">
<div class="flex items-center mobile-compact gap-3">
<div class="h-6 w-6 sm:h-8 sm:w-8 rounded-xl bg-gradient-to-br from-indigo-500 via-sky-500 to-emerald-500"></div>
<h1 class="text-base sm:text-lg font-semibold">Unified Chat</h1>
<span class="hidden sm:inline text-xs text-zinc-500">DeepSeek · xAI Grok · OpenAI</span>
</div>
<div class="flex items-center gap-2">
<button id="openSettings" class="text-xs px-2 sm:px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800">
Settings
</button>
</div>
</div>
</header>
<!-- Main -->
<main class="max-w-5xl mx-auto w-full mobile-padding px-4 py-3 sm:py-6">
<!-- Chat column only -->
<section class="flex flex-col min-h-[70vh]">
<!-- Transcript -->
<div id="transcript" class="flex-1 space-y-3 sm:space-y-4 overflow-y-auto pr-1 scrollbar-thin">
<!-- messages will render here -->
</div>
<!-- Debug -->
<details id="debugWrap" class="mt-4 hidden">
<summary class="cursor-pointer text-sm text-zinc-600 dark:text-zinc-300">Debug (request / response)</summary>
<pre id="debugArea" class="mt-2 p-3 rounded-xl bg-zinc-100 dark:bg-zinc-900 text-xs overflow-x-auto"></pre>
</details>
<!-- Composer -->
<div class="mt-3 sm:mt-4">
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-2 sm:p-3 shadow-sm">
<label class="sr-only" for="question">Your message</label>
<div class="flex items-end gap-2">
<textarea id="question" rows="3"
class="flex-1 min-h-[60px] sm:min-h-[72px] rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 mobile-text"
placeholder="Ask me anything…"></textarea>
<div class="flex flex-col items-stretch gap-2">
<button id="send" class="h-8 sm:h-10 px-3 sm:px-4 rounded-xl bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50 text-sm">
Send
</button>
<div id="status" class="text-[10px] sm:text-[11px] text-zinc-500 text-center"></div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<!-- Settings Overlay -->
<div id="settingsOverlay" class="fixed inset-0 z-40 hidden" aria-hidden="true">
<div id="overlayBackdrop" class="absolute inset-0 bg-black/40 backdrop-blur-sm"></div>
<div class="absolute inset-0 flex items-start justify-center p-2 sm:p-4">
<div role="dialog" aria-modal="true" aria-labelledby="settingsTitle"
class="w-full max-w-md mt-4 sm:mt-10 rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 shadow-xl max-h-[90vh] overflow-hidden">
<div class="p-3 sm:p-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between">
<h2 id="settingsTitle" class="font-semibold text-sm sm:text-base">Settings</h2>
<div class="flex items-center gap-2">
<button id="clearChat" class="text-xs px-2 py-1 rounded-md border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800">Clear</button>
<button id="closeSettings" class="text-xs px-2 py-1 rounded-md border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800">Close</button>
</div>
</div>
<div class="p-3 sm:p-4 space-y-3 overflow-y-auto" style="max-height: calc(90vh - 60px);">
<label class="text-sm block">Model</label>
<select id="model" class="w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-sm">
<optgroup label="OpenAI">
<option>gpt-5</option>
<option>gpt-5-mini</option>
<option>gpt-5-nano</option>
<option>gpt-5-thinking</option>
<option>gpt-5-pro</option>
<option>gpt-4o</option>
<option>gpt-4o-mini</option>
</optgroup>
<optgroup label="DeepSeek">
<option selected>deepseek-chat</option>
<option>deepseek-reasoner</option>
</optgroup>
<optgroup label="xAI (Grok)">
<option>grok-3</option>
<option>grok-3-mini</option>
<option>grok-code-fast-1</option>
<option>grok-4-0709</option>
</optgroup>
</select>
<div>
<label class="text-sm block">Max tokens: <span id="maxTokensVal" class="font-mono">800</span></label>
<input id="maxTokens" type="range" min="64" max="4096" step="32" value="800" class="w-full">
</div>
<div>
<label class="text-sm block">Temperature: <span id="tempVal" class="font-mono">0.7</span></label>
<input id="temperature" type="range" min="0" max="2" step="0.1" value="0.7" class="w-full">
<label class="flex items-center gap-2 mt-1 text-xs"><input id="forceTemperature" type="checkbox" class="accent-indigo-600"> Force temperature (for GPT-5)</label>
</div>
<div>
<label class="text-sm block mb-1">System prompt</label>
<textarea id="system" rows="3" class="w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-sm" placeholder="You are a helpful, accurate assistant. Be concise and clear. Use markdown when it helps readability."></textarea>
</div>
<div>
<label class="text-sm block mb-2">Code Response Style</label>
<div class="flex border border-zinc-300 dark:border-zinc-700 rounded-lg overflow-hidden">
<button id="tabDefault" class="flex-1 px-3 py-2 text-xs bg-indigo-600 text-white">Default</button>
<button id="tabFullCode" class="flex-1 px-3 py-2 text-xs bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700">Full Code</button>
<button id="tabSnippets" class="flex-1 px-3 py-2 text-xs bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700">Snippets</button>
</div>
<div id="promptDefault" class="mt-2 p-3 rounded-lg bg-zinc-50 dark:bg-zinc-800 text-xs">
<strong>Default:</strong> Normal responses with code in markdown blocks when helpful.
</div>
<div id="promptFullCode" class="mt-2 p-3 rounded-lg bg-zinc-50 dark:bg-zinc-800 text-xs hidden">
<strong>Full Code:</strong> Always provide complete, working code examples.
<textarea id="fullCodePrompt" rows="3" class="w-full mt-2 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-xs" placeholder="Additional instructions for full code responses...">When providing code examples, always include complete, runnable code with all necessary imports, setup, and context. Provide full file contents rather than partial snippets.</textarea>
</div>
<div id="promptSnippets" class="mt-2 p-3 rounded-lg bg-zinc-50 dark:bg-zinc-800 text-xs hidden">
<strong>Snippets:</strong> Focus on concise code snippets and key changes only.
<textarea id="snippetsPrompt" rows="3" class="w-full mt-2 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-xs" placeholder="Additional instructions for snippet responses...">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.</textarea>
</div>
</div>
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<label class="flex items-center gap-2 text-sm"><input id="includeArtifacts" type="checkbox" class="accent-indigo-600"> Include artifacts from session</label>
<label class="flex items-center gap-2 text-sm"><input id="jsonFormat" type="checkbox" class="accent-indigo-600"> Response JSON</label>
</div>
<details class="text-xs text-zinc-500">
<summary class="cursor-pointer mb-1">Session & CORS notes</summary>
<p class="mt-1">Serve this file and <code>api.php</code> from the same origin to keep PHP session history. If cross-origin, enable credentials and set a specific <code>Access-Control-Allow-Origin</code> instead of <code>*</code>.</p>
</details>
</div>
</div>
</div>
</div>
<script>
const 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'),
};
// --- 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.'
},
messages: []
});
function loadState(){ try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || defaultState(); } catch { return defaultState(); } }
function saveState(s){ localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); }
let state = loadState();
// --- 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 langLabel = document.createElement('span');
langLabel.textContent = language.toUpperCase();
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.textContent = 'Copy';
copyBtn.onclick = () => {
navigator.clipboard.writeText(code).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => copyBtn.textContent = 'Copy', 2000);
});
};
header.appendChild(langLabel);
header.appendChild(copyBtn);
const content = document.createElement('pre');
content.className = 'code-content';
content.textContent = code;
container.appendChild(header);
container.appendChild(content);
return container;
}
function renderMessage(msg) {
const wrapper = document.createElement('div');
const isUser = msg.role === 'user';
wrapper.className = `flex gap-2 sm:gap-3 ${isUser ? 'justify-end' : ''}`;
const bubble = document.createElement('div');
bubble.className = `max-w-[90%] sm:max-w-[85%] rounded-2xl px-3 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 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 || '';
// First parse with marked to get HTML
const html = marked.parse(content);
// Create a temporary div to work with the HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// Find all code blocks and replace them
const codeBlocks = tempDiv.querySelectorAll('pre code');
codeBlocks.forEach(codeEl => {
const code = codeEl.textContent;
const classList = Array.from(codeEl.classList);
let language = 'text';
// Extract language from class (language-html, language-js, etc.)
for (const cls of classList) {
if (cls.startsWith('language-')) {
language = cls.replace('language-', '');
break;
}
}
// If no language found, try to detect it
if (language === 'text') {
language = detectLanguage(code);
}
const codeBlock = createCodeBlock(code, language);
codeEl.closest('pre').replaceWith(codeBlock);
});
// Now sanitize the final HTML
const sanitizedHTML = DOMPurify.sanitize(tempDiv.innerHTML, {
ADD_TAGS: ['div', 'pre', 'button', 'span'],
ADD_ATTR: ['class', 'onclick']
});
body.innerHTML = sanitizedHTML;
} 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);
wrapper.appendChild(bubble);
return wrapper;
}
function renderTranscript() {
els.transcript.innerHTML = '';
state.messages.forEach(m => els.transcript.appendChild(renderMessage(m)));
els.transcript.scrollTop = els.transcript.scrollHeight;
}
// --- Bind settings elements (overlay content exists after open) ---
function bindSettingsElements() {
els.model = document.getElementById('model');
els.maxTokens = document.getElementById('maxTokens');
els.temperature = document.getElementById('temperature');
els.forceTemperature = document.getElementById('forceTemperature');
els.system = document.getElementById('system');
els.includeArtifacts = document.getElementById('includeArtifacts');
els.jsonFormat = document.getElementById('jsonFormat');
els.clearChat = document.getElementById('clearChat');
// Code style tabs and prompts
els.tabDefault = document.getElementById('tabDefault');
els.tabFullCode = document.getElementById('tabFullCode');
els.tabSnippets = document.getElementById('tabSnippets');
els.fullCodePrompt = document.getElementById('fullCodePrompt');
els.snippetsPrompt = document.getElementById('snippetsPrompt');
}
function hydrateSettingsUI() {
bindSettingsElements();
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.forceTemperature.checked = !!state.settings.forceTemperature;
els.includeArtifacts.checked = !!state.settings.includeArtifacts;
els.jsonFormat.checked = !!state.settings.jsonFormat;
els.system.value = state.settings.system || '';
els.fullCodePrompt.value = state.settings.fullCodePrompt || '';
els.snippetsPrompt.value = state.settings.snippetsPrompt || '';
// Set active tab
setActiveTab(state.settings.codeStyle || 'default');
}
function setActiveTab(tabName) {
// Reset all tabs
document.getElementById('tabDefault').className = 'flex-1 px-3 py-2 text-xs bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700';
document.getElementById('tabFullCode').className = 'flex-1 px-3 py-2 text-xs bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700';
document.getElementById('tabSnippets').className = 'flex-1 px-3 py-2 text-xs bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700';
// Hide all content
document.getElementById('promptDefault').classList.add('hidden');
document.getElementById('promptFullCode').classList.add('hidden');
document.getElementById('promptSnippets').classList.add('hidden');
// Set active tab and show content
if (tabName === 'fullCode') {
document.getElementById('tabFullCode').className = 'flex-1 px-3 py-2 text-xs bg-indigo-600 text-white';
document.getElementById('promptFullCode').classList.remove('hidden');
} else if (tabName === 'snippets') {
document.getElementById('tabSnippets').className = 'flex-1 px-3 py-2 text-xs bg-indigo-600 text-white';
document.getElementById('promptSnippets').classList.remove('hidden');
} else {
document.getElementById('tabDefault').className = 'flex-1 px-3 py-2 text-xs bg-indigo-600 text-white';
document.getElementById('promptDefault').classList.remove('hidden');
}
state.settings.codeStyle = tabName;
saveState(state);
}
function wireSettingsHandlers() {
els.maxTokens.addEventListener('input', () => {
document.getElementById('maxTokensVal').textContent = els.maxTokens.value;
state.settings.maxTokens = parseInt(els.maxTokens.value, 10); saveState(state);
});
els.temperature.addEventListener('input', () => {
document.getElementById('tempVal').textContent = els.temperature.value;
state.settings.temperature = parseFloat(els.temperature.value); saveState(state);
});
els.forceTemperature.addEventListener('change', () => { state.settings.forceTemperature = els.forceTemperature.checked; saveState(state); });
els.includeArtifacts.addEventListener('change', () => { state.settings.includeArtifacts = els.includeArtifacts.checked; saveState(state); });
els.jsonFormat.addEventListener('change', () => { state.settings.jsonFormat = els.jsonFormat.checked; saveState(state); });
els.model.addEventListener('change', () => { state.settings.model = els.model.value; saveState(state); });
els.system.addEventListener('input', () => { state.settings.system = els.system.value; saveState(state); });
els.fullCodePrompt.addEventListener('input', () => { state.settings.fullCodePrompt = els.fullCodePrompt.value; saveState(state); });
els.snippetsPrompt.addEventListener('input', () => { state.settings.snippetsPrompt = els.snippetsPrompt.value; saveState(state); });
// Tab handlers
els.tabDefault.addEventListener('click', () => setActiveTab('default'));
els.tabFullCode.addEventListener('click', () => setActiveTab('fullCode'));
els.tabSnippets.addEventListener('click', () => setActiveTab('snippets'));
els.clearChat.addEventListener('click', () => {
state.messages = []; saveState(state); renderTranscript();
els.status.textContent = 'Cleared local transcript.'; setTimeout(() => els.status.textContent = '', 1500);
closeSettings();
});
}
// --- Overlay behavior ---
function openSettings() {
els.overlay.classList.remove('hidden');
hydrateSettingsUI();
wireSettingsHandlers();
setTimeout(() => { try { els.model.focus(); } catch {} }, 0);
}
function closeSettings() { els.overlay.classList.add('hidden'); }
els.openSettings.addEventListener('click', openSettings);
document.getElementById('closeSettings').addEventListener('click', closeSettings);
els.overlayBackdrop.addEventListener('click', closeSettings);
window.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSettings(); });
// --- Mobile-friendly submit behavior ---
els.send.addEventListener('click', async () => {
await submitMessage();
});
// Enter key handling - submit on desktop, new line on mobile
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 = 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 = state.settings.system || '';
if (state.settings.codeStyle === 'fullCode' && state.settings.fullCodePrompt) {
systemPrompt += '\n\nCODE RESPONSE STYLE: ' + state.settings.fullCodePrompt;
} else if (state.settings.codeStyle === 'snippets' && state.settings.snippetsPrompt) {
systemPrompt += '\n\nCODE RESPONSE STYLE: ' + state.settings.snippetsPrompt;
}
const payload = {
question,
model: state.settings.model,
maxTokens: state.settings.maxTokens,
temperature: state.settings.temperature,
system: systemPrompt || undefined,
includeArtifacts: state.settings.includeArtifacts,
_t: timestamp // Cache buster
};
if (state.settings.forceTemperature) payload.forceTemperature = true;
if (state.settings.jsonFormat) payload.response_format = { type: 'json_object' };
const userMsg = { role: 'user', content: question, ts: Date.now() };
state.messages.push(userMsg); saveState(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)}` : '';
state.messages.push({ role: 'assistant', content: `❌ ${msg}${dbg}`, ts: Date.now() });
saveState(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(' · ')}*`;
state.messages.push({ role: 'assistant', content, ts: Date.now() });
saveState(state); renderTranscript(); els.status.textContent = 'Done';
setTimeout(() => els.status.textContent = '', 1200);
}
// Handle viewport changes for mobile
function handleViewportChange() {
// Adjust textarea height on mobile for better UX
if (window.innerWidth < 640) {
els.question.rows = 2;
} else {
els.question.rows = 3;
}
}
window.addEventListener('resize', handleViewportChange);
handleViewportChange(); // Call on load
// initial render
renderTranscript();
</script>
</body>
</html>