// Global namespace to share state across modules
window.App = window.App || {};
(() => {
const els = {
// dynamically injected container
settingsOverlay: document.getElementById('settingsOverlay'),
overlayBackdrop: document.getElementById('overlayBackdrop'),
openSettings: document.getElementById('openSettings'),
closeSettings: document.getElementById('closeSettings'),
settingsBody: document.getElementById('settingsBody'),
clearChat: null,
};
const STORAGE_KEY = 'unified-chat-state-v2';
// Pricing per million tokens (input / output)
const MODEL_PRICING = {
'deepseek-chat': { input: 0.27, output: 1.10, inputCache: 0.07 },
'deepseek-reasoner': { input: 0.55, output: 2.19, inputCache: 0.14 },
'gpt-4o': { input: 2.50, output: 10.00 },
'gpt-4o-mini': { input: 0.15, output: 0.60 },
'gpt-5': { input: 1.25, output: 10.00 },
'gpt-5-mini': { input: 0.25, output: 2.00 },
'gpt-5-nano': { input: 0.05, output: 0.40 },
'gpt-5-thinking': { input: 1.25, output: 10.00 },
'gpt-5-pro': { input: 2.50, output: 15.00 },
'grok-3': { input: 3.00, output: 15.00 },
'grok-3-mini': { input: 0.30, output: 0.50 },
'grok-code-fast-1': { input: 0.20, output: 1.50 },
'grok-4-0709': { input: 3.00, output: 15.00 }
};
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 },
stats: {
totalMessages: 0,
modelUsage: {} // { 'model-name': { input: 0, output: 0, calls: 0 } }
}
});
function loadState() {
try {
const state = JSON.parse(localStorage.getItem(STORAGE_KEY)) || defaultState();
// Ensure stats exists
if (!state.stats) state.stats = { totalMessages: 0, modelUsage: {} };
return state;
}
catch { return defaultState(); }
}
function saveState(s) { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); }
App.state = loadState();
App.saveState = saveState;
App.defaultState = defaultState;
// Track token usage
function trackUsage(model, usage) {
if (!usage) return;
const stats = App.state.stats;
if (!stats.modelUsage[model]) {
stats.modelUsage[model] = { input: 0, output: 0, calls: 0 };
}
stats.modelUsage[model].input += usage.prompt_tokens || 0;
stats.modelUsage[model].output += usage.completion_tokens || 0;
stats.modelUsage[model].calls += 1;
stats.totalMessages += 1;
saveState(App.state);
}
App.trackUsage = trackUsage;
// Calculate costs
function calculateCosts() {
const stats = App.state.stats;
let totalCost = 0;
const breakdown = [];
Object.keys(stats.modelUsage).forEach(model => {
const usage = stats.modelUsage[model];
const pricing = MODEL_PRICING[model];
if (!pricing) {
breakdown.push({
model,
input: usage.input,
output: usage.output,
calls: usage.calls,
cost: 0,
note: 'Pricing unknown'
});
return;
}
// Calculate cost (tokens / 1M * price)
const inputCost = (usage.input / 1000000) * pricing.input;
const outputCost = (usage.output / 1000000) * pricing.output;
const cost = inputCost + outputCost;
totalCost += cost;
breakdown.push({
model,
input: usage.input,
output: usage.output,
calls: usage.calls,
cost,
inputCost,
outputCost
});
});
return { totalCost, breakdown };
}
function formatNumber(num) {
return num.toLocaleString();
}
function formatCost(cost) {
if (cost < 0.01) return `$${cost.toFixed(4)}`;
if (cost < 1) return `$${cost.toFixed(3)}`;
return `$${cost.toFixed(2)}`;
}
function renderStats() {
const { totalCost, breakdown } = calculateCosts();
const stats = App.state.stats;
if (breakdown.length === 0) {
return `<div class="text-center text-zinc-500 text-sm py-4">No usage data yet</div>`;
}
let html = `
<div class="bg-gradient-to-br from-indigo-50 to-purple-50 dark:from-indigo-950/30 dark:to-purple-950/30 rounded-lg p-4 border border-indigo-200 dark:border-indigo-800">
<div class="flex items-center justify-between mb-2">
<h4 class="font-semibold text-sm">Conversation Statistics</h4>
<button id="resetStats" class="text-xs px-2 py-1 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800">Reset</button>
</div>
<div class="grid grid-cols-2 gap-3 mb-3">
<div class="bg-white dark:bg-zinc-900 rounded p-2">
<div class="text-xs text-zinc-500">Total Messages</div>
<div class="text-lg font-bold">${stats.totalMessages}</div>
</div>
<div class="bg-white dark:bg-zinc-900 rounded p-2">
<div class="text-xs text-zinc-500">Estimated Cost</div>
<div class="text-lg font-bold text-indigo-600 dark:text-indigo-400">${formatCost(totalCost)}</div>
</div>
</div>
<div class="space-y-2">
`;
breakdown.forEach(item => {
const totalTokens = item.input + item.output;
html += `
<div class="bg-white dark:bg-zinc-900 rounded p-3 text-xs">
<div class="flex items-center justify-between mb-1">
<span class="font-semibold">${item.model}</span>
<span class="font-bold text-indigo-600 dark:text-indigo-400">${formatCost(item.cost)}</span>
</div>
<div class="grid grid-cols-3 gap-2 text-zinc-600 dark:text-zinc-400">
<div>
<div class="text-[10px] uppercase text-zinc-400">Input</div>
<div>${formatNumber(item.input)}</div>
</div>
<div>
<div class="text-[10px] uppercase text-zinc-400">Output</div>
<div>${formatNumber(item.output)}</div>
</div>
<div>
<div class="text-[10px] uppercase text-zinc-400">Calls</div>
<div>${item.calls}</div>
</div>
</div>
${item.note ? `<div class="mt-1 text-[10px] text-amber-600 dark:text-amber-400">${item.note}</div>` : ''}
</div>
`;
});
html += `
</div>
<div class="mt-3 text-[10px] text-zinc-500">
* Costs are estimates based on standard pricing. Cache hits, promotions, and other factors may affect actual costs.
</div>
</div>
`;
return html;
}
// Render the settings body (so HTML is slimmer in index.html)
function renderSettingsBody() {
els.settingsBody.innerHTML = `
${renderStats()}
<div class="mt-4">
<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>
<div class="mt-3">
<label class="text-sm block">Max tokens: <span id="maxTokensVal" class="font-mono">${App.state.settings.maxTokens}</span></label>
<input id="maxTokens" type="range" min="64" max="4096" step="32" value="${App.state.settings.maxTokens}" class="w-full">
</div>
<div class="mt-3">
<label class="text-sm block">Temperature: <span id="tempVal" class="font-mono">${App.state.settings.temperature}</span></label>
<input id="temperature" type="range" min="0" max="2" step="0.1" value="${App.state.settings.temperature}" class="w-full">
<label class="flex items-center gap-2 mt-1 text-xs"><input id="forceTemperature" type="checkbox" class="accent-indigo-600" ${App.state.settings.forceTemperature ? 'checked' : ''}> Force temperature (for GPT-5)</label>
</div>
<div class="mt-3">
<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.">${App.state.settings.system || ''}</textarea>
</div>
<div class="mt-3">
<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">Default</button>
<button id="tabFullCode" class="mobile-tab-btn px-3 py-2 text-xs sm:text-xs">Full Code</button>
<button id="tabSnippets" class="mobile-tab-btn px-3 py-2 text-xs sm:text-xs">Snippets</button>
<button id="tabChunked" class="mobile-tab-btn px-3 py-2 text-xs sm:text-xs">Chunked</button>
</div>
<div id="promptDefault" class="mt-2 p-3 rounded-lg bg-zinc-50 dark:bg-zinc-800 text-xs hidden">
<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">${App.state.settings.fullCodePrompt || ''}</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">${App.state.settings.snippetsPrompt || ''}</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">${App.state.settings.chunkedPrompt || ''}</textarea>
</div>
</div>
<div class="mt-3 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" ${App.state.settings.includeArtifacts ? 'checked' : ''}> Include artifacts from session</label>
<label class="flex items-center gap-2 text-sm"><input id="jsonFormat" type="checkbox" class="accent-indigo-600" ${App.state.settings.jsonFormat ? 'checked' : ''}> Response JSON</label>
</div>
<div class="mt-3 flex items-center justify-end gap-2">
<button id="clearChat" class="text-xs px-3 py-1.5 rounded-md border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900">Clear Chat</button>
</div>
<details class="mt-2 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>
`;
}
App.renderSettings = renderSettingsBody;
function bindAndHydrate() {
const s = App.state.settings;
const $ = id => document.getElementById(id);
const model = $('model');
const maxTokens = $('maxTokens'); const maxTokensVal = $('maxTokensVal');
const temperature = $('temperature'); const tempVal = $('tempVal');
const forceTemperature = $('forceTemperature');
const includeArtifacts = $('includeArtifacts');
const jsonFormat = $('jsonFormat');
const system = $('system');
const fullCodePrompt = $('fullCodePrompt');
const snippetsPrompt = $('snippetsPrompt');
const chunkedPrompt = $('chunkedPrompt');
const resetStats = $('resetStats');
const clearChat = $('clearChat');
const tabDefault = $('tabDefault');
const tabFullCode = $('tabFullCode');
const tabSnippets = $('tabSnippets');
const tabChunked = $('tabChunked');
const promptDefault = $('promptDefault');
const promptFullCode = $('promptFullCode');
const promptSnippets = $('promptSnippets');
const promptChunked = $('promptChunked');
// Active tab
function setActiveTab(tabName) {
const allTabs = [tabDefault, tabFullCode, tabSnippets, tabChunked];
const panes = { default: promptDefault, fullCode: promptFullCode, snippets: promptSnippets, chunked: promptChunked };
allTabs.forEach(b => b.className = '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');
Object.values(panes).forEach(p => p.classList.add('hidden'));
const map = { default: tabDefault, fullCode: tabFullCode, snippets: tabSnippets, chunked: tabChunked };
map[tabName].className = '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';
panes[tabName].classList.remove('hidden');
s.codeStyle = tabName; App.saveState(App.state);
}
setActiveTab(s.codeStyle || 'default');
// Handlers
maxTokens.addEventListener('input', () => { maxTokensVal.textContent = maxTokens.value; s.maxTokens = +maxTokens.value; App.saveState(App.state); });
temperature.addEventListener('input', () => { tempVal.textContent = temperature.value; s.temperature = +temperature.value; App.saveState(App.state); });
forceTemperature.addEventListener('change', () => { s.forceTemperature = forceTemperature.checked; App.saveState(App.state); });
includeArtifacts.addEventListener('change', () => { s.includeArtifacts = includeArtifacts.checked; App.saveState(App.state); });
jsonFormat.addEventListener('change', () => { s.jsonFormat = jsonFormat.checked; App.saveState(App.state); });
model.addEventListener('change', () => { s.model = model.value; App.saveState(App.state); });
system.addEventListener('input', () => { s.system = system.value; App.saveState(App.state); });
fullCodePrompt.addEventListener('input', () => { s.fullCodePrompt = fullCodePrompt.value; App.saveState(App.state); });
snippetsPrompt.addEventListener('input', () => { s.snippetsPrompt = snippetsPrompt.value; App.saveState(App.state); });
chunkedPrompt.addEventListener('input', () => { s.chunkedPrompt = chunkedPrompt.value; App.saveState(App.state); });
tabDefault.addEventListener('click', () => setActiveTab('default'));
tabFullCode.addEventListener('click', () => setActiveTab('fullCode'));
tabSnippets.addEventListener('click', () => setActiveTab('snippets'));
tabChunked.addEventListener('click', () => setActiveTab('chunked'));
// Reset stats
if (resetStats) {
resetStats.addEventListener('click', () => {
if (confirm('Reset all usage statistics? This cannot be undone.')) {
App.state.stats = { totalMessages: 0, modelUsage: {} };
App.saveState(App.state);
renderSettingsBody();
bindAndHydrate();
}
});
}
// Clear chat
if (clearChat) {
clearChat.addEventListener('click', () => {
if (confirm('Clear all chat messages? This cannot be undone.')) {
App.state.messages = [];
App.saveState(App.state);
if (App.renderTranscript) App.renderTranscript();
const status = document.getElementById('status');
if (status) {
status.textContent = 'Chat cleared';
setTimeout(() => status.textContent = '', 1500);
}
closeSettings();
}
});
}
}
// Open/close overlay
function openSettings() {
els.settingsOverlay.classList.remove('hidden');
renderSettingsBody();
bindAndHydrate();
setTimeout(() => { try { document.getElementById('model').focus(); } catch {} }, 0);
}
function closeSettings() { els.settingsOverlay.classList.add('hidden'); }
// Expose for others
App.openSettings = openSettings;
App.closeSettings = closeSettings;
// Wire chrome
els.openSettings.addEventListener('click', openSettings);
els.closeSettings.addEventListener('click', closeSettings);
els.overlayBackdrop.addEventListener('click', closeSettings);
// Small viewport helper
function handleViewportChange() {
const q = document.getElementById('question');
if (!q) return;
if (window.innerWidth < 640) { q.rows = 2; document.body.style.maxWidth = '100vw'; }
else { q.rows = 3; document.body.style.maxWidth = ''; }
}
window.addEventListener('resize', handleViewportChange);
handleViewportChange();
})();