// chat.js - Chat Component for AppOverlay System
window.App = window.App || {};
window.AppItems = window.AppItems || [];
(() => {
// Initialize App.state if it doesn't exist
if (!App.state) {
App.state = {
messages: [],
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: 'Provide complete, working code.',
snippetsPrompt: 'Provide focused code snippets.',
chunkedPrompt: 'Provide code in logical chunks.'
},
stitcher: { chunks: [] },
stats: { totalMessages: 0, modelUsage: {} }
};
}
// Create App.saveState if it doesn't exist
if (!App.saveState) {
App.saveState = function(state) {
try {
localStorage.setItem('chatState', JSON.stringify(state));
} catch (e) {
console.error('Failed to save state:', e);
}
};
}
// Create App.trackUsage if it doesn't exist
if (!App.trackUsage) {
App.trackUsage = function(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;
App.saveState(App.state);
};
}
// Load from localStorage
try {
const saved = localStorage.getItem('chatState');
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.messages) App.state.messages = parsed.messages;
if (parsed.stats) App.state.stats = parsed.stats;
if (parsed.settings) Object.assign(App.state.settings, parsed.settings);
}
} catch (e) {
console.error('Failed to load chat state:', e);
}
// Utilities
function detectLanguage(code) {
if (code.includes('<!doctype html>') || code.includes('<html')) return 'html';
if (code.includes('<?php') || code.includes('$_')) return 'php';
if (code.includes('def ') && code.includes(':')) return 'python';
if (code.includes('class ') && code.includes('public')) return 'java';
if (code.includes('#include') || code.includes('int main')) return 'c';
if (code.includes('console.log') || code.includes('document.')) return 'javascript';
if (code.includes('import ') && code.includes('from ')) return 'javascript';
return 'text';
}
function fallbackCopy(text, button) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand('copy');
button.textContent = 'Copied!';
setTimeout(() => button.textContent = 'Copy', 2000);
} catch {
button.textContent = 'Failed';
setTimeout(() => button.textContent = 'Copy', 2000);
}
document.body.removeChild(ta);
}
function updateDebugInfo(message) {
const debugEl = document.getElementById('chatDebug');
if (debugEl) {
const timestamp = new Date().toLocaleTimeString();
const existing = debugEl.textContent || '';
debugEl.textContent = `[${timestamp}] ${message}\n${existing}`.slice(0, 2000);
}
}
function createCodeBlock(code, language = 'text', isUserMessage = false) {
const container = document.createElement('div');
container.style.cssText = 'margin: 1rem 0; border: 1px solid #374151; border-radius: 0.5rem; overflow: hidden; background: #111827;';
const header = document.createElement('div');
header.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 1rem; background: #1f2937; border-bottom: 1px solid #374151;';
const headerLeft = document.createElement('div');
headerLeft.style.cssText = 'display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none;';
const collapseIcon = document.createElement('span');
collapseIcon.textContent = '▼';
collapseIcon.style.cssText = 'color: #9ca3af; font-size: 0.75rem;';
const langLabel = document.createElement('span');
langLabel.textContent = (language || 'text').toUpperCase();
langLabel.style.cssText = 'color: #d1d5db; font-size: 0.75rem; font-weight: 600;';
headerLeft.append(collapseIcon, langLabel);
const headerRight = document.createElement('div');
headerRight.style.cssText = 'display: flex; gap: 0.5rem;';
const copyBtn = document.createElement('button');
copyBtn.textContent = 'Copy';
copyBtn.style.cssText = 'padding: 0.25rem 0.75rem; font-size: 0.75rem; background: #374151; color: #f3f4f6; border: none; border-radius: 0.25rem; cursor: pointer; transition: background 0.2s;';
copyBtn.onmouseover = () => copyBtn.style.background = '#4b5563';
copyBtn.onmouseout = () => copyBtn.style.background = '#374151';
headerRight.appendChild(copyBtn);
header.append(headerLeft, headerRight);
const content = document.createElement('pre');
content.style.cssText = 'margin: 0; padding: 1rem; background: #111827; color: #f3f4f6; overflow-x: auto; font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace; font-size: 0.8125rem; line-height: 1.6; -webkit-overflow-scrolling: touch;';
content.textContent = code;
container.append(header, content);
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(code).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => copyBtn.textContent = 'Copy', 2000);
}).catch(() => fallbackCopy(code, copyBtn));
} else {
fallbackCopy(code, copyBtn);
}
});
headerLeft.addEventListener('click', () => {
const isCollapsed = content.style.display === 'none';
content.style.display = isCollapsed ? 'block' : 'none';
collapseIcon.textContent = isCollapsed ? '▼' : '►';
});
return container;
}
function parseUserMessageForCodeBlocks(text) {
const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) });
}
parts.push({ type: 'code', language: match[1] || 'text', content: match[2] });
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push({ type: 'text', content: text.slice(lastIndex) });
}
return parts.length > 0 ? parts : [{ type: 'text', content: text }];
}
function renderMessage(msg, index, renderCallback) {
const wrapper = document.createElement('div');
wrapper.style.cssText = 'margin-bottom: 1.5rem; position: relative;';
const isUser = msg.role === 'user';
const deleteBtn = document.createElement('button');
deleteBtn.textContent = '×';
deleteBtn.title = 'Delete message';
deleteBtn.style.cssText = `position: absolute; top: -8px; ${isUser ? 'right: -8px' : 'left: -8px'}; width: 24px; height: 24px; border-radius: 50%; background: #ef4444; color: white; border: 2px solid #1f2937; font-size: 1.25rem; line-height: 1; cursor: pointer; opacity: 0; transition: opacity 0.2s; display: flex; align-items: center; justify-content: center; z-index: 10; font-weight: 700;`;
wrapper.onmouseenter = () => deleteBtn.style.opacity = '1';
wrapper.onmouseleave = () => deleteBtn.style.opacity = '0';
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
deleteBtn.style.opacity = '0.7';
}
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (confirm('Delete this message?')) {
App.state.messages.splice(index, 1);
App.saveState(App.state);
updateDebugInfo(`Deleted message ${index + 1}`);
if (typeof renderCallback === 'function') renderCallback();
}
});
const bubble = document.createElement('div');
bubble.style.cssText = `padding: 1rem; border-radius: 0.75rem; position: relative; ${isUser ? 'background: linear-gradient(135deg, #4f46e5, #6366f1); color: white; margin-left: 15%; box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.3);' : 'background: #1f2937; color: #f3f4f6; margin-right: 15%; border: 1px solid #374151; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);'}`;
const header = document.createElement('div');
header.style.cssText = `font-size: 0.75rem; opacity: 0.8; margin-bottom: 0.5rem; font-weight: 600; ${isUser ? 'color: rgba(255,255,255,0.9)' : 'color: #9ca3af'}`;
const dt = new Date(msg.ts || Date.now());
header.textContent = `${isUser ? 'You' : 'Assistant'} • ${dt.toLocaleTimeString()}`;
bubble.appendChild(header);
const body = document.createElement('div');
body.style.cssText = 'font-size: 0.875rem; line-height: 1.6;';
if (!isUser && typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
try {
const raw = marked.parse(msg.content || '');
const safeHtml = DOMPurify.sanitize(raw);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = safeHtml;
tempDiv.querySelectorAll('p, ul, ol, blockquote, h1, h2, h3, h4, h5, h6').forEach(el => {
el.style.color = '#f3f4f6';
if (el.tagName === 'BLOCKQUOTE') {
el.style.borderLeft = '3px solid #4f46e5';
el.style.paddingLeft = '1rem';
el.style.fontStyle = 'italic';
el.style.opacity = '0.9';
}
});
const codeBlocks = tempDiv.querySelectorAll('pre code');
codeBlocks.forEach(codeEl => {
const code = codeEl.textContent;
let language = 'text';
for (const cls of codeEl.classList) {
if (cls.startsWith('language-')) {
language = cls.slice(9);
break;
}
}
if (language === 'text') language = detectLanguage(code);
const codeBlock = createCodeBlock(code, language, false);
codeEl.closest('pre').replaceWith(codeBlock);
});
body.replaceChildren(...tempDiv.childNodes);
} catch (e) {
console.error('render error', e);
body.textContent = msg.content || '';
}
} else {
const parts = parseUserMessageForCodeBlocks(msg.content || '');
parts.forEach(part => {
if (part.type === 'text') {
const textNode = document.createElement('div');
textNode.style.whiteSpace = 'pre-wrap';
textNode.style.wordBreak = 'break-word';
textNode.textContent = part.content;
body.appendChild(textNode);
} else if (part.type === 'code') {
body.appendChild(createCodeBlock(part.content, part.language, isUser));
}
});
}
bubble.appendChild(body);
if (!isUser) {
// Check if response indicates continuation needed
const needsContinuation =
msg.content.toLowerCase().includes('[continued in next response]') ||
msg.content.toLowerCase().includes('[to be continued]') ||
msg.content.toLowerCase().includes('[more to follow]') ||
msg.content.toLowerCase().includes('[continue]') ||
msg.content.trim().endsWith('...') ||
(App.state?.settings?.codeStyle === 'chunked' && msg.content.length > 1500);
if (needsContinuation) {
const action = document.createElement('button');
action.style.cssText = 'margin-top: 0.75rem; padding: 0.625rem 1.25rem; background: linear-gradient(135deg, #10b981, #059669); color: white; border: none; border-radius: 0.5rem; font-size: 0.875rem; font-weight: 600; cursor: pointer; transition: all 0.2s; box-shadow: 0 2px 4px rgba(16, 185, 129, 0.3);';
action.textContent = '▶ Continue';
action.onmouseover = () => {
action.style.transform = 'translateY(-1px)';
action.style.boxShadow = '0 4px 6px rgba(16, 185, 129, 0.4)';
};
action.onmouseout = () => {
action.style.transform = 'translateY(0)';
action.style.boxShadow = '0 2px 4px rgba(16, 185, 129, 0.3)';
};
action.onclick = () => {
const codes = bubble.querySelectorAll('.code-container .code-content');
const questionEl = document.querySelector('#chatQuestion');
const sendBtn = document.querySelector('#chatSend');
if (!questionEl) return;
let continuePrompt = '';
if (codes.length > 0) {
const last = codes[codes.length - 1].textContent.split('\n');
const context = last.slice(-8).join('\n');
const langLabel = codes[codes.length - 1].closest('.code-container').querySelector('.code-header span');
const language = langLabel ? langLabel.textContent.toLowerCase() : 'code';
continuePrompt = `Continue from this point:\n\n\`\`\`${language}\n${context}\n\`\`\`\n\nPlease continue.`;
} else {
continuePrompt = 'Please continue from where you left off.';
}
// Set value and auto-submit
questionEl.value = continuePrompt;
if (sendBtn) sendBtn.click();
};
bubble.appendChild(action);
}
}
wrapper.appendChild(deleteBtn);
wrapper.appendChild(bubble);
return wrapper;
}
function generateChatHTML() {
return `
<div style="display: flex; flex-direction: column; height: 100%; background: #0f172a; overflow: hidden;">
<div style="flex-shrink: 0; padding: 1rem; background: #1e293b; border-bottom: 1px solid #334155;">
<div style="display: flex; gap: 0.75rem; align-items: flex-start;">
<textarea
id="chatQuestion"
placeholder="Type your message..."
style="flex: 1; min-height: 60px; max-height: 120px; padding: 0.875rem; border: 1px solid #334155; border-radius: 0.5rem; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 0.9375rem; resize: vertical; background: #0f172a; color: #f1f5f9; line-height: 1.5; box-sizing: border-box;"
></textarea>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<button
id="chatSend"
title="Send message"
style="width: 44px; height: 44px; padding: 0; background: linear-gradient(135deg, #4f46e5, #6366f1); color: white; border: none; border-radius: 0.5rem; cursor: pointer; font-size: 1.25rem; box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.4); transition: transform 0.1s, box-shadow 0.2s; display: flex; align-items: center; justify-content: center;"
onmouseover="this.style.transform='translateY(-1px)'; this.style.boxShadow='0 6px 8px -1px rgba(79, 70, 229, 0.5)'"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 6px -1px rgba(79, 70, 229, 0.4)'"
>
↑
</button>
<details style="position: relative;">
<summary style="cursor: pointer; color: #94a3b8; font-size: 1.25rem; user-select: none; list-style: none; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; border-radius: 0.5rem; background: #374151;" onmouseover="this.style.background='#4b5563'" onmouseout="this.style.background='#374151'">⋮</summary>
<div style="position: absolute; right: 0; top: 100%; margin-top: 0.5rem; background: #1e293b; border: 1px solid #374151; border-radius: 0.5rem; min-width: 300px; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3); z-index: 100;">
<div style="padding: 0.5rem; border-bottom: 1px solid #374151; color: #94a3b8; font-size: 0.75rem; font-weight: 600;">Debug Info</div>
<pre id="chatDebug" style="margin: 0; padding: 0.75rem; background: #0f172a; color: #94a3b8; font-size: 0.6875rem; overflow-x: auto; max-height: 300px; font-family: 'SF Mono', Monaco, monospace; line-height: 1.4; white-space: pre-wrap; word-break: break-word;"></pre>
</div>
</details>
</div>
</div>
<div style="margin-top: 0.5rem; padding-left: 0.25rem;">
<span id="chatStatus" style="color: #94a3b8; font-size: 0.8125rem;"></span>
</div>
</div>
<div id="chatTranscript" style="flex: 1; overflow-y: auto; padding: 1.5rem; background: #0f172a; -webkit-overflow-scrolling: touch;"></div>
</div>
`;
}
function setupChatHandlers(container) {
const transcript = container.querySelector('#chatTranscript');
const question = container.querySelector('#chatQuestion');
const send = container.querySelector('#chatSend');
const status = container.querySelector('#chatStatus');
const debug = container.querySelector('#chatDebug');
updateDebugInfo(`Chat loaded with ${App.state.messages.length} messages`);
function renderTranscript() {
transcript.innerHTML = '';
const reversed = [...App.state.messages].reverse();
reversed.forEach((msg, idx) => {
const originalIndex = App.state.messages.length - 1 - idx;
transcript.appendChild(renderMessage(msg, originalIndex, renderTranscript));
});
transcript.scrollTop = 0;
}
async function submitMessage() {
const q = question.value.trim();
if (!q) return;
const timestamp = Date.now();
const s = App.state.settings;
let systemPrompt = s.system || '';
if (s.codeStyle === 'fullCode' && s.fullCodePrompt) {
systemPrompt += '\n\nCODE RESPONSE STYLE: ' + s.fullCodePrompt;
} else if (s.codeStyle === 'snippets' && s.snippetsPrompt) {
systemPrompt += '\n\nCODE RESPONSE STYLE: ' + s.snippetsPrompt;
} else if (s.codeStyle === 'chunked' && s.chunkedPrompt) {
systemPrompt += '\n\nCODE RESPONSE STYLE: ' + s.chunkedPrompt;
}
const payload = {
question: q,
model: s.model,
maxTokens: s.maxTokens,
temperature: s.temperature,
system: systemPrompt || undefined,
includeArtifacts: s.includeArtifacts,
_t: timestamp
};
if (s.forceTemperature) payload.forceTemperature = true;
if (s.jsonFormat) payload.response_format = { type: 'json_object' };
App.state.messages.push({ role: 'user', content: q, ts: Date.now() });
App.saveState(App.state);
updateDebugInfo(`Sent: ${s.model} (${s.maxTokens} tokens, temp ${s.temperature})`);
renderTranscript();
question.value = '';
send.disabled = true;
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'
},
body: JSON.stringify(payload)
});
resJSON = await res.json();
} catch (err) {
resJSON = { error: 'Network error', debug: String(err) };
}
send.disabled = false;
debug.textContent = JSON.stringify({ request: payload, response: resJSON }, null, 2);
if (!resJSON || resJSON.error) {
const msg = resJSON?.error || 'Unknown error';
const dbg = resJSON?.debug ? `\n\nDebug: ${JSON.stringify(resJSON.debug)}` : '';
App.state.messages.push({ role: 'assistant', content: `❌ ${msg}${dbg}`, ts: Date.now() });
App.saveState(App.state);
updateDebugInfo(`Error: ${msg}`);
renderTranscript();
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(' · ')}*`;
if (usage && model) {
App.trackUsage(model, usage);
updateDebugInfo(`Tracked: ${usage.total_tokens ?? 0} tokens for ${model}`);
}
App.state.messages.push({ role: 'assistant', content, ts: Date.now() });
App.saveState(App.state);
renderTranscript();
status.textContent = 'Done';
setTimeout(() => status.textContent = '', 1200);
}
send.addEventListener('click', submitMessage);
question.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !('ontouchstart' in window)) {
e.preventDefault();
submitMessage();
}
});
setTimeout(() => question.focus(), 100);
renderTranscript();
}
// Register with AppItems
window.AppItems.push({
title: 'Chat',
html: generateChatHTML(),
onRender: setupChatHandlers
});
// Export to App namespace
App.Chat = {
getMessages: () => App.state.messages,
clearMessages: () => {
App.state.messages = [];
App.saveState(App.state);
if (window.AppOverlay) AppOverlay.close();
}
};
console.log('[Chat] Component registered with', App.state.messages.length, 'messages');
})();