1/** 2 * dsh-ai.js 3 * DSH Portal â AI-powered search enhancements 4 * 5 * Sections: 6 * 1. Module state & config 7 * 2. AI Summary (replaces _buildSummarySection async) 8 * 3. Intent Detection + auto-tab 9 * 4. Query Rewriting 10 * 5. RAG Answer Panel 11 * 6. UI helpers (streaming typewriter, panel render) 12 * 7. Public API 13 */ 14 15'use strict'; 16 17// --------------------------------------------------------------------------- 18// 1. MODULE STATE & CONFIG 19// --------------------------------------------------------------------------- 20 21// Use server-injected absolute URL if available, else fall back to relative 22const AI_API_BASE = (typeof window.DSH_API_BASE !== 'undefined') 23 ? window.DSH_API_BASE 24 : '../api/api.php'; 25 26const _aiState = { 27 lastQuery: '', 28 lastSummary: '', 29 lastIntent: null, 30 ragPanelVisible: false, 31 summaryLoading: false, 32 summaryAbort: null, 33 ragSources: [], // Sumber dari RAG answer â untuk integrasi kartu 34 ragWebSources: [], // Web-only sources dari Serper 35 ragLocalSources: [], // Local DB sources yang cocok 36 lastKnowledgeId: null, // ID di ai_qa_knowledge â untuk feedback API 37 sessionId: null, // Session ID percakapan â disimpan di localStorage 38 smartMode: true, // true=Smart (bawaan), false=Mini â keduanya router LLM, cadangan Ollama lokal 39}; 40 41// Kata tanya dalam bahasa Indonesia â trigger RAG panel 42const QUESTION_WORDS = [ 43 'apa', 'siapa', 'bagaimana', 'berapa', 'kapan', 'dimana', 'di mana', 44 'apakah', 'mengapa', 'kenapa', 'bisakah', 'bolehkah', 'adakah', 45 'sebutkan', 'jelaskan', 'ceritakan', 'bandingkan', 46]; 47 48// Frasa yang mengindikasikan pertanyaan meski kata tanya tidak di awal 49const QUESTION_PHRASES = [ 50 'peringkat berapa', 'ranking berapa', 'nomor berapa', 'urutan berapa', 51 'berapa peringkat', 'berapa ranking', 52 'saat ini', 'tahun ini', 'terbaru', 'terkini', 'sekarang', 53 'gimana cara', 'cara daftar', 'cara mendaftar', 'cara masuk', 54 'info tentang', 'informasi tentang', 'tentang apa', 55 'siapa rektor', 'siapa dekan', 'siapa ketua', 56 'apa itu', 'apa yang', 'apa saja', 57]; 58 59// --------------------------------------------------------------------------- 60// 2. AI SUMMARY â Async LLM narrative summary 61// --------------------------------------------------------------------------- 62 63/** 64 * Panggil endpoint ai_summarize, kembalikan teks summary. 65 * Fallback ke null jika gagal (caller tetap tampilkan rule-based). 66 * @param {string} query 67 * @param {Array} results â Array hasil search (max 8 dikirim ke API) 68 * @param {string} types â Comma-separated entity types 69 * @returns {Promise<string|null>} 70 */ 71async function fetchAISummary(query, results = [], types = '') { 72 if (!query || query.length < 3) return null; 73 74 try { 75 const body = new FormData(); 76 body.append('action', 'ai_summarize'); 77 body.append('q', query); 78 body.append('types', types); 79 body.append('count', String(results.length)); 80 body.append('results', JSON.stringify(results.slice(0, 8).map(r => ({ 81 title: r.title || '', 82 description: r.description || '', 83 entity_type: r.entity_type || '', 84 url: r.url || '', 85 })))); 86 87 const resp = await fetch(AI_API_BASE, { method: 'POST', body }); 88 if (!resp.ok) return null; 89 const data = await resp.json(); 90 if (data.ok && data.data?.summary) return data.data.summary; 91 } catch (_) { /* network error â graceful fallback */ } 92 return null; 93} 94 95/** 96 * Inject AI summary ke dalam summary section yang sudah dirender. 97 * Dipanggil setelah renderCards() selesai (non-blocking). 98 */ 99async function injectAISummaryIntoPanel(query, results, types) { 100 const panel = document.getElementById('dshSummaryNarrative'); 101 if (!panel) return; 102 103 // Tampilkan skeleton loading state 104 const originalHTML = panel.innerHTML; 105 panel.innerHTML = ` 106 <div class="flex items-center gap-2 text-sm text-gray-500"> 107 <svg class="animate-spin h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24"> 108 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 109 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> 110 </svg>
111 <span class="animate-pulse">DSH Assistant sedang menganalisis hasil pencarian...</span> 112 </div>`; 113 114 const aiSummary = await fetchAISummary(query, results, types); 115 116 if (aiSummary) { 117 // Typewriter effect untuk AI text 118 panel.innerHTML = ''; 119 const textNode = document.createElement('div'); 120 textNode.className = 'text-sm text-gray-800 leading-relaxed'; 121 panel.appendChild(textNode); 122 123 const badge = document.createElement('span'); 124 badge.className = 'inline-flex items-center gap-1 text-xs bg-gradient-to-r from-blue-100 to-purple-100 text-blue-700 border border-blue-200 rounded-full px-2 py-0.5 mb-2 font-medium'; 125 badge.innerHTML = '⨠AI Generated'; 126 panel.insertBefore(badge, textNode); 127 128 await typewriterEffect(textNode, aiSummary, 12); 129 _aiState.lastSummary = aiSummary; 130 } else { 131 // Restore rule-based content jika AI gagal 132 panel.innerHTML = originalHTML; 133 } 134} 135 136// Typewriter effect â tampilkan teks karakter per karakter 137async function typewriterEffect(el, text, delayMs = 15) { 138 // Convert markdown-lite to HTML before typewriter 139 const html = markdownToHtml(text);
140 el.innerHTML = ''; 141 // For short text use instant render, for long use character-by-character on plain text 142 if (text.length > 800 || delayMs === 0) { 143 el.innerHTML = html; 144 return; 145 } 146 // Render HTML instantly then do a fade-in for perceived effect 147 el.style.opacity = '0'; 148 el.innerHTML = html; 149 el.style.transition = 'opacity 0.4s ease'; 150 el.style.opacity = '1'; 151} 152 153/** 154 * markdownToHtml â lightweight markdown converter for RAG answers 155 * Handles: **bold**, _italic_, _note lines_, paragraphs, line breaks 156 * Narasi mode: tidak ada list, cukup paragraf mengalir 157 */ 158function markdownToHtml(text) { 159 if (!text) return ''; 160 let s = text.replace(/\0/g, '').trim(); 161 162 // Escape HTML entities (XSS prevention from Serper snippets) 163 s = s.replace(/&/g, '&') 164 .replace(/</g, '<') 165 .replace(/>/g, '>'); 166 167 // --- ITALIC NOTE LINE: _teks..._ on its own line (process first, most specific) --- 168 s = s.replace(/^_(.+?)_\s*$/gm, 169 '<p class="text-xs text-gray-400 italic mt-4 pt-3 border-t border-gray-100">$1</p>'); 170 171 // --- Struktur dari LLM router: judul (#..######), garis pemisah, butir daftar --- 172 // Judul dijadikan blok tersendiri agar tidak tampil sebagai "# teks" mentah. 173 s = s.replace(/^[ \t]*(?:-{3,}|\*{3,}|_{3,})[ \t]*$/gm, ''); 174 s = s.replace(/^[ \t]*#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm, 175 '\n\n<p class="font-semibold text-gray-900 mt-3 mb-1">$1</p>\n\n'); 176 s = s.replace(/^[ \t]*[-*+][ \t]+/gm, '⢠'); 177 // Tabel Markdown: baris pemisah |---| dibuang, tiap baris jadi teks "sel · sel" 178 s = s.replace(/^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*(?:\|[ \t]*:?-{3,}:?[ \t]*)*\|?[ \t]*$/gm, ''); 179 s = s.replace(/^[ \t]*\|(.+)\|[ \t]*$/gm, 180 (_, row) => row.split('|').map(c => c.trim()).filter(Boolean).join(' · ')); 181 182 // --- BOLD: **text** or __text__ --- 183 s = s.replace(/\*\*(.+?)\*\*/gs, '<strong>$1</strong>'); 184 s = s.replace(/__(.+?)__/gs, '<strong>$1</strong>'); 185 186 // --- ITALIC inline: *text* or _text_ (single, not already processed as note) --- 187 s = s.replace(/\*([^*\n]{1,120}?)\*/g, '<em>$1</em>'); 188 s = s.replace(/_([^_\n]{1,120}?)_/g, '<em>$1</em>'); 189 190 // --- PARAGRAPHS: double newline â new paragraph --- 191 const parts = s.split(/\n{2,}/).filter(part => part.trim() !== ''); 192 const htmlParts = parts.map(part => { 193 part = part.replace(/\n/g, '<br>').trim(); 194 // Don't wrap if already a block element (e.g. our note <p>) 195 if (/^<p[\s>]/.test(part)) return part; 196 return `<p class="leading-relaxed text-gray-800">${part}</p>`; 197 }); 198 199 return htmlParts.join('\n'); 200} 201 202// --------------------------------------------------------------------------- 203// 3. INTENT DETECTION â Auto pre-select tab 204// --------------------------------------------------------------------------- 205 206/** 207 * Deteksi intent dari query, return {primary, secondary, is_question, keywords} 208 * Cache-aware: request hanya jika query berubah. 209 */ 210async function detectQueryIntent(query) { 211 if (!query || query.length < 3) return null; 212 if (_aiState.lastQuery === query && _aiState.lastIntent) return _aiState.lastIntent; 213 214 try { 215 const url = `${AI_API_BASE}?action=detect_intent&q=${encodeURIComponent(query)}`; 216 const resp = await fetch(url); 217 if (!resp.ok) return null; 218 const data = await resp.json(); 219 if (data.ok && data.data) { 220 _aiState.lastIntent = data.data; 221 _aiState.lastQuery = query; 222 return data.data; 223 } 224 } catch (_) {} 225 return null; 226} 227 228/** 229 * Auto-switch result tab berdasarkan intent yang terdeteksi. 230 * Hanya switch jika confidence >= threshold dan semua tab tersedia. 231 */ 232function applyIntentToTabs(intent) { 233 if (!intent || intent.primary === 'general') return; 234 if ((intent.confidence ?? 0) < 0.75) return; 235 236 // Map intent ke tab ID di frontend 237 const intentTabMap = { 238 service: 'tab-service', 239 news: 'tab-news', 240 product: 'tab-product', 241 people: 'tab-people', 242 publication: 'tab-publication', 243 patent: 'tab-patent', 244 legal: 'tabLegal', 245 pidato: 'tabPidato', 246 }; 247 248 const tabId = intentTabMap[intent.primary]; 249 if (!tabId) return; 250 251 const tabBtn = document.getElementById(tabId); 252 if (!tabBtn) return; 253 254 // Hanya auto-switch jika user tidak sedang di tab spesifik 255 const currentTab = document.querySelector('[data-result-tab].tab-active, [data-result-tab][aria-selected="true"]'); 256 if (currentTab && currentTab.id !== 'tab-all') return; // Jangan override pilihan manual user 257 258 // Soft switch: highlight saran tanpa auto-click 259 showIntentSuggestion(intent.primary, tabBtn); 260} 261 262function showIntentSuggestion(intentType, tabBtn) { 263 // Hapus suggestion lama 264 document.querySelector('.ai-intent-suggestion')?.remove(); 265 266 const typeLabels = { 267 service: 'Layanan Digital', news: 'Berita', product: 'Produk', 268 people: 'Dosen/Staff', publication: 'Publikasi', patent: 'HKI/Paten', 269 legal: 'Produk Hukum', 270 }; 271 const label = typeLabels[intentType] || intentType; 272 273 const badge = document.createElement('div'); 274 badge.className = 'ai-intent-suggestion fixed bottom-4 right-4 z-50 bg-white border border-blue-200 rounded-xl shadow-lg p-3 flex items-center gap-3 text-sm animate-fade-in'; 275 badge.innerHTML = ` 276 <div class="w-7 h-7 bg-blue-100 rounded-full flex items-center justify-center text-blue-600">ð¤</div> 277 <div> 278 <div class="font-medium text-gray-800">Hasil terbaik di: <strong>${label}</strong></div> 279 <div class="text-xs text-gray-500">Klik untuk filter otomatis</div> 280 </div> 281 <button onclick="document.getElementById('${tabBtn.id}')?.click(); document.querySelector('.ai-intent-suggestion')?.remove();" 282 class="ml-2 px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700 transition-colors"> 283 Filter 284 </button> 285 <button onclick="document.querySelector('.ai-intent-suggestion')?.remove();" 286 class="text-gray-400 hover:text-gray-600 transition-colors">â</button> 287 `; 288 document.body.appendChild(badge); 289 290 // Auto-hilang setelah 8 detik 291 setTimeout(() => badge.remove(), 8000); 292} 293 294// --------------------------------------------------------------------------- 295// 4. QUERY REWRITING 296// --------------------------------------------------------------------------- 297 298/** 299 * Rewrite query untuk FULLTEXT yang lebih baik. 300 * Return {expanded, intent, confidence, suggestion} atau null jika gagal. 301 * Dipanggil dari dsh-search.js sebelum API call ke smart_search. 302 */ 303async function rewriteQuery(query) { 304 if (!query || query.length < 4) return null; 305 // Hanya rewrite jika query punya 1-3 kata (lebih panjang biasanya sudah spesifik) 306 const wordCount = query.trim().split(/\s+/).length; 307 if (wordCount > 5) return null; 308 309 try { 310 const body = new FormData(); 311 body.append('action', 'rewrite_query'); 312 body.append('q', query); 313 const resp = await fetch(AI_API_BASE, { method: 'POST', body }); 314 if (!resp.ok) return null; 315 const data = await resp.json(); 316 if (data.ok && data.data?.expanded && !data.data?.fallback) { 317 return data.data; 318 } 319 } catch (_) {} 320 return null; 321} 322 323// --------------------------------------------------------------------------- 324// 5. RAG ANSWER PANEL 325// --------------------------------------------------------------------------- 326 327/** 328 * Tampilkan loading dengan text berubah-ubah yang terlihat cerdas 329 * Simulasi sistem sedang berpikir dengan berbagai tahapan 330 */ 331let _loadingInterval = null; 332 333function startSmartLoading(element) { 334 if (!element) return; 335 336 const stages = [ 337 'ð¤ Membaca pertanyaan Anda...', 338 'ð Mencari di database DSH...', 339 'ð Menganalisis hasil relevan...', 340 'ð§ Memproses informasi...', 341 'âï¸ Menyusun jawaban terbaik...', 342 'â¡ Memverifikasi akurasi...', 343 'ð¡ Menyempurnakan narasi...', 344 'ð¯ Finalisasi jawaban...' 345 ]; 346 347 let stageIndex = 0; 348 349 element.innerHTML = ` 350 <div class="flex items-center gap-3"> 351 <div class="flex gap-1.5"> 352 <div class="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style="animation-delay: 0s;"></div> 353 <div class="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style="animation-delay: 0.2s;"></div> 354 <div class="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style="animation-delay: 0.4s;"></div> 355 </div>
356 <span class="text-gray-600 font-medium" id="loadingStageText">${stages[0]}</span> 357 </div> 358 `; 359 360 if (_loadingInterval) clearInterval(_loadingInterval); 361 362 _loadingInterval = setInterval(() => { 363 stageIndex = (stageIndex + 1) % stages.length; 364 const stageText = document.getElementById('loadingStageText'); 365 if (stageText) { 366 stageText.textContent = stages[stageIndex]; 367 // Subtle animation saat text berubah 368 stageText.style.opacity = '0.7'; 369 setTimeout(() => { 370 stageText.style.opacity = '1'; 371 stageText.style.transition = 'opacity 0.3s ease-in-out'; 372 }, 50); 373 } 374 }, 1800); // Ganti text setiap 1.8 detik 375} 376 377function stopSmartLoading() { 378 if (_loadingInterval) { 379 clearInterval(_loadingInterval); 380 _loadingInterval = null; 381 } 382} 383 384/** 385 * Set AI mode: 'smart' (qwen2.5:3b) atau 'free'/Mini (qwen2.5:0.5b) â keduanya Ollama lokal. 386 * Disimpan di localStorage dan dikirim ke setiap RAG request. 387 */ 388function _setAIMode(mode) { 389 _aiState.smartMode = (mode === 'smart'); // true=Smart, false=Mini 390 localStorage.setItem('dsh_ai_mode', mode); 391 _updateModeSwitchUI(mode); 392 393 // Re-trigger RAG dengan mode baru jika panel sedang terbuka 394 const query = _aiState.lastQuery || document.getElementById('searchInput')?.value?.trim() || ''; 395 if (query && _aiState.ragPanelVisible) { 396 showRAGPanel(query); 397 } 398} 399 400/** 401 * Update tampilan toggle switch berdasarkan mode aktif 402 */ 403function _updateModeSwitchUI(mode) { 404 const freeBtn = document.getElementById('ragModeFreeBtn'); 405 const smartBtn = document.getElementById('ragModeSmartBtn'); 406 if (!freeBtn || !smartBtn) return; 407 408 const activeSmart = 'px-2 py-1 rounded-md font-medium transition-all bg-amber-100 text-amber-800 shadow-sm'; 409 const activeFree = 'px-2 py-1 rounded-md font-medium transition-all bg-green-100 text-green-800 shadow-sm'; 410 const inactive = 'px-2 py-1 rounded-md font-medium transition-all text-gray-600 hover:bg-white'; 411 412 if (mode === 'smart') { 413 smartBtn.className = activeSmart; 414 freeBtn.className = inactive; 415 } else { 416 // 'free' atau default â Ollama/Mini aktif 417 freeBtn.className = activeFree; 418 smartBtn.className = inactive; 419 } 420} 421 422/** 423 * Cek apakah query adalah pertanyaan yang butuh RAG. 424 * Deteksi: kata tanya di awal ATAU frasa pertanyaan di mana saja ATAU diakhiri "?" 425 */ 426function isQuestionQuery(query) { 427 if (!query) return false; 428 const q = query.toLowerCase().trim(); 429 430 // 1. Diakhiri tanda tanya 431 if (q.endsWith('?')) return true; 432 433 // 2. Dimulai dengan kata tanya 434 if (QUESTION_WORDS.some(w => q.startsWith(w + ' ') || q.startsWith(w + '?') || q === w)) return true; 435 436 // 3. Mengandung frasa pertanyaan di mana saja 437 if (QUESTION_PHRASES.some(p => q.includes(p))) return true; 438 439 // 4. Query >= 4 kata â kemungkinan besar pertanyaan atau informational query 440 if (q.split(/\s+/).length >= 4) return true; 441 442 return false; 443} 444 445/** 446 * Tampilkan RAG Answer Panel dengan jawaban AI 447 * Dipanggil setelah search selesai jika query adalah pertanyaan 448 */ 449async function showRAGPanel(query, types = '') { 450 if (!isQuestionQuery(query)) return; 451 452 let wrapper = document.getElementById('dshRAGPanelWrapper'); 453 if (!wrapper) { 454 wrapper = createRAGPanel(); 455 // RAG panel di-inject SEBELUM resultsLayoutWrapper agar full-width di atas sidebar+konten 456 const layoutWrapper = document.getElementById('resultsLayoutWrapper'); 457 if (layoutWrapper?.parentNode) { 458 layoutWrapper.parentNode.insertBefore(wrapper, layoutWrapper); 459 } else { 460 // Fallback: sebelum resultTabsContainer 461 const tabsContainer = document.getElementById('resultTabsContainer'); 462 if (tabsContainer?.parentNode) { 463 tabsContainer.parentNode.insertBefore(wrapper, tabsContainer); 464 } 465 } 466 } 467 468 wrapper.style.display = ''; 469 _aiState.ragPanelVisible = true; 470 471 // Init mode dari localStorage (pertama kali panel dibuka) 472 // Sync mode dari localStorage setiap kali panel dibuka (default: 'smart' â qwen2.5:3b) 473 { 474 const savedMode = localStorage.getItem('dsh_ai_mode') || 'smart';
475 // Migrasi: jika user lama punya 'free' tersimpan, biarkan â itu pilihan mereka 476 _aiState.smartMode = (savedMode !== 'free'); 477 requestAnimationFrame(() => _updateModeSwitchUI(savedMode)); 478 } 479 480 // Loading state dengan text dinamis yang berubah-ubah 481 const answerEl = document.getElementById('ragAnswer'); 482 const sourcesEl = document.getElementById('ragSources'); 483 const webNoteEl = document.getElementById('ragWebNote'); 484 const feedbackEl = document.getElementById('ragFeedbackBar'); 485 if (answerEl) { 486 startSmartLoading(answerEl); 487 } 488 if (sourcesEl) sourcesEl.innerHTML = ''; 489 if (webNoteEl) { webNoteEl.textContent = ''; webNoteEl.classList.add('hidden'); } 490 if (feedbackEl) feedbackEl.innerHTML = '<span class="text-xs text-gray-300 italic">menunggu jawaban...</span>'; 491 492 try { 493 // Ambil atau buat session_id dari localStorage (persisten per browser tab) 494 if (!_aiState.sessionId) { 495 _aiState.sessionId = localStorage.getItem('dsh_ai_session') || ''; 496 if (!_aiState.sessionId) { 497 _aiState.sessionId = 'dsh_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); 498 localStorage.setItem('dsh_ai_session', _aiState.sessionId); 499 } 500 } 501 502 const body = new FormData(); 503 body.append('action', 'rag_answer'); 504 body.append('q', query); 505 body.append('types', types); 506 body.append('session_id', _aiState.sessionId); 507 body.append('smart_mode', _aiState.smartMode ? '1' : '0'); // 1 = Smart, 0 = Mini (jawaban lebih pendek) 508 509 const resp = await fetch(AI_API_BASE, { method: 'POST', body }); 510 511 // Handle 429 rate limit secara khusus â tampilkan pesan, jangan hide panel 512 if (resp.status === 429) { 513 const retryAfter = parseInt(resp.headers.get('Retry-After') || '60', 10); 514 if (answerEl) { 515 stopSmartLoading(); 516 answerEl.innerHTML = `<div class="flex items-center gap-2 text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-sm"> 517 <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> 518 <span>Terlalu banyak permintaan. Coba lagi dalam <strong>${retryAfter}</strong> detik.</span> 519 </div>`; 520 } 521 if (feedbackEl) feedbackEl.innerHTML = ''; 522 return; 523 } 524 525 const data = await resp.json(); 526 527 if (data.ok && data.data?.answer) { 528 const mode = data.data.mode || ''; 529 const aiMode = data.data.ai_mode || ''; // 'smart' | 'free' 530 const aiModel = data.data.ai_model || ''; 531 const answerText = data.data.answer || ''; 532 533 // Update mode badge â gabungkan search mode + ai mode 534 const badgeEl = document.getElementById('ragModeBadge'); 535 if (badgeEl) { 536 if (mode === 'serper_only') { 537 badgeEl.textContent = 'ð Pencarian Informasi'; 538 badgeEl.className = 'ml-2 text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-medium'; 539 } else if (aiMode === 'free') { 540 badgeEl.textContent = 'ð Mini Mode'; 541 badgeEl.className = 'ml-2 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-medium'; 542 } else if (aiMode === 'smart') { 543 badgeEl.textContent = 'â¡ Smart Mode'; 544 badgeEl.className = 'ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-medium'; 545 } else if (mode === 'rag') { 546 badgeEl.textContent = 'ð§ AI · Data DSH'; 547 badgeEl.className = 'ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-medium'; 548 } else { 549 badgeEl.textContent = 'AI · Berbasis data DSH'; 550 badgeEl.className = 'ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-medium'; 551 } 552 } 553 554 // ---- Simpan RAG sources untuk integrasi kartu ---- 555 const allSources = data.data.sources || []; 556 _aiState.ragSources = allSources; 557 _aiState.ragWebSources = allSources.filter(s => s.entity_type === 'web'); 558 _aiState.ragLocalSources = allSources.filter(s => s.entity_type !== 'web'); 559 _aiState.lastKnowledgeId = data.data.knowledge_id || null; 560 561 // Pisahkan note disclaimer web dari teks jawaban utama 562 const webNoteEl = document.getElementById('ragWebNote'); 563 const notePattern = /\n*_?Sumber:\s*hasil pencarian web[^_\n]*_?\.?\s*$/i; 564 const noteMatch = answerText.match(notePattern); 565 const cleanAnswer = answerText.replace(notePattern, '').trim(); 566
567 if (webNoteEl) { 568 if (noteMatch) { 569 webNoteEl.textContent = noteMatch[0].replace(/^[\n_]+|[_\n]+$/g, '').trim(); 570 webNoteEl.classList.remove('hidden'); 571 } else { 572 webNoteEl.textContent = ''; 573 webNoteEl.classList.add('hidden'); 574 } 575 } 576 577 if (answerEl) { 578 stopSmartLoading(); // Hentikan loading animasi sebelum menampilkan jawaban 579 await typewriterEffect(answerEl, cleanAnswer, 10); 580 } 581 if (sourcesEl && allSources.length) { 582 renderRAGSources(sourcesEl, allSources); 583 } else if (sourcesEl) { 584 sourcesEl.innerHTML = ''; 585 } 586 587 // ---- Reset feedback bar ke state aktif setelah jawaban baru ---- 588 _resetFeedbackBar(); 589 590 // ---- Integrasi: highlight kartu yang relevan + inject web cards ---- 591 integrateRAGWithCards(query, allSources, answerText); 592 593 } else { 594 hideRAGPanel(); 595 clearRAGCardIntegration(); 596 } 597 } catch (_) { 598 hideRAGPanel(); 599 clearRAGCardIntegration(); 600 } 601} 602 603function hideRAGPanel() { 604 const wrapper = document.getElementById('dshRAGPanelWrapper'); 605 if (wrapper) wrapper.style.display = 'none'; 606 _aiState.ragPanelVisible = false; 607 clearRAGCardIntegration(); 608} 609 610/** 611 * Reset sesi percakapan AI â hapus session_id dari localStorage dan state, 612 * lalu minta jawaban segar (tanpa cache KB lama) untuk query saat ini. 613 */ 614function resetAISession() { 615 // Buat session_id baru 616 const newSession = 'dsh_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); 617 _aiState.sessionId = newSession; 618 localStorage.setItem('dsh_ai_session', newSession); 619 620 // Reset knowledge_id agar feedback tidak tertaut ke jawaban lama 621 _aiState.lastKnowledgeId = null; 622 623 // Feedback animasi pada tombol 624 const btn = document.getElementById('ragResetBtn'); 625 if (btn) { 626 const original = btn.innerHTML; 627 btn.innerHTML = '<svg class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg><span>Memulai ulang...</span>'; 628 btn.disabled = true; 629 setTimeout(() => { btn.innerHTML = original; btn.disabled = false; }, 1200); 630 } 631 632 // Reset panel answer ke loading state 633 const answerEl = document.getElementById('ragAnswer'); 634 const sourcesEl = document.getElementById('ragSources'); 635 const webNoteEl = document.getElementById('ragWebNote'); 636 const feedbackEl = document.getElementById('ragFeedbackBar'); 637 if (answerEl) answerEl.innerHTML = '<span class="animate-pulse text-gray-500">ð Memuat jawaban segar tanpa cache...</span>'; 638 if (sourcesEl) sourcesEl.innerHTML = ''; 639 if (webNoteEl) { webNoteEl.textContent = ''; webNoteEl.classList.add('hidden'); } 640 if (feedbackEl) feedbackEl.innerHTML = '<span class="text-xs text-gray-300 italic">memuat ulang...</span>'; 641 642 // Ambil query saat ini dan panggil ulang RAG dengan session baru 643 const query = _aiState.lastQuery || document.getElementById('searchInput')?.value || ''; 644 if (!query) return; 645 646 // Paksa bypass cache dengan timestamp di parameter (tidak disimpan ke KB baru) 647 const body = new FormData(); 648 body.append('action', 'rag_answer'); 649 body.append('q', query); 650 body.append('session_id', newSession); 651 body.append('web', '1'); 652 body.append('smart_mode', _aiState.smartMode ? '1' : '0'); // 1 = Smart, 0 = Mini (jawaban lebih pendek) 653 654 fetch(AI_API_BASE, { method: 'POST', body }) 655 .then(r => { 656 if (r.status === 429) { 657 const retryAfter = parseInt(r.headers.get('Retry-After') || '60', 10); 658 if (answerEl) answerEl.innerHTML = `<div class="flex items-center gap-2 text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-sm"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span>Terlalu banyak permintaan. Coba lagi dalam <strong>${retryAfter}</strong> detik.</span></div>`; 659 throw new Error('rate_limited'); 660 } 661 return r.json(); 662 }) 663 .then(data => { 664 if (data.ok && data.data?.answer) { 665 _aiState.lastKnowledgeId = data.data.knowledge_id || null; 666 _aiState.ragSources = data.data.sources || []; 667 _aiState.ragWebSources = (data.data.sources || []).filter(s => s.entity_type === 'web'); 668 _aiState.ragLocalSources = (data.data.sources || []).filter(s => s.entity_type !== 'web'); 669 670 // Update mode badge 671 const badgeEl = document.getElementById('ragModeBadge'); 672 if (badgeEl) { 673 const mode = data.data.mode || ''; 674 if (mode === 'serper_only') { 675 badgeEl.textContent = 'ð Pencarian Web'; 676 badgeEl.className = 'ml-2 text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-medium'; 677 } else { 678 badgeEl.textContent = 'ð¤ AI · Sesi Baru'; 679 badgeEl.className = 'ml-2 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-medium'; 680 } 681 } 682 683 // Pisahkan note disclaimer web dari teks jawaban 684 const webNoteEl2 = document.getElementById('ragWebNote'); 685 const notePattern2 = /\n*_?Sumber:\s*hasil pencarian web[^_\n]*_?\.?\s*$/i; 686 const noteMatch2 = data.data.answer.match(notePattern2); 687 const cleanAnswer2 = data.data.answer.replace(notePattern2, '').trim(); 688
689 if (webNoteEl2) { 690 if (noteMatch2) { 691 webNoteEl2.textContent = noteMatch2[0].replace(/^[\n_]+|[_\n]+$/g, '').trim(); 692 webNoteEl2.classList.remove('hidden'); 693 } else { 694 webNoteEl2.textContent = ''; 695 webNoteEl2.classList.add('hidden'); 696 } 697 } 698 699 if (answerEl) { 700 stopSmartLoading(); // Hentikan loading animasi sebelum menampilkan jawaban refresh 701 typewriterEffect(answerEl, cleanAnswer2, 10); 702 } 703 if (sourcesEl && data.data.sources?.length) renderRAGSources(sourcesEl, data.data.sources); 704 // Reset feedback bar ke aktif setelah jawaban segar diterima 705 _resetFeedbackBar(); 706 } else { 707 if (answerEl) answerEl.innerHTML = '<span class="text-red-400 text-sm">Gagal memuat jawaban baru. Coba lagi.</span>'; 708 } 709 }) 710 .catch((err) => { 711 if (err?.message === 'rate_limited') return; // pesan sudah ditampilkan 712 if (answerEl) answerEl.innerHTML = '<span class="text-red-400 text-sm">Koneksi gagal. Coba lagi.</span>'; 713 }); 714} 715 716function createRAGPanel() { 717 const wrapper = document.createElement('div'); 718 wrapper.id = 'dshRAGPanelWrapper'; 719 wrapper.className = 'w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-4'; 720 wrapper.style.display = 'none'; // Initially hidden 721 722 const div = document.createElement('div'); 723 div.id = 'dshRAGPanel'; 724 div.className = 'bg-gradient-to-br from-amber-50/30 via-white to-orange-50/20 rounded-2xl border border-amber-200/50 shadow-lg overflow-hidden flex flex-col'; 725 div.innerHTML = ` 726 <!-- Header --> 727 <div class="flex items-center justify-between px-4 py-2.5 border-b border-amber-100/50 bg-white/70 backdrop-blur-sm"> 728 <div class="flex items-center gap-2"> 729 <div class="w-8 h-8 bg-gradient-to-br from-amber-400 to-orange-500 rounded-lg flex items-center justify-center shadow-sm shrink-0"> 730 <svg class="w-4.5 h-4.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 731 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" 732 d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/> 733 </svg> 734 </div> 735 <span class="font-semibold text-gray-800 text-sm">DSH Menjawab</span> 736 </div> 737 <div class="flex items-center gap-1.5 shrink-0"> 738 <!-- Mode Switch: Mini Mode â Smart Mode --> 739 <div id="ragModeSwitch" class="flex items-center gap-0.5 bg-amber-50 rounded-lg p-0.5 text-xs border border-amber-100"> 740 <button id="ragModeFreeBtn" onclick="_setAIMode('free')" 741 class="px-2 py-1 rounded-md font-medium transition-all text-gray-600 hover:bg-white" 742 title="Mini Mode"> 743 <span class="hidden sm:inline">ð Mini</span><span class="sm:hidden">ð</span> 744 </button> 745 <button id="ragModeSmartBtn" onclick="_setAIMode('smart')" 746 class="px-2 py-1 rounded-md font-medium transition-all text-gray-600 hover:bg-white" 747 title="Mode Smart"> 748 <span class="hidden sm:inline">â¡ Smart</span><span class="sm:hidden">â¡</span> 749 </button> 750 </div> 751 <button onclick="resetAISession()" id="ragResetBtn" 752 class="text-gray-400 hover:text-amber-600 transition-colors p-1.5 rounded-lg hover:bg-amber-50" 753 title="Mulai ulang"> 754 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 755 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 756 d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> 757 </svg> 758 </button> 759 <button onclick="hideRAGPanel()" 760 class="text-gray-400 hover:text-gray-600 transition-colors p-1.5 rounded-lg hover:bg-gray-100" title="Tutup"> 761 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 762 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> 763 </svg> 764 </button> 765 </div> 766 </div> 767 <!-- Body: jawaban, disclaimer web (opsional), sumber --> 768 <div class="flex-1 px-4 py-3 flex flex-col justify-between"> 769 <div id="ragAnswer" class="text-sm text-gray-700 leading-relaxed prose prose-sm max-w-none"></div> 770 <div id="ragWebNote" class="hidden mt-2 text-xs text-amber-700 italic bg-amber-50 px-2 py-1.5 rounded-md border border-amber-200"></div> 771 <div id="ragSources" class="not-prose mt-3"></div> 772 </div> 773 <!-- Footer --> 774 <div class="px-4 py-2 border-t border-amber-100 bg-gradient-to-r from-amber-50/50 to-orange-50/30 flex items-center justify-between gap-3"> 775 <p class="text-xs text-gray-500 leading-snug hidden sm:block"> 776 Jawaban dibuat oleh AI berdasarkan data DSH UGM. 777 </p> 778 <div id="ragFeedbackBar" class="flex items-center gap-2">
779 <span class="text-xs text-gray-500">Membantu?</span> 780 <button onclick="_ragFeedback(true)" class="text-lg hover:scale-110 transition-transform" title="Ya">ð</button> 781 <button onclick="_ragFeedback(false)" class="text-lg hover:scale-110 transition-transform" title="Tidak">ð</button> 782 </div> 783 </div>`; 784 div.innerHTML = div.innerHTML; // HTML already set above 785 wrapper.appendChild(div); 786 return wrapper; 787} 788 789function renderRAGSources(el, sources) { 790 const typeIcons = { 791 service: 'ð', news: 'ð°', product: 'ð', 792 people: 'ð¨âð«', publication: 'ð', patent: 'ð¡', 793 legal: 'âï¸', pidato: 'ð', 794 web: 'ð', 795 }; 796 797 // Label header â sesuaikan teks berdasarkan apakah ada sumber DB atau web 798 const hasDb = sources.some(s => s.entity_type !== 'web'); 799 const header = document.createElement('p'); 800 header.className = 'text-xs font-semibold text-amber-700 mb-2 flex items-center gap-1'; 801 header.textContent = hasDb ? 'ð Sumber:' : 'ð Sumber Terkait:'; 802 803 // Container chip â flex-wrap dengan gap konsisten, tidak overflow 804 const chipsRow = document.createElement('div'); 805 chipsRow.className = 'flex flex-wrap gap-1.5'; 806 807 sources.slice(0, 6).forEach((s, i) => { 808 const isWeb = s.entity_type === 'web'; 809 const hasUrl = s.url && s.url.trim() !== ''; 810 const icon = typeIcons[s.entity_type] || 'ð'; 811 const safeTitle = (s.title || '').replace(/</g, '<').replace(/>/g, '>'); 812 813 // Selalu gunakan <a href> agar link dapat diklik. 814 // Jika ada URL â buka di tab baru (utama). 815 // Side-effect: juga coba scroll ke kartu yang cocok di grid (non-blocking). 816 // Jika tidak ada URL â fallback ke button scroll saja.
817 let chip; 818 if (hasUrl) { 819 chip = document.createElement('a'); 820 chip.href = s.url; 821 chip.target = '_blank'; 822 chip.rel = 'noopener noreferrer'; 823 // Side-effect scroll (tidak memblokir navigasi URL) 824 chip.addEventListener('click', () => { 825 // Jalankan scroll asynchronously â tidak mengganggu pembukaan URL 826 setTimeout(() => _scrollToMatchingCard(s, chip), 100); 827 }); 828 } else { 829 chip = document.createElement('button'); 830 chip.type = 'button'; 831 chip.addEventListener('click', () => _scrollToMatchingCard(s, chip)); 832 } 833 834 chip.dataset.ragChipIdx = String(i); 835 chip.title = (s.title || '') + (hasUrl ? ' â klik untuk membuka' : ' â klik untuk melihat kartu'); 836 837 if (isWeb) { 838 chip.className = 'rag-src-chip inline-flex items-center gap-1 min-w-0 max-w-[180px] text-xs px-2 py-1.5 bg-amber-50 border border-amber-200 rounded-lg text-amber-800 hover:bg-amber-100 hover:border-amber-300 hover:shadow-sm transition-all group cursor-pointer'; 839 } else { 840 chip.className = 'rag-src-chip inline-flex items-center gap-1 min-w-0 max-w-[180px] text-xs px-2 py-1.5 bg-orange-50 border border-orange-200 rounded-lg text-orange-800 hover:bg-orange-100 hover:border-orange-300 hover:shadow-sm transition-all group cursor-pointer'; 841 } 842 843 // Ikon tipe 844 const iconSpan = document.createElement('span'); 845 iconSpan.className = 'shrink-0 text-xs'; 846 iconSpan.textContent = icon; 847 848 // Judul (truncate CSS) 849 const titleSpan = document.createElement('span'); 850 titleSpan.className = 'truncate min-w-0 flex-1'; 851 titleSpan.textContent = s.title || ''; 852 853 // Ikon: panah eksternal untuk web/ada URL, panah bawah untuk scroll 854 const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 855 const arrowColor = isWeb ? 'text-sky-300 group-hover:text-sky-600' : 'text-blue-300 group-hover:text-blue-600'; 856 arrow.setAttribute('class', `w-3 h-3 shrink-0 ${arrowColor} transition-colors`); 857 arrow.setAttribute('fill', 'none'); 858 arrow.setAttribute('stroke', 'currentColor'); 859 arrow.setAttribute('viewBox', '0 0 24 24'); 860 const arrowPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 861 arrowPath.setAttribute('stroke-linecap', 'round'); 862 arrowPath.setAttribute('stroke-linejoin', 'round'); 863 arrowPath.setAttribute('stroke-width', '2'); 864 // Panah eksternal (â) jika ada URL, panah bawah (â) jika scroll saja 865 arrowPath.setAttribute('d', hasUrl 866 ? 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14' 867 : 'M19 9l-7 7-7-7'); 868 arrow.appendChild(arrowPath); 869 870 chip.appendChild(iconSpan); 871 chip.appendChild(titleSpan); 872 chip.appendChild(arrow); 873 874 chipsRow.appendChild(chip); 875 }); 876 877 el.innerHTML = ''; 878 el.appendChild(header); 879 el.appendChild(chipsRow); 880} 881 882/** 883 * Scroll ke kartu di grid yang paling cocok dengan RAG source. 884 * Returns true jika kartu ditemukan & di-scroll (caller bisa preventDefault). 885 * Returns false jika tidak ditemukan (caller membuka URL sebagai fallback). 886 */ 887function _scrollToMatchingCard(source, chipEl) { 888 const grid = document.getElementById('servicesGrid'); 889 if (!grid) return false; 890 891 const normUrl = _normalizeUrl(source.url); 892 const srcTitle = (source.title || '').toLowerCase(); 893 const isWeb = source.entity_type === 'web'; 894 let bestCard = null; 895 let bestScore = 0; 896 897 grid.querySelectorAll('.svc-card').forEach(card => { 898 // Web cards: gunakan data-rag-url yang sengaja disimpan saat inject 899 if (isWeb) { 900 const ragUrl = card.getAttribute('data-rag-url') || ''; 901 if (ragUrl && ragUrl === normUrl) { bestCard = card; bestScore = 2; return; } 902 } 903 904 // Match via URL â paling akurat 905 const a = card.querySelector('a[href]'); 906 const cUrl = a ? _normalizeUrl(a.getAttribute('href')) : ''; 907 if (normUrl && cUrl && cUrl === normUrl) { bestCard = card; bestScore = 2; return; } 908 909 // Match via entity_type + entity_id (dari data attributes) 910 if (source.entity_id && card.dataset.entityId === String(source.entity_id) && 911 card.dataset.entityType === source.entity_type) { 912 bestCard = card; bestScore = 2; return; 913 } 914 915 // Title similarity â threshold lebih rendah (0.25) agar lebih toleran 916 const cTitle = (card.querySelector('h4')?.textContent || '').toLowerCase(); 917 const sim = _titleSimilarity(cTitle, srcTitle); 918 if (sim > bestScore) { bestScore = sim; bestCard = card; } 919 }); 920 921 if (!bestCard || bestScore < 0.25) return false;
922 923 // Visual feedback di chip: ring sementara 924 if (chipEl) { 925 const ringColor = source.entity_type === 'web' ? 'ring-sky-400' : 'ring-blue-400'; 926 chipEl.classList.add('ring-2', ringColor); 927 setTimeout(() => chipEl.classList.remove('ring-2', ringColor), 1200); 928 } 929 930 bestCard.scrollIntoView({ behavior: 'smooth', block: 'center' }); 931 _pulseCard(bestCard); 932 return true; 933} 934 935/** 936 * Animasi pulse ring pada kartu â kilat biru 3Ã lalu kembali normal 937 */ 938function _pulseCard(card) { 939 card.classList.add('ring-4', 'ring-blue-500', 'ring-offset-2', 'transition-all', 'duration-300'); 940 let count = 0; 941 const interval = setInterval(() => { 942 count++; 943 if (count % 2 === 0) { 944 card.classList.add('ring-4', 'ring-blue-500'); 945 card.classList.remove('ring-0'); 946 } else { 947 card.classList.remove('ring-4', 'ring-blue-500'); 948 card.classList.add('ring-0'); 949 } 950 if (count >= 5) { 951 clearInterval(interval); 952 card.classList.remove('ring-4', 'ring-blue-500', 'ring-offset-2', 'ring-0'); 953 // Kembalikan highlight permanen jika kartu memang RAG-highlighted 954 if (card.dataset.ragHighlighted === 'true') { 955 card.classList.add('ring-2', 'ring-blue-400/50'); 956 } 957 } 958 }, 280); 959} 960 961/** 962 * Reset feedback bar ke state aktif (tombol ð ð) â dipanggil setiap ada jawaban baru. 963 */ 964function _resetFeedbackBar() { 965 const bar = document.getElementById('ragFeedbackBar'); 966 if (!bar) return; 967 bar.innerHTML = 968 '<span class="text-xs text-gray-400">Apakah ini membantu?</span>' + 969 '<button onclick="_ragFeedback(true)" class="text-lg hover:scale-125 transition-transform" title="Ya">ð</button>' + 970 '<button onclick="_ragFeedback(false)" class="text-lg hover:scale-125 transition-transform" title="Tidak">ð</button>'; 971} 972 973function _ragFeedback(positive) { 974 const icon = positive ? 'ð' : 'ð'; 975 const bar = document.getElementById('ragFeedbackBar'); 976 if (!bar) return; 977 978 // Jika negatif, tampilkan input komentar singkat sebelum kirim 979 if (!positive) { 980 bar.innerHTML = ` 981 <div class="flex items-center gap-2 animate-fade-in"> 982 <span class="text-xs text-gray-500">Apa yang kurang?</span> 983 <input id="ragFeedbackText" type="text" maxlength="200" 984 placeholder="Opsional, maks 200 karakter" 985 class="text-xs border border-gray-200 rounded-lg px-2 py-1 w-40 focus:outline-none focus:border-blue-300"> 986 <button onclick="_submitRagFeedback(false, document.getElementById('ragFeedbackText')?.value || '')" 987 class="text-xs px-2 py-1 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors">Kirim</button> 988 <button onclick="_submitRagFeedback(false, '')" 989 class="text-xs text-gray-400 hover:text-gray-600">Lewati</button> 990 </div>`; 991 return; 992 } 993 994 _submitRagFeedback(true, ''); 995} 996 997function _submitRagFeedback(positive, feedbackText) { 998 const icon = positive ? 'ð' : 'ð'; 999 const bar = document.getElementById('ragFeedbackBar'); 1000 1001 // Tampilkan konfirmasi langsung 1002 if (bar) { 1003 bar.innerHTML = ` 1004 <span class="inline-flex items-center gap-1.5 text-xs text-green-600 font-medium 1005 bg-green-50 border border-green-200 rounded-full px-3 py-1 animate-fade-in"> 1006 ${icon} Terima kasih! Masukan Anda membantu DSH AI belajar. 1007 </span>`; 1008 } 1009 1010 // POST ke API â non-blocking 1011 const query = _aiState.lastQuery || document.getElementById('searchInput')?.value || ''; 1012 if (!query) return; 1013 1014 const answerEl = document.getElementById('ragAnswer'); 1015 const answerSnippet = (answerEl?.innerText || '').slice(0, 200); 1016 1017 const body = new FormData(); 1018 body.append('action', 'ai_feedback'); 1019 body.append('q', query); 1020 body.append('rating', positive ? '1' : '-1'); 1021 body.append('text', feedbackText || ''); 1022 body.append('answer_snippet', answerSnippet); 1023 // session_id dan knowledge_id untuk linking ke percakapan 1024 if (_aiState.sessionId) body.append('session_id', _aiState.sessionId); 1025 if (_aiState.lastKnowledgeId) body.append('knowledge_id', String(_aiState.lastKnowledgeId)); 1026 1027 fetch(AI_API_BASE, { method: 'POST', body }).catch(() => {/* silent fail */}); 1028} 1029 1030// --------------------------------------------------------------------------- 1031// 5b. RAG â CARD INTEGRATION 1032// --------------------------------------------------------------------------- 1033 1034/** 1035 * Normalize URL untuk perbandingan â hapus protocol, www, trailing slash 1036 */ 1037function _normalizeUrl(url) { 1038 if (!url) return ''; 1039 return url.toLowerCase() 1040 .replace(/^https?:\/\//, '') 1041 .replace(/^www\./, '') 1042 .replace(/\/+$/, '') 1043 .replace(/\?.*$/, '') // Remove query params 1044 .replace(/#.*$/, ''); // Remove fragments 1045} 1046 1047/** 1048 * Hitung kesamaan antara dua judul (Jaccard similarity on words) 1049 */ 1050function _titleSimilarity(a, b) { 1051 if (!a || !b) return 0; 1052 const wa = new Set(a.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2)); 1053 const wb = new Set(b.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2)); 1054 if (wa.size === 0 || wb.size === 0) return 0; 1055 let intersection = 0; 1056 for (const w of wa) { if (wb.has(w)) intersection++; } 1057 return intersection / Math.min(wa.size, wb.size); 1058} 1059 1060/** 1061 * Integrasi utama: setelah RAG panel tampil, proses kartu-kartu di grid
1062 * 1) Highlight kartu yang URL-nya cocok dengan RAG sources 1063 * 2) Re-order: kartu relevan pindah ke atas 1064 * 3) Inject web sources yang belum ada di kartu sebagai "web result" cards 1065 */ 1066function integrateRAGWithCards(query, sources, answerText) { 1067 const grid = document.getElementById('servicesGrid'); 1068 if (!grid || !sources?.length) return; 1069 1070 // Build lookup set dari RAG source URLs 1071 const sourceUrls = new Set(sources.map(s => _normalizeUrl(s.url))); 1072 const sourceTitles = sources.map(s => (s.title || '').toLowerCase()); 1073 1074 // ---- 1. Scan existing cards dan tandai yang relevan ---- 1075 const allCards = Array.from(grid.querySelectorAll('.svc-card')); 1076 const matched = []; 1077 const unmatched = []; 1078 1079 allCards.forEach(card => { 1080 const cardLink = card.querySelector('a[href]'); 1081 const cardUrl = cardLink ? _normalizeUrl(cardLink.getAttribute('href')) : ''; 1082 const cardTitle = (card.querySelector('h4')?.textContent || '').toLowerCase(); 1083 1084 let isRelevant = false; 1085 1086 // Match by URL 1087 if (cardUrl && sourceUrls.has(cardUrl)) { 1088 isRelevant = true; 1089 } 1090 1091 // Match by title similarity (fallback jika URL beda tapi konten sama) 1092 if (!isRelevant) { 1093 for (const sTitle of sourceTitles) { 1094 if (_titleSimilarity(cardTitle, sTitle) > 0.5) { 1095 isRelevant = true; 1096 break; 1097 } 1098 } 1099 } 1100 1101 // Match by answer text keyword overlap (kartu yang isinya disebutkan di jawaban) 1102 if (!isRelevant && answerText) { 1103 const answerLower = answerText.toLowerCase(); 1104 const titleWords = cardTitle.split(/\s+/).filter(w => w.length > 3); 1105 const matchCount = titleWords.filter(w => answerLower.includes(w)).length; 1106 if (titleWords.length > 0 && matchCount / titleWords.length > 0.5) { 1107 isRelevant = true; 1108 } 1109 } 1110 1111 if (isRelevant) { 1112 matched.push(card); 1113 _applyRAGHighlight(card); 1114 } else { 1115 unmatched.push(card); 1116 _removeRAGHighlight(card); 1117 } 1118 }); 1119 1120 // ---- 2. Re-order: kartu relevan ke atas ---- 1121 if (matched.length > 0) { 1122 // Buat divider antara "relevan" dan "lainnya" 1123 const existingDivider = grid.querySelector('.rag-relevance-divider'); 1124 if (existingDivider) existingDivider.remove(); 1125 1126 // Pindahkan kartu yang cocok ke atas 1127 matched.forEach(card => grid.prepend(card)); 1128 1129 // Tambah divider jika ada kartu yang tidak cocok juga 1130 if (unmatched.length > 0) { 1131 const divider = document.createElement('div'); 1132 divider.className = 'rag-relevance-divider col-span-full flex items-center gap-3 my-2 px-1'; 1133 divider.innerHTML = ` 1134 <div class="flex-1 h-px bg-gradient-to-r from-blue-200 to-transparent"></div> 1135 <span class="text-xs text-gray-400 font-medium whitespace-nowrap">Hasil pencarian lainnya</span> 1136 <div class="flex-1 h-px bg-gradient-to-l from-blue-200 to-transparent"></div>`; 1137 // Insert setelah kartu terakhir yang cocok 1138 const lastMatched = matched[matched.length - 1]; 1139 if (lastMatched.nextSibling) { 1140 grid.insertBefore(divider, lastMatched.nextSibling); 1141 } else { 1142 grid.appendChild(divider); 1143 } 1144 } 1145 } 1146 1147 // ---- 3. Inject web sources yang belum ada sebagai kartu web ---- 1148 const webSources = sources.filter(s => s.entity_type === 'web'); 1149 if (webSources.length > 0) { 1150 const existingCardUrls = new Set( 1151 allCards.map(c => { 1152 const a = c.querySelector('a[href]'); 1153 return a ? _normalizeUrl(a.getAttribute('href')) : ''; 1154 }).filter(Boolean) 1155 ); 1156 1157 const newWebSources = webSources.filter(s => !existingCardUrls.has(_normalizeUrl(s.url))); 1158 1159 if (newWebSources.length > 0) { 1160 // Inject paling atas, sebelum kartu yang matched 1161 const firstChild = grid.firstChild; 1162 newWebSources.slice(0, 3).reverse().forEach(src => { 1163 const webCard = _createWebSourceCard(src); 1164 if (firstChild) { 1165 grid.insertBefore(webCard, firstChild); 1166 } else { 1167 grid.appendChild(webCard); 1168 } 1169 }); 1170 1171 // Sembunyikan empty state jika ada web cards 1172 const emptyState = document.getElementById('emptyState'); 1173 if (emptyState) emptyState.classList.add('hidden'); 1174 if (typeof hideNoResultsCTA === 'function') hideNoResultsCTA(); 1175 1176 // Update result count 1177 _updateResultCountWithWeb(newWebSources.length); 1178 } 1179 } 1180} 1181 1182/** 1183 * Buat kartu untuk web source dari RAG (Serper result) â row-based layout 1184 */ 1185function _createWebSourceCard(source) { 1186 const card = document.createElement('article'); 1187 card.className = 'card svc-card w-full flex flex-row items-stretch relative rounded-xl border border-sky-200 bg-white transition-all duration-200 rag-web-card'; 1188 card.setAttribute('data-entity-type', 'web'); 1189 card.setAttribute('data-rag-source', 'true'); 1190 card.setAttribute('data-rag-url', _normalizeUrl(source.url)); 1191 1192 let domain = ''; 1193 try { domain = new URL(source.url).hostname.replace(/^www\./, ''); } catch { domain = ''; } 1194 1195 card.innerHTML = ` 1196 <!-- Left accent bar --> 1197 <div class="w-1.5 flex-shrink-0 rounded-l-xl bg-gradient-to-b from-sky-4
119700 to-blue-500"></div> 1198 <!-- Icon col --> 1199 <div class="flex-shrink-0 w-8 h-8 my-auto ml-3 rounded-lg bg-sky-50 border border-sky-100 flex items-center justify-center"> 1200 <svg class="w-4 h-4 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> 1201 <path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/> 1202 </svg> 1203 </div> 1204 <!-- Text col --> 1205 <div class="flex-1 flex flex-col justify-center px-3 py-2.5 min-w-0"> 1206 <div class="flex items-center gap-1.5 mb-1"> 1207 <button type="button" onclick="_scrollToRAGPanel(this)" 1208 class="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-semibold bg-sky-100 text-sky-700 border border-sky-200 rounded-full hover:bg-sky-200 transition-colors cursor-pointer leading-none whitespace-nowrap" 1209 title="Klik untuk melihat jawaban AI di atas"> 1210 ð Terkait jawaban DSH 1211 <svg class="w-2 h-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 1212 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/> 1213 </svg> 1214 </button> 1215 <span class="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-semibold bg-gradient-to-r from-sky-500 to-blue-500 text-white rounded-full leading-none whitespace-nowrap"> 1216 <svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> 1217 <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> 1218 </svg>Sumber Web 1219 </span> 1220 </div> 1221 <h4 class="font-semibold text-sm text-gray-900 leading-snug line-clamp-1 mb-0.5">${source.title || 'Sumber Web'}</h4> 1222 ${domain ? `<p class="text-[11px] text-gray-400 truncate">ð ${domain}</p>` : ''} 1223 </div> 1224 <!-- CTA col --> 1225 <div class="flex-shrink-0 flex items-center pr-3"> 1226 <a class="inline-flex items-center gap-1 bg-gradient-to-r from-sky-500 to-blue-500 hover:from-sky-600 hover:to-blue-600 text-white px-2.5 py-1.5 rounded-lg font-semibold text-xs transition-all duration-200 whitespace-nowrap" 1227 href="${source.url || '#'}" target="_blank" rel="noopener"> 1228 Buka 1229 <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> 1230 <path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/> 1231 </svg> 1232 </a> 1233 </div>`; 1234 1235 // Tandai bagiannya seperti kartu hasil biasa, supaya CSS mode grid ikut 1236 // menatanya. Tanpa ini kartu Sumber Web tetap memakai tata letak list dan 1237 // tampil beda sendiri di antara kartu lain saat mode grid aktif. 1238 if (typeof window.tagCardParts === 'function') window.tagCardParts(card); 1239 1240 return card; 1241} 1242 1243/** 1244 * Apply highlight visual ke kartu yang relevan dengan jawaban RAG 1245 */ 1246function _applyRAGHighlight(card) { 1247 // Hindari highlight ganda 1248 if (card.dataset.ragHighlighted === 'true') return; 1249 card.dataset.ragHighlighted = 'true'; 1250 1251 // Simpan class asli 1252 card.dataset.originalClasses = card.className; 1253 1254 // Tambah ring glow 1255 card.classList.add('ring-2', 'ring-blue-400/50', 'shadow-blue-100', 'shadow-lg'); 1256 1257 // Inject label "Terkait jawaban DSH" ke dalam kolom teks card (bukan di root card) 1258 const label = document.createElement('div'); 1259 label.className = 'rag-relevance-label mb-1'; 1260 label.innerHTML = ` 1261 <button type="button" onclick="_scrollToRAGPanel(this)" 1262 class="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold bg-blue-100 text-blue-700 border border-blue-200 rounded-full animate-fade-in hover:bg-blue-200 hover:border-blue-400 transition-colors cursor-pointer" 1263 title="Klik untuk melihat jawaban AI di atas"> 1264 <svg class="w-3 h-3 shrink-0" fill="currentColor" viewBox="0 0 20 20"> 1265 <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> 1266 </svg>
1267 <span>Terkait jawaban DSH</span> 1268 <svg class="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 1269 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/> 1270 </svg> 1271 </button>`; 1272 1273 // Pada row-based card, kolom teks adalah div.flex-col.min-w-0 (bukan root) 1274 // Cari text column: flex-col yang mengandung h4 1275 const textCol = card.querySelector('div.flex-col') || card.querySelector('div[class*="min-w-0"]'); 1276 if (textCol) { 1277 // Sisipkan label sebelum elemen pertama text column 1278 textCol.insertBefore(label, textCol.firstChild); 1279 } else { 1280 // Fallback: cari elemen pertama non-absolute di root 1281 const absoluteEls = Array.from(card.children).filter(el => 1282 el.classList.contains('absolute') || getComputedStyle(el).position === 'absolute' 1283 ); 1284 const firstFlowEl = Array.from(card.children).find(el => !absoluteEls.includes(el)); 1285 if (firstFlowEl) card.insertBefore(label, firstFlowEl); 1286 else card.appendChild(label); 1287 } 1288} 1289 1290/** 1291 * Scroll ke RAG panel dan pulsekan border-nya â dipanggil dari label kartu 1292 */ 1293function _scrollToRAGPanel(triggerEl) { 1294 const wrapper = document.getElementById('dshRAGPanelWrapper'); 1295 if (!wrapper) return; 1296 1297 wrapper.scrollIntoView({ behavior: 'smooth', block: 'start' }); 1298 1299 // Pulsekan border panel supaya user tahu 1300 const panel = document.getElementById('dshRAGPanel'); 1301 if (!panel) return; 1302 panel.classList.add('ring-2', 'ring-blue-400', 'ring-offset-2', 'transition-all'); 1303 setTimeout(() => panel.classList.remove('ring-2', 'ring-blue-400', 'ring-offset-2'), 1400); 1304 1305 // Visual feedback di label yang diklik 1306 if (triggerEl) { 1307 triggerEl.classList.add('bg-blue-600', 'text-white', 'border-blue-600'); 1308 setTimeout(() => triggerEl.classList.remove('bg-blue-600', 'text-white', 'border-blue-600'), 800); 1309 } 1310} 1311 1312/** 1313 * Hapus highlight RAG dari kartu 1314 */ 1315function _removeRAGHighlight(card) { 1316 if (card.dataset.ragHighlighted !== 'true') return; 1317 card.dataset.ragHighlighted = 'false'; 1318 1319 card.classList.remove('ring-2', 'ring-blue-400/50', 'shadow-blue-100', 'shadow-lg'); 1320 1321 const label = card.querySelector('.rag-relevance-label'); 1322 if (label) label.remove(); 1323} 1324 1325/**
1326 * Bersihkan semua integrasi RAG dari grid (saat RAG panel ditutup) 1327 */ 1328function clearRAGCardIntegration() { 1329 const grid = document.getElementById('servicesGrid'); 1330 if (!grid) return; 1331 1332 // Hapus web cards yang diinject 1333 grid.querySelectorAll('.rag-web-card').forEach(c => c.remove()); 1334 1335 // Hapus divider 1336 grid.querySelectorAll('.rag-relevance-divider').forEach(d => d.remove()); 1337 1338 // Hapus semua highlight 1339 grid.querySelectorAll('[data-rag-highlighted="true"]').forEach(card => { 1340 _removeRAGHighlight(card); 1341 }); 1342 1343 // Reset state 1344 _aiState.ragSources = []; 1345 _aiState.ragWebSources = []; 1346 _aiState.ragLocalSources = []; 1347} 1348 1349/** 1350 * Update jumlah hasil pencarian jika ada web cards yang ditambahkan 1351 */ 1352function _updateResultCountWithWeb(webCount) { 1353 const info = document.getElementById('searchResultInfo'); 1354 if (!info) return; 1355 const html = info.innerHTML || ''; 1356 // Match pattern: <strong>N hasil</strong> 1357 const match = html.match(/<strong>(\d[\d.,]*)\s*hasil<\/strong>/i); 1358 if (match) { 1359 const oldCount = parseInt(match[1].replace(/[.,]/g, '')); 1360 const newCount = oldCount + webCount; 1361 info.innerHTML = html.replace( 1362 /<strong>\d[\d.,]*\s*hasil<\/strong>/i, 1363 `<strong>${newCount.toLocaleString('id-ID')} hasil</strong>` 1364 ); 1365 } else if (!html && webCount > 0) { 1366 // Jika belum ada info result (misalnya 0 hasil lokal), buat baru 1367 info.innerHTML = `ð¯ Ditemukan <strong>${webCount} hasil dari web</strong>`; 1368 info.classList.remove('hidden'); 1369 } 1370} 1371 1372// --------------------------------------------------------------------------- 1373// 6. PUBLIC API â dipanggil dari dsh-search.js & dsh-cards.js 1374// --------------------------------------------------------------------------- 1375 1376/** 1377 * onSearchComplete â dipanggil setelah renderCards() selesai 1378 * Trigger AI summary injection + RAG panel (jika query = pertanyaan) 1379 */ 1380async function onSearchComplete(query, results, types) { 1381 if (!query) { 1382 hideRAGPanel(); 1383 return; 1384 } 1385 1386 // Sembunyikan typing hint â akan digantikan oleh RAG panel / AI summary 1387 hideAITypingHint(); 1388 clearTimeout(window._aiTypingDebounce); 1389 1390 // Jika tidak ada hasil lokal DAN bukan pertanyaan, sembunyikan RAG panel 1391 if (results.length === 0 && !isQuestionQuery(query)) { 1392 hideRAGPanel(); 1393 return; 1394 } 1395 1396 // Jalankan keduanya secara paralel 1397 const tasks = []; 1398 1399 // AI Summary hanya jika ada hasil 1400 if (results.length > 0) { 1401 tasks.push(injectAISummaryIntoPanel(query, results, types)); 1402 } 1403 1404 if (isQuestionQuery(query)) { 1405 tasks.push(showRAGPanel(query, types)); 1406 } else { 1407 hideRAGPanel(); 1408 // Soft intent suggestion (non-blocking) 1409 detectQueryIntent(query).then(intent => { 1410 if (intent) applyIntentToTabs(intent); 1411 }); 1412 } 1413 1414 await Promise.allSettled(tasks); 1415} 1416 1417/** 1418 * getRewrittenQuery â dipanggil dari dsh-search.js sebelum API call 1419 * Return expanded query string atau query asli jika rewrite gagal 1420 */ 1421async function getRewrittenQuery(query) { 1422 const rewritten = await rewriteQuery(query); 1423 if (rewritten?.expanded && rewritten.expanded !== query) { 1424 console.debug('[DSH AI] Query rewritten:', query, 'â', rewritten.expanded); 1425 return rewritten.expanded; 1426 } 1427 return query; 1428} 1429 1430// --------------------------------------------------------------------------- 1431// 7. AI TYPING HINT â Aktif saat user mengetik di search input 1432// --------------------------------------------------------------------------- 1433 1434/** 1435 * State untuk typing hint â simpan query terakhir agar tidak re-fetch 1436 * jika user kembali ke query yang sama. 1437 */ 1438const _hintCache = new Map(); // query â { emoji, msg, badge } 1439 1440/** 1441 * triggerAITypingHint â dipanggil dari index.php setelah debounce 600ms 1442 * 1443 * Alur: 1444 * 1. Cek apakah query adalah pertanyaan (isQuestionQuery) â tampilkan hint "AI siap jawab" 1445 * 2. Jika bukan pertanyaan, deteksi intent (detect_intent API, ringan) 1446 * â tampilkan hint tipe konten yang cocok 1447 * 3. Gunakan cache agar tidak re-fetch untuk query yang sama 1448 * 1449 * @param {string} query â nilai searchInput.value.trim() saat ini 1450 */ 1451async function triggerAITypingHint(query) { 1452 if (!query || query.length < 3) { hideAITypingHint(); return; } 1453 1454 const bar = document.getElementById('aiTypingHint'); 1455 const emojiEl = document.getElementById('aiTypingHintEmoji'); 1456 const msgEl = document.getElementById('aiTypingHintMsg'); 1457 const badgeEl = document.getElementById('aiTypingHintBadge'); 1458 if (!bar || !msgEl) return; 1459 1460 // Tampilkan skeleton "thinking" segera 1461 bar.classList.remove('hidden'); 1462 if (emojiEl) emojiEl.textContent = 'â¨'; 1463 msgEl.innerHTML = '<span class="animate-pulse text-white/50">DSH AI menganalisisâ¦</span>'; 1464 if (badgeEl) badgeEl.classList.add('hidden'); 1465 1466 // Gunakan cache jika ada 1467 if (_hintCache.has(query)) { 1468 _applyHintResult(bar, emojiEl, msgEl, badgeEl, _hintCache.get(query)); 1469 return; 1470 } 1471 1472 let result = null; 1473 1474 // --- Kasus 1: Query berupa pertanyaan â hint "AI siap jawab" --- 1475 if (isQuestionQuery(query)) { 1476 result = { 1477 emoji: 'ð¤', 1478 msg: 'Pertanyaan terdeteksi â <strong>AI DSH siap memberikan jawaban langsung</strong>
1478 setelah Anda klik Cari.', 1479 badge: 'RAG Answer', 1480 }; 1481 } else { 1482 // --- Kasus 2: Bukan pertanyaan â deteksi intent tipe konten --- 1483 try { 1484 const intent = await detectQueryIntent(query); 1485 if (intent && intent.primary && intent.primary !== 'general' && (intent.confidence ?? 0) >= 0.65) { 1486 const typeConfig = { 1487 service: { emoji: 'ð§', label: 'Layanan Digital' }, 1488 news: { emoji: 'ð°', label: 'Berita UGM' }, 1489 product: { emoji: 'ð', label: 'Produk/MOOC' }, 1490 people: { emoji: 'ð¨âð«', label: 'Dosen / Staff' }, 1491 publication: { emoji: 'ð', label: 'Publikasi Ilmiah' }, 1492 patent: { emoji: 'ð¡', label: 'HKI / Paten' }, 1493 legal: { emoji: 'âï¸', label: 'Produk Hukum' }, 1494 pidato: { emoji: 'ð', label: 'Pidato / Laporan Rektor' }, 1495 }; 1496 const cfg = typeConfig[intent.primary] || { emoji: 'ð', label: intent.primary }; 1497 const pct = Math.round((intent.confidence ?? 0) * 100); 1498 result = { 1499 emoji: cfg.emoji, 1500 msg: `Terdeteksi sebagai pencarian <strong>${cfg.label}</strong> â hasil terbaik ada di kategori ini.`, 1501 badge: `${pct}% yakin`, 1502 }; 1503 } else if (intent && intent.is_question) { 1504 // intent.is_question true meski primary = general 1505 result = { 1506 emoji: 'ð¤', 1507 msg: 'Sepertinya Anda memiliki pertanyaan â <strong>AI DSH siap membantu</strong> setelah Anda klik Cari.', 1508 badge: 'RAG Answer', 1509 }; 1510 } else { 1511 // Query biasa tanpa intent kuat 1512 const wordCount = query.trim().split(/\s+/).length; 1513 if (wordCount >= 3) { 1514 result = { 1515 emoji: 'ð', 1516 msg: 'Pencarian multi-kata terdeteksi â AI akan <strong>memperluas kata kunci</strong> untuk hasil lebih lengkap.', 1517 badge: 'Query Rewrite', 1518 }; 1519 } else { 1520 // Query pendek â sembunyikan hint, tidak ada info berguna 1521 hideAITypingHint(); 1522 return; 1523 } 1524 } 1525 } catch (_) { 1526 // Intent API gagal â sembunyikan hint daripada tampilkan error 1527 hideAITypingHint(); 1528 return; 1529 } 1530 } 1531 1532 // Simpan ke cache (max 30 entry) 1533 if (_hintCache.size >= 30) _hintCache.delete(_hintCache.keys().next().value); 1534 _hintCache.set(query, result); 1535 1536 _applyHintResult(bar, emojiEl, msgEl, badgeEl, result); 1537} 1538 1539/** Terapkan data hint ke elemen DOM */ 1540function _applyHintResult(bar, emojiEl, msgEl, badgeEl, result) { 1541 if (!result) { hideAITypingHint(); return; } 1542 bar.classList.remove('hidden'); 1543 if (emojiEl) emojiEl.textContent = result.emoji; 1544 if (msgEl) msgEl.innerHTML = result.msg; 1545 if (badgeEl) { 1546 if (result.badge) { 1547 badgeEl.textContent = result.badge; 1548 badgeEl.classList.remove('hidden'); 1549 } else { 1550 badgeEl.classList.add('hidden'); 1551 } 1552 } 1553} 1554 1555/** 1556 * hideAITypingHint â sembunyikan bar dan bersihkan cache query sementara 1557 */ 1558function hideAITypingHint() { 1559 const bar = document.getElementById('aiTypingHint'); 1560 if (bar) bar.classList.add('hidden'); 1561}
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.