PageSourceSearch

https://healthylifenetwork.com.au/assets/controllers/chatbot-conversation-viewer_controller-waVdUBS.js

js healthylifenetwork.com.au collected 2026-09-25 03:34:02 UTC 12,914 bytes, 297 lines download raw bytes

1import { Controller } from '@hotwired/stimulus';
2
3/**
4 * Inline 3-pane conversation review mode for the chatbot Conversations tab.
5 *
6 * Left pane lists conversations (searchable + status / needs-review filters).
7 * Selecting one loads its transcript + intelligence panel (AJAX fragment) into
8 * the right pane. From there you can rate answers (thumbs), promote a good answer
9 * into training content, and close/delete the conversation — all without leaving
10 * the page. ↑/↓ move through the visible list.
11 */
12export default class extends Controller {
13    static targets = [
14        'item', 'detail', 'search', 'statusFilter', 'needsReviewToggle', 'count',
15        'promoteDialog', 'promoteTitle', 'promoteCategory', 'promoteError', 'promoteSubmit',
16    ];
17    static values = {
18        fragmentUrlTemplate: String,
19        feedbackUrl: String,
20        promoteUrlTemplate: String,
21        bulkCloseUrl: String,
22        bulkDeleteUrl: String,
23        deleteUnansweredUrl: String,
24    };
25
26    connect() {
27        this.currentId = null;
28        this.promoteMessageId = null;
29        this._onKeydown = this.onKeydown.bind(this);
30        document.addEventListener('keydown', this._onKeydown);
31        this.filter();
32    }
33
34    disconnect() {
35        document.removeEventListener('keydown', this._onKeydown);
36    }
37
38    // ── List selection ──────────────────────────────────────────────
39    select(event) {
40        const id = event.currentTarget.dataset.conversationId;
41        if (!id) return;
42        this.load(id);
43    }
44
45    load(id) {
46        this.currentId = String(id);
47        this.highlight();
48
49        const url = this.fragmentUrlTemplateValue.replace('__ID__', encodeURIComponent(id));
50        this.detailTarget.innerHTML = '<div class="flex items-center justify-center py-16">'
51            + '<span class="loading loading-spinner loading-lg text-primary"></span></div>';
52
53        fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
54            .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
55            .then(html => {
56                this.detailTarget.innerHTML = html;
57                this.detailTarget.scrollTop = 0;
58            })
59            .catch(() => {
60                this.detailTarget.innerHTML = '<div class="alert alert-error">'
61                    + 'Could not load this conversation. Please try again.</div>';
62            });
63    }
64
65    highlight() {
66        this.itemTargets.forEach(el => {
67            const active = el.dataset.conversationId === this.currentId;
68            el.classList.toggle('bg-base-200', active);
69            el.classList.toggle('border-primary', active);
70        });
71    }
72
73    // ── Filtering ───────────────────────────────────────────────────
74    filter() {
75        const q = this.hasSearchTarget ? this.searchTarget.value.trim().toLowerCase() : '';
76        const status = this.hasStatusFilterTarget ? this.statusFilterTarget.value : '';
77        const needsOnly = this.hasNeedsReviewToggleTarget && this.needsReviewToggleTarget.checked;
78
79        let visible = 0;
80        this.itemTargets.forEach(el => {
81            const matchesSearch = !q || (el.dataset.search || '').includes(q);
82            const matchesStatus = !status || el.dataset.status === status;
83            const matchesNeeds = !needsOnly || el.dataset.needsReview === '1';
84            const show = matchesSearch && matchesStatus && matchesNeeds;
85            el.classList.toggle('hidden', !show);
86            if (show) visible++;
87        });
88
89        if (this.hasCountTarget) {
90            const total = this.itemTargets.length;
91            this.countTarget.textContent = visible === total
92                ? `${total} conversation${total === 1 ? '' : 's'}`
93                : `${visible} of ${total} shown`;
94        }
95    }
96
97    visibleItems() {
98        return this.itemTargets.filter(el => !el.classList.contains('hidden'));
99    }
100
101    onKeydown(event) {
102        // Only when a conversation is loaded and focus isn't in a text field.
103        if (!this.currentId) return;
104        const tag = (document.activeElement?.tagName || '').toLowerCase();
105        if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
106        if (event.key === 'ArrowDown') { event.preventDefault(); this.move(1); }
107        else if (event.key === 'ArrowUp') { event.preventDefault(); this.move(-1); }
108    }
109
110    move(delta) {
111        const items = this.visibleItems();
112        if (items.length === 0) return;
113        let idx = items.findIndex(el => el.dataset.conversationId === this.currentId);
114        if (idx === -1) idx = delta > 0 ? -1 : items.length;
115        const nextIdx = idx + delta;
116        if (nextIdx < 0 || nextIdx >= items.length) return;
117        const el = items[nextIdx];
118        this.load(el.dataset.conversationId);
119        el.scrollIntoView({ block: 'nearest' });
120    }
121
122    // ── Thumbs feedback (delegated from injected fragment) ───────────
123    feedback(event) {
124        const btn = event.target.closest('.feedback-btn');
125        if (!btn || btn.disabled) return;
126        const row = btn.closest('.feedback-row');
127        if (!row) return;
128
129        const messageId = parseInt(row.dataset.messageId, 10);
130        const rating = parseInt(btn.dataset.rating, 10);
131
132        fetch(this.feedbackUrlValue, {
133            method: 'POST',
134            headers: { 'Content-Type': 'application/json' },
135            credentials: 'include',
136            body: JSON.stringify({ message_id: messageId, rating: rating }),
137        }).then(resp => {
138            if (!resp.ok) return;
139            row.querySelectorAll('.feedback-btn').forEach(b => {
140                b.disabled = true;
141                b.classList.remove('text-success', 'text-error', 'btn-active');
142            });
143            btn.classList.add(rating === 5 ? 'text-success' : 'text-error', 'btn-active');
144            const label = document.createElement('span');
145            label.className = 'text-xs text-base-content/40 ml-1';
146            label.textContent = 'Rated';
147            row.appendChild(label);
148        });
149    }
150
151    // ── Promote to training ─────────────────────────────────────────
152    openPromote(event) {
153        const btn = event.target.closest('[data-message-id]');
154        if (!btn) return;
155        this.promoteMessageId = parseInt(btn.dataset.messageId, 10);
156        if (this.hasPromoteErrorTarget) this.promoteErrorTarget.classList.add('hidden');
157        if (this.hasPromoteTitleTarget) this.promoteTitleTarget.value = '';
158        if (this.hasPromoteDialogTarget) this.promoteDialogTarget.showModal();
159    }
160
161    closePromote() {
162        if (this.hasPromoteDialogTarget) this.promoteDialogTarget.close();
163    }
164
165    submitPromote() {
166        if (!this.currentId || !this.promoteMessageId) return;
167        const title = this.hasPromoteTitleTarget ? this.promoteTitleTarget.value.trim() : '';
168        let categoryId = null;
169        if (this.hasPromoteCategoryTarget) {
170            const raw = this.promoteCategoryTarget.value;
171            if (raw && /^\d+$/.test(raw)) categoryId = parseInt(raw, 10);
172        }
173
174        const url = this.promoteUrlTemplateValue.replace('__ID__', encodeURIComponent(this.currentId));
175        const btn = this.hasPromoteSubmitTarget ? this.promoteSubmitTarget : null;
176        if (btn) { btn.disabled = true; btn.classList.add('btn-disabled'); }
177
178        fetch(url, {
179            method: 'POST',
180            headers: { 'Content-Type': 'application/json' },
181            credentials: 'same-origin',
182            body: JSON.stringify({ message_id: this.promoteMessageId, title: title, category_id: categoryId }),
183        })
184            .then(async resp => {
185                const data = await resp.json().catch(() => ({}));
186                if (!resp.ok || data.success === false) {
187                    throw new Error(data.message || 'Could not save this answer as training.');
188                }
189                this.closePromote();
190                this.toast(data.message || 'Saved as training content.', 'success');
191            })
192            .catch(err => {
193                if (this.hasPromoteErrorTarget) {
194                    this.promoteErrorTarget.textContent = err.message;
195                    this.promoteErrorTarget.classList.remove('hidden');
196                }
197            })
198            .finally(() => {
199                if (btn) { btn.disabled = false; btn.classList.remove('btn-disabled'); }
200            });
201    }
202
203    // ── Close / delete (delegated from injected fragment) ────────────
204    closeConversation(event) {
205        const el = event.currentTarget;
206        const id = parseInt(el.dataset.conversationId, 10);
207        const csrf = el.dataset.csrf;
208        if (!id) return;
209        this.bulkAction(this.bulkCloseUrlValue, id, csrf, 'closed', el);
210    }
211
212    async deleteConversation(event) {
213        const el = event.currentTarget;
214        const id = parseInt(el.dataset.conversationId, 10);
215        const csrf = el.dataset.csrf;
216        if (!id) return;
217        const ok = window.eiqConfirm
218            ? await window.eiqConfirm('Delete this conversation? This cannot be undone.')
219            : true;
220        if (!ok) return;
221        this.bulkAction(this.bulkDeleteUrlValue, id, csrf, 'deleted', el);
222    }
223
224    async deleteUnanswered(event) {
225        const btn = event.currentTarget;
226        const csrf = btn.dataset.csrf || '';
227
228        const ok = await window.eiqConfirm('Delete all conversations where the visitor never replied? This cannot be undone.');
229        if (!ok) return;
230
231        btn.disabled = true;
232        btn.classList.add('btn-disabled');
233
234        fetch(this.deleteUnansweredUrlValue, {
235            method: 'POST',
236            headers: { 'Content-Type': 'application/json' },
237            credentials: 'same-origin',
238            body: JSON.stringify({ _token: csrf }),
239        })
240            .then(async resp => {
241                const data = await resp.json().catch(() => ({}));
242                if (!resp.ok || data.success === false) throw new Error(data.message || 'Failed to delete unanswered conversations.');
243                this.toast(data.message || 'Done.', 'success');
244                // Reload so the list reflects the deletions
245                setTimeout(() => window.location.reload(), 1000);
246            })
247            .catch(err => {
248                this.toast(err.message, 'error');
249                btn.disabled = false;
250                btn.classList.remove('btn-disabled');
251            });
252    }
253
254    bulkAction(url, id, csrf, verb, triggerEl) {
255        if (triggerEl) triggerEl.disabled = true;
256        fetch(url, {
257            method: 'POST',
258            headers: { 'Content-Type': 'application/json' },
259            credentials: 'same-origin',
260            body: JSON.stringify({ ids: [id], _token: csrf }),
261        })
262            .then(async resp => {
263                const data = await resp.json().catch(() => ({}));
264                if (!resp.ok || data.success === false) {
265                    throw new Error(data.message || `Could not ${verb === 'deleted' ? 'delete' : 'close'} the conversation.`);
266                }
267                this.toast(data.message || `Conversation ${verb}.`, 'success');
268                const item = this.itemTargets.find(el => el.dataset.conversationId === String(id));
269                if (verb === 'deleted') {
270                    if (item) item.remove();
271                    this.detailTarget.innerHTML = '';
272                    this.currentId = null;
273                } else if (item) {
274                    item.dataset.status = 'closed';
275                    const badge = item.querySelector('.badge');
276                    if (badge) badge.textContent = 'closed';
277                    this.load(id);
278                }
279                this.filter();
280            })
281            .catch(err => {
282                this.toast(err.message, 'error');
283                if (triggerEl) triggerEl.disabled = false;
284            });
285    }
286
287    // ── Toast ────────────────────────────────────────────────────────
288    toast(message, type) {
289        const toast = document.createElement('div');
290        toast.className = 'toast toast-top toast-end z-[9999]';
291        const alertClass = type === 'success' ? 'alert-success' : 'alert-error';
292        toast.innerHTML = '<div class="alert ' + alertClass + '"><span></span></div>';
293        toast.querySelector('span').textContent = message;
294        document.body.appendChild(toast);
295        setTimeout(() => toast.remove(), 4000);
296    }
297}

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.