1import { Controller } from '@hotwired/stimulus'; 2 3/* 4 * ticket-kb-suggest â help-desk KB integration (deflection + agent-assist). 5 * 6 * Two modes (data-ticket-kb-suggest-mode-value): 7 * - "deflect": watches the subject/message fields while a user files a ticket and 8 * surfaces the closest KB articles ("before you file this, does one of these 9 * answer it?"). Suggestion cards are links that open the article in a new tab. 10 * - "assist": an agent types a keyword (or reuses the ticket subject), and each 11 * suggestion card inserts a link to that article into the reply textarea. 12 * 13 * Retrieval is server-side and access-filtered; this controller is purely UI. 14 */ 15export default class extends Controller { 16 static targets = ['input', 'query', 'panel', 'results', 'loading', 'empty', 'insert']; 17 static values = { 18 url: String, 19 token: String, 20 mode: { type: String, default: 'deflect' }, 21 minChars: { type: Number, default: 5 }, 22 debounce: { type: Number, default: 500 }, 23 }; 24 25 connect() { 26 this._timer = null; 27 this._lastQuery = ''; 28 } 29 30 disconnect() { 31 if (this._timer) { 32 clearTimeout(this._timer); 33 } 34 } 35 36 // Debounced trigger bound to input events (deflect) or the query field (assist). 37 schedule() { 38 if (this._timer) { 39 clearTimeout(this._timer); 40 } 41 this._timer = setTimeout(() => this.refresh(), this.debounceValue); 42 } 43 44 // Immediate trigger (e.g. an explicit "Find articles" button in assist mode). 45 search(event) { 46 if (event) { 47 event.preventDefault(); 48 } 49 this.refresh(); 50 } 51 52 queryText() { 53 const sources = this.hasQueryTarget ? [this.queryTarget] : this.inputTargets; 54 return sources.map((el) => (el.value || '').trim()).filter(Boolean).join(' ').trim(); 55 } 56 57 async refresh() { 58 const query = this.queryText(); 59 if (query.length < this.minCharsValue) { 60 this.hidePanel(); 61 return; 62 } 63 if (query === this._lastQuery) { 64 return; 65 } 66 this._lastQuery = query; 67 68 this.setLoading(true); 69 try { 70 const response = await fetch(this.urlValue, { 71 method: 'POST', 72 headers: { 73 'Content-Type': 'application/json', 74 'X-Requested-With': 'XMLHttpRequest', 75 }, 76 body: JSON.stringify({ query, _token: this.tokenValue }), 77 }); 78 const data = await response.json().catch(() => ({})); 79 if (!response.ok) { 80 this.hidePanel(); 81 return; 82 } 83 this.render(Array.isArray(data.sources) ? data.sources : []); 84 } catch (e) { 85 this.hidePanel(); 86 } finally { 87 this.setLoading(false); 88 } 89 } 90 91 render(sources) { 92 this.resultsTarget.replaceChildren(); 93 94 if (sources.length === 0) { 95 if (this.hasEmptyTarget) { 96 this.emptyTarget.classList.remove('hidden'); 97 } 98 this.showPanel(); 99 return; 100 } 101 102 if (this.hasEmptyTarget) { 103 this.emptyTarget.classList.add('hidden'); 104 } 105 sources.forEach((source) => this.resultsTarget.appendChild(this.buildCard(source))); 106 this.showPanel(); 107 } 108 109 buildCard(source) { 110 if (this.modeValue === 'assist') { 111 return this.buildInsertCard(source); 112 } 113 return this.buildLinkCard(source); 114 } 115 116 buildLinkCard(source) { 117 const link = document.createElement('a'); 118 link.href = source.url; 119 link.target = '_blank'; 120 link.rel = 'noopener'; 121 link.className = 'block p-3 rounded-lg border border-base-300 hover:border-primary hover:bg-base-200/50 transition-colors'; 122 link.appendChild(this.cardBody(source)); 123 return link; 124 } 125 126 buildInsertCard(source) { 127 const wrap = document.createElement('div'); 128 wrap.className = 'flex items-center gap-2 p-3 rounded-lg border border-base-300'; 129 wrap.appendChild(this.cardBody(source)); 130 131 const btn = document.createElement('button'); 132 btn.type = 'button'; 133 btn.className = 'btn btn-xs btn-primary shrink-0'; 134 btn.textContent = 'Insert'; 135 btn.addEventListener('click', () => this.insertArticle(source)); 136 wrap.appendChild(btn); 137 return wrap; 138 } 139 140 cardBody(source) { 141 const body = document.createElement('div'); 142 body.className = 'flex-1 min-w-0'; 143 144 const title = document.createElement('div'); 145 title.className = 'font-medium text-sm'; 146 title.textContent = source.title || 'Untitled'; 147 body.appendChild(title); 148 149 if (source.abstract) { 150 const abstract = document.createElement('div'); 151 abstract.className = 'text-xs opacity-70 mt-0.5 line-clamp-2'; 152 abstract.textContent = source.abstract; 153 body.appendChild(abstract); 154 } 155 return body; 156 } 157 158 insertArticle(source) { 159 if (!this.hasInsertTarget) { 160 return; 161 } 162 const origin = window.location.origin; 163 const snippet = `See our article "${source.title}": ${origin}${source.url}`; 164 const field = this.insertTarget; 165 const existing = field.value || ''; 166 field.value = existing ? `${existing}\n\n${snippet}` : snippet; 167 field.dispatchEvent(new Event('input', { bubbles: true })); 168 field.focus(); 169 } 170
171 setLoading(isLoading) { 172 if (!this.hasLoadingTarget) { 173 return; 174 } 175 this.loadingTarget.classList.toggle('hidden', !isLoading); 176 } 177 178 showPanel() { 179 if (this.hasPanelTarget) { 180 this.panelTarget.classList.remove('hidden'); 181 } 182 } 183 184 hidePanel() { 185 if (this.hasPanelTarget) { 186 this.panelTarget.classList.add('hidden'); 187 } 188 } 189}
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.