itamarlifshitz commited on
Commit
ed666cb
ยท
verified ยท
1 Parent(s): 2a1f177

Manual changes saved

Browse files
Files changed (1) hide show
  1. index.html +541 -301
index.html CHANGED
@@ -1,327 +1,567 @@
1
- // server.mjs
2
- // One-file MVP: Node server + HTML chat UI (RTL Hebrew) + AI proxy
3
- // ===== HOW TO RUN =====
4
- // 1) Save this file as server.mjs
5
- // 2) In terminal set env vars (PowerShell example):
6
- // $env:AI_API_KEY="YOUR_KEY_HERE"
7
- // $env:AI_BASE_URL="https://ai.gateway.lovable.dev/v1" # optional, default set below
8
- // $env:AI_MODEL="google/gemini-2.5-flash" # optional
9
- // node server.mjs
10
- // 3) Open http://localhost:5173
11
- //
12
- // Notes:
13
- // - Your API key stays on the server (safe). The browser never sees it.
14
- // - You can paste your own study text ("context") so the bot answers based on it.
15
- // - Context is saved in localStorage, and also sent per-message to the server as part of the system prompt.
16
-
17
- import http from "node:http";
18
- import { readFile } from "node:fs/promises";
19
- import { fileURLToPath } from "node:url";
20
- import { dirname, join } from "node:path";
21
-
22
- // ====== CONFIG ======
23
- const PORT = process.env.PORT ? Number(process.env.PORT) : 5173;
24
- const AI_BASE_URL = process.env.AI_BASE_URL || "https://ai.gateway.lovable.dev/v1";
25
- const AI_MODEL = process.env.AI_MODEL || "google/gemini-2.5-flash";
26
- const AI_API_KEY = process.env.AI_API_KEY || ""; // MUST be set!
27
-
28
- // ====== MINI ASSETS (single-file, inline CSS/JS) ======
29
- const HTML = `<!doctype html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  <html lang="he" dir="rtl">
31
  <head>
32
- <meta charset="utf-8" />
33
- <meta name="viewport" content="width=device-width,initial-scale=1" />
34
- <title>ืขื•ื–ืจ ืœื™ืžื•ื“ื™ื ื‘ื™ืช-ืกืคืจื™ โ€” MVP</title>
35
- <style>
36
- :root{
37
- --bg: hsl(210 40% 98%);
38
- --card: #fff;
39
- --border: hsl(220 13% 91%);
40
- --text: hsl(220 15% 20%);
41
- --muted: hsl(220 15% 55%);
42
- --primary: hsl(195 85% 45%);
43
- --primary2: hsl(195 85% 65%);
44
- --shadow: 0 8px 24px rgba(14,165,233,.12);
45
- --radius: 16px;
46
- }
47
- *{box-sizing:border-box}
48
- html{font-family:system-ui,-apple-system,Segoe UI,Rubik,Arial,sans-serif;background:var(--bg);color:var(--text)}
49
- .container{max-width:980px;margin:0 auto;padding:16px}
50
- header{background:var(--card);border-bottom:1px solid var(--border)}
51
- .head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0}
52
- .brand{display:flex;align-items:center;gap:10px}
53
- .logo{width:44px;height:44px;border-radius:10px;background:linear-gradient(135deg,var(--primary),var(--primary2));display:grid;place-items:center;box-shadow:var(--shadow)}
54
- .logo svg{fill:#fff;opacity:.9}
55
- .title{font-weight:700}
56
- .sub{font-size:12px;color:var(--muted)}
57
- nav{display:flex;gap:8px}
58
- .btn{display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:#fff;border:none;padding:10px 14px;border-radius:12px;cursor:pointer;box-shadow:var(--shadow)}
59
- .btn:hover{filter:brightness(1.05)}
60
- .btn-ghost{background:#fff;color:var(--primary);border:1px solid var(--primary)}
61
- .btn-ghost:hover{background:rgba(14,165,233,.06)}
62
- .grid{display:grid;gap:16px}
63
- .card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);padding:16px}
64
- .row{display:grid;grid-template-columns:1fr 2fr;gap:16px}
65
- @media (max-width:800px){.row{grid-template-columns:1fr}}
66
- label{font-size:14px;color:var(--muted);display:block;margin-bottom:6px}
67
- input,textarea,select{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:12px;background:#fff;outline:none}
68
- textarea{min-height:120px;resize:vertical}
69
- .muted{color:var(--muted);font-size:13px}
70
- .chat{min-height:50vh;max-height:70vh;overflow:auto;display:flex;flex-direction:column;gap:10px}
71
- .bubble{max-width:76%;border:1px solid var(--border);border-radius:14px;padding:10px 12px;background:#fff}
72
- .me{margin-left:auto;background:rgba(14,165,233,.08);border-color:rgba(14,165,233,.25)}
73
- .bot .who,.me .who{font-size:12px;color:var(--muted);margin-bottom:4px}
74
- .toolbar{display:flex;gap:8px}
75
- .badge{display:inline-flex;align-items:center;font-size:12px;color:var(--muted);border:1px solid var(--border);padding:2px 8px;border-radius:999px}
76
- .row-compact{display:flex;gap:8px}
77
- </style>
78
  </head>
79
  <body>
80
- <header>
81
- <div class="container head">
82
- <div class="brand">
83
- <div class="logo" title="ื™ืฆื—ืง ืฉืžื™ืจ โ€” ืกืžืœ ืžืจื•ื‘ืข, ืœืœื ืขื™ื’ื•ืœ ืคื™ื ื•ืช">
84
- <svg viewBox="0 0 24 24" width="22" height="22"><path d="M12 3l7 4v6c0 3.866-3.582 7-8 7s-8-3.134-8-7V7l9-4z"/></svg>
85
- </div>
86
- <div>
87
- <div class="title">ืขื•ื–ืจ ืœื™ืžื•ื“ื™ื ื‘ื™ืช-ืกืคืจื™</div>
88
- <div class="sub">MVP โ€ข ืฆืณืื˜ + ื”ืงืฉืจ ืœื™ืžื•ื“ื™ (RTL)</div>
89
- </div>
 
 
 
 
90
  </div>
91
- <nav>
92
- <a class="btn-ghost" href="#context">ื—ื•ืžืจื™ ืœื™ืžื•ื“</a>
93
- <a class="btn" href="#chat">ืฆืณืื˜</a>
94
- </nav>
95
- </div>
96
- </header>
97
 
98
- <main class="container grid" id="app">
99
- <!-- Context / Materials -->
100
- <section id="context" class="card">
101
- <h2 style="margin:0 0 8px 0;">๐Ÿ“š ื—ื•ืžืจื™ ืœื™ืžื•ื“ / ื”ืงืฉืจ</h2>
102
- <p class="muted" style="margin-top:0">ื”ื“ื‘ื™ืงื• ื›ืืŸ ืชืงืฆื™ืจ/ื˜ืงืกื˜ ืžื”ืžื—ื‘ืจืช/ื’ื•ื’ืœ ื“ื•ืงืก/ืืชืจ. ื”ื‘ื•ื˜ ื™ืชื‘ืกืก ืขืœื™ื• ื‘ืชืฉื•ื‘ื•ืช.</p>
103
- <div class="row">
104
- <div>
105
- <label>ืฉื โ€œืžืจื—ื‘โ€ (ืœืžืฉืœ: ืžืชืžื˜ื™ืงื” โ€” ืคืจืง ื—ื–ืงื•ืช)</label>
106
- <input id="spaceName" placeholder="ืฉื ื”ืžืจื—ื‘" />
107
- <div style="height:8px"></div>
108
- <label>ืฉืคืช ื”ื‘ื•ื˜</label>
109
- <select id="botLang">
110
- <option value="he">ืขื‘ืจื™ืช</option>
111
- <option value="en">English</option>
112
- </select>
113
- <div style="height:8px"></div>
114
- <span class="badge" id="stats">0 ืชื•ื•ื™ื ื‘ืงื•ื ื˜ืงืกื˜</span>
115
- </div>
116
- <div>
117
- <label>ื˜ืงืกื˜ ื”ืงืฉืจ (ื ืฉืžืจ ื‘ื“ืคื“ืคืŸ, ื ืฉืœื— ืœื‘ื•ื˜ ื›ืชืžื™ื›ืช ื”ืงืฉืจ)</label>
118
- <textarea id="contextText" placeholder="ื”ื“ื‘ื™ืงื• ื›ืืŸ ื˜ืงืกื˜ ื—ืฉื•ื‘ ืžื”ื—ื•ืžืจ..."></textarea>
119
- <div style="height:8px"></div>
120
- <div class="row-compact">
121
- <button class="btn" id="saveCtx">ืฉืžื™ืจื”</button>
122
- <button class="btn-ghost" id="clearCtx">ื ื™ืงื•ื™</button>
123
- </div>
124
- </div>
125
  </div>
126
- </section>
 
127
 
128
- <!-- Chat -->
129
- <section id="chat" class="card">
130
- <h2 style="margin-top:0;">๐Ÿ’ฌ ืฆืณืื˜</h2>
131
  <div class="row">
132
- <div>
133
- <label>ื”ื•ืจืื•ืช ืžืขืจื›ืช (ืื•ืคืฆื™ื•ื ืœื™)</label>
134
- <textarea id="systemHint" placeholder="ืœืžืฉืœ: ืขื ื” ื‘ืงืฆืจื”, ืฆื™ื™ืŸ ืฉืœื‘ื™ื, ื”ืฉืชืžืฉ ื‘ื“ื•ื’ืžืื•ืช ืคืฉื•ื˜ื•ืช."></textarea>
135
- <div style="height:8px"></div>
136
- <label>ืžื•ื“ืœ (ืื•ืคืฆื™ื•ื ืœื™)</label>
137
- <input id="model" placeholder="google/gemini-2.5-flash (ื‘ืจื™ืจืช ืžื—ื“ืœ ื‘ืฉืจืช)" />
138
- <div style="height:8px"></div>
139
- <div class="muted">ื”-API key ื ืฉืืจ ืขืœ ื”ืฉืจืช โ€” ื‘ื˜ื•ื—. ืื™ืŸ ืฆื•ืจืš ืœืฉื™ื ืื•ืชื• ื‘ื“ืคื“ืคืŸ.</div>
140
- </div>
141
- <div>
142
- <div id="chatBox" class="chat" style="border:1px solid var(--border);border-radius:12px;padding:10px;background:#fff"></div>
143
- <div style="height:8px"></div>
144
- <div class="row-compact">
145
- <input id="msg" placeholder="ืฉืืœ/ื™ ืฉืืœื” ืขืœ ืกืžืš ื”ื—ื•ืžืจ..." />
146
- <button class="btn" id="send">ืฉืœื™ื—ื”</button>
147
- </div>
148
- </div>
149
  </div>
150
- </section>
151
- </main>
152
-
153
- <script>
154
- const LS_SPACE = "mvp_space_name";
155
- const LS_TEXT = "mvp_context_text";
156
- const LS_LANG = "mvp_bot_lang";
157
- const chatBox = document.getElementById("chatBox");
158
- const msgInput = document.getElementById("msg");
159
- const sendBtn = document.getElementById("send");
160
- const stats = document.getElementById("stats");
161
- const spaceName = document.getElementById("spaceName");
162
- const contextEl = document.getElementById("contextText");
163
- const saveCtx = document.getElementById("saveCtx");
164
- const clearCtx = document.getElementById("clearCtx");
165
- const botLang = document.getElementById("botLang");
166
- const systemEl = document.getElementById("systemHint");
167
- const modelEl = document.getElementById("model");
168
-
169
- // Load persisted context
170
- spaceName.value = localStorage.getItem(LS_SPACE) || "";
171
- contextEl.value = localStorage.getItem(LS_TEXT) || "";
172
- botLang.value = localStorage.getItem(LS_LANG) || "he";
173
- stats.textContent = (contextEl.value.length || 0) + " ืชื•ื•ื™ื ื‘ืงื•ื ื˜ืงืกื˜";
174
-
175
- contextEl.addEventListener("input", () => {
176
- stats.textContent = (contextEl.value.length || 0) + " ืชื•ื•ื™ื ื‘ืงื•ื ื˜ืงืกื˜";
177
- });
178
-
179
- saveCtx.addEventListener("click", () => {
180
- localStorage.setItem(LS_SPACE, spaceName.value || "");
181
- localStorage.setItem(LS_TEXT, contextEl.value || "");
182
- localStorage.setItem(LS_LANG, botLang.value || "he");
183
- alert("ื ืฉืžืจ ื‘ื”ืฆืœื—ื”");
184
- });
185
- clearCtx.addEventListener("click", () => {
186
- spaceName.value = "";
187
- contextEl.value = "";
188
- localStorage.removeItem(LS_SPACE);
189
- localStorage.removeItem(LS_TEXT);
190
- stats.textContent = "0 ืชื•ื•ื™ื ื‘ืงื•ื ื˜ืงืกื˜";
191
- });
192
-
193
- function appendBubble(role, text){
194
- const wrap = document.createElement("div");
195
- wrap.className = "bubble " + (role === "user" ? "me" : "bot");
196
- const who = document.createElement("div");
197
- who.className = "who";
198
- who.textContent = role === "user" ? "ืืช/ื”" : "ื‘ื•ื˜";
199
- const body = document.createElement("div");
200
- body.textContent = text;
201
- wrap.appendChild(who);
202
- wrap.appendChild(body);
203
- chatBox.appendChild(wrap);
204
- chatBox.scrollTop = chatBox.scrollHeight;
205
- }
206
 
207
- async function ask(){
208
- const q = (msgInput.value || "").trim();
209
- if(!q) return;
210
- appendBubble("user", q);
211
- msgInput.value = "";
212
- const ctx = contextEl.value || "";
213
- const space = spaceName.value || "ื‘ืจื™ืจืช ืžื—ื“ืœ";
214
- const lang = botLang.value || "he";
215
- const system = (systemEl.value || "") + "\\n" +
216
- (lang === "he"
217
- ? \`ืขื ื” ื‘ืขื‘ืจื™ืช, ื‘ืงืฆืจื” ื•ื‘ื‘ื”ื™ืจื•ืช. ื”ืฉืชืžืฉ ืงื•ื“ื ื›ื•ืœ ื‘ืชื•ื›ืŸ ื”ืงืฉืจ ืกื‘ื™ืจ ืœื”ืœืŸ.\`
218
- : \`Answer briefly and clearly. Prioritize the provided study context.\`) +
219
- "\\n" +
220
- \`ืฉื ื”ืžืจื—ื‘: "\${space}".\\n\\nื”ืงืฉืจ/ื—ื•ืžืจื™ ืœื™ืžื•ื“:\\n\${ctx}\`;
221
-
222
- try{
223
- const resp = await fetch("/api/chat", {
224
- method: "POST",
225
- headers: {"Content-Type":"application/json"},
226
- body: JSON.stringify({ message: q, system, model: (modelEl.value || undefined) })
227
- });
228
- const j = await resp.json();
229
- if(!resp.ok || !j.ok){
230
- appendBubble("assistant", (j.error || "ืฉื’ื™ืื” ื‘ืฉื™ืจื•ืช ื”ื‘ื™ื ื”"));
231
- return;
232
- }
233
- appendBubble("assistant", j.text || (lang==="he"?"ืื™ืŸ ืชืฉื•ื‘ื”":"No response"));
234
- }catch(e){
235
- appendBubble("assistant", (e?.message || "ืฉื’ื™ืืช ืจืฉืช"));
236
- }
237
- }
238
 
239
- sendBtn.addEventListener("click", ask);
240
- msgInput.addEventListener("keydown", (e)=>{ if(e.key==="Enter") ask(); });
 
 
 
 
 
 
 
241
 
242
- // Warm greeting
243
- if(!contextEl.value){
244
- appendBubble("assistant", "ื”ื™ื™! ื”ื•ืกื™ืคื• ื˜ืงืกื˜ ื”ืงืฉืจ (ืœืžืฉืœ ืชืงืฆื™ืจ ื—ื•ืžืจ ืœื™ืžื•ื“), ื•ืื– ืฉืืœื• ืื•ืชื™ ืฉืืœื•ืช ืขืœื™ื•.");
245
- } else {
246
- appendBubble("assistant", "ืžืขื•ืœื”! ื™ืฉ ืœื™ ื”ืงืฉืจ ืœื™ืžื•ื“ื™. ืืคืฉืจ ืœืฉืื•ืœ ื›ืœ ืฉืืœื”.");
247
- }
248
- </script>
249
- </body>
250
- </html>`;
 
 
 
 
 
 
 
 
 
