/* * 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(block.code) + '';
}
var id = 'code_' + Math.random().toString(36).substr(2, 9);
if (block.lang === 'html') {
return '' +
'' +
'' +
'' + escapeHtml(block.code) + '' +
'' +
'' + 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 = '