|
|
// server.mjs |
|
|
// One-file MVP: Node server + HTML chat UI (RTL Hebrew) + AI proxy |
|
|
// ===== HOW TO RUN ===== |
|
|
// 1) Save this file as server.mjs |
|
|
// 2) In terminal set env vars (PowerShell example): |
|
|
// $env:AI_API_KEY="YOUR_KEY_HERE" |
|
|
// $env:AI_BASE_URL="https://ai.gateway.lovable.dev/v1" # optional, default set below |
|
|
// $env:AI_MODEL="google/gemini-2.5-flash" # optional |
|
|
// node server.mjs |
|
|
// 3) Open http://localhost:5173 |
|
|
// |
|
|
// Notes: |
|
|
// - Your API key stays on the server (safe). The browser never sees it. |
|
|
// - You can paste your own study text ("context") so the bot answers based on it. |
|
|
// - Context is saved in localStorage, and also sent per-message to the server as part of the system prompt. |
|
|
|
|
|
import http from "node:http"; |
|
|
import { readFile } from "node:fs/promises"; |
|
|
import { fileURLToPath } from "node:url"; |
|
|
import { dirname, join } from "node:path"; |
|
|
|
|
|
// ====== CONFIG ====== |
|
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 5173; |
|
|
const AI_BASE_URL = process.env.AI_BASE_URL || "https://ai.gateway.lovable.dev/v1"; |
|
|
const AI_MODEL = process.env.AI_MODEL || "google/gemini-2.5-flash"; |
|
|
const AI_API_KEY = process.env.AI_API_KEY || ""; // MUST be set! |
|
|
|
|
|
// ====== MINI ASSETS (single-file, inline CSS/JS) ====== |
|
|
const HTML = `<!doctype html> |
|
|
<html lang="he" dir="rtl"> |
|
|
<head> |
|
|
<meta charset="utf-8" /> |
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" /> |
|
|
<title>ืขืืืจ ืืืืืืื ืืืช-ืกืคืจื โ MVP</title> |
|
|
<style> |
|
|
:root{ |
|
|
--bg: hsl(210 40% 98%); |
|
|
--card: #fff; |
|
|
--border: hsl(220 13% 91%); |
|
|
--text: hsl(220 15% 20%); |
|
|
--muted: hsl(220 15% 55%); |
|
|
--primary: hsl(195 85% 45%); |
|
|
--primary2: hsl(195 85% 65%); |
|
|
--shadow: 0 8px 24px rgba(14,165,233,.12); |
|
|
--radius: 16px; |
|
|
} |
|
|
*{box-sizing:border-box} |
|
|
html{font-family:system-ui,-apple-system,Segoe UI,Rubik,Arial,sans-serif;background:var(--bg);color:var(--text)} |
|
|
.container{max-width:980px;margin:0 auto;padding:16px} |
|
|
header{background:var(--card);border-bottom:1px solid var(--border)} |
|
|
.head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0} |
|
|
.brand{display:flex;align-items:center;gap:10px} |
|
|
.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)} |
|
|
.logo svg{fill:#fff;opacity:.9} |
|
|
.title{font-weight:700} |
|
|
.sub{font-size:12px;color:var(--muted)} |
|
|
nav{display:flex;gap:8px} |
|
|
.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)} |
|
|
.btn:hover{filter:brightness(1.05)} |
|
|
.btn-ghost{background:#fff;color:var(--primary);border:1px solid var(--primary)} |
|
|
.btn-ghost:hover{background:rgba(14,165,233,.06)} |
|
|
.grid{display:grid;gap:16px} |
|
|
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);padding:16px} |
|
|
.row{display:grid;grid-template-columns:1fr 2fr;gap:16px} |
|
|
@media (max-width:800px){.row{grid-template-columns:1fr}} |
|
|
label{font-size:14px;color:var(--muted);display:block;margin-bottom:6px} |
|
|
input,textarea,select{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:12px;background:#fff;outline:none} |
|
|
textarea{min-height:120px;resize:vertical} |
|
|
.muted{color:var(--muted);font-size:13px} |
|
|
.chat{min-height:50vh;max-height:70vh;overflow:auto;display:flex;flex-direction:column;gap:10px} |
|
|
.bubble{max-width:76%;border:1px solid var(--border);border-radius:14px;padding:10px 12px;background:#fff} |
|
|
.me{margin-left:auto;background:rgba(14,165,233,.08);border-color:rgba(14,165,233,.25)} |
|
|
.bot .who,.me .who{font-size:12px;color:var(--muted);margin-bottom:4px} |
|
|
.toolbar{display:flex;gap:8px} |
|
|
.badge{display:inline-flex;align-items:center;font-size:12px;color:var(--muted);border:1px solid var(--border);padding:2px 8px;border-radius:999px} |
|
|
.row-compact{display:flex;gap:8px} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<header> |
|
|
<div class="container head"> |
|
|
<div class="brand"> |
|
|
<div class="logo" title="ืืฆืืง ืฉืืืจ โ ืกืื ืืจืืืข, ืืื ืขืืืื ืคืื ืืช"> |
|
|
<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> |
|
|
</div> |
|
|
<div> |
|
|
<div class="title">ืขืืืจ ืืืืืืื ืืืช-ืกืคืจื</div> |
|
|
<div class="sub">MVP โข ืฆืณืื + ืืงืฉืจ ืืืืืื (RTL)</div> |
|
|
</div> |
|
|
</div> |
|
|
<nav> |
|
|
<a class="btn-ghost" href="#context">ืืืืจื ืืืืื</a> |
|
|
<a class="btn" href="#chat">ืฆืณืื</a> |
|
|
</nav> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<main class="container grid" id="app"> |
|
|
|
|
|
<section id="context" class="card"> |
|
|
<h2 style="margin:0 0 8px 0;">๐ ืืืืจื ืืืืื / ืืงืฉืจ</h2> |
|
|
<p class="muted" style="margin-top:0">ืืืืืงื ืืื ืชืงืฆืืจ/ืืงืกื ืืืืืืจืช/ืืืื ืืืงืก/ืืชืจ. ืืืื ืืชืืกืก ืขืืื ืืชืฉืืืืช.</p> |
|
|
<div class="row"> |
|
|
<div> |
|
|
<label>ืฉื โืืจืืโ (ืืืฉื: ืืชืืืืงื โ ืคืจืง ืืืงืืช)</label> |
|
|
<input id="spaceName" placeholder="ืฉื ืืืจืื" /> |
|
|
<div style="height:8px"></div> |
|
|
<label>ืฉืคืช ืืืื</label> |
|
|
<select id="botLang"> |
|
|
<option value="he">ืขืืจืืช</option> |
|
|
<option value="en">English</option> |
|
|
</select> |
|
|
<div style="height:8px"></div> |
|
|
<span class="badge" id="stats">0 ืชืืืื ืืงืื ืืงืกื</span> |
|
|
</div> |
|
|
<div> |
|
|
<label>ืืงืกื ืืงืฉืจ (ื ืฉืืจ ืืืคืืคื, ื ืฉืื ืืืื ืืชืืืืช ืืงืฉืจ)</label> |
|
|
<textarea id="contextText" placeholder="ืืืืืงื ืืื ืืงืกื ืืฉืื ืืืืืืจ..."></textarea> |
|
|
<div style="height:8px"></div> |
|
|
<div class="row-compact"> |
|
|
<button class="btn" id="saveCtx">ืฉืืืจื</button> |
|
|
<button class="btn-ghost" id="clearCtx">ื ืืงืื</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
|
|
|
|
|
|
<section id="chat" class="card"> |
|
|
<h2 style="margin-top:0;">๐ฌ ืฆืณืื</h2> |
|
|
<div class="row"> |
|
|
<div> |
|
|
<label>ืืืจืืืช ืืขืจืืช (ืืืคืฆืืื ืื)</label> |
|
|
<textarea id="systemHint" placeholder="ืืืฉื: ืขื ื ืืงืฆืจื, ืฆืืื ืฉืืืื, ืืฉืชืืฉ ืืืืืืืืช ืคืฉืืืืช."></textarea> |
|
|
<div style="height:8px"></div> |
|
|
<label>ืืืื (ืืืคืฆืืื ืื)</label> |
|
|
<input id="model" placeholder="google/gemini-2.5-flash (ืืจืืจืช ืืืื ืืฉืจืช)" /> |
|
|
<div style="height:8px"></div> |
|
|
<div class="muted">ื-API key ื ืฉืืจ ืขื ืืฉืจืช โ ืืืื. ืืื ืฆืืจื ืืฉืื ืืืชื ืืืคืืคื.</div> |
|
|
</div> |
|
|
<div> |
|
|
<div id="chatBox" class="chat" style="border:1px solid var(--border);border-radius:12px;padding:10px;background:#fff"></div> |
|
|
<div style="height:8px"></div> |
|
|
<div class="row-compact"> |
|
|
<input id="msg" placeholder="ืฉืื/ื ืฉืืื ืขื ืกืื ืืืืืจ..." /> |
|
|
<button class="btn" id="send">ืฉืืืื</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
</main> |
|
|
|
|
|
<script> |
|
|
const LS_SPACE = "mvp_space_name"; |
|
|
const LS_TEXT = "mvp_context_text"; |
|
|
const LS_LANG = "mvp_bot_lang"; |
|
|
const chatBox = document.getElementById("chatBox"); |
|
|
const msgInput = document.getElementById("msg"); |
|
|
const sendBtn = document.getElementById("send"); |
|
|
const stats = document.getElementById("stats"); |
|
|
const spaceName = document.getElementById("spaceName"); |
|
|
const contextEl = document.getElementById("contextText"); |
|
|
const saveCtx = document.getElementById("saveCtx"); |
|
|
const clearCtx = document.getElementById("clearCtx"); |
|
|
const botLang = document.getElementById("botLang"); |
|
|
const systemEl = document.getElementById("systemHint"); |
|
|
const modelEl = document.getElementById("model"); |
|
|
|
|
|
|
|
|
spaceName.value = localStorage.getItem(LS_SPACE) || ""; |
|
|
contextEl.value = localStorage.getItem(LS_TEXT) || ""; |
|
|
botLang.value = localStorage.getItem(LS_LANG) || "he"; |
|
|
stats.textContent = (contextEl.value.length || 0) + " ืชืืืื ืืงืื ืืงืกื"; |
|
|
|
|
|
contextEl.addEventListener("input", () => { |
|
|
stats.textContent = (contextEl.value.length || 0) + " ืชืืืื ืืงืื ืืงืกื"; |
|
|
}); |
|
|
|
|
|
saveCtx.addEventListener("click", () => { |
|
|
localStorage.setItem(LS_SPACE, spaceName.value || ""); |
|
|
localStorage.setItem(LS_TEXT, contextEl.value || ""); |
|
|
localStorage.setItem(LS_LANG, botLang.value || "he"); |
|
|
alert("ื ืฉืืจ ืืืฆืืื"); |
|
|
}); |
|
|
clearCtx.addEventListener("click", () => { |
|
|
spaceName.value = ""; |
|
|
contextEl.value = ""; |
|
|
localStorage.removeItem(LS_SPACE); |
|
|
localStorage.removeItem(LS_TEXT); |
|
|
stats.textContent = "0 ืชืืืื ืืงืื ืืงืกื"; |
|
|
}); |
|
|
|
|
|
function appendBubble(role, text){ |
|
|
const wrap = document.createElement("div"); |
|
|
wrap.className = "bubble " + (role === "user" ? "me" : "bot"); |
|
|
const who = document.createElement("div"); |
|
|
who.className = "who"; |
|
|
who.textContent = role === "user" ? "ืืช/ื" : "ืืื"; |
|
|
const body = document.createElement("div"); |
|
|
body.textContent = text; |
|
|
wrap.appendChild(who); |
|
|
wrap.appendChild(body); |
|
|
chatBox.appendChild(wrap); |
|
|
chatBox.scrollTop = chatBox.scrollHeight; |
|
|
} |
|
|
|
|
|
async function ask(){ |
|
|
const q = (msgInput.value || "").trim(); |
|
|
if(!q) return; |
|
|
appendBubble("user", q); |
|
|
msgInput.value = ""; |
|
|
const ctx = contextEl.value || ""; |
|
|
const space = spaceName.value || "ืืจืืจืช ืืืื"; |
|
|
const lang = botLang.value || "he"; |
|
|
const system = (systemEl.value || "") + "\\n" + |
|
|
(lang === "he" |
|
|
? \`ืขื ื ืืขืืจืืช, ืืงืฆืจื ืืืืืืจืืช. ืืฉืชืืฉ ืงืืื ืืื ืืชืืื ืืงืฉืจ ืกืืืจ ืืืื.\` |
|
|
: \`Answer briefly and clearly. Prioritize the provided study context.\`) + |
|
|
"\\n" + |
|
|
\`ืฉื ืืืจืื: "\${space}".\\n\\nืืงืฉืจ/ืืืืจื ืืืืื:\\n\${ctx}\`; |
|
|
|
|
|
try{ |
|
|
const resp = await fetch("/api/chat", { |
|
|
method: "POST", |
|
|
headers: {"Content-Type":"application/json"}, |
|
|
body: JSON.stringify({ message: q, system, model: (modelEl.value || undefined) }) |
|
|
}); |
|
|
const j = await resp.json(); |
|
|
if(!resp.ok || !j.ok){ |
|
|
appendBubble("assistant", (j.error || "ืฉืืืื ืืฉืืจืืช ืืืื ื")); |
|
|
return; |
|
|
} |
|
|
appendBubble("assistant", j.text || (lang==="he"?"ืืื ืชืฉืืื":"No response")); |
|
|
}catch(e){ |
|
|
appendBubble("assistant", (e?.message || "ืฉืืืืช ืจืฉืช")); |
|
|
} |
|
|
} |
|
|
|
|
|
sendBtn.addEventListener("click", ask); |
|
|
msgInput.addEventListener("keydown", (e)=>{ if(e.key==="Enter") ask(); }); |
|
|
|
|
|
// Warm greeting |
|
|
if(!contextEl.value){ |
|
|
appendBubble("assistant", "ืืื! ืืืกืืคื ืืงืกื ืืงืฉืจ (ืืืฉื ืชืงืฆืืจ ืืืืจ ืืืืื), ืืื ืฉืืื ืืืชื ืฉืืืืช ืขืืื."); |
|
|
} else { |
|
|
appendBubble("assistant", "ืืขืืื! ืืฉ ืื ืืงืฉืจ ืืืืืื. ืืคืฉืจ ืืฉืืื ืื ืฉืืื."); |
|
|
} |
|
|
</script> |
|
|
</body> |
|
|
</html>`; |
|
|
|
|
|
const INDEX = Buffer.from(HTML); |
|
|
|
|
|
// ====== SERVER (static + /api/chat) ====== |
|
|
const server = http.createServer(async (req, res) => { |
|
|
try { |
|
|
// Simple router |
|
|
if (req.method === "GET" && (req.url === "/" || req.url?.startsWith("/#"))) { |
|
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); |
|
|
res.end(INDEX); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (req.method === "POST" && req.url === "/api/chat") { |
|
|
if (!AI_API_KEY) { |
|
|
const msg = "Missing AI_API_KEY env var"; |
|
|
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" }); |
|
|
res.end(JSON.stringify({ error: msg })); |
|
|
return; |
|
|
} |
|
|
|
|
|
const chunks = []; |
|
|
for await (const c of req) chunks.push(c); |
|
|
const bodyStr = Buffer.concat(chunks).toString("utf-8"); |
|
|
const payload = JSON.parse(bodyStr || "{}"); |
|
|
|
|
|
const model = payload.model || AI_MODEL; |
|
|
const system = payload.system || "Answer briefly and clearly in Hebrew."; |
|
|
const user = payload.message || ""; |
|
|
|
|
|
// OpenAI-compatible /chat/completions |
|
|
const resp = await fetch(`${AI_BASE_URL}/chat/completions`, { |
|
|
method: "POST", |
|
|
headers: { |
|
|
"Authorization": `Bearer ${AI_API_KEY}`, |
|
|
"Content-Type": "application/json", |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
model, |
|
|
messages: [ |
|
|
{ role: "system", content: system }, |
|
|
{ role: "user", content: user } |
|
|
], |
|
|
}) |
|
|
}); |
|
|
|
|
|
if (!resp.ok) { |
|
|
const text = await resp.text(); |
|
|
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" }); |
|
|
res.end(JSON.stringify({ error: `AI error ${resp.status}`, details: text })); |
|
|
return; |
|
|
} |
|
|
|
|
|
const data = await resp.json(); |
|
|
const text = data?.choices?.[0]?.message?.content ?? ""; |
|
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); |
|
|
res.end(JSON.stringify({ ok: true, text })); |
|
|
return; |
|
|
} |
|
|
|
|
|
// 404 |
|
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); |
|
|
res.end("Not found"); |
|
|
} catch (e) { |
|
|
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" }); |
|
|
res.end(JSON.stringify({ error: e?.message || "server error" })); |
|
|
} |
|
|
}); |
|
|
|
|
|
server.listen(PORT, () => { |
|
|
console.log(`โ
Study Assistant MVP running on http://localhost:${PORT}`); |
|
|
if (!AI_API_KEY) { |
|
|
console.warn("โ ๏ธ AI_API_KEY is missing. Set it before chatting."); |
|
|
} else { |
|
|
console.log("๐ AI key loaded. Ready to chat."); |
|
|
} |
|
|
}); |
|
|
|