1import { Controller } from '@hotwired/stimulus'; 2 3const QUICK_ACTIONS = [ 4 { label: 'ð Top issues', message: 'What are my most critical SEO issues right now and exactly how should I fix each one?' }, 5 { label: 'ð¬ LLM perspective', message: 'Review this site as if you were an LLM deciding whether to recommend this business. What would make you hesitate, and what already gives you confidence?' }, 6 { label: 'ð AEO audit', message: 'Audit this site for AEO. Check structured data/schema and evidence of experience, expertise, authoritativeness, and trustworthiness. Score each area from 1â10 and identify the biggest gaps.' }, 7 { label: 'â¡ 3 fastest wins', message: 'Rank improvements by impact versus effort. Give me the 3 fastest AEO wins I can implement today â exactly what to change, and why each change makes this site easier for AI systems to recommend.' }, 8 { label: 'â FAQ ideas', message: 'Generate 10 questions a potential customer might ask before choosing this business, then write a 2-sentence answer under 100 words for the top 5. Make them AEO-ready for a FAQPage schema.' }, 9]; 10 11export default class extends Controller { 12 static targets = ['panel', 'messages', 'input', 'sendBtn', 'spinner', 'badge']; 13 static values = { url: String, token: String, domain: String }; 14 15 // Conversation history for multi-turn context 16 #history = []; 17 18 connect() { 19 this.#history = []; 20 } 21 22 togglePanel() { 23 const open = this.panelTarget.classList.toggle('hidden'); 24 // Show welcome message on first open 25 if (!open && this.messagesTarget.children.length === 0) { 26 this.#appendMessage('assistant', this.#welcomeMessage()); 27 this.#renderQuickActions(); 28 } 29 if (!open) { 30 this.inputTarget.focus(); 31 } 32 // Hide unread badge once opened 33 if (this.hasBadgeTarget) this.badgeTarget.classList.add('hidden'); 34 } 35 36 async send(e) { 37 if (e.type === 'keydown' && e.key !== 'Enter') return; 38 if (e.type === 'keydown' && e.shiftKey) return; // allow shift-enter for newlines 39 40 const message = this.inputTarget.value.trim(); 41 if (!message) return; 42 43 this.inputTarget.value = ''; 44 this.#appendMessage('user', message); 45 this.#setLoading(true); 46 47 // Remove quick-action chips after first real interaction 48 const chips = this.messagesTarget.querySelector('[data-quick-actions]'); 49 if (chips) chips.remove(); 50 51 try { 52 const body = new FormData(); 53 body.append('_token', this.tokenValue); 54 body.append('message', message); 55 body.append('history', JSON.stringify(this.#history)); 56 57 const resp = await fetch(this.urlValue, { 58 method: 'POST', 59 body, 60 headers: { 'Accept': 'application/json' }, 61 }); 62 63 const data = await resp.json(); 64 65 if (data.error) { 66 this.#appendMessage('error', data.error); 67 } else { 68 const reply = data.reply ?? ''; 69 this.#history.push({ role: 'user', content: message }); 70 this.#history.push({ role: 'assistant', content: reply }); 71 // Keep history bounded to last 10 turns (20 entries) to avoid token bloat 72 if (this.#history.length > 20) this.#history = this.#history.slice(-20); 73 this.#appendMessage('assistant', reply); 74 } 75 } catch { 76 this.#appendMessage('error', 'Network error â please try again.'); 77 } finally { 78 this.#setLoading(false); 79 this.inputTarget.focus(); 80 } 81 } 82 83 quickAction(e) { 84 const msg = e.currentTarget.dataset.message; 85 if (msg) { 86 this.inputTarget.value = msg; 87 this.send({ type: 'submit', preventDefault: () => {} }); 88 } 89 } 90 91 clearHistory() { 92 this.#history = []; 93 this.messagesTarget.innerHTML = ''; 94 this.#appendMessage('assistant', this.#welcomeMessage()); 95 this.#renderQuickActions(); 96 } 97 98 // ââ Private helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 99 100 #welcomeMessage() { 101 const domain = this.domainValue || 'your domain'; 102 return `Hi! I'm your SEO assistant for **${domain}**. I can see your audit signals, open issues, and score. Ask me anything or pick a quick action below.`; 103 } 104 105 #renderQuickActions() { 106 const wrap = document.createElement('div'); 107 wrap.setAttribute('data-quick-actions', ''); 108 wrap.className = 'flex flex-wrap gap-1.5 mt-2 mb-1 px-1'; 109 110 QUICK_ACTIONS.forEach(action => { 111 const btn = document.createElement('button'); 112 btn.type = 'button'; 113 btn.className = 'btn btn-xs btn-outline btn-primary whitespace-normal text-left h-auto py-1'; 114 btn.textContent = action.label; 115 btn.dataset.message = action.message; 116 btn.dataset.action = 'click->seo-domain-chat#quickAction'; 117 wrap.appendChild(btn); 118 }); 119 120 this.messagesTarget.appendChild(wrap); 121 this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight; 122 } 123 124 #appendMessage(role, text) { 125 const el = document.createElement('div'); 126 el.className = role === 'user' 127 ? 'flex justify-end mb-2' 128 : (role === 'error' ? 'flex justify-start mb-2' : 'flex justify-start mb-2'); 129 130 const bubble = document.createElement('div'); 131 bubble.className = role === 'user'
132 ? 'bg-primary text-primary-content rounded-2xl rounded-br-sm px-3 py-2 text-sm max-w-[85%] whitespace-pre-wrap' 133 : (role === 'error' 134 ? 'bg-error/10 border border-error/30 text-error rounded-2xl rounded-bl-sm px-3 py-2 text-sm max-w-[90%]' 135 : 'bg-base-200 rounded-2xl rounded-bl-sm px-3 py-2 text-sm max-w-[90%] whitespace-pre-wrap'); 136 137 // Render markdown-ish: bold, code blocks, bullets 138 bubble.innerHTML = this.#renderMarkdown(text); 139 el.appendChild(bubble); 140 this.messagesTarget.appendChild(el); 141 this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight; 142 } 143 144 #renderMarkdown(text) { 145 // Escape HTML first 146 let html = text 147 .replace(/&/g, '&') 148 .replace(/</g, '<') 149 .replace(/>/g, '>'); 150 151 // Code blocks (```...```) 152 html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => 153 `<pre class="bg-base-300 rounded p-2 text-xs overflow-x-auto mt-1 mb-1"><code>${code.trim()}</code></pre>` 154 ); 155 156 // Inline code 157 html = html.replace(/`([^`]+)`/g, '<code class="bg-base-300 px-1 rounded text-xs">$1</code>'); 158 159 // Bold 160 html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 161 162 // Bullet lists (lines starting with - or â¢) 163 html = html.replace(/^[-â¢]\s+(.+)$/gm, '<li class="ml-4 list-disc">$1</li>'); 164 html = html.replace(/(<li[^>]*>.*<\/li>\n?)+/g, m => `<ul class="my-1 space-y-0.5">${m}</ul>`); 165 166 // Line breaks 167 html = html.replace(/\n/g, '<br>'); 168 169 return html; 170 } 171 172 #setLoading(loading) { 173 this.sendBtnTarget.disabled = loading; 174 this.spinnerTarget.classList.toggle('hidden', !loading); 175 if (loading) { 176 // Show a "typing" indicator 177 const el = document.createElement('div'); 178 el.id = 'seo-chat-typing'; 179 el.className = 'flex justify-start mb-2'; 180 el.innerHTML = '<div class="bg-base-200 rounded-2xl rounded-bl-sm px-3 py-2 text-sm opacity-60 flex items-center gap-1"><span class="loading loading-dots loading-xs"></span> Thinkingâ¦</div>'; 181 this.messagesTarget.appendChild(el); 182 this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight; 183 } else { 184 document.getElementById('seo-chat-typing')?.remove(); 185 } 186 } 187}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.