/** * AI Assistant — Offcanvas panel with Help, Suggest, and Admin tabs * Supports: tool status indicators, link rendering, quick-reply buttons, conversation history */ (function() { 'use strict'; var panel = null; var offcanvasInstance = null; var currentTab = 'help'; var isStreaming = false; var abortController = null; // Conversation history per tab var conversationHistory = { help: [], admin: [] }; // Build the offcanvas panel HTML function buildPanel() { if (document.getElementById('aiAssistantPanel')) return; var isAdmin = document.body.getAttribute('data-is-admin') === 'true'; var html = '
'; html += '
'; html += '
AI Assistant
'; html += ''; html += '
'; html += '
'; // Tabs html += ''; // Help tab content html += '
'; html += '
'; html += '
'; html += '
'; html += ''; html += ''; html += '
'; html += '
'; html += '
'; // Suggest tab content html += ''; // Admin tab content if (isAdmin) { html += ''; // Prompt builder tab content html += ''; } html += '
'; document.body.insertAdjacentHTML('beforeend', html); panel = document.getElementById('aiAssistantPanel'); // Tab click handlers panel.querySelectorAll('[data-ai-tab]').forEach(function(btn) { btn.addEventListener('click', function() { switchTab(btn.getAttribute('data-ai-tab')); }); }); // Initialize prompt builder if present initPromptBuilder(); // Enter key handlers for text inputs ['aiInputHelp', 'aiInputAdmin'].forEach(function(id) { var el = document.getElementById(id); if (el) { el.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); var mode = id === 'aiInputHelp' ? 'help' : 'admin'; aiSendMessage(mode); } }); // Auto-resize textarea el.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 120) + 'px'; }); } }); } function switchTab(tab) { currentTab = tab; panel.querySelectorAll('[data-ai-tab]').forEach(function(btn) { btn.classList.toggle('active', btn.getAttribute('data-ai-tab') === tab); }); panel.querySelectorAll('[data-ai-content]').forEach(function(el) { el.style.display = el.getAttribute('data-ai-content') === tab ? '' : 'none'; }); } // Open the offcanvas window.openAIAssistant = function() { buildPanel(); if (!offcanvasInstance) { offcanvasInstance = new bootstrap.Offcanvas(panel); } offcanvasInstance.show(); }; // Send a chat message (help or admin mode) window.aiSendMessage = function(mode) { if (isStreaming) return; var inputId = mode === 'help' ? 'aiInputHelp' : 'aiInputAdmin'; var chatId = mode === 'help' ? 'aiChatHelp' : 'aiChatAdmin'; var input = document.getElementById(inputId); var chatArea = document.getElementById(chatId); var prompt = input.value.trim(); if (!prompt) return; // Show user message appendMessage(chatArea, prompt, 'user'); input.value = ''; input.style.height = 'auto'; // Add to conversation history conversationHistory[mode].push({ role: 'user', content: prompt }); // Show typing indicator var typing = showTyping(chatArea); // Disable input setStreaming(true, mode); // Build request var step = mode === 'admin' ? 'admin' : 'help'; var url = '/z/ai?command=assistant&step=' + step + '&skin=ajax'; var body = new FormData(); body.append('prompt', prompt); body.append('pageurl', window.location.href); body.append('pageroute', getPageRoute()); body.append('history', JSON.stringify(conversationHistory[mode].slice(0, -1))); // exclude current message abortController = new AbortController(); fetch(url, { method: 'POST', body: body, signal: abortController.signal }).then(function(response) { if (!response.ok) throw new Error('HTTP ' + response.status); return readSSEStream(response.body, chatArea, typing, mode); }).catch(function(err) { removeTyping(typing); if (err.name !== 'AbortError') { appendMessage(chatArea, 'Error: ' + err.message, 'error'); } }).finally(function() { setStreaming(false, mode); abortController = null; }); }; // Send a quick-reply message (e.g. "Confirm" or "Cancel") window.aiSendQuickReply = function(text, mode) { var inputId = mode === 'help' ? 'aiInputHelp' : 'aiInputAdmin'; var input = document.getElementById(inputId); if (input) { input.value = text; aiSendMessage(mode); } }; // Send improvement suggestion window.aiSendSuggestion = function() { var input = document.getElementById('aiInputSuggest'); var suggestion = input.value.trim(); if (!suggestion) return; var btn = document.getElementById('aiSendSuggestBtn'); btn.disabled = true; btn.innerHTML = 'Sending...'; var body = new FormData(); body.append('prompt', suggestion); body.append('pageurl', window.location.href); fetch('/z/ai?command=assistant&step=suggest&skin=ajax', { method: 'POST', body: body }).then(function(r) { return r.json(); }) .then(function(data) { var chatArea = document.getElementById('aiChatSuggest'); if (data.success) { chatArea.innerHTML = '
' + '' + '

' + escapeHtml(data.message) + '

' + '' + '
'; } else { appendMessage(chatArea, data.error || 'Failed to send suggestion', 'error'); btn.disabled = false; btn.innerHTML = 'Send Suggestion'; } }).catch(function(err) { appendMessage(document.getElementById('aiChatSuggest'), 'Error: ' + err.message, 'error'); btn.disabled = false; btn.innerHTML = 'Send Suggestion'; }); }; window.aiResetSuggest = function() { var chatArea = document.getElementById('aiChatSuggest'); chatArea.innerHTML = '
' + '

Submit an improvement suggestion for this page. It will be sent to the admin.

' + '' + '' + '
'; }; // Read SSE stream from fetch response function readSSEStream(body, chatArea, typing, mode) { var reader = body.getReader(); var decoder = new TextDecoder(); var buffer = ''; var msgDiv = null; var fullText = ''; var toolStatusEl = null; function processChunk() { return reader.read().then(function(result) { if (result.done) { removeTyping(typing); removeToolStatus(toolStatusEl); return; } buffer += decoder.decode(result.value, { stream: true }); // Process complete SSE lines var lines = buffer.split('\n'); buffer = lines.pop(); // Keep incomplete line in buffer for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (!line.startsWith('data: ')) continue; var data = line.substring(6); if (data === '[DONE]') { removeTyping(typing); removeToolStatus(toolStatusEl); // Check for confirmation pattern and add quick-reply buttons if (fullText && msgDiv) { addQuickReplies(chatArea, fullText, mode); } continue; } try { var json = JSON.parse(data); } catch (e) { continue; } if (json.type === 'delta' || json.type === 'text') { removeTyping(typing); removeToolStatus(toolStatusEl); toolStatusEl = null; if (!msgDiv) { msgDiv = document.createElement('div'); msgDiv.className = 'ai-msg ai-msg-assistant'; chatArea.appendChild(msgDiv); } fullText += json.text; msgDiv.innerHTML = renderMarkdown(fullText); chatArea.scrollTop = chatArea.scrollHeight; } else if (json.type === 'tool_status') { removeTyping(typing); // Show/update tool status indicator var toolLabel = formatToolName(json.name, json.status); if (json.status === 'running') { toolStatusEl = showToolStatus(chatArea, toolLabel); } else { removeToolStatus(toolStatusEl); toolStatusEl = null; } } else if (json.type === 'result') { // Add assistant response to history if (fullText) { conversationHistory[mode].push({ role: 'assistant', content: fullText }); } if (json.cost_usd) { var costDiv = document.createElement('div'); costDiv.className = 'ai-cost-badge'; costDiv.textContent = '$' + json.cost_usd.toFixed(4); if (json.turns && json.turns > 1) { costDiv.textContent += ' \u00b7 ' + json.turns + ' turns'; } chatArea.appendChild(costDiv); } } else if (json.type === 'error') { removeTyping(typing); removeToolStatus(toolStatusEl); appendMessage(chatArea, json.error, 'error'); } } return processChunk(); }); } return processChunk(); } // Format tool names for display function formatToolName(name, status) { var labels = { 'navigate_page': 'Navigating page', 'search_site': 'Searching', 'execute_action': 'Executing action' }; var label = labels[name] || name; return status === 'running' ? label + '...' : label + ' done'; } // Show tool status indicator function showToolStatus(chatArea, label) { var el = document.createElement('div'); el.className = 'ai-tool-status'; el.innerHTML = '' + escapeHtml(label); chatArea.appendChild(el); chatArea.scrollTop = chatArea.scrollHeight; return el; } function removeToolStatus(el) { if (el && el.parentNode) el.parentNode.removeChild(el); } // Detect confirmation patterns and add quick-reply buttons function addQuickReplies(chatArea, text, mode) { var lower = text.toLowerCase(); var hasConfirmPattern = ( lower.indexOf('confirm') !== -1 || lower.indexOf('proceed?') !== -1 || lower.indexOf('go ahead?') !== -1 || lower.indexOf('is this correct?') !== -1 || lower.indexOf('shall i') !== -1 ); if (hasConfirmPattern) { var btnDiv = document.createElement('div'); btnDiv.className = 'ai-quick-replies'; btnDiv.innerHTML = '' + ''; chatArea.appendChild(btnDiv); chatArea.scrollTop = chatArea.scrollHeight; } } // Helpers function appendMessage(chatArea, text, type) { var div = document.createElement('div'); div.className = 'ai-msg ai-msg-' + type; if (type === 'user') { div.textContent = text; } else { div.innerHTML = renderMarkdown(text); } chatArea.appendChild(div); chatArea.scrollTop = chatArea.scrollHeight; } function showTyping(chatArea) { var div = document.createElement('div'); div.className = 'ai-typing'; div.innerHTML = ''; chatArea.appendChild(div); chatArea.scrollTop = chatArea.scrollHeight; return div; } function removeTyping(el) { if (el && el.parentNode) el.parentNode.removeChild(el); } function setStreaming(val, mode) { isStreaming = val; var btnId = mode === 'admin' ? 'aiSendAdmin' : 'aiSendHelp'; var inputId = mode === 'admin' ? 'aiInputAdmin' : 'aiInputHelp'; var btn = document.getElementById(btnId); var input = document.getElementById(inputId); if (btn) btn.disabled = val; if (input) input.disabled = val; } function getPageRoute() { // Try to extract LCS route from URL params var params = new URLSearchParams(window.location.search); var parts = []; if (params.get('command')) parts.push(params.get('command')); if (params.get('step')) parts.push(params.get('step')); return parts.join(',') || document.title || 'unknown'; } function escapeHtml(str) { var div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // Prompt builder: update preview on input change function initPromptBuilder() { var urlEl = document.getElementById('aiPromptUrl'); var actionsEl = document.getElementById('aiPromptActions'); if (!urlEl || !actionsEl) return; function updatePreview() { var url = urlEl.value.trim(); var actions = actionsEl.value.trim(); var preview = document.getElementById('aiPromptPreview'); if (!preview) return; var prompt = 'Make ' + (url || '[PAGE URL]') + ' AI Agent compatible per the ARCHITECTURE.md checklist.'; if (actions) { prompt += ' Actions should include ' + actions + '.'; } prompt += ' Tag it in the menu system as ai_agent=true.'; preview.textContent = prompt; } urlEl.addEventListener('input', updatePreview); actionsEl.addEventListener('input', updatePreview); updatePreview(); } // Copy prompt to clipboard window.aiCopyPrompt = function() { var preview = document.getElementById('aiPromptPreview'); if (!preview) return; var text = preview.textContent; navigator.clipboard.writeText(text).then(function() { var btn = document.getElementById('aiCopyPromptBtn'); btn.innerHTML = 'Copied!'; btn.classList.replace('btn-primary', 'btn-success'); setTimeout(function() { btn.innerHTML = 'Copy to Clipboard'; btn.classList.replace('btn-success', 'btn-primary'); }, 2000); }); }; // Markdown renderer with link support function renderMarkdown(text) { // Escape HTML first var html = escapeHtml(text); // Code blocks (``` ... ```) html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(m, lang, code) { return '
' + code.trim() + '
'; }); // Inline code html = html.replace(/`([^`]+)`/g, '$1'); // Bold html = html.replace(/\*\*(.+?)\*\*/g, '$1'); // Italic html = html.replace(/\*(.+?)\*/g, '$1'); // Links [text](url) html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); // Auto-link URLs that aren't already in anchors html = html.replace(/(^|[^"=])(https?:\/\/[^\s<]+)/g, '$1$2'); // Headers html = html.replace(/^### (.+)$/gm, '

$1

'); html = html.replace(/^## (.+)$/gm, '

$1

'); html = html.replace(/^# (.+)$/gm, '

$1

'); // Numbered lists html = html.replace(/^\d+\. (.+)$/gm, '
  • $1
  • '); // Unordered lists html = html.replace(/^- (.+)$/gm, '
  • $1
  • '); html = html.replace(/(
  • .*<\/li>\n?)+/g, ''); // Paragraphs (double newlines) html = html.replace(/\n\n/g, '

    '); html = '

    ' + html + '

    '; // Clean up empty paragraphs html = html.replace(/

    \s*<\/p>/g, ''); // Don't wrap block elements in p tags html = html.replace(/

    (<(?:pre|ul|ol|h[1-3]|div)[\s>])/g, '$1'); html = html.replace(/(<\/(?:pre|ul|ol|h[1-3]|div)>)<\/p>/g, '$1'); // Single newlines → br (within paragraphs) html = html.replace(/([^>])\n([^<])/g, '$1
    $2'); return html; } })();