251
 
252
- const INDEX = Buffer.from(HTML);
 
 
 
 
253
 
254
- // ====== SERVER (static + /api/chat) ======
255
- const server = http.createServer(async (req, res) => {
256
  try {
257
- // Simple router
258
- if (req.method === "GET" && (req.url === "/" || req.url?.startsWith("/#"))) {
259
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
260
- res.end(INDEX);
261
- return;
262
- }
263
 
264
- if (req.method === "POST" && req.url === "/api/chat") {
265
- if (!AI_API_KEY) {
266
- const msg = "Missing AI_API_KEY env var";
267
- res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
268
- res.end(JSON.stringify({ error: msg }));
269
- return;
270
- }
 
271
 
272
- const chunks = [];
273
- for await (const c of req) chunks.push(c);
274
- const bodyStr = Buffer.concat(chunks).toString("utf-8");
275
- const payload = JSON.parse(bodyStr || "{}");
276
-
277
- const model = payload.model || AI_MODEL;
278
- const system = payload.system || "Answer briefly and clearly in Hebrew.";
279
- const user = payload.message || "";
280
-
281
- // OpenAI-compatible /chat/completions
282
- const resp = await fetch(`${AI_BASE_URL}/chat/completions`, {
283
- method: "POST",
284
- headers: {
285
- "Authorization": `Bearer ${AI_API_KEY}`,
286
- "Content-Type": "application/json",
287
- },
288
- body: JSON.stringify({
289
- model,
290
- messages: [
291
- { role: "system", content: system },
292
- { role: "user", content: user }
293
- ],
294
- })
295
- });
296
-
297
- if (!resp.ok) {
298
- const text = await resp.text();
299
- res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
300
- res.end(JSON.stringify({ error: `AI error ${resp.status}`, details: text }));
301
- return;
302
- }
303
 
304
- const data = await resp.json();
305
- const text = data?.choices?.[0]?.message?.content ?? "";
306
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
307
- res.end(JSON.stringify({ ok: true, text }));
308
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  }
 
 
310
 
311
- // 404
312
- res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
313
- res.end("Not found");
314
- } catch (e) {
315
- res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
316
- res.end(JSON.stringify({ error: e?.message || "server error" }));
317
  }
318
  });
