aboutsummaryrefslogtreecommitdiff
path: root/modules/core/module-chatstudio.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core/module-chatstudio.js')
-rw-r--r--modules/core/module-chatstudio.js1060
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');
+ };
+ 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, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;');
+ }
+
+ 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);
+ }
+ }
+}