PageSourceSearch

https://healthylifenetwork.com.au/assets/controllers/seo-domain-chat_controller-b4e4dmw.js

js healthylifenetwork.com.au collected 2026-09-25 03:36:00 UTC 8,181 bytes, 187 lines download raw bytes

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, '&lt;')
149            .replace(/>/g, '&gt;');
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.