319
 
320
- server.listen(PORT, () => {
321
- console.log(`โœ… Study Assistant MVP running on http://localhost:${PORT}`);
322
- if (!AI_API_KEY) {
323
- console.warn("โš ๏ธ AI_API_KEY is missing. Set it before chatting.");
324
- } else {
325
- console.log("๐Ÿ” AI key loaded. Ready to chat.");
 
 
 
 
 
 
 
326
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  });
 
1
+ npm i express multer openai pdf-parse mammoth cors dotenv uuid body-parser helmet express-rate-limit
2
+ node app.js
3
+ /**
4
+ * DeepStudy Pro โ€” One-File Ultra-MVP for DeepSite
5
+ * Author: ChatGPT (for Olga)
6
+ *
7
+ * Highlights:
8
+ * - Upload: PDF / DOCX / TXT
9
+ * - Robust text extraction and cleaning
10
+ * - Chunking + Map-Reduce summaries with "citations" (section refs)
11
+ * - Study Pack (Summary, Key Points, Glossary, Flashcards, Quiz + AnswerKey JSON)
12
+ * - Streaming Chat via SSE with typing indicator
13
+ * - Quiz grading with rubric (JSON in/JSON out)
14
+ * - Download Study Pack as .md
15
+ * - RTL Hebrew UI, persistent chat history (localStorage)
16
+ * - Safety first: school-only topics, gentle tone, avoid unsafe content
17
+ * - Security: helmet + rate limit; simple input sanitization
18
+ *
19
+ * ENV:
20
+ * OPENAI_API_KEY=sk-...
21
+ * (optional) OPENAI_MODEL=gpt-4o-mini (or another chat-capable model)
22
+ *
23
+ * RUN:
24
+ * node app.js (default port 3000)
25
+ * open http://localhost:3000
26
+ */
27
+
28
+ require('dotenv').config();
29
+ const path = require('path');
30
+ const fs = require('fs');
31
+ const os = require('os');
32
+ const express = require('express');
33
+ const cors = require('cors');
34
+ const bodyParser = require('body-parser');
35
+ const multer = require('multer');
36
+ const pdfParse = require('pdf-parse');
37
+ const mammoth = require('mammoth');
38
+ const { v4: uuidv4 } = require('uuid');
39
+ const helmet = require('helmet');
40
+ const rateLimit = require('express-rate-limit');
41
+
42
+ const OpenAI = require('openai');
43
+ const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
44
+ const MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini';
45
+
46
+ const app = express();
47
+
48
+ // ---- Security & basics ----
49
+ app.use(helmet({ contentSecurityPolicy: false }));
50
+ app.use(cors());
51
+ app.use(bodyParser.json({ limit: '12mb' }));
52
+ const limiter = rateLimit({ windowMs: 60_000, max: 120 }); // 120 req/min/IP
53
+ app.use(limiter);
54
+
55
+ // ---- Uploads temp ----
56
+ const upload = multer({
57
+ dest: path.join(os.tmpdir(), 'deepstudypro_uploads'),
58
+ limits: { fileSize: 30 * 1024 * 1024 } // 30MB
59
+ });
60
+
61
+ // ---- Utils ----
62
+ const sanitize = (s) => (s || '').toString().replace(/\u0000/g, '').trim();
63
+ const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
64
+
65
+ function approxTokenLen(str) {
66
+ // ื’ืก: 1 ื˜ื•ืงืŸ ~ 3.5 ืชื•ื•ื™ื ืœื˜ื™ื ื™ื™ื; ื‘ืขื‘ืจื™ืช ืงืจื•ื‘ ~ 2-3. ื ืœืš ืขืœ 3.
67
+ return Math.ceil((str || '').length / 3);
68
+ }
69
+
70
+ function chunkText(str, targetTokens = 1200, overlapTokens = 120) {
71
+ // ืคื™ืฆื•ืœ ืงืฉื™ื— ื‘ืื•ืจืš ืชื•ื•ื™ื (ื‘ืขืจืš), ืขื ื—ืคื™ืคื” ืงืœื” ื‘ื™ืŸ ืžืงื˜ืขื™ื ืœืฉืžื™ืจื” ืขืœ ื”ืงืฉืจ
72
+ const tokenSize = clamp(targetTokens, 400, 2000);
73
+ const overlap = clamp(overlapTokens, 0, Math.floor(tokenSize / 3));
74
+
75
+ const charsPerToken = 3; // ื”ืขืจื›ื”
76
+ const chunkChars = tokenSize * charsPerToken;
77
+ const overlapChars = overlap * charsPerToken;
78
+
79
+ const text = sanitize(str);
80
+ const chunks = [];
81
+ let i = 0, idx = 0;
82
+ while (i < text.length) {
83
+ const end = Math.min(text.length, i + chunkChars);
84
+ const slice = text.slice(i, end);
85
+ chunks.push({ id: `S${idx + 1}`, text: slice });
86
+ idx++;
87
+ i = end - overlapChars;
88
+ if (i < 0) i = 0;
89
+ if (i >= text.length) break;
90
+ }
91
+ return chunks;
92
+ }
93
+
94
+ async function extractText(filePath, originalName) {
95
+ const ext = (path.extname(originalName || '').toLowerCase() || '').replace('.', '');
96
+ if (ext === 'pdf') {
97
+ const data = await pdfParse(fs.readFileSync(filePath));
98
+ return sanitize(data.text);
99
+ } else if (ext === 'docx') {
100
+ const { value } = await mammoth.extractRawText({ path: filePath });
101
+ return sanitize(value);
102
+ } else if (ext === 'txt' || ext === '') {
103
+ return sanitize(fs.readFileSync(filePath, 'utf8'));
104
+ }
105
+ throw new Error(`ืกื•ื’ ืงื•ื‘ืฅ ืœื ื ืชืžืš ืขื“ื™ื™ืŸ: ${ext} (ื ืชืžืš: PDF/DOCX/TXT)`);
106
+ }
107
+
108
+ async function chat(messages, system = 'You are a careful, school-safe Hebrew tutor for middle/high-school. Avoid unsafe content. Prefer Hebrew, keep answers clear & kind.') {
109
+ const resp = await openai.chat.completions.create({
110
+ model: MODEL,
111
+ temperature: 0.35,
112
+ messages: [{ role: 'system', content: system }, ...messages]
113
+ });
114
+ return resp.choices?.[0]?.message?.content?.trim() || '';
115
+ }
116
+
117
+ function safeParseJSON(s) {
118
+ try { return JSON.parse(s); } catch { return null; }
119
+ }
120
+
121
+ // ---- Study Pack Map-Reduce prompts ----
122
+ function mapPrompt(goal, sectionId, sectionText) {
123
+ return [
124
+ { role: 'user', content:
125
+ `ืงืœื˜: ืžืงื˜ืข ืœื™ืžื•ื“ื™ ืžืกื•ืžืŸ "${sectionId}" ืžืชื•ืš ื—ื•ืžืจ ืจื—ื‘.
126
+ ืžื˜ืจื” ืคื“ื’ื•ื’ื™ืช: ${sanitize(goal) || 'ื”ื›ื ื” ืœื‘ื•ื—ืŸ ืงืฆืจ + ื”ื‘ื ื” ืžืฉืžืขื•ืชื™ืช.'}
127
+
128
+ ื˜ืงืกื˜ ื”ืžืงื˜ืข:
129
+ """
130
+ ${sectionText}
131
+ """
132
+
133
+ ื”ืคืง ื ื ืชืงืฆื™ืจ ืžืžื•ืงื“ ืœืžืงื˜ืข ื–ื” ื‘ืœื‘ื“ (5โ€“8 ืžืฉืคื˜ื™ื), ื ืงื•ื“ื•ืช ืžืคืชื— ืงืฆืจื•ืช, ื•ืžื•ืฉื’ื™ื ืžืจื›ื–ื™ื™ื.
134
+ ืคื•ืจืžื˜ (Markdown):
135
+ ## ${sectionId}
136
+ ### ืชืงืฆื™ืจ
137
+ - ...
138
+ ### ื ืงื•ื“ื•ืช ืžืคืชื—
139
+ - ...
140
+ ### ืžื•ืฉื’ื™ื
141
+ - ืžื•ืฉื’ | ื”ืกื‘ืจ ืงืฆืจ
142
+ - ...
143
+ ื“ื’ืฉื™ื: ืขื‘ืจื™ืช ืคืฉื•ื˜ื”, ื’ื™ืœ ื—ื˜"ื‘/ืชื™ื›ื•ืŸ, ืœืœื ืชื•ื›ืŸ ืœื ืจืื•ื™.`}
144
+ ];
145
+ }
146
+
147
+ function reducePrompt(goal, mappedMarkdown, mergedText) {
148
+ return [
149
+ { role: 'user', content:
150
+ `ื™ืฉ ืœื ื• ืกื˜ ืกื™ื›ื•ืžื™ื ืžืžืงื˜ืขื™ื ืฉื•ื ื™ื (ืœื”ืœืŸ). ืื™ื—ื“ ืื•ืชื ืœ"ื—ื‘ื™ืœืช ืœื™ืžื•ื“" ืื—ื•ื“ื” ื‘ืขื‘ืจื™ืช, ืขื ืฉืžื™ืจื” ืขืœ ืขืงื‘ื™ื•ืช ื•ื”ืคื ื™ื•ืช ืœืžืงื˜ืขื™ื (ืœืฆื™ื˜ื•ื˜ื™ื).
151
+ ืžื˜ืจื”: ${sanitize(goal) || 'ื”ื‘ื ื” + ื”ื›ื ื” ืœืžื‘ื—ืŸ ืงืฆืจ'}
152
+
153
+ ืกื™ื›ื•ืžื™ ื”ืžืงื˜ืขื™ื (Map):
154
+ """
155
+ ${mappedMarkdown}
156
+ """
157
+
158
+ (ืœืจืฉื•ืชืš ื’ื ื”ื˜ืงืกื˜ ื”ื’ื•ืœืžื™ ื”ืžืื•ื—ื“ ืœืงืจื™ืืช ื”ืงืฉืจ, ืื ื ื“ืจืฉ):
159
+ """
160
+ ${mergedText.slice(0, 20000)}
161
+ """
162
+
163
+ ื”ืคืง ืคื•ืจืžื˜ Markdown ืžื“ื•ื™ืง:
164
+
165
+ # ืกื™ื›ื•ื (8โ€“12 ืžืฉืคื˜ื™ื) ืขื ืื–ื›ื•ืจื™ ืžืงื˜ืขื™ื (ืœืžืฉืœ [S2], [S5])
166
+ # ื ืงื•ื“ื•ืช ืžืคืชื— (ื‘ื•ืœื˜ื™ื ืงืฆืจื™ื) ื›ื•ืœืœ [Si] ื›ืืฉืจ ืจืœื•ื•ื ื˜ื™
167
+ # ืžื™ืœื•ืŸ ืžื•ืฉื’ื™ื (ื˜ื‘ืœื”: ืžื•ืฉื’ | ื”ืกื‘ืจ ืงืฆืจ | ืžืงื˜ืขื™ื)
168
+ # ื›ืจื˜ื™ืกื™ื•ืช (8โ€“14) โ€” ื›ืœ ืฉื•ืจื”: ืฉืืœื” โ†’ ืชืฉื•ื‘ื” ืงืฆืจื”
169
+ # ืžื‘ื—ืŸ ืœื“ื•ื’ืžื” (10โ€“14 ืฉืืœื•ืช ืžืขื•ืจื‘ื•ืช)
170
+ - ื‘ืกื•ืฃ ื”ื—ื–ืจ ืžืงื˜ืข JSON ืชืงื ื™ ืขื ืžืคืชื— ืชืฉื•ื‘ื•ืช ื‘ืœื‘ื“:
171
+ {"answerKey":[{"qid":"Q1","correct":"B","explanation":"..."}, ...]}
172
+
173
+ ื›ืœืœื™ ื‘ื˜ื™ื—ื•ืช: ืื™ืŸ ืชื›ื ื™ื ืคื•ื’ืขื ื™ื™ื/ืžืกื•ื›ื ื™ื. ื”ืชืืžื” ืœื’ื™ืœ. ืฉืžื™ืจื” ืขืœ ื‘ื”ื™ืจื•ืช.`}
174
+ ];
175
+ }
176
+
177
+ async function gradeRubric(questions, userAnswers, answerKey) {
178
+ const payload = { questions, userAnswers, answerKey };
179
+ const messages = [
180
+ { role: 'user', content:
181
+ `You are a deterministic grader for school practice quizzes.
182
+ Return STRICT JSON only:
183
+ {
184
+ "perQuestion":[{"qid":"Q1","correct":true,"feedback":"..."}, ...],
185
+ "score":{"correct":N,"total":T,"percent":P}
186
+ }
187
+ Be concise and supportive.`},
188
+ { role: 'user', content: JSON.stringify(payload) }
189
+ ];
190
+ const out = await chat(messages, 'You are a fair JSON-only grader. No chatter โ€” JSON only.');
191
+ const parsed = safeParseJSON(out);
192
+ if (parsed?.perQuestion && parsed?.score) return parsed;
193
+
194
+ // Fallback strict compare
195
+ const keyMap = {};
196
+ (answerKey?.answerKey || []).forEach(k => keyMap[String(k.qid)] = String(k.correct).trim().toLowerCase());
197
+ const per = (questions || []).map((q, i) => {
198
+ const qid = q.qid || `Q${i+1}`;
199
+ const ua = String(userAnswers?.[qid] ?? '').trim().toLowerCase();
200
+ const ca = keyMap[qid] || '';
201
+ const correct = !!ua && ua === ca;
202
+ return { qid, correct, feedback: correct ? 'ื ื›ื•ืŸ โ€” ืžืขื•ืœื”!' : `ืฉื’ื•ื™. ื”ืชืฉื•ื‘ื”: ${answerKey?.answerKey?.find(x=>String(x.qid)===qid)?.correct ?? 'โ€”'}` };
203
+ });
204
+ const c = per.filter(p => p.correct).length;
205
+ return { perQuestion: per, score: { correct: c, total: per.length, percent: per.length ? Math.round(100*c/per.length) : 0 } };
206
+ }
207
+
208
+ // ------------- API -------------
209
+
210
+ app.get('/api/health', (_req, res) => res.json({ ok: true, model: MODEL }));
211
+
212
+ // Study Pack Pro: upload OR raw text, chunk โ†’ map โ†’ reduce
213
+ app.post('/api/study-pack', upload.array('files', 6), async (req, res) => {
214
+ const cleanup = () => (req.files || []).forEach(f => fs.existsSync(f.path) && fs.unlinkSync(f.path));
215
+ try {
216
+ const goal = sanitize(req.body.goal).slice(0, 600);
217
+ let merged = '';
218
+
219
+ if (req.files?.length) {
220
+ for (const f of req.files) {
221
+ const t = await extractText(f.path, f.originalname);
222
+ merged += `\n\n===== ${f.originalname} =====\n${t}\n`;
223
+ }
224
+ }
225
+ const rawText = sanitize(req.body.text || '');
226
+ if (rawText) merged += `\n\n===== Pasted Text =====\n${rawText}\n`;
227
+
228
+ if (!merged.trim()) return res.status(400).json({ ok: false, error: 'ืœื ื”ืชืงื‘ืœ ื˜ืงืกื˜. ื”ืขืœื• ืงื‘ืฆื™ื ืื• ื”ื“ื‘ื™ืงื• ื˜ืงืกื˜.' });
229
+
230
+ // Chunking
231
+ const chunks = chunkText(merged, 1200, 120);
232
+ // Map
233
+ const mappedParts = [];
234
+ for (const ch of chunks) {
235
+ const md = await chat(mapPrompt(goal, ch.id, ch.text));
236
+ mappedParts.push(md);
237
+ }
238
+ const mappedMarkdown = mappedParts.join('\n\n');
239
+
240
+ // Reduce
241
+ const reduced = await chat(reducePrompt(goal, mappedMarkdown, merged));
242
+
243
+ // Try extracting the last JSON block as answerKey
244
+ let answerKey = { answerKey: [] };
245
+ const matches = reduced.match(/\{[\s\S]*\}/g) || [];
246
+ for (let i = matches.length - 1; i >= 0; i--) {
247
+ const candidate = safeParseJSON(matches[i]);
248
+ if (candidate?.answerKey) { answerKey = candidate; break; }
249
+ }
250
+
251
+ res.json({ ok: true, studyPackMarkdown: reduced, mappedMarkdown, answerKey, sections: chunks.map(c => c.id) });
252
+ } catch (e) {
253
+ console.error(e);
254
+ res.status(500).json({ ok: false, error: 'ืฉื’ื™ืื” ื‘ื™ืฆื™ืจืช ื”ื—ื‘ื™ืœื”', details: String(e.message || e) });
255
+ } finally { cleanup(); }
256
+ });
257
+
258
+ // Quiz grading
259
+ app.post('/api/grade', async (req, res) => {
260
+ try {
261
+ const { questions, userAnswers, answerKey } = req.body || {};
262
+ if (!questions || !userAnswers || !answerKey) {
263
+ return res.status(400).json({ ok: false, error: 'ื—ืกืจ ืฉืืœื•ืช/ืชืฉื•ื‘ื•ืช/ืžืคืชื—.' });
264
+ }
265
+ const result = await gradeRubric(questions, userAnswers, answerKey);
266
+ res.json({ ok: true, result });
267
+ } catch (e) {
268
+ console.error(e);
269
+ res.status(500).json({ ok: false, error: 'ืฉื’ื™ืื” ื‘ื‘ื“ื™ืงื”', details: String(e.message || e) });
270
+ }
271
+ });
272
+
273
+ // Streaming Chat via SSE
274
+ app.get('/api/chat/stream', async (req, res) => {
275
+ // Simple, non-chunk streaming (simulate typing via SSE) โ€” robust for DeepSite proxies.
276
+ res.set({
277
+ 'Cache-Control': 'no-cache',
278
+ 'Content-Type': 'text/event-stream',
279
+ Connection: 'keep-alive'
280
+ });
281
+ res.flushHeaders();
282
+
283
+ const history = safeParseJSON(req.query.history || '[]') || [];
284
+ const message = sanitize(req.query.message || '');
285
+ const msgs = [];
286
+ for (const m of history) {
287
+ const role = m.role === 'assistant' ? 'assistant' : 'user';
288
+ msgs.push({ role, content: String(m.content || '') });
289
+ }
290
+ msgs.push({ role: 'user', content: message });
291
+
292
+ try {
293
+ const reply = await chat(
294
+ msgs,
295
+ 'You are a friendly Hebrew tutor. Keep it short unless asked for more, include one example, avoid unsafe topics.'
296
+ );
297
+ // simulate streaming: send in slices
298
+ const parts = reply.match(/.{1,60}/g) || [reply];
299
+ for (const p of parts) {
300
+ res.write(`data: ${JSON.stringify({ chunk: p })}\n\n`);
301
+ await new Promise(r => setTimeout(r, 20));
302
+ }
303
+ res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
304
+ res.end();
305
+ } catch (e) {
306
+ res.write(`data: ${JSON.stringify({ error: 'ืฉื’ื™ืื” ื‘ืฆืณืื˜' })}\n\n`);
307
+ res.end();
308
+ }
309
+ });
310
+
311
+ // -------- Frontend (one HTML) --------
312
+ app.get('/', (_req, res) => {
313
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
314
+ res.end(`<!doctype html>
315
  <html lang="he" dir="rtl">
316
  <head>
317
+ <meta charset="utf-8" />
318
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
319
+ <title>DeepStudy Pro โ€” deepsite GPT ืœืชืœืžื™ื“ื™ื</title>
320
+ <style>
321
+ :root{--bg:#060a1a;--ink:#eef1ff;--muted:#b9c3ff;--card:#0b1130;--line:rgba(255,255,255,.12);--acc:#8eb1ff;--acc2:#7affe1}
322
+ *{box-sizing:border-box} body{margin:0;background:radial-gradient(1200px 600px at 60% -200px,#0e1849,transparent),linear-gradient(180deg,#060a1a 0%,#0b1027 100%);color:var(--ink);font-family:Inter,Segoe UI,system-ui,Arial}
323
+ .wrap{max-width:1100px;margin:40px auto;padding:0 16px}
324
+ h1{margin:0 0 8px;font-size:32px;font-weight:900;letter-spacing:.2px}
325
+ .sub{margin:0 0 22px;color:var(--muted)}
326
+ .grid{display:grid;grid-template-columns:1.25fr .75fr;gap:16px}
327
+ @media(max-width:950px){.grid{grid-template-columns:1fr}}
328
+ .card{background:rgba(11,17,48,.7);backdrop-filter:blur(8px);border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:0 12px 30px rgba(0,0,0,.3)}
329
+ h3{margin:0 0 10px}
330
+ .row{display:flex;gap:8px;align-items:center;margin:10px 0}
331
+ input[type="text"],textarea{width:100%;padding:12px 14px;border-radius:12px;border:1px solid var(--line);background:#0a1030;color:var(--ink);resize:vertical}
332
+ textarea{min-height:110px}
333
+ input[type="file"]{border:1px dashed var(--line);padding:10px;border-radius:12px;width:100%;color:var(--muted)}
334
+ .btn{cursor:pointer;border:0;border-radius:12px;padding:11px 16px;font-weight:800;background:linear-gradient(90deg,var(--acc),var(--acc2));color:#03122a}
335
+ .btn.sec{background:transparent;color:var(--acc);border:1px solid var(--acc)}
336
+ .pill{display:inline-flex;align-items:center;gap:8px;background:#0a143b;padding:5px 10px;border:1px solid var(--line);border-radius:999px;font-size:12px;color:var(--muted)}
337
+ .hr{height:1px;background:linear-gradient(90deg,transparent,var(--line),transparent);margin:16px 0}
338
+ .out{white-space:pre-wrap;line-height:1.6}
339
+ .chat{height:420px;overflow:auto;background:#080f2b;border-radius:14px;padding:12px;border:1px solid var(--line)}
340
+ .bubble{padding:10px 12px;border-radius:12px;margin:8px 0;max-width:85%}
341
+ .me{background:#0f1c4e;margin-left:auto}
342
+ .bot{background:#0b1540;border:1px solid var(--line)}
343
+ .small{font-size:12px;color:var(--muted)}
344
+ .actions{display:flex;gap:8px;flex-wrap:wrap}
345
+ kbd{background:#09122e;border:1px solid var(--line);padding:2px 6px;border-radius:6px;font-size:12px}
346
+ table{border-collapse:collapse;width:100%}
347
+ td,th{border:1px solid var(--line);padding:6px}
348
+ .toast{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:#0b1438;border:1px solid var(--line);color:var(--ink);padding:10px 12px;border-radius:10px;display:none}
349
+ </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  </head>
351
  <body>
352
+ <div class="wrap">
353
+ <h1>DeepStudy Pro โ€” ืขื•ื–ืจ ืœื™ืžื•ื“ื™ ืžืฉื•ื’ืข ื˜ื•ื‘</h1>
354
+ <p class="sub">ืžืขืœื™ื ื—ื•ืžืจ โ†’ ืžืงื‘ืœื™ื ื—ื‘ื™ืœืช ืœื™ืžื•ื“ ืžืคื•ืจื˜ืช (ืกื™ื›ื•ื, ืžื•ืฉื’ื™ื, ื›ืจื˜ื™ืกื™ื•ืช, ืžื‘ื—ืŸ + ืžืคืชื—) + ืฆืณืื˜ ืžื•ืจื” ื–ื•ืจื. ื ื‘ื ื” ื‘ืžื™ื•ื—ื“ ืœึพDeepSite.</p>
355
+
356
+ <div class="grid">
357
+ <div class="card">
358
+ <h3>1) ื™ืฆื™ืจืช ื—ื‘ื™ืœืช ืœื™ืžื•ื“ (Pro)</h3>
359
+ <div class="row"><input id="goal" type="text" placeholder="ืžื˜ืจื” (ืœืžืฉืœ: 'ืœื”ื™ืขืจืš ืœืžื‘ื—ืŸ ื‘ื’ืื•ื’ืจืคื™ื” ืขืœ ืืงืœื™ื')"></div>
360
+ <div class="row"><input id="files" type="file" multiple accept=".pdf,.docx,.txt"></div>
361
+ <div class="row"><textarea id="rawText" placeholder="ืื• ื”ื“ื‘ื™ืงื• ื˜ืงืกื˜ ื‘ืžืงื•ื ืงื‘ืฆื™ื"></textarea></div>
362
+ <div class="actions">
363
+ <button class="btn" id="buildBtn">ืฆื•ืจ ื—ื‘ื™ืœืช ืœื™ืžื•ื“</button>
364
+ <button class="btn sec" id="downloadBtn" title="ืžื•ืจื™ื“ ืืช ื”ื—ื‘ื™ืœื” ื”ืื—ืจื•ื ื” ื›-Markdown">ื”ื•ืจื“ืช Markdown</button>
365
+ <span class="pill" id="health">ืžื•ื“ืœ: โ€”</span>
366
  </div>
367
+ <div class="hr"></div>
368
+ <div class="out" id="studyPackOut">โ€”</div>
369
+ <div class="hr"></div>
 
 
 
370
 
371
+ <h3>ื‘ื“ื™ืงืช ืžื‘ื—ืŸ</h3>
372
+ <p class="small">ืื ื ื•ืฆืจ ืžืคืชื— ืชืฉื•ื‘ื•ืช JSON โ€” ืžืœืื• ืชืฉื•ื‘ื•ืช ื•ืงื‘ืœื• ืฆื™ื•ืŸ.</p>
373
+ <textarea id="questionsJSON" placeholder='ืœื“ื•ื’ืžื”: [{"qid":"Q1","type":"mcq","question":"...","choices":["A","B","C","D"]}]'></textarea>
374
+ <textarea id="userAnswersJSON" placeholder='ืœื“ื•ื’ืžื”: {"Q1":"B","Q2":"True"}'></textarea>
375
+ <div class="actions">
376
+ <button class="btn sec" id="gradeBtn">ื‘ื“ื™ืงื” ื•ืฆื™ื•ืŸ</button>
377
+ <button class="btn sec" id="refineBtn" title="ื‘ืงืฉื• ื”ืจื—ื‘ื”/ื—ื™ื“ื•ื“ ืœื ื•ืฉื ืกืคืฆื™ืคื™">ืฉื“ืจื•ื’ ื ื•ืฉื</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  </div>
379
+ <div class="out" id="gradeOut"></div>
380
+ </div>
381
 
382
+ <div class="card">
383
+ <h3>2) ืฆืณืื˜ ืžื•ืจื” (SSE)</h3>
384
+ <div class="chat" id="chat"></div>
385
  <div class="row">
386
+ <input id="msg" type="text" placeholder="ืฉืืœื” ืœืžื•ืจื”... (ืœืžืฉืœ: 'ืชืกื‘ื™ืจื™ ืืช ื—ื•ืง ืื•ื ืขื ื“ื•ื’ืžื”')">
387
+ <button class="btn" id="send">ืฉืœื—</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  </div>
389
+ <p class="small">ื˜ื™ืค: <kbd>โ†‘</kbd> ืžื—ื–ื™ืจ ื”ื•ื“ืขื” ืื—ืจื•ื ื”. ื”ื”ื™ืกื˜ื•ืจื™ื” ื ืฉืžืจืช ืื•ื˜ื•ืžื˜ื™ืช (localStorage).</p>
390
+ </div>
391
+ </div>
392
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
 
394
+ <div class="toast" id="toast"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
+ <script>
397
+ const $ = (id) => document.getElementById(id);
398
+ const chatEl = $('chat'), msgEl = $('msg'), sendBtn = $('send');
399
+ const studyPackOut = $('studyPackOut'), healthEl = $('health');
400
+ const filesEl = $('files'), rawTextEl = $('rawText'), goalEl = $('goal');
401
+ const buildBtn = $('buildBtn'), downloadBtn = $('downloadBtn');
402
+ const questionsJSONEl = $('questionsJSON'), userAnswersJSONEl = $('userAnswersJSON');
403
+ const gradeBtn = $('gradeBtn'), gradeOut = $('gradeOut'), refineBtn = $('refineBtn');
404
+ const toastEl = $('toast');
405
 
406
+ let chatHistory = JSON.parse(localStorage.getItem('dsp_chat') || '[]');
407
+ let lastUserMsg = '';
408
+ let latestMarkdown = '';
409
+ let latestAnswerKey = { answerKey: [] };
410
+
411
+ function toast(txt) {
412
+ toastEl.textContent = txt;
413
+ toastEl.style.display = 'block';
414
+ setTimeout(()=> toastEl.style.display='none', 2200);
415
+ }
416
+
417
+ function addBubble(txt, who='bot'){
418
+ const b = document.createElement('div');
419
+ b.className = 'bubble ' + (who==='me'?'me':'bot');
420
+ b.textContent = txt;
421
+ chatEl.appendChild(b);
422
+ chatEl.scrollTop = chatEl.scrollHeight;
423
+ }
424
 
425
+ function renderHistory(){
426
+ chatEl.innerHTML = '';
427
+ chatHistory.forEach(m => addBubble(m.content, m.role==='user'?'me':'bot'));
428
+ }
429
+ renderHistory();
430
 
431
+ async function health() {
 
432
  try {
433
+ const r = await fetch('/api/health');
434
+ const j = await r.json();
435
+ if (j.ok) healthEl.textContent = 'ืžื•ื“ืœ: ' + (j.model || 'โ€”');
436
+ } catch {}
437
+ }
438
+ health();
439
 
440
+ sendBtn.onclick = async () => {
441
+ const m = msgEl.value.trim();
442
+ if (!m) return;
443
+ lastUserMsg = m;
444
+ addBubble(m, 'me');
445
+ msgEl.value = '';
446
+ chatHistory.push({ role:'user', content:m });
447
+ localStorage.setItem('dsp_chat', JSON.stringify(chatHistory));
448
 
449
+ const src = new EventSource('/api/chat/stream?'+new URLSearchParams({
450
+ message: m,
451
+ history: JSON.stringify(chatHistory.slice(-20)) // ืฉื•ืžืจื™ื ืขืœ ื”ืงืฉืจ ืงืฆืจ
452
+ }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
 
454
+ let acc = '';
455
+ const typing = document.createElement('div');
456
+ typing.className = 'bubble bot';
457
+ typing.textContent = 'ื”ืžื•ืจื” ืžืงืœื™ื“ื”โ€ฆ';
458
+ chatEl.appendChild(typing);
459
+ chatEl.scrollTop = chatEl.scrollHeight;
460
+
461
+ src.onmessage = (e) => {
462
+ const data = JSON.parse(e.data || '{}');
463
+ if (data.chunk) {
464
+ acc += data.chunk;
465
+ typing.textContent = acc;
466
+ }
467
+ if (data.done) {
468
+ chatHistory.push({ role:'assistant', content:acc });
469
+ localStorage.setItem('dsp_chat', JSON.stringify(chatHistory));
470
+ src.close();
471
+ }
472
+ if (data.error) {
473
+ typing.textContent = 'ืฉื’ื™ืื” ื‘ืฆืณืื˜.';
474
+ src.close();
475
  }
476
+ };
477
+ };
478
 
479
+ document.addEventListener('keydown', (ev)=>{
480
+ if (ev.key === 'ArrowUp' && document.activeElement === msgEl && !msgEl.value) {
481
+ msgEl.value = lastUserMsg;
 
 
 
482
  }
483
  });
484
 
485
+ buildBtn.onclick = async () => {
486
+ studyPackOut.textContent = 'โณ ืžืขื‘ื“ ืงื‘ืฆื™ื, ืžืคืฆืœ ืœืžืงื˜ืขื™ื, ื•ืžืจื™ืฅ ืกื™ื›ื•ืžื™ื ื—ื›ืžื™ื...';
487
+ const fd = new FormData();
488
+ if (goalEl.value.trim()) fd.append('goal', goalEl.value.trim());
489
+ if (rawTextEl.value.trim()) fd.append('text', rawTextEl.value.trim());
490
+ for (const f of filesEl.files) fd.append('files', f);
491
+
492
+ const r = await fetch('/api/study-pack', { method:'POST', body: fd });
493
+ const j = await r.json();
494
+ if (!j.ok) {
495
+ studyPackOut.textContent = 'โŒ ' + (j.error || 'ืฉื’ื™ืื”');
496
+ if (j.details) studyPackOut.textContent += '\\n' + j.details;
497
+ return;
498
  }
499
+
500
+ latestMarkdown = j.studyPackMarkdown || '';
501
+ latestAnswerKey = j.answerKey || { answerKey: [] };
502
+ studyPackOut.textContent = latestMarkdown;
503
+
504
+ // ื“ื•ื’ืžืื•ืช JSON ื‘ืฉื“ื•ืช ื‘ื“ื™ืงื”
505
+ if (!questionsJSONEl.value.trim()) {
506
+ questionsJSONEl.value = JSON.stringify([
507
+ {"qid":"Q1","type":"mcq","question":"ื“ื•ื’ืžื”: ืžื”ื™ ืื ืจื’ื™ื” ืงื™ื ื˜ื™ืช?","choices":["A","B","C","D"]},
508
+ {"qid":"Q2","type":"tf","question":"ื“ื•ื’ืžื”: ืœื—ืฅ ืžึดื“ึผึธื” ืกืงืœืจื™ืช. (True/False)"},
509
+ {"qid":"Q3","type":"short","question":"ื“ื•ื’ืžื”: ื›ืชื‘ื™ ืืช ื”ื—ื•ืง ื”ืจืืฉื•ืŸ ืฉืœ ื ื™ื•ื˜ื•ืŸ ื‘ืžืฉืคื˜ ืื—ื“."}
510
+ ], null, 2);
511
+ }
512
+ if (!userAnswersJSONEl.value.trim()) {
513
+ userAnswersJSONEl.value = JSON.stringify({"Q1":"B","Q2":"True","Q3":"ื’ื•ืฃ ืฉื•ืžืจ ืขืœ ืžืฆื‘ื•..."}, null, 2);
514
+ }
515
+ toast('ื”ื—ื‘ื™ืœื” ืžื•ื›ื ื” โœ”');
516
+ };
517
+
518
+ downloadBtn.onclick = () => {
519
+ if (!latestMarkdown.trim()) return toast('ืื™ืŸ ืชื•ื›ืŸ ืœื”ื•ืจื™ื“ ืขื“ื™ื™ืŸ');
520
+ const blob = new Blob([latestMarkdown], { type:'text/markdown;charset=utf-8' });
521
+ const a = document.createElement('a');
522
+ a.href = URL.createObjectURL(blob);
523
+ a.download = 'deepstudy_pack.md';
524
+ a.click();
525
+ URL.revokeObjectURL(a.href);
526
+ };
527
+
528
+ gradeBtn.onclick = async () => {
529
+ gradeOut.textContent = 'ื‘ื•ื“ืง...';
530
+ let questions=null, userAnswers=null;
531
+ try { questions = JSON.parse(questionsJSONEl.value); } catch { return gradeOut.textContent='JSON ืœื ืชืงื™ืŸ ื‘ืฉื“ื” ืฉืืœื•ืช'; }
532
+ try { userAnswers = JSON.parse(userAnswersJSONEl.value); } catch { return gradeOut.textContent='JSON ืœื ืชืงื™ืŸ ื‘ืฉื“ื” ืชืฉื•ื‘ื•ืช'; }
533
+
534
+ const r = await fetch('/api/grade', {
535
+ method:'POST', headers:{'Content-Type':'application/json'},
536
+ body: JSON.stringify({ questions, userAnswers, answerKey: latestAnswerKey })
537
+ });
538
+ const j = await r.json();
539
+ if (!j.ok) return gradeOut.textContent = 'ืฉื’ื™ืื” ื‘ื‘ื“ื™ืงื”.';
540
+ const g = j.result;
541
+ const lines = [];
542
+ lines.push('ืชื•ืฆืื”: ' + g.score.correct + '/' + g.score.total + ' (' + g.score.percent + '%)');
543
+ lines.push('');
544
+ (g.perQuestion||[]).forEach(p => lines.push(`${p.qid}: ${p.correct?'โœ“ ื ื›ื•ืŸ':'โœ— ืฉื’ื•ื™'} โ€” ${p.feedback||''}`));
545
+ gradeOut.textContent = lines.join('\\n');
546
+ };
547
+
548
+ refineBtn.onclick = async () => {
549
+ const sel = prompt('ืื™ื–ื” ื ื•ืฉื ืœืฉื“ืจื’/ืœื”ืจื—ื™ื‘ (ืœืžืฉืœ: "ื—ื•ืง ืฉื ื™ ืฉืœ ื ื™ื•ื˜ื•ืŸ")?');
550
+ if (!sel) return;
551
+ const ask = 'ืฉื“ืจื’/ื™ ื•ื”ืจื—ื™ื‘ื™ ื‘ืžื™ื•ื—ื“ ืืช ื”ื ื•ืฉื: ' + sel + '\\nื›ืœืœื™: ื”ืกื‘ืจ ืžื“ื•ืจื’ + ื“ื•ื’ืžื” ืžืกืคืจื™ืช + ืชืจื’ื™ืœ ืงืฆืจ ืขื ืคืชืจื•ืŸ.';
552
+ msgEl.value = ask;
553
+ sendBtn.click();
554
+ };
555
+ </script>
556
+ </body>
557
+ </html>`);
558
+ });
559
+
560
+ // ---- Start ----
561
+ const PORT = process.env.PORT || 3000;
562
+ if (!process.env.OPENAI_API_KEY) {
563
+ console.warn('โš ๏ธ ื—ืกืจ OPENAI_API_KEY โ€” ื”ื’ื“ื™ืจื™ ืžืฉืชื ื” ืกื‘ื™ื‘ื” ืœืคื ื™ ื”ื”ืจืฆื”.');
564
+ }
565
+ app.listen(PORT, () => {
566
+ console.log('DeepStudy Pro up on http://localhost:'+PORT);
567
  });