<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<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.5rem; padding-right: 0.5rem; }
.mobile-text { font-size: 0.875rem; }
.mobile-compact { gap: 0.25rem; }
.mobile-tabs { flex-direction: column; }
.mobile-tab-btn { flex: none; width: 100%; text-align: center; }
/* Ensure no horizontal overflow */
body { overflow-x: hidden; }
/* Smaller code blocks on mobile */
.code-container { margin: 0.5rem 0; }
.code-header { padding: 0.25rem 0.5rem; font-size: 0.625rem; }
.code-content { padding: 0.5rem; font-size: 0.75rem; }
.copy-btn { padding: 0.125rem 0.25rem; font-size: 0.625rem; }
.collapse-icon { font-size: 0.625rem; min-width: 0.75rem; }
.delete-btn { font-size: 0.625rem; padding: 0.125rem 0.25rem; }
/* Better mobile touch targets and animations */
.code-header { padding: 0.5rem; min-height: 2rem; }
.code-content { padding: 0.5rem; font-size: 0.75rem; min-height: 1.5rem; }
.copy-btn { min-height: 1.5rem; min-width: 2rem; }
.delete-btn { min-height: 1.5rem; min-width: 1.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;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.code-header:hover {
background: #4b5563;
}
.code-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
flex: 1;
}
.code-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon {
transition: transform 0.2s;
font-size: 0.75rem;
min-width: 1rem;
text-align: center;
}
.collapse-icon.collapsed {
transform: rotate(-90deg);
}
.copy-btn, .stitch-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;
margin-left: 0.25rem;
}
.copy-btn:hover, .stitch-btn:hover {
background: #9ca3af;
}
.stitch-btn {
background: #059669;
}
.stitch-btn:hover {
background: #047857;
}
.stitch-btn.added {
background: #dc2626;
}
.stitch-btn.added:hover {
background: #b91c1c;
}
.code-content {
padding: 1rem;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
color: #e5e7eb;
transition: max-height 0.3s ease, opacity 0.2s ease;
min-height: 2rem;
}
.code-content.collapsed {
max-height: 0 !important;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
overflow: hidden;
}
.message-actions {
position: absolute;
top: 0.25rem;
right: 0.25rem;
opacity: 0;
transition: opacity 0.2s;
z-index: 10;
}
.message-wrapper:hover .message-actions {
opacity: 1;
}
/* Show delete button on mobile with tap */
@media (max-width: 640px) {
.message-actions {
opacity: 0.7;
}
.message-wrapper:active .message-actions {
opacity: 1;
}
}
.delete-btn {
background: #ef4444;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
cursor: pointer;
transition: background-color 0.2s;
}
.delete-btn:hover {
background: #dc2626;
}
</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 px-2 sm:px-4 py-3 flex items-center justify-between gap-2 sm:gap-3">
<div class="flex items-center gap-2 sm: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-sm 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="openStitcher" class="text-xs px-2 py-1 sm:px-3 sm:py-1.5 rounded-lg border border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-900 hover:bg-emerald-100 dark:hover:bg-emerald-800 text-emerald-700 dark:text-emerald-300">
Stitcher <span id="stitcherCount" class="hidden ml-1 px-1 bg-emerald-600 text-white rounded-full text-xs">0</span>
</button>
<button id="openSettings" class="text-xs px-2 py-1 sm:px-3 sm: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 px-2 sm: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 shadow-sm">
<label class="sr-only" for="question">Your message</label>
<div class="flex items-end gap-2">
<textarea id="question" rows="2"
class="flex-1 min-h-[48px] sm:min-h-[72px] rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 sm:px-3 py-2 text-sm"
placeholder="Ask me anything…"></textarea>
<div class="flex flex-col items-stretch gap-1">
<button id="send" class="h-8 sm:h-10 px-2 sm:px-4 rounded-xl bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50 text-xs sm:text-sm">
Send
</button>
<div id="status" class="text-[9px] sm:text-[11px] text-zinc-500 text-center"></div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<!-- Stitcher Overlay -->
<div id="stitcherOverlay" class="fixed inset-0 z-40 hidden" aria-hidden="true">
<div id="stitcherBackdrop" 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="stitcherTitle"
class="w-full max-w-4xl 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="stitcherTitle" class="font-semibold text-sm sm:text-base">Code Stitcher</h2>
<div class="flex items-center gap-2">
<button id="clearStitcher" 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 All</button>
<button id="downloadStitched" class="text-xs px-2 py-1 rounded-md bg-emerald-600 text-white hover:bg-emerald-700">Download</button>
<button id="copyStitched" class="text-xs px-2 py-1 rounded-md bg-indigo-600 text-white hover:bg-indigo-700">Copy All</button>
<button id="closeStitcher" 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 overflow-y-auto" style="max-height: calc(90vh - 60px);">
<div id="stitcherContent" class="space-y-3">
<div class="text-center text-zinc-500 text-sm" id="stitcherEmpty">
No code chunks added yet. Use the "Add to Stitcher" button on code blocks to start building your complete file.
</div>
</div>
<div class="mt-4 p-3 rounded-lg bg-zinc-50 dark:bg-zinc-800">
<label class="text-sm block mb-2">Filename for download:</label>
<input id="stitcherFilename" type="text" class="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm" placeholder="my-code.html" value="stitched-code.txt">
</div>
</div>
</div>
</div>
</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 sm:flex-row flex-col mobile-tabs border border-zinc-300 dark:border-zinc-700 rounded-lg overflow-hidden">
<button id="tabDefault" class="mobile-tab-btn px-3 py-2 text-xs sm:text-xs bg-indigo-600 text-white border-b sm:border-b-0 sm:border-r border-zinc-300 dark:border-zinc-700 last:border-b-0 last:border-r-0">Default</button>
<button id="tabFullCode" class="mobile-tab-btn px-3 py-2 text-xs sm:text-xs bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 border-b sm:border-b-0 sm:border-r border-zinc-300 dark:border-zinc-700 last:border-b-0 last:border-r-0">Full Code</button>
<button id="tabSnippets" class="mobile-tab-btn px-3 py-2 text-xs sm:text-xs bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 border-b sm:border-b-0 sm:border-r border-zinc-300 dark:border-zinc-700 last:border-b-0 last:border-r-0">Snippets</button>
<button id="tabChunked" class="mobile-tab-btn px-3 py-2 text-xs sm:text-xs bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 last:border-b-0 last:border-r-0">Chunked</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 id="promptChunked" class="mt-2 p-3 rounded-lg bg-zinc-50 dark:bg-zinc-800 text-xs hidden">
<strong>Chunked:</strong> Break code into manageable chunks to fit token limits.
<textarea id="chunkedPrompt" 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 chunked responses...">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.</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'),
// 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)); }
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 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 = state.stitcher.chunks.some(chunk => chunk.code === code);
if (isAlreadyAdded) {
// Remove from stitcher
state.stitcher.chunks = state.stitcher.chunks.filter(chunk => chunk.code !== code);
stitchBtn.textContent = 'Add to Stitcher';
stitchBtn.classList.remove('added');
} else {
// Add to stitcher
state.stitcher.chunks.push({
id: chunkId,
code: code,
language: language,
timestamp: Date.now()
});
stitchBtn.textContent = 'Remove from Stitcher';
stitchBtn.classList.add('added');
}
saveState(state);
updateStitcherUI();
});
// Check if this code is already in stitcher
const isInStitcher = state.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?')) {
state.messages.splice(index, 1);
saveState(state);
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 = state.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.`;
els.question.value = nextChunkMessage;
} else {
// No code found, ask for next chunk of the explanation/content
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.`;
els.question.value = continueMessage;
} else {
els.question.value = 'Continue from where you left off.';
}
}
els.question.focus();
els.question.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
bubble.appendChild(actionBtn);
}
wrapper.appendChild(bubble);
return wrapper;
}
function renderTranscript() {
els.transcript.innerHTML = '';
state.messages.forEach((m, index) => els.transcript.appendChild(renderMessage(m, index)));
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.tabChunked = document.getElementById('tabChunked');
els.fullCodePrompt = document.getElementById('fullCodePrompt');
els.snippetsPrompt = document.getElementById('snippetsPrompt');
els.chunkedPrompt = document.getElementById('chunkedPrompt');
}
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 || '';
els.chunkedPrompt.value = state.settings.chunkedPrompt || '';
// Set active tab
setActiveTab(state.settings.codeStyle || 'default');
}
function setActiveTab(tabName) {
// Reset all tabs with mobile-friendly classes
const baseClasses = 'mobile-tab-btn px-3 py-2 text-xs sm:text-xs border-b sm:border-b-0 sm:border-r border-zinc-300 dark:border-zinc-700 last:border-b-0 last:border-r-0';
const inactiveClasses = baseClasses + ' bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700';
const activeClasses = baseClasses + ' bg-indigo-600 text-white';
document.getElementById('tabDefault').className = inactiveClasses;
document.getElementById('tabFullCode').className = inactiveClasses;
document.getElementById('tabSnippets').className = inactiveClasses;
document.getElementById('tabChunked').className = inactiveClasses;
// Hide all content
document.getElementById('promptDefault').classList.add('hidden');
document.getElementById('promptFullCode').classList.add('hidden');
document.getElementById('promptSnippets').classList.add('hidden');
document.getElementById('promptChunked').classList.add('hidden');
// Set active tab and show content
if (tabName === 'fullCode') {
document.getElementById('tabFullCode').className = activeClasses;
document.getElementById('promptFullCode').classList.remove('hidden');
} else if (tabName === 'snippets') {
document.getElementById('tabSnippets').className = activeClasses;
document.getElementById('promptSnippets').classList.remove('hidden');
} else if (tabName === 'chunked') {
document.getElementById('tabChunked').className = activeClasses;
document.getElementById('promptChunked').classList.remove('hidden');
} else {
document.getElementById('tabDefault').className = activeClasses;
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); });
els.chunkedPrompt.addEventListener('input', () => { state.settings.chunkedPrompt = els.chunkedPrompt.value; saveState(state); });
// Tab handlers
els.tabDefault.addEventListener('click', () => setActiveTab('default'));
els.tabFullCode.addEventListener('click', () => setActiveTab('fullCode'));
els.tabSnippets.addEventListener('click', () => setActiveTab('snippets'));
els.tabChunked.addEventListener('click', () => setActiveTab('chunked'));
els.clearChat.addEventListener('click', () => {
state.messages = []; saveState(state); renderTranscript();
els.status.textContent = 'Cleared local transcript.'; setTimeout(() => els.status.textContent = '', 1500);
closeSettings();
});
}
// --- Stitcher functions ---
function updateStitcherUI() {
const count = state.stitcher.chunks.length;
// Update button badge
if (count > 0) {
els.stitcherCount.textContent = count;
els.stitcherCount.classList.remove('hidden');
} else {
els.stitcherCount.classList.add('hidden');
}
// Update stitcher content
if (count === 0) {
els.stitcherEmpty.classList.remove('hidden');
els.stitcherContent.innerHTML = '<div class="text-center text-zinc-500 text-sm" id="stitcherEmpty">No code chunks added yet. Use the "Add to Stitcher" button on code blocks to start building your complete file.</div>';
} else {
els.stitcherEmpty.classList.add('hidden');
els.stitcherContent.innerHTML = '';
state.stitcher.chunks.forEach((chunk, index) => {
const chunkDiv = document.createElement('div');
chunkDiv.className = 'border border-zinc-200 dark:border-zinc-800 rounded-lg p-3 bg-zinc-50 dark:bg-zinc-800';
const header = document.createElement('div');
header.className = 'flex items-center justify-between mb-2';
const title = document.createElement('div');
title.className = 'text-sm font-medium';
title.textContent = `Chunk ${index + 1} (${chunk.language.toUpperCase()})`;
const actions = document.createElement('div');
actions.className = 'flex gap-2';
const moveUp = document.createElement('button');
moveUp.className = 'text-xs px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-700';
moveUp.textContent = '↑';
moveUp.disabled = index === 0;
if (moveUp.disabled) moveUp.className += ' opacity-50 cursor-not-allowed';
const moveDown = document.createElement('button');
moveDown.className = 'text-xs px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-700';
moveDown.textContent = '↓';
moveDown.disabled = index === count - 1;
if (moveDown.disabled) moveDown.className += ' opacity-50 cursor-not-allowed';
const remove = document.createElement('button');
remove.className = 'text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700';
remove.textContent = '×';
actions.appendChild(moveUp);
actions.appendChild(moveDown);
actions.appendChild(remove);
header.appendChild(title);
header.appendChild(actions);
const codePreview = document.createElement('pre');
codePreview.className = 'text-xs bg-zinc-900 text-zinc-100 p-2 rounded overflow-x-auto max-h-32 overflow-y-auto';
codePreview.textContent = chunk.code.substring(0, 500) + (chunk.code.length > 500 ? '...' : '');
chunkDiv.appendChild(header);
chunkDiv.appendChild(codePreview);
els.stitcherContent.appendChild(chunkDiv);
// Event listeners
moveUp.addEventListener('click', () => {
if (index > 0) {
[state.stitcher.chunks[index], state.stitcher.chunks[index - 1]] = [state.stitcher.chunks[index - 1], state.stitcher.chunks[index]];
saveState(state);
updateStitcherUI();
}
});
moveDown.addEventListener('click', () => {
if (index < count - 1) {
[state.stitcher.chunks[index], state.stitcher.chunks[index + 1]] = [state.stitcher.chunks[index + 1], state.stitcher.chunks[index]];
saveState(state);
updateStitcherUI();
}
});
remove.addEventListener('click', () => {
state.stitcher.chunks.splice(index, 1);
saveState(state);
updateStitcherUI();
renderTranscript(); // Update stitch button states
});
});
}
}
function getStitchedCode() {
return state.stitcher.chunks.map(chunk => chunk.code).join('\n\n');
}
function downloadStitchedCode() {
const code = getStitchedCode();
const filename = 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 = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// --- 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'); }
function openStitcher() {
els.stitcherOverlay.classList.remove('hidden');
updateStitcherUI();
}
function closeStitcher() { els.stitcherOverlay.classList.add('hidden'); }
els.openSettings.addEventListener('click', openSettings);
document.getElementById('closeSettings').addEventListener('click', closeSettings);
els.overlayBackdrop.addEventListener('click', closeSettings);
els.openStitcher.addEventListener('click', openStitcher);
els.closeStitcher.addEventListener('click', closeStitcher);
els.stitcherBackdrop.addEventListener('click', closeStitcher);
els.clearStitcher.addEventListener('click', () => {
if (confirm('Clear all chunks from stitcher?')) {
state.stitcher.chunks = [];
saveState(state);
updateStitcherUI();
renderTranscript(); // Update button states
}
});
els.copyStitched.addEventListener('click', () => {
const code = getStitchedCode();
if (code) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code).then(() => {
els.copyStitched.textContent = 'Copied!';
setTimeout(() => els.copyStitched.textContent = 'Copy All', 2000);
});
} else {
fallbackCopyTextToClipboard(code, els.copyStitched);
}
}
});
els.downloadStitched.addEventListener('click', downloadStitchedCode);
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeSettings();
closeStitcher();
}
});
// --- 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;
} else if (state.settings.codeStyle === 'chunked' && state.settings.chunkedPrompt) {
systemPrompt += '\n\nCODE RESPONSE STYLE: ' + state.settings.chunkedPrompt;
}
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;
// Ensure mobile-friendly max width
document.body.style.maxWidth = '100vw';
} else {
els.question.rows = 3;
document.body.style.maxWidth = '';
}
}
window.addEventListener('resize', handleViewportChange);
handleViewportChange(); // Call on load
// initial render and stitcher setup
renderTranscript();
updateStitcherUI();
</script>
</body>
</html>