/* * ChatStudio Overlay Module for newOS4 — reads window.NEWOS4_VERSION at runtime * Integrates ChatStudio Lite's full chat UI as an overlay launched via `chat` command. * All Lite engine functions are inlined from lite/cslite-core.js, adapted to use * the terminal's OllamaManager for endpoint configuration. */ class ChatStudioModule { constructor() { this.commands = {}; this.terminal = null; this._overlay = null; this._active = false; this._cssLoaded = false; this._escapeHandler = null; this._initialized = false; } getHelpInfo() { return { 'chat': 'Toggle the ChatStudio overlay (inside: /setapi, /setmodel, /ask, /help)', }; } registerCommands() { this.commands['chat'] = () => this.toggleChatOverlay(); } toggleChatOverlay() { if (this._active) { this._closeOverlay(); } else { this._openOverlay(); } } _loadOverlayAssets() { return new Promise((resolve, reject) => { // Load CSS — gate resolve on onload to prevent FOUC var cssReady = Promise.resolve(); if (!this._cssLoaded) { cssReady = new Promise((res, rej) => { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'chat/chat-ui.css?v=' + (window.NEWOS4_VERSION || '5'); link.onload = () => { this._cssLoaded = true; res(); }; link.onerror = () => rej(new Error('Failed to load chat/chat-ui.css')); document.head.appendChild(link); }); } // Expose ALL Lite globals BEFORE scripts load so chat-modules.js & chat-commands.js can use them if (!window._csGlobalsInited) { window.OLLAMA_URL = (this.terminal && this.terminal.ollamaManager && this.terminal.ollamaManager.endpoint) || '/ollama'; window.OLLAMA_API_KEY = ''; window.DEFAULT_MODEL = 'llama3.2'; window.USE_STREAMING = true; window.API_BASE = '/api'; window.latestAiContent = ''; window.logs = []; window.messages = []; window.isStreaming = false; window.CHATSTUDIO_VERSION = window.NEWOS4_VERSION || '1.0.0-lite-inline'; window.modules = []; window.currentChatModule = 'default'; // Utility functions window.utf8_to_b64 = function(str) { return btoa(unescape(encodeURIComponent(str))); }; window.b64_to_utf8 = function(str) { return decodeURIComponent(escape(atob(str))); }; window.escapeHtml = function(text) { if (typeof text !== 'string') return ''; return text.replace(/&/g, '&').replace(//g, '>').replace(/\\\"/g, '"').replace(/'/g, '''); }; window.generateId = function() { return Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); }; window.showToast = function(msg) { var t = document.getElementById('toast'); if (!t) return; t.textContent = msg; t.style.display = 'block'; setTimeout(function() { t.style.display = 'none'; }, 2500); }; window.addLog = function(action, detail) { // Placeholder — replaced by full impl in _initLiteEngine }; // Placeholder functions — replaced by full implementations in _initLiteEngine window.addMessage = function(role, content) {}; window.showTyping = function() {}; window.hideTyping = function() {}; window.loadSettings = function() {}; window.saveSettings = function() {}; window.loadModels = function() {}; window.switchModule = function(id) {}; window.getCurrentModule = function() {}; window.sendMessage = function() {}; window.clearChat = function() {}; window.closeSettingsModal = function() {}; window.openSettingsModal = function() {}; window._csGlobalsInited = true; } // Fetch HTML template (cached after first load) — wait for CSS first if (this._overlayHtml) { // Overlay div already exists (just hidden). Wait for CSS then resolve. cssReady.then(() => resolve()); return; } cssReady .then(() => fetch('chat/chat-ui.html?v=' + (window.NEWOS4_VERSION || '5'))) .then(r => r.text()) .then(html => { this._overlayHtml = html; // cache for re-open // Insert overlay into body if not already there if (!document.getElementById('chat-overlay')) { const temp = document.createElement('div'); temp.innerHTML = html; document.body.appendChild(temp.firstElementChild); // Load commands.js and modules.js dynamically const loadScript = (src) => { return new Promise((res, rej) => { const s = document.createElement('script'); s.src = src + '?v=' + (window.NEWOS4_VERSION || '5'); s.onload = () => res(); s.onerror = () => rej(new Error('Failed to load ' + src)); document.body.appendChild(s); }); }; Promise.all([ loadScript('chat/chat-commands.js'), loadScript('chat/chat-modules.js') ]).then(() => { resolve(); }); } else { resolve(); } }) .catch(() => reject(new Error('Failed to load chat/chat-ui.html'))); }); } async _openOverlay() { if (this._active) return; this._active = true; try { // Load overlay assets first (CSS, HTML, scripts) await this._loadOverlayAssets(); // Initialize the Lite engine this._initLiteEngine(); // Show overlay const overlay = document.getElementById('chat-overlay'); if (overlay) { overlay.style.display = 'flex'; overlay.style.flexDirection = 'column'; } // Add Escape listener this._escapeHandler = (e) => { if (e.key === 'Escape') { this._closeOverlay(); } }; document.addEventListener('keydown', this._escapeHandler); // Set window-level endpoint for Lite to use if (this.terminal && this.terminal.ollamaManager) { window.ollamaEndpoint = this.terminal.ollamaManager.endpoint; } } catch (err) { // Print error to terminal if (this.terminal && this.terminal.print) { this.terminal.print('Chat overlay failed: ' + err.message, 'error'); } // Show a toast on the main page if possible if (typeof window.showToast === 'function') { window.showToast('Chat overlay failed: ' + err.message); } // Clean up any partial overlay DOM — hide instead of remove so cached HTML path still works const partialOverlay = document.getElementById('chat-overlay'); if (partialOverlay) { partialOverlay.style.display = 'none'; } // Reset state so user can retry this._active = false; } } _closeOverlay() { if (!this._active) return; this._active = false; const overlay = document.getElementById('chat-overlay'); if (overlay) { overlay.style.display = 'none'; } // Remove Escape listener if (this._escapeHandler) { document.removeEventListener('keydown', this._escapeHandler); this._escapeHandler = null; } // Focus terminal input if (this.terminal && this.terminal.inputField) { setTimeout(() => this.terminal.inputField.focus(), 50); } } _initLiteEngine() { // Already initialized — no-op if (this._initialized) return; // Set the overlay reference this._overlay = document.getElementById('chat-overlay'); if (!this._overlay) { if (this.terminal && this.terminal.print) { this.terminal.print('Chat overlay: #chat-overlay element not found', 'error'); } return; } this._initialized = true; try { // Close button already exists in HTML as "✕ Close" button calling closeChatOverlay() window.closeChatOverlay = () => this._closeOverlay(); // ── Constants ── const CHATSTUDIO_VERSION = window.NEWOS4_VERSION || '1.0.0-lite-inline'; const API_KEY = 'webx11_chat_api'; const MESSAGES_KEY = 'cslite_messages'; const SETTINGS_KEY = 'webx11_chat_settings'; const LOGS_KEY = 'webx11_chat_logs'; // P2#11: API key is now kept in-memory only (not persisted to localStorage) // It's loaded into OLLAMA_API_KEY during session, cleared on page reload // ── State ── let OLLAMA_URL = '/ollama'; let OLLAMA_API_KEY = ''; let DEFAULT_MODEL = 'llama3.2'; let USE_STREAMING = true; let API_BASE = '/api'; let latestAiContent = ''; let logs = []; let messages = []; let isStreaming = false; // Sync OLLAMA_URL from terminal if (this.terminal && this.terminal.ollamaManager) { OLLAMA_URL = this.terminal.ollamaManager.endpoint || '/ollama'; } // ── Utility Functions ── function escapeHtml(text) { if (typeof text !== 'string') return ''; return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function utf8_to_b64(str) { return btoa(unescape(encodeURIComponent(str))); } function b64_to_utf8(str) { return decodeURIComponent(escape(atob(str))); } function generateId() { return Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); } // Fetch with timeout — AbortController-based, 30s default var _streamAbort = null; // AbortController for streaming, null when not active function fetchWithTimeout(url, opts, timeout) { timeout = timeout || 30000; opts = opts || {}; var ctrl = new AbortController(); opts.signal = ctrl.signal; var timer = setTimeout(function() { ctrl.abort(); }, timeout); return fetch(url, opts).finally(function() { clearTimeout(timer); }); } function showToast(msg) { var t = document.getElementById('toast'); if (!t) return; t.textContent = msg; t.style.display = 'block'; setTimeout(function () { t.style.display = 'none'; }, 2500); } function autoExpand(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 150) + 'px'; } // Close sidebar when clicking outside on mobile var backdrop = document.getElementById('sidebarBackdrop'); if (!backdrop) { backdrop = document.createElement('div'); backdrop.id = 'sidebarBackdrop'; backdrop.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:99;display:none;background:transparent'; backdrop.onclick = function() { closeSidebar(); }; document.getElementById('chat-overlay')?.appendChild(backdrop); } function toggleSidebar() { var s = document.getElementById('sidebar'); if (!s) return; // On mobile (<700px) use .open, on desktop use .collapsed var isMobile = window.innerWidth <= 700; if (isMobile) { s.classList.toggle('open'); } else { s.classList.toggle('collapsed'); // Toggle backdrop on mobile regardless } if (backdrop) { backdrop.style.display = s.classList.contains('open') || s.classList.contains('collapsed') ? 'block' : 'none'; } } function closeSidebar() { var s = document.getElementById('sidebar'); if (s) { s.classList.remove('open'); s.classList.remove('collapsed'); } if (backdrop) backdrop.style.display = 'none'; } // ── Log System ── var MAX_LOGS = 200; // P2#17: Configurable log limit (was hardcoded 50) function addLog(action, detail) { var time = new Date().toLocaleTimeString(); logs.unshift({ time: time, action: action, detail: detail }); if (logs.length > MAX_LOGS) logs.length = MAX_LOGS; renderLogs(); try { // P2#12: Use plain JSON instead of base64 (consistent with messages storage) localStorage.setItem(LOGS_KEY, JSON.stringify(logs)); } catch (e) { /* ignore */ } } function renderLogs() { var container = document.getElementById('logsContent'); if (!container) return; container.innerHTML = logs.map(function (l) { return '
' + '' + escapeHtml(l.time) + '' + '' + escapeHtml(l.action) + '' + '' + escapeHtml(l.detail) + '' + '
'; }).join(''); } function copyLogs() { var text = logs.map(function (l) { return l.time + ' | ' + l.action + ' | ' + l.detail; }).join('\n'); navigator.clipboard.writeText(text).then(function () { showToast('Logs copied!'); }); } function clearLogs() { if (!confirm('Clear all activity logs?')) return; logs = []; try { localStorage.removeItem(LOGS_KEY); } catch (e) { /* ignore */ } renderLogs(); showToast('Logs cleared'); } function loadLogs() { try { var saved = localStorage.getItem(LOGS_KEY); if (saved) { // P2#12: Handle both old base64 and new plain JSON try { logs = JSON.parse(saved); } catch (e1) { logs = JSON.parse(b64_to_utf8(saved)); } } } catch (e) { logs = []; } renderLogs(); } // ── Settings ── function loadSettings() { try { var saved = localStorage.getItem(SETTINGS_KEY); if (saved) { // P2#12: Handle both old base64 and new plain JSON var settings; try { settings = JSON.parse(saved); } catch (e1) { settings = JSON.parse(b64_to_utf8(saved)); } OLLAMA_URL = settings.ollamaUrl || '/ollama'; OLLAMA_API_KEY = settings.ollamaApiKey || ''; DEFAULT_MODEL = settings.defaultModel || 'llama3.2'; USE_STREAMING = settings.useStreaming !== false; API_BASE = settings.apiBase || '/api'; } } catch (e) { console.warn('Failed to load settings:', e); } // Sync from terminal if available var term = window.webx11; if (term && term.ollamaManager) { OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL; } var urlEl = document.getElementById('ollamaUrl'); var keyEl = document.getElementById('ollamaApiKey'); var modelEl = document.getElementById('defaultModel'); var streamEl = document.getElementById('streamingToggle'); var apiEl = document.getElementById('apiBase'); if (urlEl) urlEl.value = OLLAMA_URL; if (keyEl) keyEl.value = OLLAMA_API_KEY; if (modelEl) modelEl.value = DEFAULT_MODEL; if (streamEl) streamEl.checked = USE_STREAMING; if (apiEl) apiEl.value = API_BASE; } function saveSettings() { OLLAMA_URL = document.getElementById('ollamaUrl').value.trim() || '/ollama'; OLLAMA_API_KEY = document.getElementById('ollamaApiKey').value.trim(); DEFAULT_MODEL = document.getElementById('defaultModel').value.trim() || 'llama3.2'; USE_STREAMING = document.getElementById('streamingToggle').checked; API_BASE = (document.getElementById('apiBase')?.value || '/api').trim(); try { var settings = { ollamaUrl: OLLAMA_URL, // P2#11: Don't persist API key — keep in memory only defaultModel: DEFAULT_MODEL, useStreaming: USE_STREAMING, apiBase: API_BASE }; // P2#12: Use plain JSON instead of base64 (consistent storage) localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (e) { console.warn('Failed to save settings:', e); } closeSettingsModal(); showToast('Settings saved!'); } function openSettingsModal() { loadSettings(); var m = document.getElementById('settingsModal'); if (m) m.classList.add('open'); } function closeSettingsModal() { var m = document.getElementById('settingsModal'); if (m) m.classList.remove('open'); } async function testConnection() { var status = document.getElementById('connectionStatus'); if (!status) return; status.textContent = 'Testing...'; status.style.color = 'var(--text2)'; try { var headers = { 'Content-Type': 'application/json' }; if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY; var response = await fetchWithTimeout(OLLAMA_URL + '/api/tags', { headers: headers }); if (response.ok) { var data = await response.json(); var modelCount = data.models ? data.models.length : 0; status.textContent = 'Connected! ' + modelCount + ' models available.'; status.style.color = 'var(--accent)'; } else { status.textContent = 'Connection failed: HTTP ' + response.status; status.style.color = 'var(--danger)'; } } catch (e) { status.textContent = 'Error: ' + e.message; status.style.color = 'var(--danger)'; } } // ── Models ── async function loadModels() { try { var response = await fetchWithTimeout(OLLAMA_URL + '/api/tags'); if (!response.ok) throw new Error('Failed to fetch models'); var data = await response.json(); var select = document.getElementById('modelSelect'); if (!select) return; var shortNames = { 'llama3.2': 'Llama3.2', 'llama3': 'Llama3', 'gemma4:31b-cloud': 'Gemma4-31B', 'gemma2:2b': 'Gemma2', 'qwen2.5:7b-instruct': 'Qwen2.5', 'qwen3:8b': 'Qwen3', 'mistral': 'Mistral', 'tinyllama': 'Tiny' }; select.innerHTML = data.models.map(function (m) { var name = m.name.split(':')[0]; var display = shortNames[m.name] || name.substring(0, 14); return ''; }).join(''); addLog('Init', 'Loaded ' + data.models.length + ' models'); } catch (e) { console.error('Failed to load models:', e); var select = document.getElementById('modelSelect'); if (select) select.innerHTML = ''; } } var modelSelect = document.getElementById('modelSelect'); if (modelSelect) { modelSelect.addEventListener('change', function () { addLog('Model', 'Switched to ' + this.value); }); } // ── Message History ── var MAX_MESSAGES = 100; // P2#13: Cap chat history to prevent localStorage overflow function saveMessages(msgs) { msgs = msgs || messages; // P2#13: Trim to last MAX_MESSAGES before saving if (msgs.length > MAX_MESSAGES) { msgs = msgs.slice(-MAX_MESSAGES); messages.length = 0; messages.push.apply(messages, msgs); } try { localStorage.setItem(MESSAGES_KEY, JSON.stringify(msgs)); } catch (e) { console.warn('Failed to save messages:', e); } } function loadMessages() { try { var raw = localStorage.getItem(MESSAGES_KEY); if (raw) { messages = JSON.parse(raw); } } catch (e) { console.warn('Failed to load messages:', e); messages = []; } } function renderMessagesFromData() { var container = document.getElementById('chatContainer'); if (!container) return; if (!messages || messages.length === 0) { return; } container.innerHTML = ''; for (var i = 0; i < messages.length; i++) { var msg = messages[i]; appendMessageToDOM(msg.role, msg.content, msg.id, false); } container.scrollTop = container.scrollHeight; } function appendMessageToDOM(role, content, msgId, save) { if (save === undefined) save = true; var container = document.getElementById('chatContainer'); if (!container) return; var div = document.createElement('div'); div.className = 'message ' + role; if (msgId) div.dataset.msgId = msgId; var previewBtn = role === 'ai' ? '' : ''; if (role === 'ai') latestAiContent = content; // P2#16: Apply markdown formatting to user messages too (was plain escapeHtml only) var renderedContent = role === 'user' ? formatContent(escapeHtml(content)) : formatContent(content); div.innerHTML = '
' + '' + (role === 'user' ? 'You' : 'AI') + '' + '
' + '' + previewBtn + '
' + '
' + renderedContent + '
'; div.dataset.content = content; container.appendChild(div); container.scrollTop = container.scrollHeight; if (save) { saveMessages(); } } function addMessage(role, content) { var msgId = generateId(); var msg = { id: msgId, role: role, content: content, timestamp: Date.now() }; messages.push(msg); appendMessageToDOM(role, content, msgId, true); return msgId; } // ── Content Formatting ── function formatContent(text) { // Step 1: Extract code blocks into placeholders var blocks = []; var processed = text .replace(/```html\n?([\s\S]*?)```/g, function(m, code) { var idx = blocks.length; blocks.push({lang: 'html', code: code, isBlock: true}); return '%%CODEBLOCK_' + idx + '%%'; }) .replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) { var idx = blocks.length; blocks.push({lang: lang, code: code, isBlock: true}); return '%%CODEBLOCK_' + idx + '%%'; }) .replace(/`([^`]+)`/g, function(m, content) { var idx = blocks.length; blocks.push({lang: '', code: content, isBlock: false, isInline: true}); return '%%CODEBLOCK_' + idx + '%%'; }); // Step 2: Escape everything outside code blocks processed = escapeHtml(processed); // Step 3: Apply markdown formatting on the escaped text processed = processed .replace(/\*\*([^*]+)\*\*/g, function(m, content) { return '' + content + ''; }) .replace(/(https?:\/\/[^\s<]+)/g, '$1') .replace(/\n/g, '
'); // Step 4: Restore code blocks with proper HTML processed = processed.replace(/%%CODEBLOCK_(\d+)%%/g, function(m, idx) { idx = parseInt(idx); var block = blocks[idx]; if (!block) return m; if (block.isInline) { return '' + escapeHtml(block.code) + ''; } var id = 'code_' + Math.random().toString(36).substr(2, 9); if (block.lang === 'html') { return '
' + '
' +
                        '' +
                        '' +
                        '' + escapeHtml(block.code) + '
'; } return '
' +
                    '' +
                    '' + escapeHtml(block.code) + '
'; }); return processed; } // ── Copy & Preview ── function copyMessage(btn, role) { var content; if (role === 'code') { content = btn.nextElementSibling.textContent; } else { var msg = btn.closest('.message'); content = msg.dataset.content; } navigator.clipboard.writeText(content).then(function () { showToast('Copied!'); }); } function showHtmlPreview() { var frame = document.getElementById('previewFrame'); if (!frame) return; var doc = frame.contentWindow.document; doc.open(); doc.write(latestAiContent); doc.close(); var m = document.getElementById('previewModal'); if (m) m.classList.add('open'); } function previewCodeBlock(btn) { var block = btn.closest('.code-block'); if (!block) return; var code = decodeURIComponent(block.dataset.code); var frame = document.getElementById('previewFrame'); if (!frame) return; var doc = frame.contentWindow.document; doc.open(); doc.write(code); doc.close(); var m = document.getElementById('previewModal'); if (m) m.classList.add('open'); } function closePreviewModal() { var m = document.getElementById('previewModal'); if (m) m.classList.remove('open'); } // ── Typing Indicator ── function showTyping() { var container = document.getElementById('chatContainer'); if (!container) return; var div = document.createElement('div'); div.className = 'typing'; div.id = 'typingIndicator'; div.innerHTML = 'AI is thinking' + '' + '' + ''; container.appendChild(div); container.scrollTop = container.scrollHeight; } function hideTyping() { var t = document.getElementById('typingIndicator'); if (t) t.remove(); } // ── Send Message ── async function sendMessage() { if (isStreaming) return; var input = document.getElementById('chatInput'); if (!input) return; var msg = input.value.trim(); if (!msg) return; input.value = ''; input.style.height = 'auto'; addMessage('user', msg); addLog('User', msg.substring(0, 50) + (msg.length > 50 ? '...' : '')); if (msg.startsWith('/')) { await handleCommand(msg); return; } showTyping(); // Sync OLLAMA_URL from terminal before sending var term = window.webx11; if (term && term.ollamaManager) { OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL; } // Re-set window-level endpoint window.ollamaEndpoint = OLLAMA_URL; var modelSelect = document.getElementById('modelSelect'); var model = modelSelect ? modelSelect.value : DEFAULT_MODEL; var mod = typeof getCurrentModule === 'function' ? getCurrentModule() : null; var systemPrompt = mod && mod.prompt ? mod.prompt : ''; // Inject current date into system prompt var now = new Date(); var dateStr = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0'); var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; var timeStr = String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0'); systemPrompt = 'Current date: ' + dateStr + ' (' + days[now.getDay()] + ') ' + timeStr + '\n\n' + systemPrompt; var chatHistory = buildChatContext(); try { var headers = { 'Content-Type': 'application/json' }; if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY; var fullPrompt = systemPrompt ? systemPrompt + '\n\n' + chatHistory + '\n\nUser: ' + msg + '\n\nAI:' : chatHistory + '\n\nUser: ' + msg + '\n\nAI:'; if (USE_STREAMING) { await sendStreamingRequest(headers, model, fullPrompt); } else { await sendNonStreamingRequest(headers, model, fullPrompt); } } catch (e) { hideTyping(); console.error('Chat error:', e); addMessage('ai', 'Sorry, I could not connect to the AI service. Make sure Ollama is running on your server.\n\nError: ' + e.message); } } async function sendStreamingRequest(headers, model, prompt) { isStreaming = true; hideTyping(); var msgId = generateId(); var container = document.getElementById('chatContainer'); if (!container) { isStreaming = false; return; } var div = document.createElement('div'); div.className = 'message ai'; div.dataset.msgId = msgId; div.innerHTML = '
AI
' + '' + '' + '' + '
'; div.dataset.content = ''; container.appendChild(div); container.scrollTop = container.scrollHeight; var contentEl = div.querySelector('.msg-content'); var fullContent = ''; try { _streamAbort = new AbortController(); var response = await fetch(OLLAMA_URL + '/api/generate', { method: 'POST', headers: headers, body: JSON.stringify({ model: model, prompt: prompt, stream: true }), signal: _streamAbort.signal }); if (!response.ok) { contentEl.textContent = 'AI request failed: HTTP ' + response.status; throw new Error('AI request failed: HTTP ' + response.status); } var reader = response.body.getReader(); var decoder = new TextDecoder(); var buffer = ''; var lastFormat = 0; var formatInterval = 400; while (true) { var result = await reader.read(); if (result.done) break; buffer += decoder.decode(result.value, { stream: true }); var lines = buffer.split('\n'); buffer = lines.pop() || ''; for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (!line) continue; try { var parsed = JSON.parse(line); if (parsed.response) { fullContent += parsed.response; var now = Date.now(); if (now - lastFormat >= formatInterval) { contentEl.innerHTML = formatContent(fullContent) + ''; container.scrollTop = container.scrollHeight; lastFormat = now; } } if (parsed.done) { break; } } catch (e) { } } } // Process remaining buffer if (buffer.trim()) { try { var parsed = JSON.parse(buffer); if (parsed.response) { fullContent += parsed.response; } } catch (e) { /* ignore */ } } // Final render without cursor contentEl.innerHTML = formatContent(fullContent); div.dataset.content = fullContent; latestAiContent = fullContent; container.scrollTop = container.scrollHeight; messages.push({ id: msgId, role: 'ai', content: fullContent, timestamp: Date.now() }); saveMessages(); addLog('AI', fullContent.substring(0, 50) + '...'); } catch (e) { if (e.name === 'AbortError') { if (contentEl && !fullContent) contentEl.textContent = '⏹ Stopped by user.'; } else { if (contentEl) contentEl.textContent = 'Error: ' + e.message; console.error('Stream error:', e); } } isStreaming = false; _streamAbort = null; // Remove stop button if present var stopBtn = document.getElementById('streamStopBtn'); if (stopBtn) stopBtn.remove(); } function stopStreaming() { if (_streamAbort) { _streamAbort.abort(); _streamAbort = null; } isStreaming = false; } async function sendNonStreamingRequest(headers, model, prompt) { try { var response = await fetchWithTimeout(OLLAMA_URL + '/api/generate', { method: 'POST', headers: headers, body: JSON.stringify({ model: model, prompt: prompt, stream: false }) }); hideTyping(); if (!response.ok) throw new Error('AI request failed'); var data = await response.json(); addMessage('ai', data.response || 'No response'); addLog('AI', (data.response || '').substring(0, 50) + '...'); } catch (e) { hideTyping(); throw e; } } function buildChatContext() { var context = ''; for (var i = 0; i < messages.length; i++) { var m = messages[i]; var role = m.role === 'user' ? 'User' : 'AI'; // P2#15: Use DOMParser instead of naive regex HTML strip var content = m.content; try { var doc = new DOMParser().parseFromString(content, 'text/html'); content = doc.body.textContent || doc.body.innerText || ''; } catch (e) { // Fallback to regex if DOMParser fails content = content.replace(/<[^>]+>/g, ' '); } content = content.replace(/\s+/g, ' ').trim(); context += role + ': ' + content + '\n'; } return context; } function getChatHistory() { return buildChatContext(); } // ── Clear & Share ── function clearChat() { if (!confirm('Clear chat history?')) return; messages = []; localStorage.removeItem(MESSAGES_KEY); var container = document.getElementById('chatContainer'); if (container) { container.innerHTML = '
' + '
AI
' + 'Chat cleared! How can I help you?' + '
'; } addLog('Clear', 'Chat history cleared'); } function shareChat() { var msgs = []; for (var i = 0; i < messages.length; i++) { var m = messages[i]; var role = m.role === 'user' ? 'User' : 'AI'; msgs.push('**' + role + ':** ' + m.content); } var text = '# WebX11 AI Chat\n\n' + msgs.join('\n\n'); if (navigator.clipboard) { navigator.clipboard.writeText(text).then(function () { showToast('Chat copied to clipboard!'); addLog('Share', 'Chat copied to clipboard'); }); } else { var blob = new Blob([text], { type: 'text/plain' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'chat-export.txt'; a.click(); URL.revokeObjectURL(url); addLog('Share', 'Chat downloaded as file'); } } // ── Expose Lite globals for commands.js and modules.js ── // All functions exposed under CSLite namespace AND as individual window.* // for backward compatibility with chat-commands.js and chat-modules.js window.CSLite = { CHATSTUDIO_VERSION: CHATSTUDIO_VERSION, OLLAMA_URL: OLLAMA_URL, OLLAMA_API_KEY: OLLAMA_API_KEY, DEFAULT_MODEL: DEFAULT_MODEL, USE_STREAMING: USE_STREAMING, API_BASE: API_BASE, getApiUrl: function(path) { return (window.CSLite.API_BASE || '/api') + path; }, latestAiContent: latestAiContent, logs: logs, messages: messages, isStreaming: isStreaming, modules: [], currentChatModule: 'default', escapeHtml: escapeHtml, utf8_to_b64: utf8_to_b64, b64_to_utf8: b64_to_utf8, generateId: generateId, showToast: showToast, autoExpand: autoExpand, toggleSidebar: toggleSidebar, closeSidebar: closeSidebar, addLog: addLog, renderLogs: renderLogs, loadSettings: loadSettings, saveSettings: saveSettings, loadModels: loadModels, loadMessages: loadMessages, saveMessages: saveMessages, appendMessageToDOM: appendMessageToDOM, addMessage: addMessage, formatContent: formatContent, copyMessage: copyMessage, showHtmlPreview: showHtmlPreview, previewCodeBlock: previewCodeBlock, closePreviewModal: closePreviewModal, showTyping: showTyping, hideTyping: hideTyping, sendMessage: sendMessage, sendStreamingRequest: sendStreamingRequest, sendNonStreamingRequest: sendNonStreamingRequest, buildChatContext: buildChatContext, getChatHistory: getChatHistory, clearChat: clearChat, shareChat: shareChat, copyLogs: copyLogs, clearLogs: clearLogs, loadLogs: loadLogs, testConnection: testConnection, openSettingsModal: openSettingsModal, closeSettingsModal: closeSettingsModal }; // Individual window.* assignments for backward compat window.CHATSTUDIO_VERSION = CHATSTUDIO_VERSION; window.OLLAMA_URL = OLLAMA_URL; window.OLLAMA_API_KEY = OLLAMA_API_KEY; window.DEFAULT_MODEL = DEFAULT_MODEL; window.USE_STREAMING = USE_STREAMING; window.API_BASE = API_BASE; window.getApiUrl = function(path) { return (window.API_BASE || '/api') + path; }; window.latestAiContent = latestAiContent; window.logs = logs; window.messages = messages; window.isStreaming = isStreaming; window.escapeHtml = escapeHtml; window.utf8_to_b64 = utf8_to_b64; window.b64_to_utf8 = b64_to_utf8; window.generateId = generateId; window.showToast = showToast; window.autoExpand = autoExpand; window.toggleSidebar = toggleSidebar; window.closeSidebar = closeSidebar; window.addLog = addLog; window.renderLogs = renderLogs; window.loadSettings = loadSettings; window.saveSettings = saveSettings; window.loadModels = loadModels; window.loadMessages = loadMessages; window.saveMessages = saveMessages; window.appendMessageToDOM = appendMessageToDOM; window.addMessage = addMessage; window.formatContent = formatContent; window.copyMessage = copyMessage; window.showHtmlPreview = showHtmlPreview; window.previewCodeBlock = previewCodeBlock; window.closePreviewModal = closePreviewModal; window.showTyping = showTyping; window.hideTyping = hideTyping; window.sendMessage = sendMessage; window.sendStreamingRequest = sendStreamingRequest; window.sendNonStreamingRequest = sendNonStreamingRequest; window.buildChatContext = buildChatContext; window.getChatHistory = getChatHistory; window.clearChat = clearChat; window.shareChat = shareChat; window.copyLogs = copyLogs; window.clearLogs = clearLogs; window.loadLogs = loadLogs; window.testConnection = testConnection; window.openSettingsModal = openSettingsModal; window.closeSettingsModal = closeSettingsModal; // ── Init ── loadSettings(); loadMessages(); loadLogs(); loadModels(); renderMessagesFromData(); addLog('Init', 'Chat overlay initialized'); } catch (err) { // Reset initialization flags so user can retry this._initialized = false; window._csGlobalsInited = false; // Print error to terminal if (this.terminal && this.terminal.print) { this.terminal.print('Chat overlay init error: ' + err.message, 'error'); } console.error('ChatStudioModule._initLiteEngine error:', err); } } }