📜
chat.js
Back
📝 Javascript ⚡ Executable Ctrl+S: Save • Ctrl+R: Run • Ctrl+F: Find
// chat.js - Simple Chat Component (with Continue wiring, copy buttons, END handling, stats-ready hooks) // - Works with your Settings module (uses App.state.settings.*), including optional settings.continuePrompt // - Continue button appears for chunked style until a single final (END) is present // - Copy buttons on assistant outputs (header-level + per-code block in snippets) // - Smart append rules (no extra newline before closers ; ) ] } , > ) // - Abortable, timeout'd fetch; request ordering guard // - AppMenu + AppOverlay integration (so stats/settings modules can attach) // - LocalStorage guard window.App = window.App || {}; window.AppItems = window.AppItems || []; window.AppMenu = window.AppMenu || []; (() => { // ---------- Defaults ---------- const defaultSettings = { model: 'grok-code-fast-1', maxTokens: 800, temperature: 0.7, forceTemperature: false, includeArtifacts: false, jsonFormat: false, system: 'You are a helpful assistant.', codeStyle: 'default', // 'default' | 'fullCode' | 'snippets' | 'chunked' fullCodePrompt: "You are a coding assistant. Output only raw code with no explanations, no comments, and no backticks. Return the entire document in a single response if possible. Finish with a single line containing exactly (END).", 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'.", // Optional: a separate continuation prompt template; {{TAIL}} will be replaced with recent tail of code continuePrompt: "Continue outputting the code from exactly where the last output stopped. Do not repeat any previous lines. Output only raw code—no backticks, no explanations. Context tail:\n\n{{TAIL}}\n\nResume immediately. When and only when the entire document is complete, write (END) on a new line by itself.", debugMode: false }; // ---------- Utilities ---------- 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()}`; } function ensureState() { if (!App.state) { App.state = { sessionId: newSessionId(), messages: [], settings: { ...defaultSettings } }; } 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 }; // Stats bucket (used by stats.js if present) if (!App.state.stats) { App.state.stats = { totalMessages: 0, modelUsage: {} }; } } // Persist helpers (with guard) if (!App.saveState) { App.saveState = function (state) { try { localStorage.setItem('chatState', JSON.stringify(state)); } catch (e) { console.error('Save failed:', e); openModal({ title: 'Storage Full', content: 'Conversation is too large to save. Consider exporting and starting a new session.' }); } }; } try { const saved = localStorage.getItem('chatState'); if (saved) App.state = JSON.parse(saved); } catch (e) { console.error('Load failed:', e); } ensureState(); // ---------- Minimal AppOverlay (if none) for external modules (settings/stats) ---------- window.AppOverlay = window.AppOverlay || (() => { let backdrop = null; function close() { if (backdrop) { backdrop.remove(); backdrop = null; } } function open(slides, startIndex = 0) { close(); const slide = Array.isArray(slides) ? slides[startIndex] : null; if (!slide) return; backdrop = document.createElement('div'); backdrop.className = 'chat-modal__backdrop'; backdrop.style.zIndex = 1000000; const modal = document.createElement('div'); modal.className = 'chat-modal'; modal.style.width = 'min(900px, 92vw)'; modal.style.maxHeight = '80vh'; modal.style.background = '#0f172a'; modal.style.border = '1px solid #334155'; modal.style.borderRadius = '0.75rem'; modal.style.boxShadow = '0 20px 50px rgba(0,0,0,0.4)'; modal.style.display = 'flex'; modal.style.flexDirection = 'column'; modal.style.overflow = 'hidden'; const header = document.createElement('div'); header.className = 'chat-modal__header'; const title = document.createElement('div'); title.className = 'chat-modal__title'; title.textContent = slide.title || 'Overlay'; const actions = document.createElement('div'); actions.className = 'chat-modal__actions'; const closeBtn = document.createElement('button'); closeBtn.className = 'chat-btn chat-btn--danger'; closeBtn.textContent = 'Close'; closeBtn.onclick = close; actions.appendChild(closeBtn); header.appendChild(title); header.appendChild(actions); const body = document.createElement('div'); body.className = 'chat-modal__body'; body.innerHTML = slide.html || ''; modal.appendChild(header); modal.appendChild(body); backdrop.appendChild(modal); backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); }); document.body.appendChild(backdrop); } return { open, close }; })(); // ---------- Styles (one-time) ---------- 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: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9rem; } .chat-fenced { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9rem; background: #0b1220; border: 1px solid #1f2a44; border-radius: 0.5rem; padding: 0.75rem 1rem; color: #e5e7eb; position: relative; } .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-copy-inline { position: absolute; top: 6px; right: 6px; } .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-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 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; } .chat-modal__backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; z-index: 999999; } .chat-modal { width: min(900px, 92vw); max-height: 80vh; background: #0f172a; border: 1px solid #334155; border-radius: 0.75rem; box-shadow: 0 20px 50px rgba(0,0,0,0.4); display: flex; flex-direction: column; overflow: hidden; z-index: 1000000; } .chat-modal__header { padding: 0.875rem 1rem; background: #111827; border-bottom: 1px solid #334155; display: flex; gap: 0.5rem; align-items: center; justify-content: space-between; } .chat-modal__title { font-weight: 700; font-size: 0.95rem; color: #e5e7eb; } .chat-modal__actions { display: flex; gap: 0.5rem; } .chat-modal__body { padding: 1rem; overflow: auto; } .chat-modal__pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; line-height: 1.5; color: #e5e7eb; background: #0b1220; border: 1px solid #1f2a44; border-radius: 0.5rem; padding: 1rem; } #menu-dropdown { z-index: 500000; } .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, .chat-modal__pre { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; line-break: anywhere; overflow: auto; } .chat-modal, .chat-modal__body { min-width: 0; max-width: 100%; } .chat-debug-pre { background:#0b1220; border:1px solid #334155; border-radius:0.5rem; padding:1rem; max-height:420px; overflow:auto; white-space:pre-wrap; word-break:break-word; font-size:0.8125rem; color:#f1f5f9; } `; document.head.appendChild(style); } // ---------- Helpers ---------- function copyText(text, btn) { const toCopy = String(text || '').replace(/\u00a0/g, ' '); if (navigator.clipboard?.writeText) { navigator.clipboard.writeText(toCopy).then(() => { if (btn) { const prev = btn.textContent; btn.textContent = 'Copied ✓'; setTimeout(() => (btn.textContent = prev || 'Copy'), 1200); } }).catch(() => fallbackCopy(toCopy, btn)); } else { fallbackCopy(toCopy, btn); } } function fallbackCopy(str, btn) { const ta = document.createElement('textarea'); ta.value = str; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); if (btn) { const prev = btn.textContent; btn.textContent = 'Copied ✓'; setTimeout(() => (btn.textContent = prev || 'Copy'), 1200); } } 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.addEventListener('click', (e) => { if (e.target === backdrop) backdrop.remove(); }); const modal = document.createElement('div'); modal.className = 'chat-modal'; modal.addEventListener('click', e => e.stopPropagation()); const header = document.createElement('div'); header.className = 'chat-modal__header'; const hTitle = document.createElement('div'); hTitle.className = 'chat-modal__title'; hTitle.textContent = title; const actions = document.createElement('div'); actions.className = 'chat-modal__actions'; const copyBtn = document.createElement('button'); copyBtn.className = 'chat-btn'; copyBtn.textContent = 'Copy'; copyBtn.onclick = () => copyText(content, copyBtn); 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.className = 'chat-modal__body'; const pre = document.createElement('pre'); pre.className = 'chat-modal__pre'; pre.textContent = content; body.appendChild(pre); modal.appendChild(header); modal.appendChild(body); backdrop.appendChild(modal); document.body.appendChild(backdrop); } 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 buildContinuationPromptFromSettings(s, tail) { const template = (s.continuePrompt || defaultSettings.continuePrompt); return String(template).replace('{{TAIL}}', String(tail || '')); } function computeHistoryOverride() { return (App.state.messages || []) .filter(m => m.role === 'user' || m.role === 'assistant') .map(m => ({ role: m.role, content: String(m.content || '') })); } // Smart append: avoid extra newline when next chunk begins with a closer punctuation/brace/etc. function appendSmart(prevText, nextChunk) { if (!nextChunk) return prevText || ''; let prev = String(prevText || ''); let next = String(nextChunk || ''); // Normalize line breaks prev = prev.replace(/\r\n/g, '\n'); next = next.replace(/\r\n/g, '\n'); // If prev already ends with (END), avoid adding anything past duplicate ends; but still tidy below // (We still let the END tidy handle multiples.) const needsNewline = !/\n$/.test(prev) && !/^\n/.test(next) && // if next starts with a closer, no newline !/^[\s]*[;)\]}>,]/.test(next); // Merge const merged = (needsNewline ? prev + '\n' : prev) + next; // Collapse repeated END variants at the tail to a single, canonical (END) if present return merged.replace(/\s*(?:\(?END\)?\s*){2,}$/i, '(END)'); } function isComplete(text) { return /\(?END\)?\s*$/i.test(String(text || '')); } // Abortable, timeout'd JSON fetch with one retry on transient errors async function fetchJSON(url, options = {}, { timeoutMs = 60000, retryOn = [408, 429, 500, 502, 503, 504] } = {}) { const attempt = async () => { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(url, { ...options, signal: ctrl.signal }); if (!res.ok) { const msg = `${res.status} ${res.statusText}`; const body = await res.text().catch(() => ''); throw Object.assign(new Error(msg), { status: res.status, body }); } return await res.json(); } finally { clearTimeout(t); } }; try { return await attempt(); } catch (e) { if (retryOn.includes(e.status)) { await new Promise(r => setTimeout(r, 700)); return attempt(); } throw e; } } // Request ordering guard let REQ_ID = 0; function nextReqId() { return ++REQ_ID; } function isLatestReq(id) { return id === REQ_ID; } function extractTextFromAPI(data) { // Try common shapes: {answer}, OpenAI-like {choices[0].message.content} or {choices[0].text} if (typeof data?.answer === 'string') return data.answer; const c0 = data?.choices?.[0]; if (c0?.message?.content) return c0.message.content; if (typeof c0?.text === 'string') return c0.text; return ''; } // ---------- Rendering ---------- 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;'; // Copy button (assistant messages only) if (msg.role === 'assistant') { const copyBtn = document.createElement('button'); copyBtn.className = 'chat-btn'; copyBtn.textContent = 'Copy'; copyBtn.title = 'Copy full assistant message'; copyBtn.onclick = () => { const raw = String(msg.content || '').replace(/\s*(?:\(?END\)?\s*)+$/i, '(END)'); copyText(raw, copyBtn); }; headerBtns.appendChild(copyBtn); } const deleteBtn = document.createElement('button'); deleteBtn.className = 'chat-btn chat-btn--danger'; deleteBtn.textContent = '×'; deleteBtn.title = 'Delete this message'; 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); // DEBUG MODE if (s.debugMode && msg.role === 'assistant') { const pre = document.createElement('pre'); pre.className = 'chat-debug-pre'; const debugObj = { sent: msg.meta?.sent ?? null, response: msg.raw ?? msg.content ?? null }; pre.textContent = JSON.stringify(debugObj, null, 2); card.appendChild(header); card.appendChild(pre); wrapper.appendChild(card); return wrapper; } const content = document.createElement('div'); content.className = 'chat-card__content'; const raw = (msg.content || '').replace(/\s*(?:\(?END\)?\s*)$/i, ''); // hide trailing END in view // USER messages: collapsible 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); } } // DEFAULT: plain text else if (msg.role === 'assistant' && s.codeStyle === 'default') { const node = document.createElement('div'); node.textContent = raw.trim(); content.appendChild(node); } // SNIPPETS: interleaved text and code, with copy buttons on code blocks 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 }); } const totalLines = parts.reduce((sum, part) => sum + part.content.split('\n').length, 0); const shouldCollapse = totalLines > 10; function renderPart(container, part, truncated = false) { if (part.type === 'text') { const textDiv = document.createElement('div'); textDiv.style.cssText = 'margin-bottom: 0.75rem; line-height: 1.6;'; textDiv.textContent = truncated ? (part.content.split('\n').slice(0, 3).join('\n') + '…') : part.content; container.appendChild(textDiv); } else { const pre = document.createElement('pre'); pre.className = 'chat-fenced'; pre.style.cssText = 'margin-bottom: 0.75rem;'; pre.textContent = truncated ? (part.content.split('\n').slice(0, 8).join('\n') + (part.content.split('\n').length > 8 ? '\n…' : '')) : part.content; // Inline copy button const copyBtn = document.createElement('button'); copyBtn.className = 'chat-btn chat-copy-inline'; copyBtn.textContent = 'Copy'; copyBtn.onclick = (e) => { e.stopPropagation(); copyText(part.content, copyBtn); }; const box = document.createElement('div'); box.style.position = 'relative'; box.appendChild(pre); box.appendChild(copyBtn); container.appendChild(box); } } if (!shouldCollapse) { parts.forEach(p => renderPart(content, p)); } else { const previewContainer = document.createElement('div'); const fullContainer = document.createElement('div'); fullContainer.style.display = 'none'; let previewLines = 0, previewIndex = 0; for (let i = 0; i < parts.length; i++) { const lineCount = parts[i].content.split('\n').length; if (previewLines + lineCount <= 8) { previewLines += lineCount; previewIndex = i + 1; } else break; } parts.slice(0, Math.max(1, previewIndex)).forEach(p => renderPart(previewContainer, p, true)); parts.forEach(p => renderPart(fullContainer, p, false)); content.appendChild(previewContainer); content.appendChild(fullContainer); 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 = fullContainer.style.display !== 'none'; previewContainer.style.display = isExpanded ? 'block' : 'none'; fullContainer.style.display = isExpanded ? 'none' : 'block'; expandBtn.textContent = isExpanded ? '▼ Show more' : '▲ Show less'; }; content.appendChild(expandBtn); } } // CHUNKED / FULLCODE: code view with collapse + header copy works else if (isRawCodeOutput) { const lines = raw.split('\n'); const shouldCollapse = lines.length > 14; 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, 12).join('\n') + '\n…'; full.textContent = lines.slice(12).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 for chunked const isLast = index === App.state.messages.length - 1; if ( App.state.settings.codeStyle === 'chunked' && msg.role === 'assistant' && isLast && !isComplete(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; background: linear-gradient(135deg, #10b981, #059669); color: white; border: none; font-weight: 600;'; continueBtn.onclick = async () => { const s2 = App.state.settings; const sendBtn = document.querySelector('#send'); const statusEl = document.querySelector('#status'); if (!sendBtn || !statusEl) return; continueBtn.disabled = true; continueBtn.textContent = 'Continuing...'; statusEl.textContent = 'Thinking...'; sendBtn.disabled = true; // Build continuation prompt with tail const fullSoFar = String(App.state.messages[index].content || ''); const tailChars = 1600; const tail = fullSoFar.slice(-tailChars); const continuationPrompt = buildContinuationPromptFromSettings(s2, tail); const systemToSend = buildSystemPromptFromSettings(s2); const payload = { question: continuationPrompt, model: s2.model, maxTokens: Math.max(2048, s2.maxTokens || 0), temperature: Math.min(0.2, s2.temperature ?? 0.7), system: systemToSend, includeHistory: true, sessionId: App.state.sessionId, history: computeHistoryOverride() }; if (s2.forceTemperature) payload.forceTemperature = true; if (s2.includeArtifacts) payload.includeArtifacts = true; // IMPORTANT: no JSON mode for raw code continuation if (s2.jsonFormat) ; // do nothing; JSON mode off by omission const reqId = nextReqId(); try { const data = await fetchJSON('api.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-Id': App.state.sessionId }, body: JSON.stringify(payload) }); if (!isLatestReq(reqId)) return; const nextChunk = extractTextFromAPI(data) || ''; const before = App.state.messages[index].content; const merged = appendSmart(before, nextChunk); App.state.messages[index].content = merged; // END tidy & button state const doneNow = isComplete(merged) || isComplete(nextChunk); if (doneNow) { App.state.messages[index].content = App.state.messages[index].content.replace(/\s*(?:\(?END\)?\s*)+$/i, '(END)').trimEnd(); continueBtn.remove(); statusEl.textContent = 'Complete!'; } else { // No-op guard if ((merged.length - before.length) <= 0) { statusEl.textContent = 'No new text returned—try Continue again.'; } else { statusEl.textContent = `Added ${merged.length - before.length} chars`; } continueBtn.disabled = false; continueBtn.textContent = '▶ Continue'; } App.saveState(App.state); const transcript = document.querySelector('#transcript'); if (transcript && transcript._renderFunction) { transcript._renderFunction(); transcript.scrollTop = transcript.scrollHeight; } } catch (err) { alert('Network error: ' + err.message); continueBtn.disabled = false; continueBtn.textContent = '▶ Continue'; } finally { sendBtn.disabled = false; if (statusEl.textContent === 'Thinking...') statusEl.textContent = ''; } }; card.appendChild(continueBtn); } wrapper.appendChild(card); return wrapper; } // ---------- 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" title="Send (Ctrl/Cmd+Enter)">↑</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: 260px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 1200;"> <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> <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> <!-- AppMenu items (stats/settings) appended below --> </div> <div id="status" style="margin-top: 0.5rem; color: #94a3b8; font-size: 0.875rem;"></div> </div> </div> `; } // ---------- Handlers ---------- 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 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; 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); if (transcript && transcript._renderFunction) transcript._renderFunction(); }); 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); if (transcript && 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)', continuePrompt: s.continuePrompt ? '[customized]' : '(default)', debugMode: !!s.debugMode }; 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 (selected only) —\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; // AppMenu (Stats / Settings modules can register) try { if (window.AppMenu && window.AppMenu.length) { const sep = document.createElement('div'); sep.style.cssText = 'border-top: 1px solid #334155; margin-top: 0.5rem;'; menuDropdown.appendChild(sep); window.AppMenu.forEach(item => { const b = document.createElement('button'); b.style.cssText = 'width: 100%; padding: 0.75rem; text-align: left; background: none; border: none; color: #f1f5f9; font-size: 0.875rem; cursor: pointer;'; b.textContent = item.label || 'Item'; b.addEventListener('click', (e) => { e.stopPropagation(); try { item.action && item.action(); } catch (err) { console.error(err); } menuDropdown.style.display = 'none'; }); menuDropdown.appendChild(b); }); } } catch (e) { console.warn('AppMenu render failed:', e); } // ---- Submit + Continue plumbing ---- 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 reqId = nextReqId(); try { const data = await fetchJSON('api.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-Id': App.state.sessionId }, body: JSON.stringify(payload) }); if (!isLatestReq(reqId)) return; const extracted = extractTextFromAPI(data); App.state.messages.push({ role: 'assistant', content: extracted || '(no response)', raw: data, meta: { sent: { system: systemToSend, payload } }, ts: Date.now() }); } catch (err) { App.state.messages.push({ role: 'assistant', content: '❌ ' + err.message, raw: { error: String(err) }, meta: { sent: { system: systemToSend, payload } }, ts: Date.now() }); } App.saveState(App.state); render(); send.disabled = false; status.textContent = ''; } send.onclick = submit; question.onkeydown = (e) => { // Enter to send (Shift+Enter for newline) if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } // Cmd/Ctrl+Enter also sends if ((e.key === 'Enter') && (e.metaKey || e.ctrlKey)) { 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(); } // Mount const mountHTML = generateHTML(); window.AppItems.push({ title: 'Chat', html: mountHTML, onRender: setupHandlers }); // Optional helpers for external usage 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 }); })();