PageSourceSearch

https://indianhillschools.org/AI/ai-chat.js

js indianhillschools.org collected 2026-10-02 11:26:23 UTC 13,282 bytes, 334 lines download raw bytes

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">&times;</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, '&quot;') + '"';
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.