1import { Controller } from '@hotwired/stimulus'; 2import { marked } from 'marked'; 3 4export default class extends Controller { 5 static targets = ['pastList', 'history', 'input', 'vendorSelect', 'modelSelect', 'personaSelect']; 6 static values = { 7 vendor: String, 8 currentConversationId: Number 9 }; 10 11 connect() { 12 console.log('Chat controller connected'); 13 14 // Restore from sessionStorage 15 const savedVendor = sessionStorage.getItem('ai_chat_vendor'); 16 const savedModel = sessionStorage.getItem('ai_chat_model'); 17 const savedPersona = sessionStorage.getItem('ai_chat_persona'); 18 const savedConvId = sessionStorage.getItem('ai_chat_conversation_id'); 19 20 if (savedVendor) { 21 this.vendorValue = savedVendor; 22 if (this.hasVendorSelectTarget) { 23 this.vendorSelectTarget.value = savedVendor; 24 } 25 } 26 27 console.log('Vendor:', this.vendorValue); 28 29 // Initial load of models for the current vendor 30 this.loadModels(this.vendorValue).then(() => { 31 if (savedModel && this.hasModelSelectTarget) { 32 this.modelSelectTarget.value = savedModel; 33 } 34 }); 35 36 // Load personas 37 this.loadPersonas().then(() => { 38 if (savedPersona && this.hasPersonaSelectTarget) { 39 this.personaSelectTarget.value = savedPersona; 40 } 41 }); 42 43 if (savedConvId && savedConvId !== '0') { 44 this.loadConversation(this.vendorValue, savedConvId); 45 } 46 47 // Configure marked with highlight.js if available 48 if (window.hljs) { 49 const renderer = new marked.Renderer(); 50 renderer.code = ({ text, lang }) => { 51 const validLang = !!(lang && hljs.getLanguage(lang)); 52 const highlighted = validLang 53 ? hljs.highlight(text, { language: lang }).value 54 : hljs.highlightAuto(text).value; 55 return `<pre><code class="hljs ${validLang ? `language-${lang}` : ''}">${highlighted}</code></pre>`; 56 }; 57 marked.setOptions({ renderer }); 58 } 59 60 // Auto-grow textarea 61 this.inputTarget.addEventListener('input', () => { 62 this.inputTarget.style.height = 'auto'; 63 this.inputTarget.style.height = (this.inputTarget.scrollHeight) + 'px'; 64 }); 65 66 // Render existing assistant messages as markdown 67 this.historyTarget.querySelectorAll('.ai-message .ai-chat-content').forEach(el => { 68 const content = el.textContent.trim(); 69 // Check if it's already HTML (e.g. from server persistence) or needs marked 70 // el.textContent might be HTML if it was rendered by Twig, but actually 71 // the Twig template just outputs {{ message.content }} 72 // If it's already HTML, don't parse it with marked. 73 if (!/<[a-z][\s\S]*>/i.test(el.innerHTML)) { 74 el.innerHTML = marked.parse(content); 75 } 76 77 // Highlight code blocks 78 if (window.hljs) { 79 el.querySelectorAll('pre code').forEach((block) => { 80 hljs.highlightElement(block); 81 }); 82 } 83 }); 84 } 85 86 togglePastConversations(event) { 87 event.preventDefault(); 88 const rootId = event.params.id; 89 console.log(`Toggle past conversations (Root ID: ${rootId})`); 90 91 if (this.pastListTarget.classList.contains('d-none')) { 92 this.loadHistoryList(rootId); 93 } 94 95 this.pastListTarget.classList.toggle('d-none'); 96 } 97 98 loadHistoryList(rootId) { 99 // Fetch global history regardless of current vendor 100 fetch(`/ai/chat/all/history`) 101 .then(response => response.json()) 102 .then(data => { 103 if (data.status === 'success') { 104 this.pastListTarget.innerHTML = ''; 105 if (data.conversations.length === 0) { 106 const li = document.createElement('li'); 107 li.className = 'list-group-item'; 108 li.textContent = 'No past conversations.'; 109 this.pastListTarget.appendChild(li); 110 } else { 111 data.conversations.forEach(conv => { 112 const li = document.createElement('li'); 113 li.className = 'list-group-item list-group-item-action'; 114 li.style.cursor = 'pointer'; 115 li.textContent = conv.title; 116 li.setAttribute('data-action', 'click->chat#selectConversation:params'); 117 li.setAttribute('data-chat-vendor-param', conv.vendor); 118 li.setAttribute('data-chat-conversation-id-p
118aram', conv.id); 119 li.setAttribute('data-chat-id-param', rootId); 120 this.pastListTarget.appendChild(li); 121 }); 122 } 123 } 124 }); 125 } 126 127 sendRequest(event) { 128 event.preventDefault(); 129 const vendor = this.vendorValue; 130 const rootId = event.params.id; 131 const message = this.inputTarget.value.trim(); 132 if (!message) return; 133 134 console.log(`Sending request to ${vendor} (Root ID: ${rootId}):`, message); 135 136 // Use fetch to send the request to the backend 137 fetch(`/ai/chat/${vendor}/send`, { 138 method: 'POST', 139 headers: { 140 'Content-Type': 'application/json', 141 }, 142 body: JSON.stringify({ 143 vendor: vendor, 144 message: message, 145 conversationId: this.currentConversationIdValue || null 146 }) 147 }) 148 .then(response => response.json()) 149 .then(data => { 150 console.log('Success:', data); 151 if (data.status === 'success') { 152 this.appendMessage('user', message, rootId); 153 this.appendMessage('assistant', data.response, rootId); 154 this.inputTarget.value = ''; 155 this.inputTarget.style.height = 'auto'; // Reset height 156 157 // Update current conversation ID if it's a new conversation 158 if (data.conversationId) { 159 this.currentConversationIdValue = data.conversationId; 160 // Refresh the history list to include the new/updated conversation 161 this.loadHistoryList(rootId); 162 } 163 164 // Manually call highlightAll if hljs is available 165 if (window.hljs) { 166 hljs.highlightAll(); 167 } 168 169 this.scrollToBottom(); 170 } else { 171 alert('Error: ' + data.message); 172 } 173 }) 174 .catch((error) => { 175 console.error('Error:', error); 176 alert('An error occurred while sending the message.'); 177 }); 178 } 179 180 appendMessage(role, content, rootId = null) { 181 const messageDiv = document.createElement('div'); 182 const timestamp = Date.now() + Math.floor(Math.random() * 1000); 183 const messageId = rootId ? `${rootId}-message-${timestamp}` : `message-${timestamp}`; 184 185 const alignment = role === 'user' ? 'align-self-end bg-primary text-white shadow-sm' : 'align-self-start bg-light shadow-sm border'; 186 const label = role === 'user' ? 'You' : 'AI'; 187 const labelClass = role === 'user' ? 'border-white border-opacity-25' : ''; 188 189 messageDiv.id = messageId; 190 messageDiv.className = `ai-chat-message ${role}-message ${alignment} p-2 rounded mb-2`; 191 messageDiv.style.maxWidth = '80%'; 192 193 // If it's an assistant message, it might be HTML from the server or raw text that needs parsing 194 let renderedContent = content; 195 if (role === 'assistant') { 196 // Check if it's already HTML (simple check) or needs marked 197 const isHtml = /<[a-z][\s\S]*>/i.test(content); 198 renderedContent = isHtml ? content : marked.parse(content); 199 } 200 201 messageDiv.innerHTML = `<div class="small fw-bold border-bottom ${labelClass} mb-1">${label}</div><div class="${role === 'assistant' ? 'ai-chat-content' : ''}">${renderedContent}</div>`; 202 203 this.historyTarget.appendChild(messageDiv); 204 205 // Highlight code blocks if hljs is available 206 if (role === 'assistant' && window.hljs) { 207 messageDiv.querySelectorAll('pre code').forEach((block) => { 208 hljs.highlightElement(block); 209 }); 210 } 211 } 212 213 scrollToBottom() { 214 this.historyTarget.scrollTop = this.historyTarget.scrollHeight; 215 } 216 217 vendorValueChanged(vendor) { 218 if (vendor) { 219 sessionStorage.setItem('ai_chat_vendor', vendor); 220 } 221 } 222 223 currentConversationIdValueChanged(id) { 224 sessionStorage.setItem('ai_chat_conversation_id', id); 225 } 226 227 selectConversation(event) { 228 event.preventDefault(); 229 const vendor = event.params.vendor; // Use vendor from the conversation 230 const conversationId = event.params.conversationId; 231 const rootId = event.params.id; 232 233 // Update UI to reflect the vendor of the selected conversation 234 if (this.vendorValue !== vendor) { 235 this.vendorValue = vendor; 236 if (this.hasVendorSelectTarget) { 237 this.vendorSelectTarget.value = vendor; 238 }
239 // Load models for the new vendor before loading the conversation 240 this.loadModels(vendor).then(() => { 241 this.loadConversation(vendor, conversationId, rootId); 242 }); 243 } else { 244 this.loadConversation(vendor, conversationId, rootId); 245 } 246 } 247 248 loadConversation(vendor, conversationId, rootId = null) { 249 console.log(`Loading conversation ${conversationId} for vendor ${vendor} (Root ID: ${rootId})`); 250 251 return fetch(`/ai/chat/${vendor}/conversation/${conversationId}`, { 252 method: 'POST', 253 headers: { 254 'Content-Type': 'application/json', 255 }, 256 body: JSON.stringify({ 257 vendor: vendor 258 }) 259 }) 260 .then(response => response.json()) 261 .then(data => { 262 console.log('Conversation loaded:', data); 263 if (data.status === 'success') { 264 // Update current conversation ID 265 this.currentConversationIdValue = conversationId; 266 267 // Update model select if model is returned 268 if (data.model && this.hasModelSelectTarget) { 269 this.modelSelectTarget.value = data.model; 270 sessionStorage.setItem('ai_chat_model', data.model); 271 } 272 273 // Update persona select if persona is returned 274 if (data.persona && this.hasPersonaSelectTarget) { 275 this.personaSelectTarget.value = data.persona; 276 sessionStorage.setItem('ai_chat_persona', data.persona); 277 } 278 279 // Clear current history 280 this.historyTarget.innerHTML = ''; 281 // Append loaded history
282 data.history.forEach((msg, index) => { 283 let role = msg.type === 'request' ? 'user' : 'assistant'; 284 this.appendMessage(role, msg.text, rootId); 285 }); 286 this.scrollToBottom(); 287 // Optionally hide the list on mobile 288 this.pastListTarget.classList.add('d-none'); 289 } else { 290 alert('Error: ' + data.message); 291 } 292 }) 293 .catch((error) => { 294 console.error('Error:', error); 295 // alert('An error occurred while loading the conversation.'); 296 }); 297 } 298 299 startNewConversation(event) { 300 if (event) event.preventDefault(); 301 const vendor = this.vendorValue; 302 console.log(`Starting new conversation for vendor: ${vendor}`); 303 304 // Reset current conversation ID 305 this.currentConversationIdValue = 0; 306 sessionStorage.removeItem('ai_chat_conversation_id'); 307 308 // Clear history UI 309 this.historyTarget.innerHTML = ''; 310 311 // Clear input 312 this.inputTarget.value = ''; 313 this.inputTarget.style.height = 'auto'; 314 315 // Close history list if open 316 this.pastListTarget.classList.add('d-none'); 317 } 318 319 changeVendor(event) { 320 const vendor = event.target.value; 321 console.log(`Vendor changed to: ${vendor}`); 322 this.vendorValue = vendor; 323 this.loadModels(vendor); 324 325 // Clear history and reset conversation when switching vendors 326 this.startNewConversation(event); 327 sessionStorage.removeItem('ai_chat_model'); 328 } 329 330 loadModels(vendor) { 331 return fetch(`/ai/chat/${vendor}/models`) 332 .then(response => response.json()) 333 .then(data => { 334 if (data.status === 'success') { 335 this.modelSelectTarget.innerHTML = '<option value="">Select Model</option>'; 336 if (data.models && data.models.length > 0) { 337 data.models.forEach(model => { 338 const option = document.createElement('option'); 339 option.value = model; 340 option.textContent = model; 341 this.modelSelectTarget.appendChild(option); 342 }); 343 this.modelSelectTarget.disabled = false; 344 } else { 345 this.modelSelectTarget.disabled = true; 346 } 347 } 348 }); 349 } 350 351 changeModel(event) { 352 const model = event.target.value; 353 const vendor = this.vendorValue; 354 console.log(`Model changed to: ${model} for vendor: ${vendor}`); 355 356 if (!model) { 357 sessionStorage.removeItem('ai_chat_model'); 358 return; 359 } 360 361 sessionStorage.setItem('ai_chat_model', model); 362 363 fetch(`/ai/chat/${vendor}/set-model`, { 364 method: 'POST', 365 headers: { 366 'Content-Type': 'application/json', 367 }, 368 body: JSON.stringify({ model: model }) 369 }) 370 .then(response => response.json()) 371 .then(data => { 372 if (data.status !== 'success') { 373 alert('Error setting model: ' + data.message); 374 } 375 }); 376 } 377 378 loadPersonas() { 379 return fetch(`/ai/chat/personas`) 380 .then(response => response.json()) 381 .then(data => { 382 if (data.status === 'success') { 383 this.personaSelectTarget.innerHTML = '<option value="">Select Persona</option>'; 384 Object.entries(data.personas).forEach(([name, prompt]) => { 385 const option = document.createElement('option'); 386 option.value = prompt; 387 option.textContent = name; 388 this.personaSelectTarget.appendChild(option); 389 }); 390 } 391 }); 392 } 393 394 changePersona(event) { 395 const persona = event.target.value; 396 console.log(`Persona changed to: ${persona}`); 397 398 if (!persona) { 399 sessionStorage.removeItem('ai_chat_persona'); 400 return; 401 } 402 403 sessionStorage.setItem('ai_chat_persona', persona); 404 405 fetch(`/ai/chat/set-persona`, { 406 method: 'POST', 407 headers: { 408 'Content-Type': 'application/json', 409 }, 410 body: JSON.stringify({ persona: persona }) 411 }) 412 .then(response => response.json()) 413 .then(data => { 414 if (data.status !== 'success') { 415 alert('Error setting persona: ' + data.message); 416 } 417 }); 418 } 419}
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.