diff options
| author | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
| commit | 4baa484b84c5184286cac2743575251454a5ee2b (patch) | |
| tree | cb9002f93644f363d0f35831dc97e6492bbb0ed6 /modules/core/module-chatstudio.js | |
auto: 2026-05-25_18:02 changes
Diffstat (limited to 'modules/core/module-chatstudio.js')
| -rw-r--r-- | modules/core/module-chatstudio.js | 1060 |
1 files changed, 1060 insertions, 0 deletions
diff --git a/modules/core/module-chatstudio.js b/modules/core/module-chatstudio.js new file mode 100644 index 0000000..5777acf --- /dev/null +++ b/modules/core/module-chatstudio.js @@ -0,0 +1,1060 @@ +/* + * ChatStudio Overlay Module for newOS4 v4.2 + * 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', + 'chat-setapi': 'Set the API base URL for ChatStudio', + 'chat-setmodel': 'Set the default model for ChatStudio', + 'chat-ask': 'Ask a question directly via ChatStudio' + }; + } + + registerCommands() { + this.commands['chat'] = () => this.toggleChatOverlay(); + this.commands['chat-setapi'] = (args) => { + if (!args[0]) { + const base = window.API_BASE || '/api'; + this.terminal.print('Current API base: ' + base, 'info'); + this.terminal.print('Usage: chat-setapi <base-url>', 'info'); + this.terminal.print('Example: chat-setapi /api', 'info'); + return; + } + const url = args[0].replace(/\/+$/, ''); + window.API_BASE = url; + try { + var settings = JSON.parse(localStorage.getItem('webx11_chat_settings') || '{}'); + settings.apiBase = url; + localStorage.setItem('webx11_chat_settings', JSON.stringify(settings)); + } catch (e) {} + this.terminal.print('API base set to: ' + url + ' (reopen chat to apply)', 'success'); + }; + } + + toggleChatOverlay() { + if (this._active) { + this._closeOverlay(); + } else { + this._openOverlay(); + } + } + + _loadOverlayAssets() { + return new Promise((resolve, reject) => { + // Load CSS + if (!this._cssLoaded) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'chat/chat-ui.css?v=4'; + link.onload = () => { this._cssLoaded = true; }; + link.onerror = () => reject(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 = '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 + fetch('chat/chat-ui.html?v=4') + .then(r => r.text()) + .then(html => { + // 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; + s.onload = () => res(); + s.onerror = () => rej(new Error('Failed to load ' + src)); + document.body.appendChild(s); + }); + }; + + Promise.all([ + loadScript('chat/chat-commands.js?v=4'), + loadScript('chat/chat-modules.js?v=4') + ]).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 + const partialOverlay = document.getElementById('chat-overlay'); + if (partialOverlay) { + partialOverlay.remove(); + } + + // 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 = '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'; + + // ── 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); + } + + 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'; + } + + function toggleSidebar() { + var s = document.getElementById('sidebar'); + if (s) s.classList.toggle('open'); + var b = document.getElementById('sidebarBackdrop'); + if (b) b.classList.toggle('open'); + } + function closeSidebar() { + var s = document.getElementById('sidebar'); + if (s) s.classList.remove('open'); + var b = document.getElementById('sidebarBackdrop'); + if (b) b.classList.remove('open'); + } + + // ── Log System ── + function addLog(action, detail) { + var time = new Date().toLocaleTimeString(); + logs.unshift({ time: time, action: action, detail: detail }); + if (logs.length > 50) logs.pop(); + renderLogs(); + try { + localStorage.setItem(LOGS_KEY, utf8_to_b64(JSON.stringify(logs))); + } catch (e) { /* ignore */ } + } + + function renderLogs() { + var container = document.getElementById('logsContent'); + if (!container) return; + container.innerHTML = logs.map(function (l) { + return '<div class="log-entry">' + + '<span class="log-time">' + escapeHtml(l.time) + '</span>' + + '<span class="log-action">' + escapeHtml(l.action) + '</span>' + + '<span class="log-detail">' + escapeHtml(l.detail) + '</span>' + + '</div>'; + }).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) logs = JSON.parse(b64_to_utf8(saved)); + } catch (e) { + logs = []; + } + renderLogs(); + } + + // ── Settings ── + function loadSettings() { + try { + var saved = localStorage.getItem(SETTINGS_KEY); + if (saved) { + var 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.trim() || '/api'; + try { + var settings = { + ollamaUrl: OLLAMA_URL, + ollamaApiKey: OLLAMA_API_KEY, + defaultModel: DEFAULT_MODEL, + useStreaming: USE_STREAMING, + apiBase: API_BASE + }; + localStorage.setItem(SETTINGS_KEY, utf8_to_b64(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 fetch(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 fetch(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', + 'gemma3:4b': 'Gemma3-4B', + 'gemma3:1b': 'Gemma3-1B', + '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 '<option value="' + escapeHtml(m.name) + '">' + escapeHtml(display) + '</option>'; + }).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 = '<option value="llama3.2">llama3.2 (default)</option>'; + } + } + + var modelSelect = document.getElementById('modelSelect'); + if (modelSelect) { + modelSelect.addEventListener('change', function () { + addLog('Model', 'Switched to ' + this.value); + }); + } + + // ── Message History ── + function saveMessages(msgs) { + msgs = msgs || messages; + 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' + ? '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>' + : ''; + if (role === 'ai') latestAiContent = content; + var renderedContent = role === 'user' ? escapeHtml(content) : formatContent(content); + div.innerHTML = '<div class="role">' + + '<span>' + (role === 'user' ? 'You' : 'AI') + '</span>' + + '<div style="display:flex;gap:4px;">' + + '<button class="msg-copy" onclick="copyMessage(this, \'' + role + '\')" title="Copy">📋</button>' + + previewBtn + + '</div></div>' + + '<div class="msg-content">' + renderedContent + '</div>'; + 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 '<strong>' + content + '</strong>'; }) + .replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>') + .replace(/\n/g, '<br>'); + + // 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 '<code>' + escapeHtml(block.code) + '</code>'; + } + var id = 'code_' + Math.random().toString(36).substr(2, 9); + if (block.lang === 'html') { + return '<div class="code-block" data-code="' + encodeURIComponent(block.code) + '">' + + '<pre>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="previewCodeBlock(this)">👁️</button>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;margin-right:8px;" onclick="copyMessage(this, \'code\')">📋</button>' + + '<code id="' + id + '">' + escapeHtml(block.code) + '</code></pre></div>'; + } + return '<pre>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="copyMessage(this, \'code\')">📋</button>' + + '<code>' + escapeHtml(block.code) + '</code></pre>'; + }); + + 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' + + '<span class="typing-dots">' + + '<span></span><span></span><span></span>' + + '</span>'; + 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 : ''; + 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 = '<div class="role"><span>AI</span><div style="display:flex;gap:4px;">' + + '<button class="msg-copy" onclick="copyMessage(this, \'ai\')" title="Copy">📋</button>' + + '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>' + + '</div></div><div class="msg-content"><span class="streaming-cursor"></span></div>'; + div.dataset.content = ''; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + + var contentEl = div.querySelector('.msg-content'); + var fullContent = ''; + + try { + var response = await fetch(OLLAMA_URL + '/api/generate', { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: true + }) + }); + + 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) + '<span class="streaming-cursor"></span>'; + 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 (contentEl) contentEl.textContent = 'Error: ' + e.message; + console.error('Stream error:', e); + } + + isStreaming = false; + } + + async function sendNonStreamingRequest(headers, model, prompt) { + try { + var response = await fetch(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'; + var content = m.content.replace(/<[^>]+>/g, ' ').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 = + '<div class="message ai">' + + '<div class="role">AI</div>' + + 'Chat cleared! How can I help you?' + + '</div>'; + } + 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 flag so user can retry + this._initialized = 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); + } + } +} |