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.