// chat.js ā Standalone Chat UI for api.php (with client stats + username header)
(function () {
window.App = window.App || {};
window.AppItems = window.AppItems || [];
// ---------- Defaults ----------
const defaultSettings = {
model: 'grok-code-fast-1',
maxTokens: 800,
temperature: 0.7,
system: 'You are a helpful assistant.',
codeStyle: 'default', // default | fullCode | snippets | chunked
username: 'guest', // sent as X-Username header
fullCodePrompt: '',
snippetsPrompt:
"You are a coding assistant. Output the entire response inside a single triple-backtick code block. Do not explain anything outside the code block. Do not break across multiple responses. Complete the entire document inside the code block.",
chunkedPrompt:
"you are a coding assistant. Output only raw code. Do not explain, do not comment, do not use backticks. If the code is too long for one reply, stop exactly where you must and wait. When I say 'continue,' you will resume exactly where you left off, without repeating or skipping. At the very end of the entire document, output a single line containing exactly (END) and nothing else. You must always finish with (END). Never stop before (END) unless you are waiting for me to say 'continue'.",
debugMode: false,
includeArtifacts: false,
forceTemperature: false,
jsonFormat: false
};
// ---------- State / Persistence ----------
function uuid() {
if (crypto?.randomUUID) return crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function newSessionId() { return `sess_${uuid()}`; }
if (!App.saveState) {
App.saveState = function (state) {
try { localStorage.setItem('chatState', JSON.stringify(state)); }
catch (e) { console.error('Save failed:', e); }
};
}
try {
const saved = localStorage.getItem('chatState');
if (saved) App.state = JSON.parse(saved);
} catch (e) { console.error('Load failed:', e); }
function ensureState() {
if (!App.state) {
App.state = {
sessionId: newSessionId(),
messages: [],
settings: { ...defaultSettings }
};
return;
}
if (!App.state.sessionId) App.state.sessionId = newSessionId();
if (!App.state.messages) App.state.messages = [];
if (!App.state.settings) App.state.settings = { ...defaultSettings };
else App.state.settings = { ...defaultSettings, ...App.state.settings };
}
ensureState();
// ---------- Styles (injected once) ----------
const styleId = 'chat-component-styles';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.chat-message { margin: 1rem 0; position: relative; }
.chat-card { border: 1px solid #3f3f46; background: #1f2937; border-radius: 0.5rem; padding: 1rem; position: relative; }
.chat-message--user .chat-card { background: linear-gradient(135deg, #4f46e5, #6366f1); border-color: #4338ca; color: white; }
.chat-message--assistant .chat-card { background: #1f2937; border-color: #3f3f46; }
.chat-card__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; font-size: 0.875rem; font-weight: 600; opacity: 0.9; }
.chat-card__content { white-space: pre-wrap; word-break: break-word; line-height: 1.6; }
.chat-code-output { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'SF Mono', Monaco, Consolas, monospace; font-size: 0.9rem; }
.chat-fenced { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'SF Mono', Monaco, Consolas, monospace; font-size: 0.9rem; background: #0b1220; border: 1px solid #1f2a44; border-radius: 0.5rem; padding: 0.75rem 1rem; color: #e5e7eb; }
.chat-btn { font-size: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 0.375rem; border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1); color: currentColor; cursor: pointer; transition: all 0.2s; }
.chat-btn:hover { background: rgba(255,255,255,0.2); }
.chat-btn--danger { background: #dc2626; color: white; border-color: #b91c1c; }
.chat-btn--danger:hover { background: #b91c1c; }
.chat-btn[disabled] { opacity: 0.5; cursor: not-allowed; }
.chat-textarea { width: 100%; min-height: 60px; max-height: 200px; resize: vertical; padding: 0.875rem; border-radius: 0.5rem; border: 1px solid #3f3f46; background: #0f172a; color: #e5e7eb; font-size: 0.9375rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; }
.chat-send-btn { width: 44px; height: 44px; background: linear-gradient(135deg, #4f46e5, #6366f1); color: white; border: none; border-radius: 0.5rem; font-size: 1.25rem; cursor: pointer; transition: transform 0.1s, box-shadow 0.2s; box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.4); }
.chat-send-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 8px -1px rgba(79, 70, 229, 0.5); }
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
#menu-dropdown { z-index: 500000; position: absolute; }
.chat-message, #transcript, .chat-card, .chat-card__content { min-width: 0; max-width: 100%; box-sizing: border-box; }
.chat-card__content, .chat-fenced, .chat-code-output { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; line-break: anywhere; overflow: auto; }
`;
document.head.appendChild(style);
}
// ---------- Helpers ----------
function buildSystemPromptFromSettings(s) {
const baseSystem = (s.system ?? '').trim() || 'You are a helpful assistant.';
let codeStylePrompt = '';
if (s.codeStyle === 'fullCode' && s.fullCodePrompt) codeStylePrompt = s.fullCodePrompt;
else if (s.codeStyle === 'snippets' && s.snippetsPrompt) codeStylePrompt = s.snippetsPrompt;
else if (s.codeStyle === 'chunked' && s.chunkedPrompt) codeStylePrompt = s.chunkedPrompt;
return codeStylePrompt ? `${baseSystem}\n\nCODE RESPONSE STYLE: ${codeStylePrompt}` : baseSystem;
}
function openModal({ title = 'Details', content = '' }) {
const existing = document.querySelector('.chat-modal__backdrop');
if (existing) existing.remove();
const backdrop = document.createElement('div');
backdrop.className = 'chat-modal__backdrop';
backdrop.style.cssText = 'position:fixed; inset:0; background:rgba(0,0,0,0.6); display:flex; align-items:center; justify-content:center; z-index:999999;';
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) backdrop.remove(); });
const modal = document.createElement('div'); modal.style.cssText = 'width:min(900px,92vw); max-height:80vh; background:#0f172a; border:1px solid #334155; border-radius:0.75rem; display:flex; flex-direction:column; overflow:hidden;';
const header = document.createElement('div'); header.style.cssText = 'padding:0.875rem 1rem; background:#111827; border-bottom:1px solid #334155; display:flex; align-items:center; justify-content:space-between;';
const hTitle = document.createElement('div'); hTitle.style.cssText = 'font-weight:700; color:#e5e7eb;'; hTitle.textContent = title;
const actions = document.createElement('div'); actions.style.cssText = 'display:flex; gap:0.5rem;';
const copyBtn = document.createElement('button'); copyBtn.className = 'chat-btn'; copyBtn.textContent = 'Copy';
copyBtn.onclick = async () => {
try { await navigator.clipboard.writeText(content.replace(/\u00a0/g, ' ')); copyBtn.textContent = 'Copied ā'; setTimeout(() => (copyBtn.textContent = 'Copy'), 1200); }
catch { const ta = document.createElement('textarea'); ta.value = content; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); copyBtn.textContent = 'Copied ā'; setTimeout(() => (copyBtn.textContent = 'Copy'), 1200); }
};
const closeBtn = document.createElement('button'); closeBtn.className = 'chat-btn chat-btn--danger'; closeBtn.textContent = 'Close';
closeBtn.onclick = () => backdrop.remove();
actions.appendChild(copyBtn); actions.appendChild(closeBtn);
header.appendChild(hTitle); header.appendChild(actions);
const body = document.createElement('div'); body.style.cssText = 'padding:1rem; overflow:auto;';
const pre = document.createElement('pre'); pre.style.cssText = 'margin:0; white-space:pre-wrap; word-break:break-word; background:#0b1220; border:1px solid #1f2a44; color:#e5e7eb; border-radius:0.5rem; padding:1rem; font-size:0.85rem;';
pre.textContent = content;
body.appendChild(pre); modal.appendChild(header); modal.appendChild(body); backdrop.appendChild(modal); document.body.appendChild(backdrop);
}
function computeHistoryOverride() {
return (App.state.messages || [])
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({ role: m.role, content: String(m.content || '') }));
}
// ---------- Render Message ----------
function renderMessage(msg, index, onDelete) {
const wrapper = document.createElement('div');
wrapper.className = `chat-message chat-message--${msg.role}`;
const card = document.createElement('div');
card.className = 'chat-card';
const s = App.state.settings;
const isRawCodeOutput = msg.role === 'assistant' && (s.codeStyle === 'chunked' || s.codeStyle === 'fullCode');
const isSnippets = msg.role === 'assistant' && (s.codeStyle === 'snippets');
const header = document.createElement('div');
header.className = 'chat-card__header';
const title = document.createElement('div');
title.textContent = msg.role === 'user' ? 'You' : (s.debugMode ? 'Assistant (debug)' : isRawCodeOutput ? 'Code Output' : isSnippets ? 'Assistant (snippets)' : 'Assistant');
const headerBtns = document.createElement('div'); headerBtns.style.cssText = 'display:flex; gap:0.5rem;';
const deleteBtn = document.createElement('button'); deleteBtn.className = 'chat-btn chat-btn--danger'; deleteBtn.textContent = 'Ć';
deleteBtn.onclick = () => {
if (confirm('Delete this message?')) {
App.state.messages.splice(index, 1);
App.saveState(App.state);
onDelete();
}
};
headerBtns.appendChild(deleteBtn);
header.appendChild(title); header.appendChild(headerBtns);
const content = document.createElement('div');
content.className = 'chat-card__content';
const raw = (msg.content || '').replace(/END\s*$/i, '');
if (msg.role === 'user') {
const lines = raw.split('\n');
const charCount = raw.length;
const shouldCollapse = lines.length > 5 || charCount > 300;
if (!shouldCollapse) {
const node = document.createElement('div');
node.textContent = raw.trim();
content.appendChild(node);
} else {
const preview = document.createElement('div');
const full = document.createElement('div');
full.style.display = 'none';
preview.textContent = lines.slice(0, 5).join('\n') + (lines.length > 5 ? '\n...' : '');
full.textContent = raw.trim();
const expandBtn = document.createElement('button');
expandBtn.className = 'chat-btn';
expandBtn.textContent = 'ā¼ Show more';
expandBtn.style.cssText = 'margin-top: 0.5rem; font-size: 0.75rem;';
expandBtn.onclick = () => {
const isExpanded = full.style.display !== 'none';
preview.style.display = isExpanded ? 'block' : 'none';
full.style.display = isExpanded ? 'none' : 'block';
expandBtn.textContent = isExpanded ? 'ā¼ Show more' : 'ā² Show less';
};
content.appendChild(preview);
content.appendChild(full);
content.appendChild(expandBtn);
}
} else if (msg.role === 'assistant' && s.codeStyle === 'default') {
const node = document.createElement('div');
node.textContent = raw.trim();
content.appendChild(node);
} else if (isSnippets) {
const parts = [];
let lastIndex = 0;
const fenceRegex = /```[\w]*\n([\s\S]*?)```/g;
let match;
while ((match = fenceRegex.exec(raw)) !== null) {
if (match.index > lastIndex) {
const textBefore = raw.substring(lastIndex, match.index).trim();
if (textBefore) parts.push({ type: 'text', content: textBefore });
}
parts.push({ type: 'code', content: match[1].trim() });
lastIndex = fenceRegex.lastIndex;
}
if (lastIndex < raw.length) {
const textAfter = raw.substring(lastIndex).trim();
if (textAfter) parts.push({ type: 'text', content: textAfter });
}
parts.forEach((part) => {
if (part.type === 'text') {
const textDiv = document.createElement('div');
textDiv.style.cssText = 'margin-bottom: 0.75rem; line-height: 1.6;';
textDiv.textContent = part.content;
content.appendChild(textDiv);
} else {
const pre = document.createElement('pre');
pre.className = 'chat-fenced';
pre.style.cssText = 'margin-bottom: 0.75rem;';
pre.textContent = part.content;
content.appendChild(pre);
}
});
} else if (isRawCodeOutput) {
const lines = raw.split('\n');
const shouldCollapse = lines.length > 5;
if (!shouldCollapse) {
const codeDiv = document.createElement('div');
codeDiv.className = 'chat-code-output';
codeDiv.textContent = raw.trim();
content.appendChild(codeDiv);
} else {
const preview = document.createElement('div');
preview.className = 'chat-code-output';
const full = document.createElement('div');
full.className = 'chat-code-output';
full.style.display = 'none';
preview.textContent = lines.slice(0, 5).join('\n');
full.textContent = lines.slice(5).join('\n');
const expandBtn = document.createElement('button');
expandBtn.className = 'chat-btn';
expandBtn.textContent = 'ā¼ Show more';
expandBtn.style.cssText = 'margin-top: 0.5rem; font-size: 0.75rem;';
expandBtn.onclick = () => {
const isExpanded = full.style.display !== 'none';
full.style.display = isExpanded ? 'none' : 'block';
expandBtn.textContent = isExpanded ? 'ā¼ Show more' : 'ā² Show less';
};
content.appendChild(preview);
content.appendChild(full);
content.appendChild(expandBtn);
}
}
card.appendChild(header);
card.appendChild(content);
// Continue button (chunked mode)
if (
App.state.settings.codeStyle === 'chunked' &&
msg.role === 'assistant' &&
index === App.state.messages.length - 1 &&
!/END\s*$/i.test(msg.content || '')
) {
const continueBtn = document.createElement('button');
continueBtn.className = 'chat-btn';
continueBtn.textContent = 'ā¶ Continue';
continueBtn.style.cssText = 'margin-top:0.75rem; padding:0.5rem 1rem;';
continueBtn.onclick = async () => {
const s2 = App.state.settings;
const sendBtn = document.querySelector('#send');
const statusEl = document.querySelector('#status');
if (!sendBtn) return;
continueBtn.disabled = true; continueBtn.textContent = 'Continuing...';
statusEl.textContent = 'Thinking...'; sendBtn.disabled = true;
const systemToSend = buildSystemPromptFromSettings(s2);
const payload = {
question: 'Continue outputting the code from where you left off.',
model: s2.model,
maxTokens: s2.maxTokens,
temperature: s2.temperature,
system: systemToSend,
includeHistory: true,
sessionId: App.state.sessionId,
history: computeHistoryOverride()
};
if (s2.forceTemperature) payload.forceTemperature = true;
if (s2.jsonFormat) payload.response_format = { type: 'json_object' };
if (s2.includeArtifacts) payload.includeArtifacts = true;
const started = performance.now();
let data = null;
let ok = false;
try {
const res = await fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-Id': App.state.sessionId,
'X-Username': App.state.settings.username || 'guest'
},
body: JSON.stringify(payload)
});
data = await res.json();
if (data.error) {
statusEl.textContent = 'Error: ' + data.error;
continueBtn.disabled = false; continueBtn.textContent = 'ā¶ Continue';
} else {
const newContent = data.answer || '';
const oldLength = App.state.messages[index].content.length;
App.state.messages[index].content += '\n' + newContent;
App.saveState(App.state);
statusEl.textContent = `Added ${newContent.length} chars (was ${oldLength}, now ${App.state.messages[index].content.length})`;
if (newContent.includes('(END)')) { continueBtn.remove(); statusEl.textContent = 'Complete!'; }
else { continueBtn.disabled = false; continueBtn.textContent = 'ā¶ Continue'; }
const transcript = document.querySelector('#transcript');
if (transcript && transcript._renderFunction) { transcript._renderFunction(); transcript.scrollTop = transcript.scrollHeight; }
ok = true;
}
} catch (err) {
alert('Network error: ' + err.message);
}
const ended = performance.now();
const latencyMs = Math.round(ended - started);
// Fire-and-forget stats
try {
const usage = (data && data.usage) || {};
const answerText = (data && data.answer) ? String(data.answer) : '';
const statsPayload = {
sessionId: App.state.sessionId,
model: (data && data.model) || s2.model,
provider: (data && data.provider) || '',
latencyMs,
inputChars: (payload.question || '').length,
outputChars: answerText.length,
promptTokens: usage.prompt_tokens || usage.promptTokens || 0,
completionTokens: usage.completion_tokens || usage.completionTokens || 0,
totalTokens: usage.total_tokens || usage.totalTokens || 0,
ok: ok,
extra: { codeStyle: s2.codeStyle, kind: 'continue' }
};
fetch('api.php?action=submit_client_stats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-Id': App.state.sessionId,
'X-Username': App.state.settings.username || 'guest'
},
body: JSON.stringify(statsPayload)
}).catch(() => {});
} catch {}
sendBtn.disabled = false; statusEl.textContent = '';
};
card.appendChild(continueBtn);
}
wrapper.appendChild(card);
return wrapper;
}
// ---------- Component HTML ----------
function generateHTML() {
return `
<div style="display:flex; flex-direction:column; height:100%; background:#0f172a; position:relative;">
<div id="transcript" style="flex:1; overflow-y:auto; padding:1rem; padding-bottom:180px;"></div>
<div style="position:fixed; bottom:40px; left:0; right:0; padding:1rem; background:#1e293b; border-top:1px solid #334155; z-index:100;">
<div style="display:flex; gap:0.5rem;">
<textarea id="question" class="chat-textarea" placeholder="Type your message..."></textarea>
<div style="display:flex; flex-direction:column; gap:0.5rem;">
<button id="send" class="chat-send-btn">ā</button>
<button id="clear" class="chat-btn chat-btn--danger" style="width:44px; height:44px; font-size:0.875rem;" title="New conversation (new session)">š</button>
<button id="menu" class="chat-btn" style="width:44px; height:44px; font-size:1rem; background:#6b7280; border-color:#4b5563;" title="Menu">ā®</button>
</div>
</div>
<div id="menu-dropdown" style="display:none; position:absolute; bottom:100%; right:1rem; margin-bottom:0.5rem; background:#1e293b; border:1px solid #334155; border-radius:0.5rem; min-width:280px; box-shadow:0 10px 15px -3px rgba(0,0,0,0.3);">
<div style="padding:0.75rem; border-bottom:1px solid #334155;">
<label style="display:block; font-size:0.75rem; color:#94a3b8; margin-bottom:0.25rem;">Model</label>
<select id="quickModel" style="width:100%; padding:0.375rem; border-radius:0.375rem; border:1px solid #334155; background:#0f172a; color:#f1f5f9; font-size:0.8125rem;">
<option value="grok-code-fast-1">grok-code-fast-1</option>
<option value="grok-3">grok-3</option>
<option value="grok-3-mini">grok-3-mini</option>
<option value="deepseek-chat">deepseek-chat</option>
<option value="deepseek-reasoner">deepseek-reasoner</option>
<option value="gpt-4o">gpt-4o</option>
<option value="gpt-4o-mini">gpt-4o-mini</option>
<option value="gpt-5">gpt-5</option>
<option value="gpt-5-mini">gpt-5-mini</option>
<option value="gpt-5-turbo">gpt-5-turbo</option>
</select>
</div>
<div style="padding:0.75rem; border-bottom:1px solid #334155;">
<label style="display:block; font-size:0.75rem; color:#94a3b8; margin-bottom:0.25rem;">Response Style</label>
<select id="quickCodeStyle" style="width:100%; padding:0.375rem; border-radius:0.375rem; border:1px solid #334155; background:#0f172a; color:#f1f5f9; font-size:0.8125rem;">
<option value="default">Default</option>
<option value="fullCode">Full Code</option>
<option value="snippets">Snippets</option>
<option value="chunked">Chunked</option>
</select>
</div>
<div style="padding:0.75rem; border-bottom:1px solid #334155;">
<label style="display:block; font-size:0.75rem; color:#94a3b8; margin-bottom:0.25rem;">Username</label>
<input id="quickUsername" placeholder="guest" style="width:100%; padding:0.375rem; border-radius:0.375rem; border:1px solid #334155; background:#0f172a; color:#f1f5f9; font-size:0.8125rem;" />
</div>
<button id="newChat" style="width:100%; padding:0.75rem; text-align:left; background:none; border:none; color:#34d399; font-size:0.875rem; cursor:pointer; border-top:1px solid #334155;">New Chat (Reset Session)</button>
<button id="showPrompt" style="width:100%; padding:0.75rem; text-align:left; background:none; border:none; color:#60a5fa; font-size:0.875rem; cursor:pointer;">Show Full Prompt</button>
<button id="clearMemory" style="width:100%; padding:0.75rem; text-align:left; background:none; border:none; color:#f87171; font-size:0.875rem; cursor:pointer; border-radius:0 0 0.5rem 0.5rem;">Clear Memory</button>
</div>
<div id="status" style="margin-top:0.5rem; color:#94a3b8; font-size:0.875rem;"></div>
</div>
</div>
`;
}
// ---------- Handlers / Mount ----------
function setupHandlers(container) {
const transcript = container.querySelector('#transcript');
const question = container.querySelector('#question');
const send = container.querySelector('#send');
const clear = container.querySelector('#clear');
const menu = container.querySelector('#menu');
const menuDropdown = container.querySelector('#menu-dropdown');
const quickModel = container.querySelector('#quickModel');
const quickCodeStyle = container.querySelector('#quickCodeStyle');
const quickUsername = container.querySelector('#quickUsername');
const clearMemory = container.querySelector('#clearMemory');
const showPrompt = container.querySelector('#showPrompt');
const newChat = container.querySelector('#newChat');
const status = container.querySelector('#status');
quickModel.value = App.state.settings.model;
quickCodeStyle.value = App.state.settings.codeStyle;
quickUsername.value = App.state.settings.username || 'guest';
menu.onclick = (e) => { e.stopPropagation(); menuDropdown.style.display = menuDropdown.style.display === 'none' ? 'block' : 'none'; };
menuDropdown.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', () => { menuDropdown.style.display = 'none'; });
quickModel.addEventListener('change', () => { App.state.settings.model = quickModel.value; App.saveState(App.state); status.textContent = `Model: ${quickModel.value}`; setTimeout(() => (status.textContent = ''), 1200); });
quickCodeStyle.addEventListener('change', () => { App.state.settings.codeStyle = quickCodeStyle.value; App.saveState(App.state); status.textContent = `Style: ${quickCodeStyle.value}`; setTimeout(() => (status.textContent = ''), 1200); transcript._renderFunction?.(); });
quickUsername.addEventListener('input', () => { App.state.settings.username = quickUsername.value.trim() || 'guest'; App.saveState(App.state); });
clearMemory.onclick = () => { if (confirm('Clear all stored memory and reset? This resets settings and messages.')) { localStorage.clear(); location.reload(); } };
newChat.onclick = () => {
if (!confirm('Start a new chat? This clears messages and resets the session context.')) return;
App.state.messages = [];
App.state.sessionId = newSessionId();
App.saveState(App.state);
transcript._renderFunction && transcript._renderFunction();
status.textContent = `New session: ${App.state.sessionId}`;
setTimeout(() => (status.textContent = ''), 2000);
};
showPrompt.onclick = (e) => {
e.stopPropagation();
const s = App.state.settings;
let codeStylePrompt = '';
if (s.codeStyle === 'fullCode' && s.fullCodePrompt) codeStylePrompt = s.fullCodePrompt;
else if (s.codeStyle === 'snippets' && s.snippetsPrompt) codeStylePrompt = s.snippetsPrompt;
else if (s.codeStyle === 'chunked' && s.chunkedPrompt) codeStylePrompt = s.chunkedPrompt;
const baseSystem = (s.system ?? '').trim() || 'You are a helpful assistant.';
const systemPrompt = codeStylePrompt ? `${baseSystem}\n\nCODE RESPONSE STYLE: ${codeStylePrompt}` : baseSystem;
const activeSettingsSlim = {
model: s.model,
maxTokens: s.maxTokens,
temperature: s.temperature,
system: s.system,
codeStyle: s.codeStyle,
selectedPrompt: codeStylePrompt || '(none)',
debugMode: !!s.debugMode,
username: s.username || 'guest'
};
const nextPayload = {
question: '(your next message)',
model: s.model,
maxTokens: s.maxTokens,
temperature: s.temperature,
system: systemPrompt,
includeHistory: true,
sessionId: App.state.sessionId,
history: computeHistoryOverride()
};
const content =
'ā SYSTEM PROMPT ā\n' + systemPrompt +
'\n\nā ACTIVE SETTINGS ā\n' + JSON.stringify(activeSettingsSlim, null, 2) +
`\n\nā SESSION ā\n${App.state.sessionId}` +
'\n\nā NEXT REQUEST PAYLOAD (Preview) ā\n' + JSON.stringify(nextPayload, null, 2);
openModal({ title: 'Full Prompt Preview', content });
};
function render() {
transcript.innerHTML = '';
App.state.messages.forEach((msg, i) => {
transcript.appendChild(renderMessage(msg, i, render));
});
transcript.scrollTop = transcript.scrollHeight;
}
transcript._renderFunction = render;
async function submit() {
const q = question.value.trim();
if (!q) return;
const s = App.state.settings;
const systemToSend = buildSystemPromptFromSettings(s);
App.state.messages.push({ role: 'user', content: q, ts: Date.now() });
App.saveState(App.state);
render();
question.value = '';
send.disabled = true;
status.textContent = 'Thinking...';
const payload = {
question: q,
model: s.model,
maxTokens: s.maxTokens,
temperature: s.temperature,
system: systemToSend,
includeHistory: true,
sessionId: App.state.sessionId,
history: computeHistoryOverride()
};
if (s.forceTemperature) payload.forceTemperature = true;
if (s.jsonFormat) payload.response_format = { type: 'json_object' };
if (s.includeArtifacts) payload.includeArtifacts = true;
const started = performance.now();
let data = null;
let ok = false;
try {
const res = await fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-Id': App.state.sessionId,
'X-Username': App.state.settings.username || 'guest'
},
body: JSON.stringify(payload)
});
data = await res.json();
if (data.error) {
App.state.messages.push({
role: 'assistant',
content: 'ā ' + data.error,
raw: data,
meta: { sent: { system: systemToSend, payload } },
ts: Date.now()
});
} else {
App.state.messages.push({
role: 'assistant',
content: data.answer || '(no response)',
raw: data,
meta: { sent: { system: systemToSend, payload } },
ts: Date.now()
});
ok = true;
}
} catch (err) {
App.state.messages.push({
role: 'assistant',
content: 'ā Network error: ' + err.message,
raw: { error: String(err) },
meta: { sent: { system: systemToSend, payload } },
ts: Date.now()
});
}
const ended = performance.now();
const latencyMs = Math.round(ended - started);
// Fire-and-forget stats
try {
const usage = (data && data.usage) || {};
const answerText = (data && data.answer) ? String(data.answer) : '';
const statsPayload = {
sessionId: App.state.sessionId,
model: (data && data.model) || s.model,
provider: (data && data.provider) || '',
latencyMs,
inputChars: q.length,
outputChars: answerText.length,
promptTokens: usage?.prompt_tokens ?? usage?.promptTokens ?? 0,
completionTokens: usage?.completion_tokens ?? usage?.completionTokens ?? 0,
totalTokens: usage?.total_tokens ?? usage?.totalTokens ?? 0,
ok: ok,
extra: { codeStyle: s.codeStyle, kind: 'send' }
};
fetch('api.php?action=submit_client_stats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-Id': App.state.sessionId,
'X-Username': App.state.settings.username || 'guest'
},
body: JSON.stringify(statsPayload)
}).catch(() => {});
} catch {}
App.saveState(App.state);
render();
send.disabled = false;
status.textContent = '';
}
send.onclick = submit;
question.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } };
clear.onclick = () => {
if (confirm('Start a new conversation? This clears messages and creates a new session.')) {
App.state.messages = [];
App.state.sessionId = newSessionId();
App.saveState(App.state);
transcript._renderFunction && transcript._renderFunction();
}
};
render();
question.focus();
}
// ---------- Register component ----------
window.AppItems.push({ title: 'Chat', html: generateHTML(), onRender: setupHandlers });
// Optional helpers
App.Chat = {
getMessages: () => App.state.messages,
clearMessages: () => { App.state.messages = []; App.saveState(App.state); },
newSession: () => { App.state.sessionId = newSessionId(); App.saveState(App.state); }
};
console.log('[Chat] Loaded', { messages: App.state.messages.length, sessionId: App.state.sessionId });
})();