Spaces:
Running
Running
File size: 18,685 Bytes
fc75aa2 f9d798d fc75aa2 f9d798d fc75aa2 f9d798d fc75aa2 f9d798d fc75aa2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 | /* MIS 752 Lite labs: shared engine. Every no-code lab page loads this, then its own lab.js.
*
* Runs entirely in the student's browser: the page calls OpenRouter directly with the key the
* student pastes, so the key never passes through any server and is never saved (it lives only in
* this page's memory and is gone when the tab closes). OpenRouter allows these browser calls (CORS).
* When the page is opened from the instructor's own computer (localhost) with no key, it uses the
* local Ollama model instead, so the instructor can demo with no account.
*/
"use strict";
const OR = "https://openrouter.ai/api/v1";
const Lite = {
key: "",
model: "",
showPaid: false,
free: [],
paid: [],
LOCAL_MODEL: "qwen2.5:3b-instruct",
isLocal: ["localhost", "127.0.0.1"].includes(location.hostname),
FREE_PREFERRED: ["qwen/qwen3.8-27b:free", "google/gemma-4-31b-it:free"],
/* "Show paid models too" lists only these, not OpenRouter's few hundred: low-cost models from well-known
* makers that take tool calls. Starred ones come first; the first one is the default. Prices are read live
* from OpenRouter, and a model that disappears or stops taking tools drops out of the list by itself.
* Not GPT-3.5: it costs more than every model here, and OpenAI replaced it with GPT-4o mini in July 2024. */
RECOMMENDED_PAID: [
{id: "openai/gpt-4o-mini", name: "GPT-4o mini", maker: "OpenAI", star: true, why: "replaced GPT-3.5 in ChatGPT; cheaper and better with tools"},
{id: "google/gemini-2.5-flash-lite", name: "Gemini 2.5 Flash-Lite", maker: "Google", star: true, why: "fast and low-cost"},
{id: "qwen/qwen3-235b-a22b-2507", name: "Qwen3 235B", maker: "Alibaba", star: true, why: "a large open model, strong with tools"},
{id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", maker: "DeepSeek", why: "DeepSeek's low-cost model"},
{id: "openai/gpt-4.1-nano", name: "GPT-4.1 nano", maker: "OpenAI", why: "OpenAI's smallest quick model"},
{id: "mistralai/mistral-small-3.2-24b-instruct", name: "Mistral Small 3.2", maker: "Mistral AI", why: "a small open model from France"},
{id: "openai/gpt-4.1-mini", name: "GPT-4.1 mini", maker: "OpenAI", why: "a step up; costs a little more, very dependable with tools"},
],
// Rough size of one full pass through a lab (both sides of every example, plus a few retries), for the cost estimate.
LAB_TOKENS: {in: 25000, out: 5000},
esc(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]));
},
sleep(ms) { return new Promise(r => setTimeout(r, ms)); },
$(sel, root = document) { return root.querySelector(sel); },
/* ---------- models and keys ---------- */
async loadModels() {
try {
const r = await fetch(OR + "/models");
const data = (await r.json()).data || [];
const tool = data.filter(m => (m.supported_parameters || []).includes("tools"));
this.free = tool.filter(m => m.id.endsWith(":free")).map(m => ({id: m.id, label: m.id}))
.sort((a, b) => a.id.localeCompare(b.id));
const live = Object.fromEntries(tool.map(m => [m.id, m]));
this.paid = this.RECOMMENDED_PAID.filter(r => live[r.id]).map(r => this.priced(r, live[r.id].pricing));
} catch (e) {
this.free = this.FREE_PREFERRED.map(id => ({id, label: id}));
this.paid = [];
}
if (!this.free.length) this.free = this.FREE_PREFERRED.map(id => ({id, label: id}));
if (!this.paid.length) this.paid = this.RECOMMENDED_PAID.map(r => this.priced(r, null));
},
/* One shortlist entry with its live price and what a whole lab costs on it, in plain words. */
priced(r, pricing) {
const pin = parseFloat(pricing && pricing.prompt) * 1e6, pout = parseFloat(pricing && pricing.completion) * 1e6;
const known = isFinite(pin) && isFinite(pout);
const cents = (pin * this.LAB_TOKENS.in + pout * this.LAB_TOKENS.out) / 1e6 * 100;
const cost = !known ? "" : cents < 1 ? "under 1¢" : `about ${Math.ceil(cents)}¢`;
return {...r, pin, pout, known, cost,
label: `${r.star ? "⭐ " : ""}${r.name} (${r.maker})${cost ? ` · ${cost} for the whole lab` : ""}`};
},
fillModelSelect(select) {
const opts = list => list.map(m => `<option value="${this.esc(m.id)}">${this.esc(m.label)}</option>`).join("");
let pick;
if (this.showPaid && this.paid.length) {
const groups = [["⭐ Recommended: start here", this.paid.filter(m => m.star)], ["Also good", this.paid.filter(m => !m.star)]];
select.innerHTML = groups.filter(g => g[1].length)
.map(([label, list]) => `<optgroup label="${this.esc(label)}">${opts(list)}</optgroup>`).join("");
pick = this.paid[0].id;
} else {
select.innerHTML = opts(this.free);
pick = this.FREE_PREFERRED.find(p => this.free.some(m => m.id === p)) || (this.free[0] && this.free[0].id) || "";
}
select.value = pick;
this.model = pick;
},
/* The note under the model list while paid models show: who makes the chosen one, why it is on the list, its cost. */
paidNote() {
if (!this.showPaid) return "";
const m = this.paid.find(p => p.id === this.model);
const about = !m ? "" : `<br><b>${this.esc(m.name)}</b> (${this.esc(m.maker)}) · ${this.esc(m.why)}` +
(m.known ? ` · $${m.pin.toFixed(2)} in / $${m.pout.toFixed(2)} out per million tokens · this whole lab: <b>${m.cost}</b>` : "");
return `<div class="keystat meh">💳 <b>Paid models charge your OpenRouter credit.</b> Untick the box to go back to free models.${about}</div>`;
},
async checkKey(key) {
key = (key || "").trim();
if (!key) return "";
if (!key.startsWith("sk-or-") || key.length < 30) {
return '<div class="keystat bad">❌ That does not look like an OpenRouter key. OpenRouter keys start with <b>sk-or-</b>. Copy the whole key from openrouter.ai, under Keys.</div>';
}
let r;
try {
r = await fetch(OR + "/key", {headers: {"Authorization": "Bearer " + key}});
} catch (e) {
return '<div class="keystat meh">⚠️ Could not reach OpenRouter to check the key. Try a run anyway.</div>';
}
if (r.status === 401) {
return '<div class="keystat bad">❌ OpenRouter did not accept this key. It may be mistyped, deleted, or expired. Make a new one at openrouter.ai, under Keys.</div>';
}
if (r.status === 403) {
return '<div class="keystat meh">⚠️ OpenRouter refused to check this key (403). That usually means a spending limit or budget was reached, not a bad key. Try a free model (its name ends in :free).</div>';
}
if (!r.ok) return `<div class="keystat meh">⚠️ Could not check the key right now (OpenRouter said ${r.status}). Try a run anyway.</div>`;
const d = ((await r.json()) || {}).data || {};
const parts = ["✅ <b>Key works.</b>"];
if (typeof d.limit === "number" && d.limit) {
const left = typeof d.limit_remaining === "number" ? d.limit_remaining : d.limit - (d.usage || 0);
parts.push(`Credit left: <b>$${left.toFixed(2)}</b> of $${d.limit.toFixed(2)}.`);
} else if (typeof d.usage === "number") {
parts.push(`Used so far: $${d.usage.toFixed(2)} (no spending limit set on this key).`);
}
if (d.is_free_tier) parts.push("This is a <b>free-tier</b> key: free models only, about 20 requests a minute.");
return `<div class="keystat ok">${parts.join(" ")}</div>`;
},
backend() {
if (this.key) return {url: OR + "/chat/completions", key: this.key, model: this.model, where: "OpenRouter"};
if (this.isLocal) return {url: "http://localhost:11434/v1/chat/completions", key: "ollama", model: this.LOCAL_MODEL, where: "local Ollama"};
return null;
},
friendly(status, data) {
const m = String((data && data.error && (data.error.message || data.error)) || "").replace(/\s+/g, " ").trim();
if (status === 429) return "This free model is busy, or you hit the free limit (about 20 requests a minute). Wait a minute, or pick another model at the top.";
if (status === 401) return "The key was not accepted. Check that you pasted the whole OpenRouter key.";
if (status === 402) return "This key has no credit left for that model. Add credit at openrouter.ai, or pick a free model (its name ends in :free).";
if (status === 403) {
if (/budget|limit|spend|credit/i.test(m)) return `Your key is fine, but it has reached a spending limit, so OpenRouter refused this paid model. Pick a free model (its name ends in :free), or raise the limit at openrouter.ai under Settings. OpenRouter said: ${m.slice(0, 160)}`;
if (/moderat|flag/i.test(m)) return `OpenRouter's safety filter blocked this request. OpenRouter said: ${m.slice(0, 160)}`;
return `OpenRouter refused this request (403). Your key may still be fine: try a free model (its name ends in :free). OpenRouter said: ${m.slice(0, 160)}`;
}
if (status === 404) return /tool/i.test(m) ? "That model cannot use tools right now. Pick another one at the top." : "That model is not available right now. Pick another one at the top.";
return `The model service returned an error (${status}): ${m.slice(0, 200)}`;
},
/* One model call, retried once on a free-tier rate limit. Returns {message, usage, secs, model, where}. */
async chat(messages, tools) {
const b = this.backend();
if (!b) { const e = new Error("Paste your OpenRouter key in block 0 at the top first."); e.friendly = true; throw e; }
const body = {model: b.model, messages, temperature: 0, max_tokens: 1500};
if (tools && tools.length) body.tools = tools;
for (let attempt = 0; attempt < 2; attempt++) {
const t0 = performance.now();
let r;
try {
// Local Ollama only accepts plain requests from a web page; OpenRouter also takes the key and a title.
const headers = b.where === "local Ollama" ? {"Content-Type": "application/json"}
: {"Authorization": "Bearer " + b.key, "Content-Type": "application/json", "X-Title": "MIS 752 Lite labs"};
r = await fetch(b.url, {method: "POST", body: JSON.stringify(body), headers});
} catch (err) {
const e = new Error(b.where === "local Ollama" ? "The local model is not running. Start Ollama, or paste an OpenRouter key."
: "Could not reach OpenRouter. Check your internet connection.");
e.friendly = true; throw e;
}
const secs = (performance.now() - t0) / 1000;
let data = {};
try { data = await r.json(); } catch (e) { /* empty body */ }
if (r.status === 429 && attempt === 0) { await this.sleep(4000); continue; }
if (!r.ok || data.error) { const e = new Error(this.friendly(r.status, data)); e.friendly = true; throw e; }
const message = (data.choices && data.choices[0] && data.choices[0].message) || {};
return {message, usage: data.usage || {}, secs, model: b.model, where: b.where};
}
},
/* ---------- drawing ---------- */
VERDICT: {
right: ["✅", "RIGHT", "grounded in the facts", "ok"],
honest: ["🤷", "COULDN'T ANSWER", "honest, but no help", "meh"],
wrong: ["❌", "MADE IT UP", "sounds sure, not checked", "bad"],
skipped: ["⚠️", "SKIPPED A STEP", "a lookup it needed never happened", "bad"],
miscopied: ["✏️", "COPIED IT WRONG", "the tool was right, the answer is not", "bad"],
open: ["💬", "ANSWERED", "your question has no answer key", "meh"],
error: ["⛔", "COULD NOT RUN", "", "bad"],
used: ["✅", "USED YOUR TOOL", "it read your paragraph and called it", "ok"],
unused: ["⚠️", "IGNORED YOUR TOOL", "rewrite the 'what it is for' paragraph and try again", "meh"],
},
step(icon, who, text, cls, fresh = false) {
return `<div class="tl-row ${cls}${fresh ? " new" : ""}"><span class="tl-ic">${icon}</span><span class="tl-who">${who}</span>` +
`<span class="tl-tx">${this.esc(text)}</span></div>`;
},
pill(code) {
if (!code) return "";
const [emo, label, mean, css] = this.VERDICT[code];
return `<div class="vpill ${css}"><span class="v-emo">${emo}</span><span><span class="v-label">${label}</span>` +
`<span class="v-mean">${mean}</span></span></div>`;
},
/* One result panel: headline first (verdict), then the answer, then how it got there, then the raw output drawer. */
panel({cls, title, sub, verdict, answer, busy, rows, term}) {
const out = [`<div class="p-top"><div><div class="p-title">${title}</div><div class="p-sub">${sub}</div></div>${this.pill(verdict)}</div>`];
if (answer != null) out.push(`<div class="answer"><div class="a-label">💬 Its answer</div>${this.esc(answer)}</div>`);
else if (busy) out.push('<div class="answer waiting"><div class="a-label">💬 Its answer</div>⏳ working…</div>');
if (rows && rows.length) out.push(`<div class="tl"><div class="tl-head">How it got there</div>${rows.join("")}</div>`);
out.push(term || "");
return `<div class="panel ${cls}">${out.join("")}</div>`;
},
terminal(system, question, events, offered) {
const L = [`$ model: ${events.model || "?"}`, `$ tools offered: ${offered}`, "", `→ SYSTEM ${system}`, `→ USER ${question}`];
for (const e of events) {
if (e[0] === "raw") {
const r = e[1];
let meta = `${r.secs.toFixed(1)} s`;
if (r.tokens_in != null) meta += ` · ${r.tokens_in} tokens in, ${r.tokens_out} out`;
L.push("", `── call #${r.turn + 1} to the model (${meta}) ──`);
for (const c of r.tool_calls) L.push(`← MODEL asks for a tool: ${c.name}(${c.arguments})`);
if ((r.content || "").trim() || !r.tool_calls.length) L.push(`← MODEL says: ${(r.content || "").trim() || "(nothing)"}`);
} else if (e[0] === "result") {
L.push(`⚙ YOUR SYSTEM ran ${e[1]} and returned:`, JSON.stringify(e[2], null, 2), "→ that result goes back to the model");
} else if (e[0] === "error") {
L.push(`!! ERROR: ${e[1]}`);
}
}
return `<details class="out"><summary>Output: what the computer actually sent and received</summary><pre class="term">${this.esc(L.join("\n"))}</pre></details>`;
},
note(text, big = false) { return `<div class="note${big ? " big" : ""}">${this.esc(text)}</div>`; },
/* ---------- thinking aids shared by every lab: book links and model answers ---------- */
BOOK: "https://deepneuro.ai/books/ai-healthcare/Healthcare-Analytics-and-AI.pdf",
/* A "Read more in the book" link to one section: pdf is the PDF page (for #page=), page is the printed page number. */
bookLink(title, pdf, page) {
return `<a class="bookref" href="${this.BOOK}#page=${pdf}" target="_blank" rel="noopener">📖 Read more in the book: ${this.esc(title)} (p. ${page})</a>`;
},
words(t) { return (String(t || "").trim().match(/\S+/g) || []).length; },
sentences(t) { return (String(t || "").match(/[^.!?]{12,}[.!?](\s|$)/g) || []).length; },
/* "Compare with a model answer": the button unlocks only after the student has written two sentences of their own.
* onReveal gets what they had written at that moment, so the hand-in can show their thinking came first. */
modelAnswer(textarea, holder, answer, onReveal) {
holder.innerHTML = '<button type="button" class="ma-btn" disabled>💡 Compare with a model answer</button>' +
'<span class="ma-hint">Unlocks after you write two sentences of your own.</span>';
const btn = holder.querySelector(".ma-btn"), hint = holder.querySelector(".ma-hint");
const sync = () => { const ok = this.sentences(textarea.value) >= 2 || this.words(textarea.value) >= 30; btn.disabled = !ok; hint.hidden = ok; };
textarea.addEventListener("input", sync);
sync();
btn.addEventListener("click", () => {
if (onReveal) onReveal(textarea.value.trim());
holder.innerHTML = `<div class="ma-box"><div class="ma-label">💡 One good answer (yours can differ and still be right)</div>${this.esc(answer)}</div>`;
});
},
/* Three big prediction buttons. Returns a function that reads the current choice. Each lab passes its own lead-in. */
predict(container, choices, lead = "Without tools, the model will…") {
let chosen = "";
container.innerHTML = `<div class="predlabel">🤔 What do you think will happen? <span>${this.esc(lead)}</span></div>` +
`<div class="btnrow">${choices.map(([emo, txt]) => `<button type="button" class="predbtn" data-v="${this.esc(txt)}">${emo} ${this.esc(txt)}</button>`).join("")}</div>`;
container.querySelectorAll("button.predbtn").forEach(btn => btn.addEventListener("click", () => {
chosen = btn.dataset.v;
container.querySelectorAll("button.predbtn").forEach(b => b.classList.toggle("selected", b === btn));
}));
return () => chosen;
},
download(filename, text) {
const url = URL.createObjectURL(new Blob([text], {type: "text/plain;charset=utf-8"}));
const a = document.createElement("a");
a.href = url; a.download = filename; document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
},
today() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
},
/* Wire block 0: key box, model list, paid checkbox. */
async wireConnect() {
const key = this.$("#key"), sel = this.$("#model"), paid = this.$("#paid"), stat = this.$("#keystat"), pnote = this.$("#paidnote");
if (this.isLocal) this.$("#localnote").hidden = false;
sel.innerHTML = '<option>loading models…</option>';
await this.loadModels();
this.showPaid = paid.checked; // a browser can restore the tick on reload
this.fillModelSelect(sel);
pnote.innerHTML = this.paidNote();
sel.addEventListener("change", () => { this.model = sel.value; pnote.innerHTML = this.paidNote(); });
paid.addEventListener("change", () => {
this.showPaid = paid.checked;
this.fillModelSelect(sel);
pnote.innerHTML = this.paidNote();
});
let lastChecked = "";
const check = async () => {
this.key = key.value.trim();
if (this.key === lastChecked) return;
lastChecked = this.key;
stat.innerHTML = this.key ? '<div class="status">checking the key…</div>' : "";
stat.innerHTML = await this.checkKey(this.key);
};
key.addEventListener("input", () => { this.key = key.value.trim(); if (this.key.startsWith("sk-or-") && this.key.length > 40) check(); });
key.addEventListener("change", check);
key.addEventListener("blur", check);
},
};
|