PageSourceSearch

https://nismc.com/wp-content/plugins/ai-learning-assistant-pro/public/js/chat-widget.js?ver=2.16.1.1790160580

js nismc.com collected 2026-10-07 03:10:19 UTC 73,845 bytes, 1,922 lines download raw bytes

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, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' );
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.