1/* ============================================================ 2 Ask Indian Hill â AI Chatbot Widget 3 ============================================================ 4 Self-contained floating chat widget. Requires jQuery + Font Awesome. 5 Companion files: ai-chat.css, AskIH.ashx (this folder). 6 To install on a new site: 7 1. Copy this /AI/ folder into the site root 8 2. In your master page <head>, add: 9 <link href="AI/ai-chat.css" rel="stylesheet" /> 10 <script src="AI/ai-chat.js"></script> 11 3. Set ClaudeApiKey + ClaudeModel in Web.config <appSettings> 12 ============================================================ */ 13$(function () { 14 (function initAskIH() { 15 var HANDLER = '/AI/AskIH.ashx'; 16 var MAX_CHARS = 600; 17 var STORAGE_KEY = 'IH_Chat_State'; 18 var BLOCKED = ['fuck', 'shit', 'bitch', 'cunt', 'nigger', 'faggot', 'asshole']; 19 var QUICKSTART = [ 20 'What time does school start?', 21 'How do I enroll?', 22 "What's for lunch today?", 23 'Upcoming events this week' 24 ]; 25 26 // State 27 var history = []; // API conversation history [{role, content}] 28 var messages = []; // persisted message records [{type, text/html, suggestions}] 29 var limited = false; // true when session limit hit 30 var isOpen = false; 31 var isWaiting = false; 32 var initialized = false; // true once greeting/restore has run 33 34 // Build DOM 35 var btn = $('<button id="ih-chat-btn" aria-label="Ask IH" title="Ask IH">' + 36 '<i class="fa-solid fa-comments" aria-hidden="true"></i>' + 37 '<span class="ih-chat-badge">Ask IH</span>' + 38 '</button>'); 39 40 var panel = $( 41 '<div id="ih-chat-panel" role="dialog" aria-label="Ask IH">' + 42 '<div id="ih-chat-header">' + 43 '<div class="ih-chat-avatar"><i class="fa-solid fa-graduation-cap" aria-hidden="true"></i></div>' + 44 '<div><div class="ih-chat-title">Ask IH</div><div class="ih-chat-subtitle">How may I assist you?</div></div>' + 45 '<button id="ih-chat-close" aria-label="Close chat">×</button>' + 46 '</div>' + 47 '<div id="ih-chat-messages"></div>' + 48 '<div id="ih-chat-footer">' + 49 '<div id="ih-chat-input-wrap">' + 50 '<input id="ih-chat-input" type="text" placeholder="Ask a question..." maxlength="600" autocomplete="off" />' + 51 '<span id="ih-chat-charcount">600</span>' + 52 '</div>' + 53 '<button id="ih-chat-send" aria-label="Send"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i></button>' + 54 '</div>' + 55 '<div id="ih-chat-disclaimer">Responses may not always be accurate. Please verify important details.</div>' + 56 '</div>' 57 ); 58 59 $('body').append(panel).append(btn); 60 if (window.FontAwesome) window.FontAwesome.dom.i2svg(); 61 62 var $messages = $('#ih-chat-messages'); 63 var $input = $('#ih-chat-input'); 64 var $send = $('#ih-chat-send'); 65 var $charcount = $('#ih-chat-charcount'); 66 67 // ââ Helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 68 function scrollBottom() { $messages.scrollTop($messages[0].scrollHeight); } 69 70 function convertMarkdown(text) { 71 text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 72 text = text.replace(/(?<![*])\*(?!\*)(.+?)(?<![*])\*(?!\*)/g, '<em>$1</em>'); 73 74 var lines = text.split('\n'); 75 var out = [], inUl = false, inOl = false; 76 for (var i = 0; i < lines.length; i++) { 77 var line = lines[i]; 78 var ulMatch = line.match(/^\s*[-â¢]\s+(.+)/); 79 var olMatch = line.match(/^\s*\d+[.)]\s+(.+)/); 80 if (ulMatch) { 81 if (!inUl) { out.push('<ul>'); inUl = true; } 82 out.push('<li>' + ulMatch[1] + '</li>'); 83 } else if (olMatch) { 84 if (!inOl) { out.push('<ol>'); inOl = true; } 85 out.push('<li>' + olMatch[1] + '</li>'); 86 } else { 87 if (inUl) { out.push('</ul>'); inUl = false; } 88 if (inOl) { out.push('</ol>'); inOl = false; } 89 out.push(line); 90 } 91 } 92 if (inUl) out.push('</ul>'); 93 if (inOl) out.push('</ol>'); 94 text = out.join('\n'); 95 96 text = text.replace(/\n\n+/g, '</p><p>'); 97 text = text.replace(/\n/g, '<br>'); 98 return text; 99 } 100 101 function sanitizeHtml(html) { 102 var ALLOWED = /^(strong|em|b|i|br|ul|ol|li|p|a)$/i; 103 return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, function(tag, name) { 104 if (!ALLOWED.test(name)) return ''; 105 if (name.toLowerCase() === 'a') { 106 var href = tag.match(/href\s*=\s*"([^"]*)"/i); 107 var tgt = tag.match(/target\s*=\s*"([^"]*)"/i); 108 if (tag.charAt(1) === '/') return '</a>'; 109 var safe = '<a'; 110 if (href) { 111 var url = href[1]; 112 // Only allow safe schemes â block javascript:, data:, vbscript:, etc. 113 if (/^(https?:\/\/|\/|#)/i.test(url)) { 114 safe += ' href="' + url.replace(/"/g, '"') + '"'; 115 } 116 } 117 // Only allow standard frame targets â reject anything else 118 if (tgt && /^_(blank|self|parent|top)$/i.test(tgt[1])) { 119 safe += ' target="' + tgt[1] + '"'; 120 } 121 return safe + '>'; 122 } 123 if (tag.charAt(1) === '/') return '</' + name + '>'; 124 return '<' + name + '>'; 125 }); 126 } 127 128 function processLinks(html) { 129 return html.replace(/<a\s+(?!.*target=)/gi, '<a target="_blank" rel="noopener noreferrer" '); 130 } 131 132 function stripHtml(html) { return $('<div>').html(html).text(); } 133 134 function isProfane(text) { 135 var lower = text.toLowerCase(); 136 return BLOCKED.some(function(w) { return lower.indexOf(w) !== -1; }); 137 } 138 139 function updateCharCount() { 140 var remaining = MAX_CHARS - $input.val().length; 141 $charcount.text(remaining); 142 $charcount.toggleClass('near-limit', remaining < 80); 143 } 144 145 function saveToStorage() {
146 try { 147 sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ 148 messages: messages, 149 history: history, 150 limited: limited 151 })); 152 } catch(e) {} 153 } 154 155 // ââ Append helpers âââââââââââââââââââââââââââââââââââââââââââââââââââ 156 function appendUser(text, skipSave) { 157 $messages.append( 158 '<div class="ih-chat-msg ih-chat-msg--user">' + 159 '<div class="ih-chat-bubble">' + $('<div>').text(text).html() + '</div>' + 160 '</div>' 161 ); 162 scrollBottom(); 163 if (!skipSave) { 164 messages.push({ type: 'user', text: text }); 165 saveToStorage(); 166 } 167 } 168 169 function appendBot(html, suggestions, skipSave) { 170 html = convertMarkdown(html); 171 html = sanitizeHtml(html); 172 html = processLinks(html); 173 suggestions = suggestions || []; 174 var $wrap = $('<div class="ih-chat-msg ih-chat-msg--bot"></div>'); 175 $wrap.append($('<div class="ih-chat-bubble"></div>').html(html)); 176 if (suggestions.length) { 177 var $chips = $('<div class="ih-chat-suggestions"></div>'); 178 suggestions.forEach(function(s) { 179 $('<button class="ih-chat-suggestion-chip"></button>').text(s).appendTo($chips); 180 }); 181 $wrap.append($chips); 182 } 183 $messages.append($wrap); 184 scrollBottom(); 185 if (!skipSave) { 186 messages.push({ type: 'bot', html: html, suggestions: suggestions }); 187 saveToStorage(); 188 } 189 } 190 191 function showTyping() { 192 $messages.append( 193 '<div class="ih-chat-msg ih-chat-msg--bot ih-chat-typing-wrap">' + 194 '<div class="ih-chat-typing"><span></span><span></span><span></span></div>' + 195 '</div>' 196 ); 197 scrollBottom(); 198 } 199 200 function hideTyping() { $messages.find('.ih-chat-typing-wrap').remove(); } 201 202 function showQuickStart() { 203 var $qs = $('<div class="ih-chat-quickstart"></div>'); 204 $qs.append('<div class="ih-chat-qs-label">Common questions:</div>'); 205 var $chips = $('<div class="ih-chat-qs-chips"></div>'); 206 QUICKSTART.forEach(function(q) { 207 $('<button class="ih-chat-qs-chip"></button>').text(q).appendTo($chips); 208 }); 209 $qs.append($chips); 210 $messages.append($qs); 211 scrollBottom(); 212 } 213 214 // ââ SessionStorage restore âââââââââââââââââââââââââââââââââââââââââââ 215 function restoreFromStorage() { 216 try { 217 var saved = sessionStorage.getItem(STORAGE_KEY); 218 if (!saved) return false; 219 var state = JSON.parse(saved); 220 if (!state.messages || !state.messages.length) return false; 221 messages = state.messages; 222 history = state.history || []; 223 limited = state.limited || false; 224 state.messages.forEach(function(m) { 225 if (m.type === 'user') appendUser(m.text, true); 226 else appendBot(m.html, m.suggestions, true); 227 }); 228 if (limited) { 229 $input.prop('disabled', true).attr('placeholder', 'Message limit reached'); 230 $send.prop('disabled', true); 231 } 232 return true; 233 } catch(e) { return false; } 234 } 235 236 // ââ Open / Close âââââââââââââââââââââââââââââââââââââââââââââââââââââ 237 function openChat() { 238 isOpen = true; 239 panel.addClass('ih-chat-open'); 240 btn.addClass('ih-chat-visible'); 241 if (!limited) $input.focus(); 242 if (!initialized) { 243 initialized = true; 244 if (!restoreFromStorage()) { 245 appendBot('Hi there! How may I assist you? Ask me anything about Indian Hill schools, enrollment, events, staff, or anything else!', [], false); 246 showQuickStart(); 247 } 248 } 249 } 250 251 function closeChat() { 252 isOpen = false; 253 panel.removeClass('ih-chat-open'); 254 btn.removeClass('ih-chat-visible'); 255 } 256 257 btn.on('click', function() { isOpen ? closeChat() : openChat(); }); 258 $('#ih-chat-close').on('click', closeChat); 259 $(document).on('keydown', function(e) { if (e.key === 'Escape' && isOpen) closeChat(); }); 260 261 // ââ Event delegation for all chip types ââââââââââââââââââââââââââââââ 262 $messages.on('click', '.ih-chat-qs-chip, .ih-chat-suggestion-chip', function() { 263 if (limited || isWaiting) return; 264 var text = $(this).text();
265 $('.ih-chat-quickstart').remove(); 266 $input.val(text); 267 updateCharCount(); 268 sendMessage(); 269 }); 270 271 // ââ Character counter ââââââââââââââââââââââââââââââââââââââââââââââââ 272 $input.on('input', updateCharCount); 273 274 // ââ Send message âââââââââââââââââââââââââââââââââââââââââââââââââââââ 275 function sendMessage() { 276 var q = $input.val().trim(); 277 if (!q || isWaiting || limited) return; 278 279 if (isProfane(q)) { 280 appendBot('Please keep questions school-appropriate â this assistant is for students, parents, and staff.', []); 281 $input.val(''); 282 updateCharCount(); 283 return; 284 } 285 286 $('.ih-chat-quickstart').remove(); 287 appendUser(q); 288 $input.val(''); 289 updateCharCount(); 290 isWaiting = true; 291 $send.prop('disabled', true); 292 showTyping(); 293 294 $.ajax({ 295 url: HANDLER, 296 method: 'POST', 297 contentType: 'application/json', 298 data: JSON.stringify({ question: q, history: history }), 299 timeout: 15000, 300 success: function(data) { 301 hideTyping(); 302 if (data && data.answer) { 303 history.push({ role: 'user', content: q }); 304 history.push({ role: 'assistant', content: stripHtml(data.answer) }); 305 if (history.length > 10) history = history.slice(history.length - 10); 306 appendBot(data.answer, data.suggestions || []); 307 if (data.limited) { 308 limited = true; 309 $input.prop('disabled', true).attr('placeholder', 'Message limit reached'); 310 $send.prop('disabled', true); 311 saveToStorage(); 312 } 313 } else { 314 appendBot('Sorry, I couldn\'t find an answer. Please call <strong>(513) 272-4500</strong> for assistance.', []); 315 } 316 }, 317 error: function() { 318 hideTyping(); 319 appendBot('Sorry, something went wrong. Please call <strong>(513) 272-4500</strong> or visit <a href="https://www.indianhillschools.org">indianhillschools.org</a>.', []); 320 }, 321 complete: function() { 322 isWaiting = false; 323 if (!limited) { 324 $send.prop('disabled', false); 325 $input.focus(); 326 } 327 } 328 }); 329 } 330 331 $send.on('click', sendMessage); 332 $input.on('keydown', function(e) { if (e.key === 'Enter') sendMessage(); }); 333 })(); 334});
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.