1/* global ALAP_DATA */ 2( function () { 3 'use strict'; 4 5 if ( typeof ALAP_DATA === 'undefined' ) { 6 return; 7 } 8 9 // ---- Icon set (proper SVGs, not emoji) ---- 10 var ICONS = { 11 menu: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>', 12 close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>', 13 theme: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>', 14 copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>', 15 check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>', 16 thumbsUp: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11"/><path d="M15 5.88 14 10h6.29a2 2 0 0 1 1.94 2.5l-2.35 9A2 2 0 0 1 17.94 23H7a2 2 0 0 1-2-2v-9a2 2 0 0 1 .59-1.42L10 6a2 2 0 0 0 .59-1.42V4a2 2 0 0 1 4 0z"/></svg>', 17 thumbsDown: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 14V3"/><path d="M9 18.12 10 14H3.71a2 2 0 0 1-1.94-2.5l2.35-9A2 2 0 0 1 6.06 1H17a2 2 0 0 1 2 2v9a2 2 0 0 1-.59 1.42L14 17a2 2 0 0 0-.59 1.42V20a2 2 0 0 1-4 0z"/></svg>', 18 regenerate: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>', 19 attach: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>', 20 send: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2 21l21-9L2 3v7l15 2-15 2z"/></svg>', 21 stop: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>', 22 plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>', 23 logout: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>', 24 trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>', 25 pdf: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="9" y1="18" x2="13" y2="18"/></svg>', 26 expand: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>', 27 collapse: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/>
27<line x1="3" y1="21" x2="10" y2="14"/></svg>', 28 }; 29 30 var STORAGE_KEY = 'alap_session_v1'; 31 var root = document.getElementById( 'alap-chat-root' ); 32 if ( ! root ) { 33 return; 34 } 35 36 var state = { 37 open: false, 38 dark: ALAP_DATA.darkMode, 39 sessionToken: null, 40 student: null, 41 courseId: null, 42 conversationId: null, 43 sending: false, 44 abortController: null, 45 }; 46 47 // Restore a previous session (until it expires server-side). 48 try { 49 var saved = JSON.parse( window.localStorage.getItem( STORAGE_KEY ) || 'null' ); 50 if ( saved && saved.sessionToken ) { 51 state.sessionToken = saved.sessionToken; 52 state.student = saved.student; 53 state.courseId = saved.courseId; 54 } 55 } catch ( e ) { /* ignore corrupt storage */ } 56 57 function persist() { 58 try { 59 window.localStorage.setItem( STORAGE_KEY, JSON.stringify( { 60 sessionToken: state.sessionToken, 61 student: state.student, 62 courseId: state.courseId, 63 } ) ); 64 } catch ( e ) { /* storage may be unavailable (private browsing) */ } 65 } 66 67 function api( path, body ) { 68 return fetch( ALAP_DATA.restUrl + path, { 69 method: 'POST', 70 headers: { 71 'Content-Type': 'application/json', 72 'X-WP-Nonce': ALAP_DATA.nonce, 73 }, 74 body: JSON.stringify( body || {} ), 75 } ).then( function ( res ) { 76 return res.json().then( function ( data ) { 77 if ( ! res.ok ) { 78 var err = new Error( data && data.message ? data.message : 'Request failed' ); 79 throw err; 80 } 81 return data; 82 } ); 83 } ); 84 } 85 86 /** 87 * navigator.clipboard.writeText only exists in a secure context (HTTPS, 88 * or localhost) â on a plain-HTTP site it's simply undefined, so the old 89 * "navigator.clipboard && navigator.clipboard.writeText(text)" silently 90 * did nothing with zero feedback, looking exactly like a broken button. 91 * This falls back to the older execCommand('copy') approach (via a 92 * temporary hidden textarea) so copying still works on HTTP sites too. 93 */ 94 function copyTextToClipboard( text ) { 95 if ( navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext ) { 96 return navigator.clipboard.writeText( text ); 97 } 98 return new Promise( function ( resolve, reject ) { 99 var ta = document.createElement( 'textarea' ); 100 ta.value = text; 101 ta.setAttribute( 'readonly', '' ); 102 ta.style.position = 'fixed'; 103 ta.style.top = '-1000px'; 104 ta.style.left = '-1000px'; 105 document.body.appendChild( ta ); 106 ta.select(); 107 ta.setSelectionRange( 0, text.length ); 108 var ok = false; 109 try { 110 ok = document.execCommand( 'copy' ); 111 } catch ( e ) { 112 ok = false; 113 } 114 document.body.removeChild( ta ); 115 if ( ok ) { 116 resolve(); 117 } else { 118 reject( new Error( 'copy failed' ) ); 119 } 120 } ); 121 } 122 123 /** 124 * Every message action (copy/like/dislike) used to fire its request with 125 * no visible confirmation at all â clicking felt like nothing happened, 126 * even when it worked. This briefly swaps the button's tooltip (and icon, 127 * if given) to confirm the action, then restores it. 128 */ 129 function flashButtonFeedback( btn, tempIconSvg, tempTitle ) { 130 var originalTitle = btn.getAttribute( 'title' ); 131 var originalHtml = btn.innerHTML; 132 if ( tempIconSvg ) { 133 btn.innerHTML = tempIconSvg; 134 } 135 btn.setAttribute( 'title', tempTitle ); 136 btn.classList.add( 'alap-action-flash' ); 137 setTimeout( function () { 138 btn.innerHTML = originalHtml; 139 btn.setAttribute( 'title', originalTitle ); 140 btn.classList.remove( 'alap-action-flash' ); 141 }, 1500 ); 142 } 143 144 // ---- Minimal, safe markdown renderer (bold, italics, code, links, lists, tables) ---- 145 function escapeHtml( str ) { 146 return str.replace( /&/g, '&' ).replace( /</g, '<' ).replace( />/g, '>' ); 147 } 148 149 /** 150 * Safety net for the system-prompt instruction telling the AI never to 151 * use LaTeX â models occasionally slip into it anyway (e.g. for a step 152 * calculation), and since this widget only renders plain Markdown, raw 153 * "\times", "\text{...}", "$...$" etc. would otherwise show up as broken 154 * literal code to the student. This cleans up the common patterns before 155 * markdown rendering runs. 156 */ 157 function stripLatexArtifacts( text ) { 158 return text 159 // \text{...} / \mathrm{...} / \mathbf{...} -> just the inner content 160 .replace( /\\(?:text|mathrm|mathbf|textbf)\{([^}]*)\}/g, '$1' ) 161 // \frac{a}{b} -> a/b 162 .replace( /\\frac\{([^}]*)\}\{([^}]*)\}/g, '$1/$2' ) 163 // Common LaTeX operators/symbols -> plain equivalents 164 .replace( /\\times/g, 'Ã' ) 165 .replace( /\\div/g, '÷' ) 166 .replace( /\\pm/g, '±' ) 167 .replace( /\\cdot/g, 'Ã' ) 168 .replace( /\\approx/g, 'â' ) 169 .replace( /\\leq/g, 'â¤' ) 170 .replace( /\\geq/g, 'â¥' ) 171 .replace( /\\%/g, '%' ) 172 // Leftover math-mode delimiters: $$...$$ and $...$ -> strip the 173 // dollar signs but keep the content (do double first). 174 .replace( /\$\$([^$]*)\$\$/g, '$1' ) 175 .replace( /\$([^$\n]*)\$/g, '$1' ) 176 // Any remaining bare LaTeX command word (\something) -> drop the backslash 177 .replace( /\\([a-zA-Z]+)/g, '$1' ); 178 } 179 180 // ---- Chart rendering (Chart.js) ---- 181 // The AI can include a fenced ```chart block containing JSON describing a 182 // graph (e.g. a stock's historical price trend, a quarterly-results 183 // comparison). Pulled out with a placeholder token BEFORE escapeHtml runs 184 // (so the raw JSON is never itself escaped/mangled), then swapped for 185 // real <canvas> markup at the very end, after all other markdown 186 // processing â that final swap uses split/join, not a regex, since the 187 // token is plain alphanumerics and needs no escaping itself. 188 var alapChartRegistry = {}; 189 var alapTvChartRegistry = {}; 190 var alapChartCounter = 0; 191 var ALAP_CHART_COLORS = [ '#2563eb', '#16a34a', '#dc2626', '#d97706', '#7c3aed', '#0891b2' ]; 192 193 function isValidChartConfig( config ) { 194 if ( ! config || typeof config !== 'object' ) { 195 return false;
196 } 197 if ( ! Array.isArray( config.labels ) || ! config.labels.length || config.labels.length > 50 ) { 198 return false; 199 } 200 201 if ( 'candlestick' === config.type ) { 202 // One OHLC series only â a candlestick chart showing multiple 203 // overlapping securities isn't meaningfully readable anyway. 204 if ( ! Array.isArray( config.datasets ) || 1 !== config.datasets.length ) { 205 return false; 206 } 207 var ohlc = config.datasets[ 0 ]; 208 return !! ohlc && typeof ohlc.label === 'string' && Array.isArray( ohlc.data ) && ohlc.data.length === config.labels.length && 209 ohlc.data.every( function ( pt ) { 210 return pt && [ 'o', 'h', 'l', 'c' ].every( function ( k ) { return typeof pt[ k ] === 'number' && isFinite( pt[ k ] ); } ) && 211 pt.l <= pt.o && pt.l <= pt.c && pt.h >= pt.o && pt.h >= pt.c && pt.h >= pt.l; 212 } ); 213 } 214 215 if ( [ 'line', 'bar', 'pie', 'doughnut' ].indexOf( config.type ) === -1 ) { 216 return false; 217 } 218 if ( ! Array.isArray( config.datasets ) || ! config.datasets.length || config.datasets.length > 6 ) { 219 return false; 220 } 221 return config.datasets.every( function ( ds ) { 222 return ds && typeof ds.label === 'string' && Array.isArray( ds.data ) && ds.data.length === config.labels.length && 223 ds.data.every( function ( v ) { return typeof v === 'number' && isFinite( v ); } ); 224 } ); 225 } 226 227 function extractChartPlaceholders( text ) { 228 var placeholders = {}; 229 var idx = 0; 230 var replaced = text.replace( /```chart\s*([\s\S]*?)```/gi, function ( m, jsonStr ) { 231 var token = '\u0000ALAPCHART' + ( idx++ ) + '\u0000'; 232 var config = null; 233 try { 234 config = JSON.parse( jsonStr.trim() ); 235 } catch ( e ) { 236 config = null; 237 } 238 placeholders[ token ] = isValidChartConfig( config ) ? config : null; 239 return token; 240 } ); 241 return { text: replaced, placeholders: placeholders }; 242 } 243 244 // ---- TradingView live chart embed ---- 245 // Instead of asking the AI to know/search up real OHLC numbers (which it 246 // can get wrong or refuse for lack of data), this embeds TradingView's 247 // own free, official "Advanced Chart" widget for a given symbol â a 248 // real, always-current, interactive candlestick chart with TradingView's 249 // own live data, not anything the AI generated. No API key needed; this 250 // is TradingView's standard public embed, meant exactly for 3rd-party 251 // sites. The AI only ever has to name a symbol, never any price data. 252 var ALAP_TV_SYMBOL_RE = /^[A-Z0-9_.]{1,15}(:[A-Z0-9_.]{1,20})?$/; 253 var ALAP_TV_INTERVALS = [ '1', '5', '15', '30', '60', '120', '240', 'D', 'W', 'M' ]; 254 255 function isValidTvConfig( config ) { 256 return !! config && typeof config === 'object' && 257 typeof config.symbol === 'string' && 258 ALAP_TV_SYMBOL_RE.test( config.symbol.toUpperCase() ); 259 } 260 261 function extractTvChartPlaceholders( text ) { 262 var placeholders = {}; 263 var idx = 0; 264 var replaced = text.replace( /```tvchart\s*([\s\S]*?)```/gi, function ( m, jsonStr ) { 265 var token = '\u0000ALAPTVCHART' + ( idx++ ) + '\u0000'; 266 var config = null; 267 try { 268 config = JSON.parse( jsonStr.trim() ); 269 } catch ( e ) { 270 config = null; 271 } 272 placeholders[ token ] = isValidTvConfig( config ) ? config : null; 273 return token; 274 } ); 275 return { text: replaced, placeholders: placeholders }; 276 } 277 278 // Renders the actual TradingView widget into an already-DOM-attached 279 // container. Script tags inserted via innerHTML never execute (a 280 // standard browser quirk), so the <script> is built with createElement 281 // and appended for real here, once the container div genuinely exists 282 // in the document â never via the message's innerHTML string itself. 283 // Only a strictly-validated symbol/interval (never raw AI JSON) is ever 284 // written into the widget config, so nothing from the AI's output reaches 285 // the page as arbitrary script content. 286 function renderPendingTvCharts( container ) { 287 container.querySelectorAll( '[data-alap-tv-id]' ).forEach( function ( holder ) { 288 var id = holder.getAttribute( 'data-alap-tv-id' ); 289 var config = alapTvChartRegistry[ id ]; 290 if ( ! config ) { 291 return; 292 } 293 delete alapTvChartRegistry[ id ]; 294 295 var symbol = config.symbol.toUpperCase(); 296 var interval = ALAP_TV_INTERVALS.indexOf( config.interval ) !== -1 ? config.interval : 'D'; 297 298 var widgetDiv = document.createElement( 'div' ); 299 widgetDiv.className = 'tradingview-widget-container__widget'; 300 holder.appendChild( widgetDiv ); 301 302 var script = document.createElement( 'script' ); 303 script.type = 'text/javascript'; 304 script.src = 'https://s3.tradingview.com/external-embedding/embed-widget-advanced-chart.js'; 305 script.async = true; 306 script.text = JSON.stringify( { 307 autosize: true, 308 symbol: symbol, 309 interval: interval, 310 timezone: 'Asia/Kolkata', 311 theme: document.getElementById( 'alap-chat-root' ) && document.getElementById( 'alap-chat-root' ).classList.contains( 'alap-dark' ) ? 'dark' : 'light', 312 style: '1', // Candlestick style 313 locale: 'en', 314 enable_publishing: false, 315 hide_top_toolbar: false, 316 hide_legend: true, 317 save_image: false, 318 calendar: false, 319 support_host: 'https://www.tradingview.com', 320 } ); 321 holder.appendChild( script ); 322 } ); 323 } 324 325 function buildChartJsConfig( config ) { 326 if ( 'candlestick' === config.type ) { 327 var ohlc = config.datasets[ 0 ]; 328 var labels = config.labels; 329 return { 330 type: 'candlestick', 331 data: { 332 datasets: [ { 333 label: ohlc.label, 334 data: ohlc.data.map( function ( pt, i ) { return { x: i, o: pt.o, h: pt.h, l: pt.l, c: pt.c }; } ), 335 } ], 336 }, 337 options: { 338 responsive: true, 339 maintainAspectRatio: false, 340 plugins: { 341 title: { display: !! config.title, text: config.title || '' }, 342 legend: { display: false }, 343 }, 344 scales: { 345 x: { 346 type: 'linear', 347 min: 0, 348 max: labels.length - 1, 349 ticks: { 350 stepSize: 1, 351 callback: function ( val ) { return labels[ val ] !== undefined ? labels[ val ] : ''; }, 352 }, 353 }, 354 y: { type: 'linear' }, 355 }, 356 }, 357 }; 358 } 359 360 var isPie = 'pie' === config.type || 'doughnut' === config.type; 361 return { 362 type: config.type, 363 data: { 364 labels: config.labels, 365 datasets: config.datasets.map( function ( ds, i ) { 366 var color = ALAP_CHART_COLORS[ i % ALAP_CHART_COLORS.length ]; 367 return { 368 label: ds.label, 369 data: ds.data, 370 borderColor: color, 371 backgroundColor: isPie ? ALAP_CHART_COLORS.slice( 0, config.labels.length ) : color + '33', 372 fill: 'line' === config.type, 373 tension: 0.25, 374 }; 375 } ), 376 }, 377 options: { 378 responsive: true, 379 maintainAspectRatio: false, 380 plugins: { 381 title: { display: !! config.title, text: config.title || '' }, 382 legend: { display: config.datasets.length > 1 || isPie }, 383 }, 384 }, 385 }; 386 } 387 388 // Call after a message's HTML (with chart placeholder <canvas> elements 389 // already swapped in) is actually attached to the DOM â Chart.js needs a 390 // real, attached canvas element to measure and draw into. 391 function renderPendingCharts( container ) { 392 if ( 'undefined' === typeof Chart ) { 393 return; 394 } 395 container.querySelectorAll( 'canvas[data-alap-chart-id]' ).forEach( function ( canvas ) { 396 var id = canvas.getAttribute( 'data-alap-chart-id' ); 397 var config = alapChartRegistry[ id ]; 398 if ( ! config ) { 399 return; 400 } 401 try { 402 new Chart( canvas, buildChartJsConfig( config ) ); // eslint-disable-line no-new 403 } catch ( e ) { 404 // Most likely cause: the chartjs-chart-financial CDN script 405 // (needed only for candlestick charts) didn't load â fail 406 // quietly rather than breaking the rest of the message. 407 var wrap = canvas.closest( '.alap-chart-wrap' ); 408 if ( wrap ) { 409 wrap.style.display = 'none'; 410 } 411 } 412 delete alapChartRegistry[ id ]; 413 } ); 414 } 415 416 function renderMarkdown( text ) { 417 var extractedTv = extractTvChartPlaceholders( text ); 418 var extracted = extractChartPlaceholders( extractedTv.text ); 419 var html = escapeHtml( stripLatexArtifacts( extracted.text ) ); 420 421 // Fenced code blocks 422 html = html.replace( /```([\s\S]*?)```/g, function ( m, code ) { 423 return '<pre><code>' + code.trim() + '</code></pre>'; 424 } ); 425 // Inline code 426 html = html.replace( /`([^`]+)`/g, '<code>$1</code>' ); 427 428 // Headers (# / ## / ### ...), tolerating leading whitespace/indentation. 429 // Must run BEFORE bold/italics so a leading "#" is never mistaken for 430 // emphasis markup, and before bullets so "### Heading" isn't parsed 431 // as if it were a "*"-style list item. 432 html = html.replace( /^[ \t]*(#{1,6})[ \t]+(.+)$/gm, function ( m, hashes, content ) { 433 return '<div class="alap-md-heading alap-md-h' + hashes.length + '">' + content.trim() + '</div>'; 434 } ); 435 436 // Links 437 html = html.replace( /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>' ); 438 439 // Simple GitHub-style tables 440 var lines = html.split( '\n' ); 441 var out = []; 442 var i = 0; 443 while ( i < lines.length ) { 444 if ( lines[ i ].indexOf( '|' ) !== -1 && lines[ i + 1 ] && /^[\s|:-]+$/.test( lines[ i + 1 ] ) ) { 445 var header = lines[ i ].split( '|' ).map( function ( c ) { return c.trim(); } ).filter( Boolean ); 446 var body = []; 447 var j = i + 2; 448 while ( j < lines.length && lines[ j ].indexOf( '|' ) !== -1 ) { 449 body.push( lines[ j ].split( '|' ).map( function ( c ) { return c.trim(); } ).filter( Boolean ) ); 450 j++; 451 } 452 var t = '<div class="alap-table-wrap"><table><thead><tr>' + header.map( function ( h ) { return '<th>' + h + '</th>'; } ).join( '' ) + '</tr></thead><tbody>'; 453 body.forEach( function ( row ) { 454 t += '<tr>' + row.map( function ( c ) { return '<td>' + c + '</td>'; } ).join( '' ) + '</tr>'; 455 } ); 456 t += '</tbody></table></div>'; 457 out.push( t ); 458 i = j; 459 continue; 460 } 461 out.push( lines[ i ] ); 462 i++; 463 } 464 html = out.join( '\n' ); 465 466 // Simple bullet lists â allow indentation before the "-"/"*" marker 467 // (AI-generated notes often indent sub-bullets), and require the 468 // marker to be followed by a space so a stray "*bold*" run isn't 469 // mistaken for a list item. 470 html = html.replace( /(?:^|\n)([ \t]*[-*][ \t]+.+(?:\n[ \t]*[-*][ \t]+.+)*)/g, function ( m, block ) { 471 var items = block.split( '\n' ).map( function ( l ) { return '<li>' + l.replace( /^[ \t]*[-*][ \t]+/, '' ) + '</li>'; } ).join( '' ); 472 return '\n<ul>' + items + '</ul>'; 473 } ); 474 475 // Bold / italics â runs last among the block-level markers so a 476 // bullet's "*" marker (already stripped above) is never confused 477 // with emphasis markup. 478 html = html.replace( /\*\*(.+?)\*\*/g, '<strong>$1</strong>' ); 479 html = html.replace( /\*(.+?)\*/g, '<em>$1</em>' ); 480 481 html = html.replace( /\n/g, '<br>' ).replace( /<br>(<\/?(ul|li|table|thead|tbody|tr|th|td|pre|div)>)/g, '$1' ).replace( /(<\/div>)<br>/g, '$1' ); 482 483 // Swap chart placeholder tokens for real canvas markup last, after all 484 // other markdown processing â a null (invalid JSON) placeholder is 485 // dropped silently rather than showing broken raw JSON to the student. 486 Object.keys( extracted.placeholders ).forEach( function ( token ) { 487 var config = extracted.placeholders[ token ]; 488 var replacement = ''; 489 if ( config ) { 490 var id = 'alap-chart-' + ( ++alapChartCounter ); 491 alapChartRegistry[ id ] = config; 492 replacement = '<div class="alap-chart-wrap"><canvas data-alap-chart-id="' + id + '"></canvas></div>'; 493 } 494 html = html.split( token ).join( replacement ); 495 } ); 496 497 Object.keys( extractedTv.placeholders ).forEach( function ( token ) { 498 var config = extractedTv.placeholders[ token ]; 499 var replacement = ''; 500 if ( config ) { 501 var id = 'alap-tv-' + ( ++alapChartCounter ); 502 alapTvChartRegistry[ id ] = config; 503 replacement = '<div class="alap-tv-chart-wrap tradingview-widget-container" data-alap-tv-id="' + id + '"></div>'; 504 } 505 html = html.split( token ).join( replacement ); 506 } ); 507 508 return html; 509 } 510 511 // ---- DOM construction ---- 512 root.innerHTML = 513 '<div class="alap-bubble" id="alap-bubble-btn" role="button" aria-label="Open chat">' + 514 '<span class="alap-bubble-pulse"></span>' + 515 ( ALAP_DATA.brandLogo 516 ? '<img class="alap-bubble-photo" src="' + ALAP_DATA.brandLogo + '" alt="" />' 517 : '<svg viewBox="0 0 24 24">
517<path d="M12 12.5c2.5 0 4.5-2 4.5-4.5S14.5 3.5 12 3.5 7.5 5.5 7.5 8s2 4.5 4.5 4.5z"/><path d="M4 20.5c0-4.4 3.6-7.5 8-7.5s8 3.1 8 7.5H4z"/><path d="M6.5 6.2L12 4l5.5 2.2L12 8.4 6.5 6.2z"/></svg>' ) + 518 '</div>' + 519 '<div class="alap-window" id="alap-window">' + 520 '<div class="alap-header">' + 521 '<button class="alap-icon-btn" id="alap-history-btn" title="Chat history">' + ICONS.menu + '</button>' + 522 ( ALAP_DATA.brandLogo ? '<img src="' + ALAP_DATA.brandLogo + '" alt="" />' : '' ) + 523 '<div class="alap-header-text"><div class="alap-header-title">' + escapeHtml( ALAP_DATA.brandName ) + '</div><div class="alap-header-sub" id="alap-header-sub">Your personal mentor</div></div>' + 524 '<button class="alap-icon-btn" id="alap-theme-toggle" title="Toggle theme">' + ICONS.theme + '</button>' + 525 '<button class="alap-icon-btn" id="alap-expand-btn" title="Expand to full screen">' + ICONS.expand + '</button>' + 526 '<button class="alap-icon-btn" id="alap-close-btn" title="Close">' + ICONS.close + '</button>' + 527 '</div>' + 528 '<div class="alap-main">' + 529 '<div class="alap-sidebar-backdrop" id="alap-sidebar-backdrop"></div>' + 530 '<div class="alap-sidebar" id="alap-sidebar">' + 531 '<div class="alap-sidebar-header">' + 532 '<button class="alap-new-chat-btn" id="alap-new-chat-btn">' + ICONS.plus + ' New Chat</button>' + 533 '<button class="alap-icon-btn" id="alap-sidebar-close" title="Close">' + ICONS.close + '</button>' + 534 '</div>' + 535 '<div class="alap-conversation-list" id="alap-conversation-list"></div>' + 536 '<div class="alap-sidebar-footer">' + 537 '<button class="alap-logout-btn" id="alap-logout-btn">' + ICONS.logout + ' Logout</button>' + 538 '</div>' + 539 '</div>' + 540 '<div class="alap-chat-panel" id="alap-chat-panel">' + 541 '<div class="alap-body" id="alap-body"></div>' + 542 '<div id="alap-input-area"></div>' + 543 '</div>' + 544 '</div>' + 545 '</div>'; 546 547 var bubbleBtn = document.getElementById( 'alap-bubble-btn' ); 548 var closeBtn = document.getElementById( 'alap-close-btn' ); 549 var themeToggle = document.getElementById( 'alap-theme-toggle' ); 550 var expandBtn = document.getElementById( 'alap-expand-btn' ); 551 var body = document.getElementById( 'alap-body' ); 552 var chatPanel = document.getElementById( 'alap-chat-panel' ); 553 var inputArea = document.getElementById( 'alap-input-area' ); 554 var headerSub = document.getElementById( 'alap-header-sub' ); 555 var historyBtn = document.getElementById( 'alap-history-btn' ); 556 var sidebarBackdrop = document.getElementById( 'alap-sidebar-backdrop' ); 557 var sidebarCloseBtn = document.getElementById( 'alap-sidebar-close' ); 558 var newChatBtn = document.getElementById( 'alap-new-chat-btn' ); 559 var conversationList = document.getElementById( 'alap-conversation-list' ); 560 var logoutBtn = document.getElementById( 'alap-logout-btn' ); 561 562 if ( state.dark ) { 563 root.classList.add( 'alap-dark' ); 564 } 565 root.style.setProperty( '--alap-brand', ALAP_DATA.brandColor || '#6366F1' ); 566 567 // Animated "AI network" background: a canvas of drifting, connected 568 // nodes with a pulsing core "heartbeat", a faint drifting candlestick 569 // field, and mouse/touch ripples that push nodes and light up their 570 // connections â ported from the reference page the user supplied, 571 // scoped to the chat panel instead of the whole browser window/page, 572 // and re-themed so it reads correctly in both light and dark mode 573 // (the original was dark-only). Runs once per widget instance; pauses 574 // its heavy work whenever the widget is closed or the browser tab is 575 // hidden, since it would otherwise burn CPU in the background forever. 576 ( function setupAnimatedBackground() { 577 var reduceMotion = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; 578 if ( reduceMotion ) { return; } 579 580 var canvas = document.createElement( 'canvas' ); 581 canvas.id = 'alap-bg-canvas'; 582 chatPanel.insertBefore( canvas, chatPanel.firstChild ); 583 var ctx = canvas.getContext( '2d' ); 584 585 var W = 0, H = 0, DPR = Math.min( window.devicePixelRatio || 1, 2 ); 586 var nodes = [], candles = [], ripples = [], sparks = []; 587 588 function isDark() { return root.classList.contains( 'alap-dark' ); } 589 590 // Two colour palettes â same shapes/behaviour, different tuning so 591 // the network reads clearly on a light popup as well as a dark one 592 // (the original's very pale greens would be almost invisible on 593 // white). 594 var DARK = { 595 bgStops: [ '#04140d', '#03100a', '#020705' ], 596 nodeColors: [ '#3fe89a', '#12a25a', '#8dffc0', '#0c3d2a' ], 597 ring: '63,232,154', rippleStroke: '141,255,192', connection: '63,232,154', 598 candleUp: '141,255,192', candleDown: '18,90,60', 599 orbInner: '141,255,192', orbOuter: '18,162,90', spark: '216,255,233' 600 }; 601 var LIGHT = { 602 bgStops: [ '#ffffff', '#f2fbf6', '#eafaf1' ], 603 nodeColors: [ '#0f9d57', '#0c7a44', '#10b96a', '#0a5c37' ], 604 ring: '16,150,85', rippleStroke: '13,120,70', connection: '16,150,85', 605 candleUp: '15,120,70', candleDown: '150,40,40', 606 orbInner: '15,140,80', orbOuter: '12,110,65', spark: '10,70,45' 607 }; 608 function theme() { return isDark() ? DARK : LIGHT; } 609 610 function rand( a, b ) { return a + Math.random() * ( b - a ); } 611 612 function resize() { 613 W = chatPanel.clientWidth; 614 H = chatPanel.clientHeight; 615 if ( W <= 0 || H <= 0 ) { return; } 616 canvas.width = Math.floor( W * DPR ); 617 canvas.height = Math.floor( H * DPR ); 618 canvas.style.width = W + 'px'; 619 canvas.style.height = H + 'px'; 620 ctx.setTransform( DPR, 0, 0, DPR, 0, 0 ); 621 buildScene(); 622 } 623 624 function buildScene() { 625 var th = theme(); 626 var area = W * H; 627 var targetCount = Math.round( area / 16000 ); 628 var count = Math.max( 40, Math.min( 130, targetCount ) ); 629 nodes = new Array( count ).fill( 0 ).map( function () { 630 return { 631 x: rand( 0, W ), y: rand( 0, H ), 632 vx: rand( -0.06, 0.06 ), vy: rand( -0.16, -0.04 ), 633 r: rand( 1.1, 2.6 ), baseAlpha: rand( 0.25, 0.65 ), boost: 0, 634 color: th.nodeColors[ Math.floor( Math.random() * th.nodeColors.length ) ], 635 phase: rand( 0, Math.PI * 2 ) 636 }; 637 } ); 638 var barCount = Math.max( 18, Math.round( W / 34 ) ); 639 candles = new Array( barCount ).fill( 0 ).map( function ( _, i ) { 640 return { 641 x: ( i / barCount ) * W + rand( -10, 10 ), w: rand( 4, 8 ), 642 bandBottom: H, h: rand( 30, 160 ), wickH: rand( 10, 40 ), 643 up: Math.random() > 0.45, alpha: rand( 0.05, 0.15 ), 644 speed: rand( 2, 6 ), flicker: rand( 0, Math.PI * 2 ) 645 }; 646 } ); 647 } 648 649 var mouse = { x: -9999, y: -9999 }; 650 var lastRippleTime = 0; 651 function spawnRipple( x, y, strength ) { 652 ripples.push( { x: x, y: y, r: 0, alpha: 0.55 * strength, speed: 260, maxR: 260 * strength } ); 653 } 654 function handlePointer( clientX, clientY ) { 655 var rect = chatPanel.getBoundingClientRect(); 656 var x = clientX - rect.left, y = clientY - rect.top; 657 mouse.x = x; mouse.y = y; 658 var now = performance.now(); 659 if ( now - lastRippleTime > 260 ) { 660 spawnRipple( x, y, rand( 0.7, 1 ) );
661 lastRippleTime = now; 662 } 663 } 664 chatPanel.addEventListener( 'mousemove', function ( e ) { handlePointer( e.clientX, e.clientY ); } ); 665 chatPanel.addEventListener( 'touchmove', function ( e ) { 666 if ( e.touches && e.touches[ 0 ] ) { handlePointer( e.touches[ 0 ].clientX, e.touches[ 0 ].clientY ); } 667 }, { passive: true } ); 668 chatPanel.addEventListener( 'click', function ( e ) { 669 var rect = chatPanel.getBoundingClientRect(); 670 spawnRipple( e.clientX - rect.left, e.clientY - rect.top, 1.4 ); 671 } ); 672 673 var pulseT = 0, last = performance.now(); 674 675 function drawCoreOrb( th ) { 676 var cx = W * 0.5, cy = H * 0.5; 677 var breathe = ( Math.sin( pulseT * 0.6 ) + 1 ) / 2; 678 var baseR = Math.min( W, H ) * ( 0.10 + breathe * 0.015 ); 679 for ( var i = 0; i < 3; i++ ) { 680 var phase = ( pulseT * 0.35 + i / 3 ) % 1; 681 var rr = baseR + phase * Math.min( W, H ) * 0.32; 682 var alpha = ( 1 - phase ) * 0.10; 683 if ( alpha <= 0 ) { continue; } 684 ctx.beginPath(); 685 ctx.arc( cx, cy, rr, 0, Math.PI * 2 ); 686 ctx.strokeStyle = 'rgba(' + th.ring + ',' + alpha.toFixed( 3 ) + ')'; 687 ctx.lineWidth = 1.2; 688 ctx.stroke(); 689 } 690 var glow = ctx.createRadialGradient( cx, cy, 0, cx, cy, baseR * 1.6 ); 691 glow.addColorStop( 0, 'rgba(' + th.orbInner + ',0.10)' ); 692 glow.addColorStop( 0.5, 'rgba(' + th.orbOuter + ',0.05)' ); 693 glow.addColorStop( 1, 'rgba(' + th.orbOuter + ',0)' ); 694 ctx.fillStyle = glow; 695 ctx.beginPath(); 696 ctx.arc( cx, cy, baseR * 1.6, 0, Math.PI * 2 ); 697 ctx.fill(); 698 } 699 700 function drawCandlesticks( dt, th ) { 701 ctx.save(); 702 for ( var i = 0; i < candles.length; i++ ) { 703 var c = candles[ i ]; 704 c.x -= c.speed * dt; 705 if ( c.x < -20 ) { c.x = W + 20; } 706 c.flicker += dt * 0.8; 707 var flick = 0.85 + Math.sin( c.flicker ) * 0.15; 708 var yBase = c.bandBottom, bodyTop = yBase - c.h; 709 var color = c.up ? th.candleUp : th.candleDown; 710 ctx.strokeStyle = 'rgba(' + color + ',' + ( c.alpha * 0.7 * flick ).toFixed( 3 ) + ')'; 711 ctx.lineWidth = 1; 712 ctx.beginPath(); 713 ctx.moveTo( c.x + c.w / 2, bodyTop - c.wickH ); 714 ctx.lineTo( c.x + c.w / 2, yBase + c.wickH * 0.3 ); 715 ctx.stroke(); 716 ctx.fillStyle = 'rgba(' + color + ',' + ( c.alpha * flick ).toFixed( 3 ) + ')'; 717 ctx.fillRect( c.x, bodyTop, c.w, c.h ); 718 } 719 ctx.restore(); 720 } 721 722 function updateNodes( dt ) { 723 for ( var i = 0; i < nodes.length; i++ ) { 724 var n = nodes[ i ]; 725 n.x += n.vx * ( dt * 60 ); 726 n.y += n.vy * ( dt * 60 ); 727 n.phase += dt * 1.4; 728 if ( n.y < -10 ) { n.y = H + 10; n.x = rand( 0, W ); } 729 if ( n.x < -10 ) { n.x = W + 10; } 730 if ( n.x > W + 10 ) { n.x = -10; } 731 n.boost *= Math.pow( 0.001, dt ); 732 } 733 for ( var r = 0; r < ripples.length; r++ ) { 734 var rp = ripples[ r ]; 735 for ( var j = 0; j < nodes.length; j++ ) { 736 var nn = nodes[ j ]; 737 var dx = nn.x - rp.x, dy = nn.y - rp.y; 738 var d = Math.sqrt( dx * dx + dy * dy ); 739 var band = 26; 740 if ( Math.abs( d - rp.r ) < band ) { 741 var closeness = 1 - Math.abs( d - rp.r ) / band; 742 nn.boost = Math.max( nn.boost, closeness ); 743 if ( d > 0.001 ) { 744 var push = closeness * 6 * dt; 745 nn.x += ( dx / d ) * push; 746 nn.y += ( dy / d ) * push; 747 } 748 } 749 } 750 } 751 } 752 753 function updateRipples( dt, th ) { 754 for ( var i = 0; i < ripples.length; i++ ) { 755 ripples[ i ].r += ripples[ i ].speed * dt; 756 ripples[ i ].alpha *= Math.pow( 0.02, dt ); 757 } 758 ripples = ripples.filter( function ( r ) { return r.r < r.maxR && r.alpha > 0.01; } ); 759 for ( var j = 0; j < ripples.length; j++ ) { 760 var r = ripples[ j ]; 761 ctx.beginPath(); 762 ctx.arc( r.x, r.y, r.r, 0, Math.PI * 2 ); 763 ctx.strokeStyle = 'rgba(' + th.rippleStroke + ',' + r.alpha.toFixed( 3 ) + ')'; 764 ctx.lineWidth = 1.4; 765 ctx.stroke(); 766 } 767 } 768 769 function nodeAlpha( n ) { 770 var shimmer = ( Math.sin( n.phase ) + 1 ) / 2 * 0.15; 771 return Math.min( 1, n.baseAlpha + shimmer + n.boost * 0.6 ); 772 } 773 774 function drawConnections( th ) { 775 var maxDist = Math.min( 150, Math.max( 90, W / 9 ) ); 776 ctx.lineWidth = 1; 777 for ( var i = 0; i < nodes.length; i++ ) { 778 var a = nodes[ i ]; 779 for ( var j = i + 1; j < nodes.length; j++ ) { 780 var b = nodes[ j ]; 781 var dx = a.x - b.x, dy = a.y - b.y; 782 var d2 = dx * dx + dy * dy; 783 if ( d2 < maxDist * maxDist ) { 784 var d = Math.sqrt( d2 ); 785 var alpha = ( 1 - d / maxDist ) * 0.16;
786 alpha += Math.max( a.boost, b.boost ) * 0.35; 787 if ( alpha <= 0.01 ) { continue; } 788 ctx.strokeStyle = 'rgba(' + th.connection + ',' + alpha.toFixed( 3 ) + ')'; 789 ctx.beginPath(); 790 ctx.moveTo( a.x, a.y ); 791 ctx.lineTo( b.x, b.y ); 792 ctx.stroke(); 793 if ( alpha > 0.22 && Math.random() < 0.0025 ) { 794 sparks.push( { ax: a.x, ay: a.y, bx: b.x, by: b.y, p: 0, speed: rand( 0.6, 1.1 ) } ); 795 } 796 } 797 } 798 } 799 } 800 801 function drawSparks( dt, th ) { 802 sparks.forEach( function ( s ) { s.p += dt * s.speed; } ); 803 sparks = sparks.filter( function ( s ) { return s.p < 1; } ); 804 for ( var i = 0; i < sparks.length; i++ ) { 805 var s = sparks[ i ]; 806 var x = s.ax + ( s.bx - s.ax ) * s.p, y = s.ay + ( s.by - s.ay ) * s.p; 807 var fade = Math.sin( s.p * Math.PI ); 808 ctx.beginPath(); 809 ctx.arc( x, y, 1.8, 0, Math.PI * 2 ); 810 ctx.fillStyle = 'rgba(' + th.spark + ',' + ( 0.8 * fade ).toFixed( 3 ) + ')'; 811 ctx.fill(); 812 } 813 } 814 815 function drawNodes( th ) { 816 for ( var i = 0; i < nodes.length; i++ ) { 817 var n = nodes[ i ]; 818 var a = nodeAlpha( n ); 819 var r = n.r + n.boost * 1.8; 820 if ( n.boost > 0.05 ) { 821 ctx.beginPath(); 822 ctx.arc( n.x, n.y, r * 4, 0, Math.PI * 2 ); 823 ctx.fillStyle = 'rgba(' + th.orbInner + ',' + ( n.boost * 0.10 ).toFixed( 3 ) + ')'; 824 ctx.fill(); 825 } 826 ctx.beginPath(); 827 ctx.arc( n.x, n.y, r, 0, Math.PI * 2 ); 828 ctx.fillStyle = n.color; 829 ctx.globalAlpha = a; 830 ctx.fill(); 831 ctx.globalAlpha = 1; 832 } 833 } 834 835 function step( now ) { 836 requestAnimationFrame( step ); 837 // Skip the (fairly expensive) per-frame work entirely while the 838 // widget is closed or the browser tab isn't visible â no point 839 // animating something nobody can see. 840 if ( !root.classList.contains( 'alap-open' ) || document.hidden ) { 841 last = now; 842 return; 843 } 844 if ( W === 0 || H === 0 ) { resize(); if ( W === 0 || H === 0 ) { return; } } 845 var dt = Math.min( 50, now - last ) / 1000; 846 last = now; 847 pulseT += dt; 848 var th = theme(); 849 850 ctx.clearRect( 0, 0, W, H ); 851 var g = ctx.createRadialGradient( W * 0.5, H * 0.62, 0, W * 0.5, H * 0.62, Math.max( W, H ) * 0.75 ); 852 g.addColorStop( 0, th.bgStops[ 0 ] ); 853 g.addColorStop( 0.55, th.bgStops[ 1 ] ); 854 g.addColorStop( 1, th.bgStops[ 2 ] ); 855 ctx.fillStyle = g; 856 ctx.fillRect( 0, 0, W, H ); 857 858 drawCoreOrb( th ); 859 drawCandlesticks( dt, th ); 860 updateRipples( dt, th ); 861 updateNodes( dt ); 862 drawConnections( th ); 863 drawNodes( th ); 864 drawSparks( dt, th ); 865 } 866 867 if ( window.ResizeObserver ) { 868 new ResizeObserver( resize ).observe( chatPanel ); 869 } else { 870 window.addEventListener( 'resize', resize ); 871 } 872 resize(); 873 // Rebuild node/candle colours immediately when the theme toggle is 874 // clicked, instead of waiting for the next natural resize. 875 themeToggle.addEventListener( 'click', function () { setTimeout( buildScene, 0 ); } ); 876 requestAnimationFrame( step ); 877 } )(); 878 879 // History is per-student â never show it (or any leftover data in it) 880 // until this browser has actually verified as a student. 881 historyBtn.style.display = state.sessionToken ? '' : 'none'; 882 883 function syncBodyScrollLock() { 884 var shouldLock = root.classList.contains( 'alap-open' ) && root.classList.contains( 'alap-maximized' ); 885 document.documentElement.style.overflow = shouldLock ? 'hidden' : ''; 886 document.body.style.overflow = shouldLock ? 'hidden' : ''; 887 } 888 889 bubbleBtn.addEventListener( 'click', function () { 890 root.classList.toggle( 'alap-open' ); 891 if ( root.classList.contains( 'alap-open' ) && body.childElementCount === 0 ) { 892 bootConversation(); 893 } 894 syncBodyScrollLock(); 895 } ); 896 closeBtn.addEventListener( 'click', function () { 897 root.classList.remove( 'alap-open' ); 898 syncBodyScrollLock(); 899 } ); 900 themeToggle.addEventListener( 'click', function () { 901 root.classList.toggle( 'alap-dark' ); 902 } ); 903 expandBtn.addEventListener( 'click', function () { 904 var isMax = root.classList.toggle( 'alap-maximized' ); 905 expandBtn.innerHTML = isMax ? ICONS.collapse : ICONS.expand; 906 expandBtn.title = isMax ? 'Exit full screen' : 'Expand to full screen'; 907 syncBodyScrollLock(); 908 } ); 909 910 function openSidebar() { 911 // Defense in depth: never show the sidebar (or whatever old HTML 912 // might still be sitting in it) for a browser that isn't currently 913 // logged in as a verified student. 914 if ( ! state.sessionToken ) { 915 return; 916 } 917 root.classList.add( 'alap-sidebar-open' ); 918 loadConversationList(); 919 } 920 function closeSidebar() { 921 root.classList.remove( 'alap-sidebar-open' ); 922 } 923 historyBtn.addEventListener( 'click', openSidebar ); 924 sidebarCloseBtn.addEventListener( 'click', closeSidebar ); 925 sidebarBackdrop.addEventListener( 'click', closeSidebar ); 926 newChatBtn.addEventListener( 'click', function () { 927 closeSidebar(); 928 startNewConversation(); 929 } ); 930 logoutBtn.addEventListener( 'click', function () { 931 performLogout( 'You have been logged out.' ); 932 } ); 933 934 // ---- 30-minute inactivity auto-logout ---- 935 var INACTIVITY_MS = 30 * 60 * 1000; 936 var inactivityTimer = null; 937 function resetInactivityTimer() { 938 if ( inactivityTimer ) { 939 clearTimeout( inactivityTimer ); 940 inactivityTimer = null; 941 } 942 if ( ! state.sessionToken ) { 943 return; 944 } 945 inactivityTimer = setTimeout( function () { 946 if ( state.sessionToken ) { 947 performLogout( 'You were logged out after 30 minutes of inactivity. Please verify again to continue.' ); 948 } 949 }, INACTIVITY_MS ); 950 } 951 root.addEventListener( 'click', resetInactivityTimer ); 952 root.addEventListener( 'keydown', resetInactivityTimer ); 953 954 function performLogout( message ) { 955 var token = state.sessionToken; 956 if ( inactivityTimer ) { 957 clearTimeout( inactivityTimer ); 958 inactivityTimer = null; 959 } 960 if ( token ) { 961 // Best-effort server-side revocation; local logout proceeds regardless of the result. 962 fetch( ALAP_DATA.restUrl + 'logout', { 963 method: 'POST', 964 headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': ALAP_DATA.nonce }, 965 body: JSON.stringify( { session_token: token } ), 966 } ).catch( function () {} ); 967 } 968 state.sessionToken = null; 969 state.student = null; 970 state.conversationId = null; 971 try { 972 window.localStorage.removeItem( STORAGE_KEY ); 973 } catch ( e ) { /* storage may be unavailable */ } 974 closeSidebar(); 975 // Wipe any previously-loaded conversation list/messages so nothing 976 // from this (or a different) student lingers in the DOM after logout. 977 conversationList.innerHTML = ''; 978 historyBtn.style.display = 'none'; 979 body.innerHTML = ''; 980 if ( message ) { 981 addMessage( 'assistant', message ); 982 } 983 headerSub.textContent = 'Your personal mentor'; 984 bootConversation(); 985 } 986 987 function loadConversationList() { 988 conversationList.innerHTML = '<div class="alap-conversation-loading">Loadingâ¦</div>';
989 fetch( ALAP_DATA.restUrl + 'conversations?session_token=' + encodeURIComponent( state.sessionToken ), { 990 headers: { 'X-WP-Nonce': ALAP_DATA.nonce }, 991 } ) 992 .then( function ( r ) { return r.json(); } ) 993 .then( function ( data ) { 994 renderConversationList( data.conversations || [] ); 995 } ) 996 .catch( function () { 997 conversationList.innerHTML = '<div class="alap-conversation-loading">Could not load history.</div>'; 998 } ); 999 } 1000 1001 function renderConversationList( list ) { 1002 if ( ! list.length ) { 1003 conversationList.innerHTML = '<div class="alap-conversation-empty">No past chats yet.</div>'; 1004 return; 1005 } 1006 conversationList.innerHTML = ''; 1007 list.forEach( function ( c ) { 1008 var item = document.createElement( 'div' ); 1009 item.className = 'alap-conversation-item' + ( state.conversationId && String( state.conversationId ) === String( c.id ) ? ' alap-conversation-item-active' : '' ); 1010 1011 var textWrap = document.createElement( 'div' ); 1012 textWrap.className = 'alap-conversation-text'; 1013 textWrap.innerHTML = 1014 '<div class="alap-conversation-title">' + escapeHtml( c.title || 'Untitled chat' ) + '</div>' + 1015 '<div class="alap-conversation-preview">' + escapeHtml( ( c.last_message || '' ).slice( 0, 60 ) ) + '</div>' + 1016 ( c.course_name ? '<div class="alap-conversation-course">' + escapeHtml( c.course_name ) + '</div>' : '' ); 1017 textWrap.addEventListener( 'click', function () { 1018 closeSidebar(); 1019 loadConversation( c.id ); 1020 } ); 1021 1022 var deleteBtn = document.createElement( 'button' ); 1023 deleteBtn.className = 'alap-conversation-delete'; 1024 deleteBtn.title = 'Delete this conversation'; 1025 deleteBtn.innerHTML = ICONS.trash; 1026 deleteBtn.addEventListener( 'click', function ( e ) { 1027 e.stopPropagation(); 1028 if ( ! window.confirm( 'Delete this conversation? This only removes it from your own history.' ) ) { 1029 return; 1030 } 1031 deleteConversation( c.id, item ); 1032 } ); 1033 1034 item.appendChild( textWrap ); 1035 item.appendChild( deleteBtn ); 1036 conversationList.appendChild( item ); 1037 } ); 1038 } 1039 1040 function deleteConversation( convId, itemEl ) { 1041 fetch( ALAP_DATA.restUrl + 'conversations/' + convId + '?session_token=' + encodeURIComponent( state.sessionToken ), { 1042 method: 'DELETE', 1043 headers: { 'X-WP-Nonce': ALAP_DATA.nonce }, 1044 } ) 1045 .then( function ( r ) { return r.json(); } ) 1046 .then( function ( data ) { 1047 if ( data && data.success ) { 1048 itemEl.remove(); 1049 if ( ! conversationList.children.length ) { 1050 conversationList.innerHTML = '<div class="alap-conversation-empty">No past chats yet.</div>'; 1051 } 1052 if ( state.conversationId && String( state.conversationId ) === String( convId ) ) { 1053 startNewConversation(); 1054 } 1055 } 1056 } ) 1057 .catch( function () {} ); 1058 } 1059 1060 function loadConversation( convId ) { 1061 body.innerHTML = ''; 1062 inputArea.innerHTML = '<div class="alap-conversation-loading" style="padding:14px;">Loading conversationâ¦</div>'; 1063 fetch( ALAP_DATA.restUrl + 'conversations/' + convId + '?session_token=' + encodeURIComponent( state.sessionToken ), { 1064 headers: { 'X-WP-Nonce': ALAP_DATA.nonce }, 1065 } ) 1066 .then( function ( r ) { return r.json(); } ) 1067 .then( function ( data ) { 1068 if ( ! data.messages ) { 1069 addMessage( 'assistant', data.message || 'Could not load that conversation.' ); 1070 renderComposer(); 1071 return; 1072 } 1073 state.conversationId = convId; 1074 body.innerHTML = ''; 1075 data.messages.forEach( function ( m ) { 1076 var opts = { imageUrl: m.attachment_url || null, messageId: 'assistant' === m.role ? m.id : null }; 1077 var text = m.content; 1078 if ( 'assistant' === m.role && text && '{' === text.charAt( 0 ) ) { 1079 try { 1080 var parsed = JSON.parse( text ); 1081 if ( parsed && parsed.questions ) { 1082 opts.contentType = 'quiz'; 1083 opts.quizData = parsed; 1084 text = 'Here\'s your quiz on ' + ( parsed.topic || 'this topic' ) + '! Answer the questions below.'; 1085 } 1086 } catch ( e ) { /* not JSON, render as plain text */ } 1087 } 1088 addMessage( m.role, text, opts ); 1089 } ); 1090 renderComposer(); 1091 } ) 1092 .catch( function () { 1093 addMessage( 'assistant', 'Could not load that conversation.' ); 1094 renderComposer(); 1095 } ); 1096 } 1097 1098 function startNewConversation() { 1099 state.conversationId = null; 1100 body.innerHTML = ''; 1101 addMessage( 'assistant', 'Starting a new chat. What would you like to ask?' ); 1102 renderComposer(); 1103 } 1104 1105 function mentorInitial() { 1106 var name = ALAP_DATA.brandName || 'Mentor'; 1107 return name.trim().charAt( 0 ).toUpperCase() || 'M'; 1108 } 1109 1110 var PDF_ELIGIBLE_TYPES = [ 'notes', 'assignment', 'practice_questions', 'interview_questions' ]; 1111 1112 function addMessage( role, text, opts ) { 1113 opts = opts || {}; 1114 var row = document.createElement( 'div' ); 1115 row.className = 'alap-msg-row alap-msg-row-' + role; 1116 1117 if ( 'assistant' === role ) { 1118 var avatar = document.createElement( 'div' ); 1119 avatar.className = 'alap-msg-avatar'; 1120 avatar.innerHTML = ALAP_DATA.brandLogo 1121 ? '<img src="' + ALAP_DATA.brandLogo + '" alt="" />' 1122 : '<span class="alap-msg-avatar-fallback">' + mentorInitial() + '</span>'; 1123 row.appendChild( avatar ); 1124 } 1125 1126 var wrap = document.createElement( 'div' ); 1127 wrap.className = 'alap-msg alap-msg-' + role; 1128 var html = ''; 1129 if ( opts.imageUrl ) { 1130 html += '<img class="alap-msg-image" src="' + opts.imageUrl + '" alt="" />'; 1131 } 1132 html += renderMarkdown( text ); 1133 wrap.innerHTML = html; 1134 row.appendChild( wrap ); 1135 body.appendChild( row ); 1136 renderPendingCharts( wrap ); 1137 renderPendingTvCharts( wrap ); 1138 1139 if ( 'quiz' === opts.contentType && opts.quizData ) { 1140 wrap.appendChild( buildQuizUI( opts.quizData ) ); 1141 } 1142 1143 if ( 'assistant' === role && opts.messageId ) { 1144 var actions = document.createElement( 'div' ); 1145 actions.className = 'alap-msg-actions'; 1146 actions.innerHTML = 1147 '<button data-action="copy" title="Copy">' + ICONS.copy + '</button>' + 1148 '<button data-action="up" title="Good response">' + ICONS.thumbsUp + '</button>' + 1149 '<button data-action="down" title="Bad response">' + ICONS.thumbsDown + '</button>' + 1150 '<button data-action="regenerate" title="Regenerate"' + ( opts.lastUserMessage ? '' : ' disabled' ) + '>
1150' + ICONS.regenerate + '</button>' + 1151 ( PDF_ELIGIBLE_TYPES.indexOf( opts.contentType ) !== -1 ? '<button data-action="download-pdf" title="Download as PDF">' + ICONS.pdf + ' PDF</button>' : '' ); 1152 actions.addEventListener( 'click', function ( e ) { 1153 var btn = e.target.closest ? e.target.closest( 'button' ) : null; 1154 var action = btn ? btn.getAttribute( 'data-action' ) : null; 1155 if ( ! action || btn.disabled ) { 1156 return; 1157 } 1158 if ( 'copy' === action ) { 1159 copyTextToClipboard( text ).then( function () { 1160 flashButtonFeedback( btn, ICONS.check || ICONS.copy, 'Copied!' ); 1161 } ).catch( function () { 1162 flashButtonFeedback( btn, ICONS.copy, 'Could not copy â select text manually' ); 1163 } ); 1164 } else if ( 'up' === action || 'down' === action ) { 1165 var upBtn = actions.querySelector( '[data-action="up"]' ); 1166 var downBtn = actions.querySelector( '[data-action="down"]' ); 1167 upBtn.disabled = true; 1168 downBtn.disabled = true; 1169 btn.classList.add( 'alap-action-active' ); 1170 api( 'feedback', { message_id: opts.messageId, feedback: action } ).catch( function () { 1171 // Request failed â unlock so the student can retry instead of being stuck. 1172 upBtn.disabled = false; 1173 downBtn.disabled = false; 1174 btn.classList.remove( 'alap-action-active' ); 1175 flashButtonFeedback( btn, null, 'Could not send feedback â try again' ); 1176 } ); 1177 } else if ( 'regenerate' === action && opts.lastUserMessage ) { 1178 sendMessage( opts.lastUserMessage, true ); 1179 } else if ( 'download-pdf' === action ) { 1180 downloadNotesAsPdf( opts.topic || 'Notes', text ); 1181 } 1182 } ); 1183 wrap.appendChild( actions ); 1184 } 1185 1186 body.scrollTop = body.scrollHeight; 1187 return wrap; 1188 } 1189 1190 /** Builds an interactive multiple-choice quiz: radio options per question, a Submit button, then inline scoring + explanations. */ 1191 function buildQuizUI( quizData ) { 1192 var container = document.createElement( 'div' ); 1193 container.className = 'alap-quiz'; 1194 1195 var questions = quizData.questions || []; 1196 questions.forEach( function ( q, qIndex ) { 1197 var qEl = document.createElement( 'div' ); 1198 qEl.className = 'alap-quiz-question'; 1199 qEl.innerHTML = '<div class="alap-quiz-question-text">' + ( qIndex + 1 ) + '. ' + escapeHtml( q.question ) + '</div>'; 1200 1201 var optionsEl = document.createElement( 'div' ); 1202 optionsEl.className = 'alap-quiz-options'; 1203 ( q.options || [] ).forEach( function ( optText, optIndex ) { 1204 var label = document.createElement( 'label' ); 1205 label.className = 'alap-quiz-option'; 1206 label.innerHTML = 1207 '<input type="radio" name="alap-quiz-q' + qIndex + '" value="' + optIndex + '" /> ' + 1208 '<span>' + escapeHtml( optText ) + '</span>'; 1209 optionsEl.appendChild( label ); 1210 } ); 1211 qEl.appendChild( optionsEl ); 1212 1213 var explanationEl = document.createElement( 'div' ); 1214 explanationEl.className = 'alap-quiz-explanation'; 1215 explanationEl.style.display = 'none'; 1216 qEl.appendChild( explanationEl ); 1217 1218 container.appendChild( qEl ); 1219 } ); 1220 1221 var submitBtn = document.createElement( 'button' ); 1222 submitBtn.className = 'alap-quiz-submit'; 1223 submitBtn.textContent = 'Submit Quiz'; 1224 1225 var scoreEl = document.createElement( 'div' ); 1226 scoreEl.className = 'alap-quiz-score'; 1227 scoreEl.style.display = 'none'; 1228 1229 submitBtn.addEventListener( 'click', function () { 1230 var correctCount = 0; 1231 var questionEls = container.querySelectorAll( '.alap-quiz-question' ); 1232 questions.forEach( function ( q, qIndex ) { 1233 var qEl = questionEls[ qIndex ]; 1234 var selected = qEl.querySelector( 'input[name="alap-quiz-q' + qIndex + '"]:checked' ); 1235 var selectedIndex = selected ? parseInt( selected.value, 10 ) : -1; 1236 var isCorrect = selectedIndex === q.correct_index; 1237 if ( isCorrect ) { 1238 correctCount++; 1239 } 1240 1241 var optionLabels = qEl.querySelectorAll( '.alap-quiz-option' );
1242 optionLabels.forEach( function ( labelEl, optIndex ) { 1243 var input = labelEl.querySelector( 'input' ); 1244 input.disabled = true; 1245 if ( optIndex === q.correct_index ) { 1246 labelEl.classList.add( 'alap-quiz-option-correct' ); 1247 } else if ( optIndex === selectedIndex ) { 1248 labelEl.classList.add( 'alap-quiz-option-incorrect' ); 1249 } 1250 } ); 1251 1252 var explanationEl = qEl.querySelector( '.alap-quiz-explanation' ); 1253 if ( q.explanation ) { 1254 explanationEl.textContent = ( isCorrect ? 'â ' : 'â ' ) + q.explanation; 1255 explanationEl.className = 'alap-quiz-explanation ' + ( isCorrect ? 'alap-quiz-explanation-correct' : 'alap-quiz-explanation-incorrect' ); 1256 explanationEl.style.display = 'block'; 1257 } 1258 } ); 1259 1260 scoreEl.textContent = 'Score: ' + correctCount + ' / ' + questions.length; 1261 scoreEl.style.display = 'block'; 1262 submitBtn.disabled = true; 1263 body.scrollTop = body.scrollHeight; 1264 } ); 1265 1266 container.appendChild( submitBtn ); 1267 container.appendChild( scoreEl ); 1268 return container; 1269 } 1270 1271 function showTyping() { 1272 var row = document.createElement( 'div' ); 1273 row.className = 'alap-msg-row alap-msg-row-assistant'; 1274 row.id = 'alap-typing-indicator'; 1275 1276 var avatar = document.createElement( 'div' ); 1277 avatar.className = 'alap-msg-avatar'; 1278 avatar.innerHTML = ALAP_DATA.brandLogo 1279 ? '<img src="' + ALAP_DATA.brandLogo + '" alt="" />' 1280 : '<span class="alap-msg-avatar-fallback">' + mentorInitial() + '</span>'; 1281 row.appendChild( avatar ); 1282 1283 var t = document.createElement( 'div' ); 1284 t.className = 'alap-typing'; 1285 t.innerHTML = '<span></span><span></span><span></span>'; 1286 row.appendChild( t ); 1287 1288 body.appendChild( row ); 1289 body.scrollTop = body.scrollHeight; 1290 } 1291 1292 function hideTyping() { 1293 var t = document.getElementById( 'alap-typing-indicator' ); 1294 if ( t ) { 1295 t.remove(); 1296 } 1297 } 1298 1299 // ---- Boot flow: greeting -> course pick -> student verification -> chat ---- 1300 function bootConversation() { 1301 addMessage( 'assistant', ALAP_DATA.greeting ); 1302 1303 if ( state.sessionToken && state.student ) { 1304 resumeSession(); 1305 return; 1306 } 1307 1308 // No course picker here on purpose: which course a student can chat 1309 // about is always their own enrolled course from their student 1310 // record, enforced server-side â letting them pick a different one 1311 // here would be purely cosmetic (the server ignores it) and 1312 // confusing, so we go straight to verification. 1313 renderVerifyStep1(); 1314 } 1315 1316 /** Called when reopening the widget with an already-verified session: resumes the most recent past conversation instead of always starting fresh. */ 1317 function resumeSession() { 1318 historyBtn.style.display = ''; 1319 showStudentCard(); 1320 resetInactivityTimer(); 1321 fetch( ALAP_DATA.restUrl + 'conversations?session_token=' + encodeURIComponent( state.sessionToken ), { 1322 headers: { 'X-WP-Nonce': ALAP_DATA.nonce }, 1323 } ) 1324 .then( function ( r ) { return r.json(); } ) 1325 .then( function ( data ) { 1326 var list = data.conversations || []; 1327 if ( list.length ) { 1328 loadConversation( list[0].id ); 1329 } else { 1330 addMessage( 'assistant', 'You can now ask me anything about your course material â happy to help however I can.' ); 1331 renderComposer(); 1332 } 1333 } ) 1334 .catch( function () { 1335 addMessage( 'assistant', 'You can now ask me anything about your course material â happy to help however I can.' ); 1336 renderComposer(); 1337 } ); 1338 } 1339 1340 function renderVerifyStep1() { 1341 chatPanel.classList.add( 'alap-onboarding' ); 1342 addMessage( 'assistant', 'Please verify your account â enter your full name, date of birth, and registered email.' ); 1343 inputArea.innerHTML = '<form class="alap-form" id="alap-verify-form">' + 1344 '<input type="text" id="alap-full-name" placeholder="Full Name" required />' + 1345 '<input type="date" id="alap-dob" placeholder="Date of Birth" required />' + 1346 '<input type="email" id="alap-email" placeholder="Registered Email" required />' + 1347 '<button type="submit">Verify</button>' + 1348 '<div class="alap-error-text" id="alap-verify-error"></div>' + 1349 '</form>'; 1350 1351 document.getElementById( 'alap-verify-form' ).addEventListener( 'submit', function ( e ) { 1352 e.preventDefault(); 1353 var fullName = document.getElementById( 'alap-full-name' ).value.trim(); 1354 var dob = document.getElementById( 'alap-dob' ).value; 1355 var email = document.getElementById( 'alap-email' ).value.trim(); 1356 if ( ! fullName || ! dob || ! email ) { 1357 return; 1358 } 1359 submitVerify( fullName, dob, email, e.target ); 1360 } ); 1361 } 1362 1363 function submitVerify( fullName, dob, email, formEl ) { 1364 var errorEl = formEl.querySelector( '.alap-error-text' ); 1365 var btn = formEl.querySelector( 'button[type="submit"]' ); 1366 btn.disabled = true; 1367 btn.textContent = 'Verifyingâ¦'; 1368 1369 state.pendingVerify = { fullName: fullName, dob: dob, email: email }; 1370 1371 api( 'verify-student', { full_name: fullName, date_of_birth: dob, email: email } ) 1372 .then( function ( data ) { 1373 if ( ! data.success ) { 1374 if ( data.pending_approval ) { 1375 addMessage( 'assistant', data.message || 'Your device is awaiting admin a
1375pproval.' ); 1376 renderPendingApproval(); 1377 return; 1378 } 1379 if ( data.otp_required ) { 1380 addMessage( 'assistant', data.message || 'Enter the verification code sent to your email.' ); 1381 renderOtpStep( fullName, dob, email ); 1382 return; 1383 } 1384 if ( errorEl ) { 1385 errorEl.textContent = data.message || "Sorry, we couldn't verify your account. Please contact administration."; 1386 } 1387 btn.disabled = false; 1388 btn.textContent = 'Verify'; 1389 return; 1390 } 1391 state.sessionToken = data.session_token; 1392 state.student = data.student; 1393 state.courseId = data.student.course_id; 1394 persist(); 1395 resetInactivityTimer(); 1396 showWelcomeBack(); 1397 } ) 1398 .catch( function ( err ) { 1399 if ( errorEl ) { 1400 errorEl.textContent = err.message; 1401 } 1402 btn.disabled = false; 1403 btn.textContent = 'Verify'; 1404 } ); 1405 } 1406 1407 function renderOtpStep( fullName, dob, email ) { 1408 chatPanel.classList.add( 'alap-onboarding' ); 1409 inputArea.innerHTML = '<form class="alap-form" id="alap-otp-form">' + 1410 '<input type="text" id="alap-otp-input" inputmode="numeric" maxlength="6" placeholder="6-digit code" required />' + 1411 '<button type="submit">Verify Code</button>' + 1412 '<button type="button" id="alap-otp-resend" class="alap-link-btn">Resend code</button>' + 1413 '<div class="alap-error-text" id="alap-otp-error"></div>' + 1414 '</form>'; 1415 1416 document.getElementById( 'alap-otp-form' ).addEventListener( 'submit', function ( e ) { 1417 e.preventDefault(); 1418 var otp = document.getElementById( 'alap-otp-input' ).value.trim(); 1419 var errorEl = document.getElementById( 'alap-otp-error' ); 1420 var btn = e.target.querySelector( 'button[type="submit"]' ); 1421 if ( ! otp ) { 1422 return; 1423 } 1424 btn.disabled = true; 1425 btn.textContent = 'Verifyingâ¦'; 1426 1427 api( 'verify-otp', { full_name: fullName, date_of_birth: dob, email: email, otp: otp } ) 1428 .then( function ( data ) { 1429 if ( ! data.success ) { 1430 if ( data.pending_approval ) { 1431 addMessage( 'assistant', data.message || 'Your device is awaiting admin approval.' ); 1432 renderPendingApproval(); 1433 return; 1434 } 1435 errorEl.textContent = data.message || 'Incorrect code. Please try again.'; 1436 btn.disabled = false; 1437 btn.textContent = 'Verify Code'; 1438 return; 1439 } 1440 state.sessionToken = data.session_token; 1441 state.student = data.student; 1442 state.courseId = data.student.course_id; 1443 persist(); 1444 resetInactivityTimer(); 1445 showWelcomeBack(); 1446 } ) 1447 .catch( function ( err ) { 1448 errorEl.textContent = err.message; 1449 btn.disabled = false; 1450 btn.textContent = 'Verify Code'; 1451 } ); 1452 } ); 1453 1454 document.getElementById( 'alap-otp-resend' ).addEventListener( 'click', function ( e ) { 1455 var btn = e.target; 1456 btn.disabled = true; 1457 btn.textContent = 'Sendingâ¦'; 1458 api( 'verify-student', { full_name: fullName, date_of_birth: dob, email: email } ) 1459 .then( function ( data ) { 1460 addMessage( 'assistant', data.message || 'A new code has been sent.' ); 1461 btn.disabled = false; 1462 btn.textContent = 'Resend code'; 1463 } ) 1464 .catch( function ( err ) { 1465 addMessage( 'assistant', 'Sorry â ' + err.message ); 1466 btn.disabled = false; 1467 btn.textContent = 'Resend code'; 1468 } ); 1469 } ); 1470 } 1471 1472 function renderPendingApproval() { 1473 chatPanel.classList.add( 'alap-onboarding' ); 1474 inputArea.innerHTML = '<div class="alap-form">' + 1475 '<button id="alap-recheck-approval">Check Again</button>' + 1476 '</div>'; 1477 document.getElementById( 'alap-recheck-approval' ).addEventListener( 'click', function ( e ) { 1478 var btn = e.target; 1479 var pv = state.pendingVerify; 1480 if ( ! pv ) { 1481 renderVerifyStep1(); 1482 return; 1483 } 1484 btn.disabled = true; 1485 btn.textContent = 'Checkingâ¦'; 1486 api( 'verify-student', { full_name: pv.fullName, date_of_birth: pv.dob, email: pv.email } ) 1487 .then( function ( data ) { 1488 if ( ! data.success ) { 1489 if ( data.otp_required ) { 1490 addMessage( 'assistant', data.message || 'Enter the verification code sent to your email.' ); 1491 renderOtpStep( pv.fullName, pv.dob, pv.email ); 1492 return; 1493 } 1494 addMessage( 'assistant', data.message || 'Still waiting for approval.' ); 1495 btn.disabled = false;
1496 btn.textContent = 'Check Again'; 1497 return; 1498 } 1499 state.sessionToken = data.session_token; 1500 state.student = data.student; 1501 state.courseId = data.student.course_id; 1502 persist(); 1503 resetInactivityTimer(); 1504 showWelcomeBack(); 1505 } ) 1506 .catch( function ( err ) { 1507 addMessage( 'assistant', 'Sorry â ' + err.message ); 1508 btn.disabled = false; 1509 btn.textContent = 'Check Again'; 1510 } ); 1511 } ); 1512 } 1513 1514 function showStudentCard() { 1515 var s = state.student; 1516 if ( ! s ) { 1517 return; 1518 } 1519 var row = document.createElement( 'div' ); 1520 row.className = 'alap-msg-row alap-msg-row-assistant'; 1521 1522 var avatar = document.createElement( 'div' ); 1523 avatar.className = 'alap-msg-avatar'; 1524 avatar.innerHTML = ALAP_DATA.brandLogo 1525 ? '<img src="' + ALAP_DATA.brandLogo + '" alt="" />' 1526 : '<span class="alap-msg-avatar-fallback">' + mentorInitial() + '</span>'; 1527 row.appendChild( avatar ); 1528 1529 var card = document.createElement( 'div' ); 1530 card.className = 'alap-msg alap-msg-assistant'; 1531 card.innerHTML = 1532 '<div class="alap-student-card">' + 1533 ( s.photo ? '<img src="' + s.photo + '" alt="" />' : '' ) + 1534 '<div><div class="alap-student-name">Welcome, ' + escapeHtml( s.name || 'Student' ) + '!</div>' + 1535 '<div class="alap-student-meta">' + escapeHtml( s.course || '' ) + ( s.batch ? ' · ' + escapeHtml( s.batch ) : '' ) + '</div>' + 1536 ( s.institute ? '<div class="alap-student-meta">' + escapeHtml( s.institute ) + '</div>' : '' ) + 1537 '</div></div>'; 1538 row.appendChild( card ); 1539 1540 body.appendChild( row ); 1541 body.scrollTop = body.scrollHeight; 1542 headerSub.textContent = s.course ? 'Course Assistant · ' + s.course : 'Your personal mentor'; 1543 } 1544 1545 function showWelcomeBack() { 1546 historyBtn.style.display = ''; 1547 showStudentCard(); 1548 addMessage( 'assistant', 'You can now ask me anything about your course material â happy to help however I can.' ); 1549 renderComposer(); 1550 } 1551 1552 // Holds a file the student picked/pasted but hasn't sent yet, so they can 1553 // type what they want done with it first instead of it posting instantly. 1554 var pendingAttachment = null; // { file, previewUrl|null, isImage } 1555 1556 var DOCUMENT_ACCEPT = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.csv,.txt,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/csv,text/plain'; 1557 1558 function renderComposer() { 1559 chatPanel.classList.remove( 'alap-onboarding' ); 1560 inputArea.innerHTML = 1561 '<div class="alap-pending-attachment" id="alap-pending-attachment" style="display:none;"></div>' + 1562 '<div class="alap-composer">' + 1563 ( ALAP_DATA.enableImageUpload 1564 ? '<button class="alap-attach-btn" id="alap-attach-btn" title="Attach a photo or file (PDF, Word, Excel, PowerPoint)" type="button">' + ICONS.attach + '</button>' + 1565 '<input type="file" id="alap-image-input" accept="image/*,' + DOCUMENT_ACCEPT + '" style="display:none;" />' 1566 : '' ) + 1567 '<textarea id="alap-composer-input" rows="1" placeholder="Ask a course questionâ¦"></textarea>' + 1568 '<button class="alap-send-btn" id="alap-send-btn" title="Send">' + ICONS.send + '</button>' + 1569 '</div>'; 1570 1571 var textarea = document.getElementById( 'alap-composer-input' ); 1572 var sendBtn = document.getElementById( 'alap-send-btn' ); 1573 var attachBtn = document.getElementById( 'alap-attach-btn' ); 1574 var imageInput = document.getElementById( 'alap-image-input' ); 1575 var pendingBox = document.getElementById( 'alap-pending-attachment' ); 1576 1577 function renderPendingAttachment() { 1578 if ( ! pendingAttachment ) { 1579 pendingBox.style.display = 'none'; 1580 pendingBox.innerHTML = ''; 1581 return; 1582 } 1583 pendingBox.style.display = ''; 1584 pendingBox.innerHTML = 1585 '<div class="alap-pending-chip">' + 1586 ( pendingAttachment.isImage 1587 ? '<img class="alap-pending-thumb" src="' + pendingAttachment.previewUrl + '" alt="" />' 1588 : '<span class="alap-pending-file-icon">' + ICONS.attach + '</span>' ) + 1589 '<span class="alap-pending-name">' + escapeHtml( pendingAttachment.file.name ) + '</span>' + 1590 '<button type="button" class="alap-pending-remove" id="alap-pending-remove" title="Remove">' + ICONS.close + '</button>' + 1591 '</div>' + 1592 '<div class="alap-pending-hint">Type what you\'d like help with, then hit send.</div>'; 1593 var removeBtn = document.getElementById( 'alap-pending-remove' ); 1594 if ( removeBtn ) { 1595 removeBtn.addEventListener( 'click', function () { 1596 if ( pendingAttachment && pendingAttachment.previewUrl ) { 1597 URL.revokeObjectURL( pendingAttachment.previewUrl ); 1598 } 1599 pendingAttachment = null; 1600 renderPendingAttachment(); 1601 textarea.focus(); 1602 } ); 1603 } 1604 } 1605 renderPendingAttachment(); 1606 1607 function submit() { 1608 var text = textarea.value.trim(); 1609 if ( state.sending ) { 1610 return; 1611 } 1612 if ( pendingAttachment ) { 1613 var file = pendingAttachment.file; 1614 var previewUrl = pendingAttachment.previewUrl; 1615 pendingAttachment = null; 1616 renderPendingAttachment(); 1617 textarea.value = ''; 1618 sendAttachmentMessage( file, text, previewUrl ); 1619 return; 1620 } 1621 if ( ! text ) { 1622 return; 1623 } 1624 textarea.value = ''; 1625 sendMessage( text, false ); 1626 } 1627 1628 sendBtn.addEventListener( 'click', submit ); 1629 textarea.addEventListener( 'keydown', function ( e ) { 1630 if ( 'Enter' === e.key && ! e.shiftKey ) { 1631 e.preventDefault(); 1632 submit(); 1633 } 1634 } ); 1635
1636 function acceptFile( file ) { 1637 if ( ! file ) { 1638 return; 1639 } 1640 if ( pendingAttachment && pendingAttachment.previewUrl ) { 1641 URL.revokeObjectURL( pendingAttachment.previewUrl ); 1642 } 1643 var isImage = file.type && 0 === file.type.indexOf( 'image/' ); 1644 pendingAttachment = { 1645 file: file, 1646 previewUrl: isImage ? URL.createObjectURL( file ) : null, 1647 isImage: isImage, 1648 }; 1649 renderPendingAttachment(); 1650 textarea.focus(); 1651 } 1652 1653 if ( attachBtn && imageInput ) { 1654 attachBtn.addEventListener( 'click', function () { 1655 if ( ! state.sending ) { 1656 imageInput.click(); 1657 } 1658 } ); 1659 imageInput.addEventListener( 'change', function () { 1660 var file = imageInput.files && imageInput.files[0]; 1661 acceptFile( file ); 1662 imageInput.value = ''; 1663 } ); 1664 } 1665 1666 // Let the student paste a screenshot (Ctrl+V) straight into the 1667 // composer instead of only being able to pick a file from disk. 1668 if ( ALAP_DATA.enableImageUpload ) { 1669 textarea.addEventListener( 'paste', function ( e ) { 1670 var items = e.clipboardData && e.clipboardData.items; 1671 if ( ! items ) { 1672 return; 1673 } 1674 for ( var i = 0; i < items.length; i++ ) { 1675 if ( 0 === items[ i ].type.indexOf( 'image/' ) ) { 1676 var pastedFile = items[ i ].getAsFile(); 1677 if ( pastedFile ) { 1678 e.preventDefault(); 1679 acceptFile( pastedFile ); 1680 } 1681 break; 1682 } 1683 } 1684 } ); 1685 } 1686 } 1687 1688 function setSending( isSending ) { 1689 state.sending = isSending; 1690 var sendBtn = document.getElementById( 'alap-send-btn' ); 1691 if ( sendBtn ) { 1692 sendBtn.disabled = isSending; 1693 sendBtn.innerHTML = isSending ? ICONS.stop : ICONS.send; 1694 sendBtn.title = isSending ? 'Stop' : 'Send'; 1695 } 1696 } 1697 1698 function sendMessage( text, isRegenerate ) { 1699 if ( ! isRegenerate ) { 1700 addMessage( 'user', text ); 1701 } 1702 setSending( true ); 1703 showTyping(); 1704 1705 state.abortController = new AbortController(); 1706 1707 fetch( ALAP_DATA.restUrl + 'chat', { 1708 method: 'POST', 1709 signal: state.abortController.signal, 1710 headers: { 1711 'Content-Type': 'application/json', 1712 'X-WP-Nonce': ALAP_DATA.nonce, 1713 }, 1714 body: JSON.stringify( { 1715 message: text, 1716 session_token: state.sessionToken, 1717 course_id: state.courseId, 1718 conversation_id: state.conversationId, 1719 } ), 1720 } ) 1721 .then( function ( res ) { 1722 return res.json().then( function ( data ) { 1723 if ( ! res.ok ) { 1724 var err = new Error( data && data.message ? data.message : 'Something went wrong.' ); 1725 err.status = res.status; 1726 throw err; 1727 } 1728 return data; 1729 } ); 1730 } ) 1731 .then( function ( data ) { 1732 hideTyping(); 1733 state.conversationId = data.conversation_id; 1734 if ( data.session_token ) { 1735 state.sessionToken = data.session_token; 1736 persist(); 1737 resetInactivityTimer(); 1738 } 1739 addMessage( 'assistant', data.reply, { messageId: data.message_id, lastUserMessage: text, contentType: data.content_type, topic: data.topic, quizData: data.quiz } ); 1740 } ) 1741 .catch( function ( err ) { 1742 hideTyping(); 1743 if ( 'AbortError' === err.name ) { 1744 addMessage( 'assistant', 'Generation stopped.' ); 1745 return; 1746 } 1747 if ( 401 === err.status ) { 1748 performLogout( err.message || 'Your session has expired. Please verify again to continue.' ); 1749 return; 1750 } 1751 addMessage( 'assistant', 'Sorry â ' + err.message ); 1752 } ) 1753 .finally( function () { 1754 setSending( false ); 1755 } ); 1756 } 1757 1758 // Stop generation: clicking send while sending aborts. 1759 document.addEventListener( 'click', function ( e ) { 1760 if ( e.target && 'alap-send-btn' === e.target.id && state.sending && state.abortController ) { 1761 state.abortController.abort(); 1762 } 1763 } ); 1764 1765 function sendAttachmentMessage( file, caption, previewUrl ) { 1766 var maxBytes = 8 * 1024 * 1024; 1767 if ( file.size > maxBytes ) { 1768 addMessage( 'assistant', 'That file is too large (max 8MB). Please try a smaller one.' ); 1769 if ( previewUrl ) { 1770 URL.revokeObjectURL( previewUrl ); 1771 } 1772 return; 1773 } 1774 1775 var isImage = file.type && 0 === file.type.indexOf( 'image/' ); 1776 if ( isImage ) { 1777 addMessage( 'user', caption || '', { imageUrl: previewUrl || URL.createObjectURL( file ) } ); 1778 } else { 1779 addMessage( 'user', ( caption ? caption + '\n' : '' ) + 'ð ' + file.name, {} ); 1780 } 1781 1782 setSending( true ); 1783 showTyping(); 1784 1785 var formData = new FormData(); 1786 formData.append( 'image', file ); 1787 formData.append( 'message', caption || '' ); 1788 formData.append( 'session_token', state.sessionToken || '' ); 1789 formData.append( 'course_id', state.courseId || '' ); 1790 formData.append( 'conversation_id', state.conversationId || '' ); 1791 1792 fetch( ALAP_DATA.restUrl + 'chat-with-image', { 1793 method: 'POST', 1794 headers: { 'X-WP-Nonce': ALAP_DATA.nonce }, 1795 body: formData, 1796 } ) 1797 .then( function ( res ) { 1798 return res.json().then( function ( data ) { 1799 if ( ! res.ok ) { 1800 var err = new Error( data && data.message ? data.message : 'Something went wrong.' ); 1801 err.status = res.status; 1802 throw err; 1803 } 1804 return data; 1805 } ); 1806 } ) 1807 .then( function ( data ) { 1808 hideTyping(); 1809 state.conversationId = data.conversation_id; 1810 if ( data.session_token ) { 1811 state.sessionToken = data.session_token; 1812 persist(); 1813 resetInactivityTimer(); 1814 } 1815 addMessage( 'assistant', data.reply, { messageId: data.message_id, lastUserMessage: caption } ); 1816 } ) 1817 .catch( function ( err ) { 1818 hideTyping(); 1819 if ( 401 === err.status ) { 1820 performLogout( err.message || 'Your session has expired. Please verify again to continue.' ); 1821 return; 1822 } 1823 addMessage( 'assistant', 'Sorry â ' + err.message ); 1824 } ) 1825 .finally( function () { 1826 setSending( false ); 1827 } ); 1828 } 1829 1830 // ---- PDF export for AI-generated notes (client-side, via jsPDF from CDN) ---- 1831 var jsPdfLoadPromise = null; 1832 function ensureJsPdfLoaded() { 1833 if ( window.jspdf && window.jspdf.jsPDF ) { 1834 return Promise.resolve(); 1835 } 1836 if ( jsPdfLoadPromise ) { 1837 return jsPdfLoadPromise; 1838 } 1839 jsPdfLoadPromise = new Promise( function ( resolve, reject ) { 1840 var script = document.createElement( 'script' ); 1841 script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js'; 1842 script.onload = function () { 1843 resolve(); 1844 }; 1845 script.onerror = function () { 1846 jsPdfLoadPromise = null; 1847 reject( new Error( 'Could not load the PDF library.' ) ); 1848 }; 1849 document.head.appendChild( script ); 1850 } ); 1851 return jsPdfLoadPromise; 1852 } 1853 1854 function downloadNotesAsPdf( topic, content ) { 1855 // Strip any ```chart blocks before laying out the PDF â this exporter 1856 // is a plain-text line printer, not a renderer, so a raw JSON chart 1857 // block would otherwise get printed as ugly literal text. 1858 content = String( content ).replace( /```(?:chart|tvchart)\s*[\s\S]*?```/gi, '' ); 1859 ensureJsPdfLoaded() 1860 .then( function () { 1861 var jsPDF = window.jspdf.jsPDF; 1862 var doc = new jsPDF( { unit: 'pt', format: 'a4' } ); 1863 var pageWidth = doc.internal.pageSize.getWidth(); 1864 var pageHeight = doc.internal.pageSize.getHeight(); 1865 var margin = 48; 1866 var maxWidth = pageWidth - margin * 2; 1867 var y = margin; 1868 1869 function addPageIfNeeded( lineHeight ) { 1870 if ( y + lineHeight > pageHeight - margin ) { 1871 doc.addPage(); 1872 y = margin; 1873 } 1874 } 1875 1876 doc.setFont( 'helvetica', 'bold' ); 1877 doc.setFontSize( 16 ); 1878 var titleLines = doc.splitTextToSize( topic, maxWidth );
1879 titleLines.forEach( function ( line ) { 1880 addPageIfNeeded( 22 ); 1881 doc.text( line, margin, y ); 1882 y += 22; 1883 } ); 1884 y += 8; 1885 1886 var rawLines = String( content ).split( /\r?\n/ ); 1887 rawLines.forEach( function ( rawLine ) { 1888 var line = rawLine.trim(); 1889 if ( '' === line ) { 1890 y += 8; 1891 return; 1892 } 1893 var isHeader = /^#{1,3}\s+/.test( line ); 1894 var isBullet = /^[-*]\s+/.test( line ); 1895 var text = line.replace( /^#{1,3}\s+/, '' ).replace( /^[-*]\s+/, '⢠' ).replace( /\*\*(.+?)\*\*/g, '$1' ); 1896 1897 doc.setFont( 'helvetica', isHeader ? 'bold' : 'normal' ); 1898 doc.setFontSize( isHeader ? 13 : 11 ); 1899 var indent = isBullet ? margin + 14 : margin; 1900 var wrapWidth = isBullet ? maxWidth - 14 : maxWidth; 1901 var lines = doc.splitTextToSize( text, wrapWidth ); 1902 if ( isHeader ) { 1903 y += 6; 1904 } 1905 lines.forEach( function ( l ) { 1906 addPageIfNeeded( 16 ); 1907 doc.text( l, indent, y ); 1908 y += 16; 1909 } ); 1910 if ( isHeader ) { 1911 y += 2; 1912 } 1913 } ); 1914 1915 var filename = String( topic ).toLowerCase().replace( /[^a-z0-9]+/g, '-' ).replace( /(^-|-$)/g, '' ) || 'notes'; 1916 doc.save( filename + '.pdf' ); 1917 } ) 1918 .catch( function ( err ) { 1919 addMessage( 'assistant', 'Sorry, could not generate the PDF â ' + err.message ); 1920 } ); 1921 } 1922} )();
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.