CatThanapha/integer
0
1<!DOCTYPE html>
2<html lang="th">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>🕵️♂️ Detective Equation — ภารกิจตามหาสมการที่หายไป</title>
7
8 <!-- ─────────── CDN Libraries ─────────── -->
9 <!-- Tailwind CSS -->
10 <script src="https://cdn.tailwindcss.com"></script>
11
12 <!-- Google Fonts -->
13 <link href="https://fonts.googleapis.com/css2?family=Sarabun:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
14
15 <!-- Font Awesome -->
16 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
17
18 <!-- KaTeX (Module M1) -->
19 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css">
20 <script src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js"></script>
21 <script src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/auto-render.min.js"></script>
22
23 <!-- MathLive (Module M1) -->
24 <script defer src="https://unpkg.com/mathlive"></script>
25
26 <!-- DOMPurify (Module M3) -->
27 <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.9/purify.min.js"></script>
28
29 <style>
30 :root {
31 --primary: #1d4ed8;
32 --primary-mid: #2563eb;
33 --bg: #f8fafc;
34 --card-bg: #ffffff;
35 --text: #0f172a;
36 --border: #e2e8f0;
37 }
38
39 body {
40 font-family: 'Sarabun', sans-serif;
41 background: var(--bg);
42 color: var(--text);
43 margin: 0;
44 height: 100vh;
45 display: flex;
46 flex-direction: column;
47 }
48
49 /* ── Header ── */
50 .hero {
51 background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
52 position: relative;
53 box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
54 }
55
56 /* ── Chat & Scenario Area ── */
57 .main-container {
58 display: flex;
59 flex-direction: column;
60 flex: 1;
61 overflow: hidden;
62 }
63
64 @media (min-width: 1024px) {
65 .main-container {
66 flex-direction: row;
67 }
68 }
69
70 .panel {
71 flex: 1;
72 display: flex;
73 flex-direction: column;
74 overflow-y: auto;
75 padding: 1.5rem;
76 }
77
78 .scenario-panel {
79 background: var(--card-bg);
80 border-right: 1px solid var(--border);
81 }
82
83 .chat-panel {
84 background: #f1f5f9;
85 }
86
87 /* ── Chat Messages ── */
88 .chat-msg {
89 max-width: 85%;
90 padding: 1rem;
91 border-radius: 1rem;
92 margin-bottom: 1rem;
93 line-height: 1.6;
94 animation: fadeIn 0.3s ease-out;
95 }
96
97 .msg-ai {
98 background: white;
99 border: 1px solid var(--border);
100 border-bottom-left-radius: 0.25rem;
101 align-self: flex-start;
102 }
103
104 .msg-user {
105 background: var(--primary);
106 color: white;
107 border-bottom-right-radius: 0.25rem;
108 align-self: flex-end;
109 }
110
111 .messages-wrapper {
112 flex: 1;
113 overflow-y: auto;
114 display: flex;
115 flex-direction: column;
116 padding-right: 0.5rem;
117 }
118
119 /* ── Spinner ── */
120 .loader {
121 border: 3px solid #f3f3f3;
122 border-top: 3px solid var(--primary);
123 border-radius: 50%;
124 width: 24px;
125 height: 24px;
126 animation: spin 1s linear infinite;
127 display: inline-block;
128 }
129
130 @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
131 @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
132
133 /* Options */
134 .option-btn {
135 border: 2px solid var(--border);
136 border-radius: 0.75rem;
137 padding: 1rem;
138 text-align: left;
139 transition: all 0.2s;
140 }
141 .option-btn:hover:not(:disabled) {
142 border-color: var(--primary-mid);
143 background: #eff6ff;
144 }
145 .option-btn.selected-correct {
146 border-color: #22c55e;
147 background: #dcfce7;
148 }
149 .option-btn.selected-wrong {
150 border-color: #ef4444;
151 background: #fee2e2;
152 }
153 </style>
154</head>
155
156<body>
157
158 <!-- ── Header ── -->
159 <header class="hero text-white py-4 px-6 flex justify-between items-center z-10 shrink-0">
160 <div class="flex items-center gap-4">
161 <div class="bg-indigo-900 rounded-full w-12 h-12 flex items-center justify-center text-2xl border-2 border-indigo-400">
162 🕵️♂️
163 </div>
164 <div>
165 <h1 class="text-xl font-bold tracking-tight">นักสืบสมการ (Detective Equation)</h1>
166 <p class="text-indigo-200 text-sm">ภารกิจ: ตามหาสมการที่หายไป</p>
167 </div>
168 </div>
169 <button onclick="openSettings()" class="text-indigo-200 hover:text-white transition text-xl p-2">
170 <i class="fa-solid fa-gear"></i>
171 </button>
172 </header>
173
174 <!-- ── Mission Progress (Module B) ── -->
175 <div class="bg-indigo-50 border-b border-indigo-100 px-6 py-3 flex items-center gap-4 shrink-0">
176 <span class="font-bold text-indigo-900 text-sm min-w-[max-content]"><i class="fa-solid fa-map-location-dot"></i> ความคืบหน้าคดี:</span>
177 <div class="w-full bg-indigo-200 rounded-full h-4 overflow-hidden relative">
178 <div id="missionProgress" class="bg-indigo-600 h-4 rounded-full transition-all duration-500 w-0"></div>
179 </div>
180 <span id="missionText" class="text-sm font-semibold text-indigo-700 min-w-[max-content]">0 / 3 ภารกิจ</span>
181 </div>
182
183 <!-- ── Main Area ── -->
184 <main class="main-container">
185
186 <!-- Left Panel: Scenario (Module F) -->
187 <div class="panel scenario-panel">
188 <div class="flex justify-between items-center mb-6">
189 <h2 class="text-lg font-bold text-gray-800"><i class="fa-solid fa-file-signature text-blue-600"></i> แฟ้มคดีปริศนา (โจทย์ปัจจุบัน)</h2>
190 <!-- Module F: Infinite Scenario Generator -->
191 <button onclick="generateNewScenario()" id="btnNewScenario" class="bg-blue-100 text-blue-700 hover:bg-blue-200 px-4 py-2 rounded-lg font-semibold text-sm transition flex items-center gap-2 shadow-sm">
192 <i class="fa-solid fa-arrows-rotate"></i> สร้างโจทย์ใหม่
193 </button>
194 </div>
195
196 <div id="scenarioContainer" class="flex-1 flex flex-col items-center justify-center text-center p-4">
197 <div class="text-gray-400 mb-4">
198 <i class="fa-solid fa-folder-open text-6xl mb-2"></i>
199 <p>กด "สร้างโจทย์ใหม่" เพื่อเริ่มแฟ้มคดี</p>
200 </div>
201 </div>
202 </div>
203
204 <!-- Right Panel: Chatbot Agent (Module B & D) -->
205 <div class="panel chat-panel relative">
206 <div class="messages-wrapper" id="chatMessages">
207 <!-- Messages will be injected here via renderMathFeedback() -->
208 </div>
209
210 <!-- Chat Input -->
211 <div class="mt-4 bg-white rounded-xl border border-gray-300 p-2 flex items-center gap-2 shadow-sm shrink-0">
212 <input type="text" id="chatInput" placeholder="ถามเบาะแสเพิ่มเติมกับนักสืบสมการ..."
213 class="flex-1 px-3 py-2 outline-none text-gray-700 bg-transparent"
214 onkeypress="if(event.key === 'Enter') sendUserMessage()">
215 <button onclick="sendUserMessage()" class="bg-blue-600 hover:bg-blue-700 text-white w-10 h-10 rounded-lg flex items-center justify-center transition">
216 <i class="fa-solid fa-paper-plane"></i>
217 </button>
218 </div>
219 </div>
220
221 </main>
222
223 <!-- ── Settings Modal (Universal API Caller) ── -->
224 <div id="settingsModal" class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
225 <div class="bg-white rounded-2xl max-w-md w-full p-6 shadow-2xl relative">
226 <h3 class="text-xl font-bold mb-4 flex items-center gap-2"><i class="fa-solid fa-key text-yellow-500"></i> ตั้งค่า API (LLM Integration)</h3>
227
228 <div class="mb-4">
229 <label class="block text-sm font-semibold text-gray-700 mb-1">Gemini API Key</label>
230 <input type="password" id="inputApiKey" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 outline-none" placeholder="AIzaSy...">
231 <p class="text-xs text-gray-500 mt-1">ถูกบันทึกไว้ใน localStorage ของเบราว์เซอร์คุณเท่านั้น</p>
232 </div>
233
234 <div class="mb-6">
235 <label class="block text-sm font-semibold text-gray-700 mb-1">Model Name</label>
236 <input type="text" id="inputModelName" value="gemini-2.0-flash" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 outline-none">
237 </div>
238
239 <div class="flex justify-end gap-3">
240 <button onclick="closeSettings()" id="btnCancelSettings" class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg font-medium transition">ยกเลิก</button>
241 <button onclick="saveSettings()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg font-medium transition shadow-md">บันทึก</button>
242 </div>
243 </div>
244 </div>
245
246 <!-- ── JavaScript Logic ── -->
247 <script>
248 /*
249 * 1. 🛠️ Web Components สำหรับ Math Display (Module M3)
250 * สร้าง custom elements <math-span> และ <math-div> ให้เรนเดอร์ KaTeX อัตโนมัติ
251 */
252 class MathSpan extends HTMLElement {
253 connectedCallback() {
254 // Inline math
255 katex.render(this.textContent, this, { throwOnError: false, displayMode: false });
256 }
257 }
258 class MathDiv extends HTMLElement {
259 connectedCallback() {
260 // Block math
261 katex.render(this.textContent, this, { throwOnError: false, displayMode: true });
262 }
263 }
264 customElements.define('math-span', MathSpan);
265 customElements.define('math-div', MathDiv);
266
267 /*
268 * 2. 🛠️ State Management
269 */
270 let currentScenario = null;
271 let missionSolved = 0;
272 const totalMissions = 3;
273
274 /*
275 * 3. 🛠️ Settings & Universal API Caller
276 */
277 function openSettings() {
278 document.getElementById('settingsModal').classList.remove('hidden');
279 document.getElementById('inputApiKey').value = localStorage.getItem('geminiApiKey') || '';
280 document.getElementById('inputModelName').value = localStorage.getItem('geminiModel') || 'gemini-2.0-flash';
281
282 // บังคับตั้งค่าถ้าไม่มี Key
283 if(!localStorage.getItem('geminiApiKey')) {
284 document.getElementById('btnCancelSettings').style.display = 'none';
285 } else {
286 document.getElementById('btnCancelSettings').style.display = 'block';
287 }
288 }
289
290 function closeSettings() {
291 if(!localStorage.getItem('geminiApiKey')) return; // ห้ามปิดถ้าไม่มี Key
292 document.getElementById('settingsModal').classList.add('hidden');
293 }
294
295 function saveSettings() {
296 const key = document.getElementById('inputApiKey').value.trim();
297 const model = document.getElementById('inputModelName').value.trim();
298 if(key) {
299 localStorage.setItem('geminiApiKey', key);
300 localStorage.setItem('geminiModel', model || 'gemini-2.0-flash');
301 closeSettings();
302
303 // ถ้ายกเลิกการแสดงผลเริ่มต้น ให้ทักทาย
304 if(document.getElementById('chatMessages').children.length === 0) {
305 appendSystemGreeting();
306 }
307 } else {
308 alert("กรุณาใส่ API Key");
309 }
310 }
311
312 // ระบบ Socratic Method และ Constraints (1.3)
313 const SYSTEM_INSTRUCTION = `
314คุณคือ "สารวัตรสมการ" (Detective Equation) สวมบทบาทนักสืบชั้นยอดแห่งกรมตำรวจคณิตศาสตร์ In-Character ตลอดเวลา
315เป้าหมาย: พานักเรียนทำภารกิจไขคดี "การหาสมการ" ให้สำเร็จ
316
317Math-Specific Pedagogy Constraints (ข้อบังคับเด็ดขาด):
3181. Socratic Method: ห้ามบอกเฉลยตรงๆ ทันที ให้ใช้วิธีตั้งคำถามย้อนกลับเพื่อให้นักเรียนค้นพบคำตอบด้วยตัวเอง
3192. Productive Struggle: ปล่อยให้นักเรียนได้พยายามและทำผิดพลาดก่อน หากผิดให้ไกด์ถึง Misconception อย่างนุ่มนวล
3203. PDPA: ห้ามบันทึก ขอ หรืออ้างอิงข้อมูลส่วนตัว/ชื่อจริงของนักเรียนเด็ดขาด
321
322รูปแบบ:
323- ใช้ภาษาไทย สุภาพ เป็นกันเอง มีโทนของนักสืบ (เช่น "เอาล่ะ คู่หู...", "มาดูเบาะแสกัน...")
324- ใช้ $...$ สำหรับสมการบรรทัดเดียวกัน และ $$...$$ สำหรับสมการแยกบรรทัด
325`;
326
327 // Universal API Caller ฟังก์ชันหลัก
328 async function callLLM(prompt, isJsonMode = false) {
329 const apiKey = localStorage.getItem('geminiApiKey');
330 const modelName = localStorage.getItem('geminiModel') || 'gemini-2.0-flash';
331 if (!apiKey) {
332 openSettings();
333 throw new Error("Missing API Key");
334 }
335
336 const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`;
337
338 const payload = {
339 system_instruction: { parts: [{ text: SYSTEM_INSTRUCTION }] },
340 contents: [{ role: "user", parts: [{ text: prompt }] }],
341 generationConfig: {
342 temperature: isJsonMode ? 0.2 : 0.7,
343 }
344 };
345
346 if(isJsonMode) {
347 payload.generationConfig.responseMimeType = "application/json";
348 }
349
350 try {
351 const response = await fetch(endpoint, {
352 method: 'POST',
353 headers: { 'Content-Type': 'application/json' },
354 body: JSON.stringify(payload)
355 });
356
357 const data = await response.json();
358 if (!response.ok) {
359 throw new Error(data.error?.message || "API Error");
360 }
361 return data.candidates[0].content.parts[0].text;
362 } catch (error) {
363 console.error("LLM API Error:", error);
364 alert(`เกิดข้อผิดพลาดในการเชื่อมต่อ API: \n${error.message}`);
365 return null;
366 }
367 }
368
369 /*
370 * 4. 🛠️ Module F: Infinite Scenario Generator
371 */
372 async function generateNewScenario() {
373 const btn = document.getElementById('btnNewScenario');
374 const container = document.getElementById('scenarioContainer');
375
376 btn.disabled = true;
377 btn.innerHTML = `<span class="loader w-4 h-4 border-2"></span> กำลังสร้างเบาะแส...`;
378 container.innerHTML = `<div class="flex flex-col items-center text-blue-500"><span class="loader mb-4"></span><p>สารวัตรกำลังเตรียมแฟ้มคดีใหม่...</p></div>`;
379
380 const prompt = `
381สร้างโจทย์คณิตศาสตร์ 1 ข้อ เรื่อง "การหาสมการ" (เช่น หาสมการเส้นตรงจากจุด, หาสมการพหุนาม, ฯลฯ) ระดับมัธยม
382บังคับ: ส่งคืนข้อมูลเป็น Strict JSON format เท่านั้น ห้ามมีข้อความอื่นปน
383Schema:
384{
385 "question": "ข้อความโจทย์ปัญหา (ใช้ $...$ สำหรับสมการในโจทย์)",
386 "mainEquation": "สมการหลักที่เกี่ยวข้องหรือสูตรตั้งต้น (ถ้ามี)",
387 "options": ["ตัวเลือก 1", "ตัวเลือก 2", "ตัวเลือก 3", "ตัวเลือก 4"],
388 "correctIndex": 0 // index ของคำตอบที่ถูกต้อง (0 ถึง 3)
389}`;
390
391 const responseText = await callLLM(prompt, true);
392
393 if (responseText) {
394 try {
395 // ทำความสะอาด JSON ในกรณีที่ติด Markdown backticks มา
396 let cleanJson = responseText.replace(/```json/g, '').replace(/```/g, '').trim();
397 currentScenario = JSON.parse(cleanJson);
398 renderScenarioUI();
399 appendAiMessage(`แฟ้มคดีใหม่พร้อมแล้วคู่หู! ลองดูเบาะแสบนหน้ากระดานแล้วเลือกคำตอบที่คิดว่าใช่ดูสิ`);
400 } catch (e) {
401 console.error("JSON Parse Error:", e);
402 container.innerHTML = `<div class="text-red-500"><i class="fa-solid fa-triangle-exclamation text-3xl mb-2"></i><br>เกิดข้อผิดพลาดในการสร้างโจทย์ โปรดลองอีกครั้ง</div>`;
403 }
404 }
405
406 btn.disabled = false;
407 btn.innerHTML = `<i class="fa-solid fa-arrows-rotate"></i> สร้างโจทย์ใหม่`;
408 }
409
410 function renderScenarioUI() {
411 if(!currentScenario) return;
412 const container = document.getElementById('scenarioContainer');
413
414 // สร้าง HTML สำหรับโจทย์ โดยใช้ DOMPurify (Module M3) และ custom element
415 let html = `
416 <div class="bg-blue-50 rounded-xl p-6 w-full shadow-sm text-left border border-blue-100 mb-4">
417 <h3 class="text-lg font-bold text-blue-900 mb-3"><i class="fa-solid fa-magnifying-glass"></i> คดีปริศนา</h3>
418 <p class="text-gray-800 text-lg leading-relaxed mb-4" id="qText">${currentScenario.question}</p>
419 ${currentScenario.mainEquation ? `<div class="bg-white p-3 rounded-lg border text-center mb-4"><math-div mode="displaystyle">${currentScenario.mainEquation}</math-div></div>` : ''}
420 </div>
421 <div class="grid grid-cols-1 md:grid-cols-2 gap-3 w-full" id="optionsGrid">
422 `;
423
424 currentScenario.options.forEach((opt, idx) => {
425 html += `
426 <button onclick="handleAnswer(${idx}, this)" class="option-btn bg-white w-full shadow-sm flex items-center gap-3">
427 <div class="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center font-bold text-gray-500 shrink-0 border border-gray-300">
428 ${String.fromCharCode(65 + idx)}
429 </div>
430 <div class="flex-1 text-gray-700 text-left">${opt}</div>
431 </button>
432 `;
433 });
434
435 html += `</div>`;
436
437 // M1 & M3: ใช้ renderMathFeedback จัดการ XSS และ Render Math
438 renderMathFeedback(container, html);
439 }
440
441 /*
442 * 5. 🛠️ Module D: Explain "Why I'm Wrong" & Answer Logic
443 */
444 function handleAnswer(selectedIndex, btnElement) {
445 const isCorrect = (selectedIndex === currentScenario.correctIndex);
446 const grid = document.getElementById('optionsGrid');
447 const buttons = grid.querySelectorAll('.option-btn');
448
449 // ปิดปุ่มทั้งหมด
450 buttons.forEach(b => b.disabled = true);
451
452 if (isCorrect) {
453 btnElement.classList.add('selected-correct');
454 btnElement.innerHTML += `<i class="fa-solid fa-circle-check text-green-500 text-xl ml-auto"></i>`;
455 updateMissionProgress(1);
456 appendAiMessage(`ยอดเยี่ยมมาก! นั่นคือคำตอบที่ถูกต้อง คดีนี้ถูกไขกระจ่างแล้ว 🎉<br>กด <b>"สร้างโจทย์ใหม่"</b> เพื่อสืบคดีต่อไป!`);
457 } else {
458 btnElement.classList.add('selected-wrong');
459 btnElement.innerHTML += `<i class="fa-solid fa-circle-xmark text-red-500 text-xl ml-auto"></i>`;
460
461 // Highlight ข้อที่ถูกให้เห็น
462 buttons[currentScenario.correctIndex].classList.add('selected-correct', 'opacity-50');
463
464 // แทรกปุ่ม "ทำไมข้อนี้ถึงผิด?" ใต้ Options
465 const explanationDiv = document.createElement('div');
466 explanationDiv.className = "w-full mt-4 text-center fade-in";
467 explanationDiv.innerHTML = `
468 <button onclick="explainWhyWrong(${selectedIndex})" class="bg-amber-100 text-amber-800 hover:bg-amber-200 border border-amber-300 px-5 py-2 rounded-full font-bold shadow-sm transition">
469 ❓ ทำไมข้อนี้ถึงผิด? (ถามสารวัตร)
470 </button>
471 `;
472 document.getElementById('scenarioContainer').appendChild(explanationDiv);
473 }
474 }
475
476 async function explainWhyWrong(wrongIndex) {
477 const wrongText = currentScenario.options[wrongIndex];
478 const correctText = currentScenario.options[currentScenario.correctIndex];
479
480 appendUserMessage(`ทำไมตัวเลือก "${wrongText}" ถึงผิดครับ/ค่ะ?`);
481 showTypingIndicator();
482
483 const prompt = `นักเรียนเลือกคำตอบผิดคือ "${wrongText}" จากคำถาม: "${currentScenario.question}" (คำตอบที่ถูกคือ "${correctText}")
484หน้าที่ของคุณ (สารวัตรสมการ):
4851. อธิบาย Misconception หรือจุดที่มักเข้าใจผิดเกี่ยวกับสมการในตัวเลือกนี้
4862. อธิบายกระบวนการคิดที่นำไปสู่คำตอบที่ถูกต้อง โดยยกตัวอย่างรูปธรรม
4873. ใช้ Socratic Method ถามนำในตอนท้ายเพื่อทดสอบความเข้าใจ
4884. ห้ามดุ ให้กำลังใจในฐานะคู่หูนักสืบ`;
489
490 const reply = await callLLM(prompt, false);
491 removeTypingIndicator();
492 if (reply) appendAiMessage(reply);
493 }
494
495 /*
496 * 6. 🛠️ Chat Interface Logic
497 */
498 async function sendUserMessage() {
499 const input = document.getElementById('chatInput');
500 const text = input.value.trim();
501 if(!text) return;
502
503 appendUserMessage(text);
504 input.value = '';
505
506 showTypingIndicator();
507 const reply = await callLLM(`บริบทการสนทนา: ${text}`, false);
508 removeTypingIndicator();
509
510 if (reply) appendAiMessage(reply);
511 }
512
513 function appendSystemGreeting() {
514 appendAiMessage(`สวัสดีคู่หู! ผมคือ <b>สารวัตรสมการ</b> ยินดีต้อนรับสู่กองสืบสวนคณิตศาสตร์ 🕵️♂️<br><br>คดีวันนี้คือการ "ตามหาสมการที่หายไป" คุณพร้อมที่จะสืบหาความจริงแล้วหรือยัง? กดปุ่ม <span class="bg-blue-100 text-blue-800 px-2 py-0.5 rounded text-xs font-bold"><i class="fa-solid fa-arrows-rotate"></i> สร้างโจทย์ใหม่</span> ด้านซ้ายมือเพื่อเริ่มคดีแรกได้เลย!`);
515 }
516
517 function appendUserMessage(text) {
518 const wrapper = document.getElementById('chatMessages');
519 const msgDiv = document.createElement('div');
520 msgDiv.className = 'chat-msg msg-user';
521
522 // XSS Protection for User Input
523 msgDiv.textContent = text;
524 wrapper.appendChild(msgDiv);
525 wrapper.scrollTop = wrapper.scrollHeight;
526 }
527
528 function appendAiMessage(htmlText) {
529 const wrapper = document.getElementById('chatMessages');
530 const msgDiv = document.createElement('div');
531 msgDiv.className = 'chat-msg msg-ai shadow-sm';
532
533 wrapper.appendChild(msgDiv);
534 // เรนเดอร์ Math และ Sanitize (Module M1 & M3)
535 renderMathFeedback(msgDiv, htmlText);
536 wrapper.scrollTop = wrapper.scrollHeight;
537 }
538
539 function showTypingIndicator() {
540 const wrapper = document.getElementById('chatMessages');
541 const typingDiv = document.createElement('div');
542 typingDiv.id = 'typingIndicator';
543 typingDiv.className = 'chat-msg msg-ai text-gray-400 flex gap-1 items-center py-3';
544 typingDiv.innerHTML = `<span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></span><span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.1s"></span><span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></span>`;
545 wrapper.appendChild(typingDiv);
546 wrapper.scrollTop = wrapper.scrollHeight;
547 }
548
549 function removeTypingIndicator() {
550 const el = document.getElementById('typingIndicator');
551 if(el) el.remove();
552 }
553
554 /*
555 * 7. 🛠️ Module M1 & M3: Math Rendering Engine & Sanitization
556 */
557 function renderMathFeedback(container, htmlText) {
558 // M3: ป้องกัน XSS
559 container.innerHTML = DOMPurify.sanitize(htmlText, {
560 ADD_TAGS: ['math-span', 'math-div'] // อนุญาต Custom Elements
561 });
562
563 // M1: แปลง $...$ และ $$...$$ ให้เป็นสมการ
564 renderMathInElement(container, {
565 delimiters: [
566 {left: '$$', right: '$$', display: true},
567 {left: '$', right: '$', display: false}
568 ],
569 throwOnError: false,
570 errorColor: '#ef4444'
571 });
572 }
573
574 /*
575 * 8. 🛠️ Module B: Progress System
576 */
577 function updateMissionProgress(add) {
578 missionSolved += add;
579 const percentage = Math.min((missionSolved / totalMissions) * 100, 100);
580 document.getElementById('missionProgress').style.width = percentage + '%';
581 document.getElementById('missionText').innerText = `${missionSolved} / ${totalMissions} ภารกิจ`;
582
583 if (missionSolved >= totalMissions) {
584 setTimeout(() => {
585 appendAiMessage(`🌟 <b>สุดยอดมาก คู่หู!</b> เราไขรหัสผ่านตู้เซฟแห่งสมการได้ครบ ${totalMissions} คดีแล้ว คดีนี้ปิดแฟ้มได้อย่างสวยงาม!`);
586 }, 1000);
587 }
588 }
589
590 // Initialize App
591 window.onload = () => {
592 if(!localStorage.getItem('geminiApiKey')) {
593 openSettings();
594 } else {
595 appendSystemGreeting();
596 }
597 };
598
599 </script>
600</body>
601</html>