Spaces:
Running
Running
| /* Nawah-Router demo — editable categories, live routing, connectors weighted by probability. */ | |
| /* Three backbones on the same head: a 6M bidirectional BERT encoder (Arabic), the 52M | |
| Llama decoder (Arabic), and a 6M BERT encoder pretrained bilingually (Arabic+English). | |
| Switching re-routes immediately so the comparison lands on the same text and the same | |
| categories. Declared here because run() references it. */ | |
| var currentModel = "6M-BERT"; | |
| const $ = id => document.getElementById(id); | |
| const PRESETS = [ | |
| { name: "توجيه مساعد ذكي", | |
| text: "عندي كود بايثون بيطلع خطأ عند قراءة ملف CSV كبير، ممكن تشوف السبب وتظبطه؟", | |
| cats: ["مهمة برمجية بسيطة", "مهمة برمجية معقدة", "ترجمة", "كتابة إبداعية", | |
| "بحث عن معلومة مباشرة", "محادثة عابرة"] }, | |
| { name: "فرز خدمة العملاء", | |
| text: "الطلب تأخر ساعة ونصف عن الموعد والسائق ما رد على الاتصال، وأبغى تعويض عن التأخير.", | |
| cats: ["شكوى غاضبة تحتاج تصعيدًا فوريًا", "استفسار عادي غير مستعجل", | |
| "طلب استرجاع مبلغ", "رسالة شكر وثناء", "رسالة مزعجة إعلانية"] }, | |
| { name: "تصنيف المحتوى", | |
| text: "بعد شهرين من الاستخدام اليومي، جودة الصوت ممتازة لكن البطارية لا تدوم أكثر من خمس ساعات.", | |
| cats: ["مراجعة منتج", "خبر اقتصادي", "إعلان تجاري", "مقال رأي", "محتوى تقني متقدم"] }, | |
| { name: "تعقيد الطلب", | |
| text: "أحتاج خطة كاملة لترحيل قاعدة بيانات من MySQL إلى PostgreSQL مع الحفاظ على البيانات وبدون توقف الخدمة.", | |
| cats: ["طلب بسيط ومباشر", "يحتاج خطوتين أو ثلاثًا", "طلب معقّد متعدد الخطوات", | |
| "يحتاج نموذجًا أكبر"] }, | |
| { name: "أي أداة نستدعي", | |
| text: "كم صار سعر صرف الدولار مقابل الجنيه اليوم؟", | |
| cats: ["بحث في الويب", "حاسبة", "تقويم ومواعيد", "قاعدة بيانات", "لا يحتاج أداة"] }, | |
| { name: "دعم فني", | |
| text: "ما أقدر أسجل دخول للحساب، يطلع لي أن كلمة المرور غلط مع إني متأكد منها.", | |
| cats: ["مشكلة في الدخول إلى الحساب", "استفسار عن الفاتورة", "طلب ميزة جديدة", | |
| "بلاغ عطل حرج يوقف الخدمة", "أخرى / لا ينطبق"] }, | |
| { name: "English: assistant routing", | |
| text: "My Python script throws an error reading a large CSV file, can you find the bug and fix it?", | |
| cats: ["simple coding task", "complex coding task", "translation", "creative writing", | |
| "direct factual lookup", "casual conversation"] }, | |
| { name: "English: support triage", | |
| text: "The order is an hour and a half late and the driver isn't answering my calls. I want a refund for the delay.", | |
| cats: ["angry complaint needing immediate escalation", "routine non-urgent enquiry", | |
| "refund request", "thank-you message", "spam / promotional message"] }, | |
| { name: "English: content classification", | |
| text: "After two months of daily use, the sound quality is excellent but the battery doesn't last more than five hours.", | |
| cats: ["product review", "economic news", "advertisement", "opinion piece", "advanced technical content"] }, | |
| { name: "English: task complexity", | |
| text: "I need a full plan to migrate a database from MySQL to PostgreSQL, keeping the data intact with no service downtime.", | |
| cats: ["simple, direct request", "needs two or three steps", "complex multi-step request", | |
| "needs a larger model"] }, | |
| { name: "English: tool routing", | |
| text: "What's today's exchange rate between the dollar and the pound?", | |
| cats: ["web search", "calculator", "calendar and scheduling", "database", "no tool needed"] }, | |
| { name: "English: technical support", | |
| text: "I can't log into my account, it says my password is wrong even though I'm sure it's correct.", | |
| cats: ["account login issue", "billing enquiry", "feature request", | |
| "critical outage report", "other / not applicable"] }, | |
| ]; | |
| let rows = []; // [{el, input, fill, pct}] | |
| let timer = null, seq = 0; | |
| /* A half-typed category name is not a category, so edits to the lane list do not auto-route the | |
| way prompt edits do. Any change here sets `dirty`, which suspends auto-routing until the user | |
| submits — otherwise every keystroke would score a lane that does not exist yet. */ | |
| let dirty = false; | |
| function setDirty(on) { | |
| dirty = on; | |
| $("go").classList.toggle("dirty", on); | |
| $("go").disabled = !on && !$("text").value.trim(); | |
| $("dirtynote").textContent = on ? "تغيّرت الفئات — اضغط للتوجيه" : ""; | |
| } | |
| function cats() { | |
| return rows.map(r => r.input.value.trim()); | |
| } | |
| function addRow(value = "", focus = false) { | |
| const el = document.createElement("div"); | |
| el.className = "row"; | |
| el.innerHTML = `<div class="fill" style="width:0%"></div> | |
| <input type="text" value="" placeholder="اسم الفئة…"> | |
| <span class="pct">—</span> | |
| <button class="del" type="button" title="حذف">×</button>`; | |
| const input = el.querySelector("input"); | |
| input.value = value; | |
| input.dir = "auto"; // mixed Arabic/English category text, now that both route | |
| input.addEventListener("input", () => setDirty(true)); | |
| input.addEventListener("keydown", ev => { | |
| if (ev.key === "Enter") { ev.preventDefault(); submit(); } | |
| }); | |
| el.querySelector(".del").addEventListener("click", () => { | |
| rows = rows.filter(r => r.el !== el); | |
| el.remove(); drawWires(); setDirty(true); | |
| }); | |
| $("rows").appendChild(el); | |
| rows.push({ el, input, fill: el.querySelector(".fill"), pct: el.querySelector(".pct") }); | |
| if (focus) input.focus(); | |
| drawWires(); | |
| } | |
| function setPreset(p, btn) { | |
| document.querySelectorAll(".preset").forEach(b => b.classList.remove("on")); | |
| if (btn) btn.classList.add("on"); | |
| $("text").value = p.text; | |
| $("rows").innerHTML = ""; rows = []; | |
| p.cats.forEach(c => addRow(c)); | |
| updateCount(); setDirty(false); schedule(0); | |
| } | |
| /* Connectors: prompt -> model -> each category, thickness and opacity carrying the score. | |
| Redrawn on resize and whenever rows change, since row heights move. */ | |
| function drawWires(scores) { | |
| const svg = $("wires"), stage = svg.parentElement; | |
| if (window.innerWidth <= 900) { svg.innerHTML = ""; return; } | |
| const S = stage.getBoundingClientRect(); | |
| const P = $("promptcard").getBoundingClientRect(); | |
| const N = $("node").getBoundingClientRect(); | |
| const rtl = getComputedStyle(document.body).direction === "rtl"; | |
| // in RTL the prompt sits on the right, so wires run right-to-left | |
| const px = (rtl ? P.left : P.right) - S.left, py = P.top + P.height / 2 - S.top; | |
| const nIn = (rtl ? N.right : N.left) - S.left, nOut = (rtl ? N.left : N.right) - S.left; | |
| const ny = N.top + N.height / 2 - S.top; | |
| let d = `<path d="M ${px} ${py} C ${(px + nIn) / 2} ${py}, ${(px + nIn) / 2} ${ny}, ${nIn} ${ny}" | |
| fill="none" stroke="var(--acc-line)" stroke-width="2" opacity=".7"/>`; | |
| rows.forEach((r, i) => { | |
| const R = r.el.getBoundingClientRect(); | |
| const cx = (rtl ? R.right : R.left) - S.left, cy = R.top + R.height / 2 - S.top; | |
| const s = scores ? scores[i] : null; | |
| const w = s == null ? 1.2 : 1 + s * 4.5; | |
| const o = s == null ? .3 : .18 + s * .72; | |
| d += `<path d="M ${nOut} ${ny} C ${(nOut + cx) / 2} ${ny}, ${(nOut + cx) / 2} ${cy}, ${cx} ${cy}" | |
| fill="none" stroke="var(--acc-line)" stroke-width="${w.toFixed(2)}" | |
| opacity="${o.toFixed(2)}" stroke-linecap="round"/>`; | |
| }); | |
| svg.setAttribute("viewBox", `0 0 ${S.width} ${S.height}`); | |
| svg.innerHTML = d; | |
| } | |
| function updateCount() { $("charcount").textContent = $("text").value.length; } | |
| function schedule(delay = 260) { | |
| updateCount(); | |
| if (dirty) return; // lane list is mid-edit; wait for an explicit submit | |
| clearTimeout(timer); | |
| timer = setTimeout(run, delay); | |
| } | |
| function submit() { | |
| clearTimeout(timer); | |
| setDirty(false); | |
| run(); | |
| } | |
| async function run() { | |
| const text = $("text").value.trim(), cs = cats().filter(Boolean); | |
| if (!text || !cs.length) { | |
| rows.forEach(r => { r.fill.style.width = "0%"; r.pct.textContent = "—"; | |
| r.el.classList.remove("top"); }); | |
| $("stats").textContent = ""; drawWires(); return; | |
| } | |
| const my = ++seq; | |
| $("orb").classList.add("busy"); | |
| try { | |
| const res = await fetch("/api/route", { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ text, cats: cats(), model: currentModel }) | |
| }); | |
| const data = await res.json(); | |
| if (my !== seq) return; // a newer keystroke already superseded this | |
| const byName = new Map(data.results.map(r => [r.route, r.score])); | |
| const scores = rows.map(r => byName.get(r.input.value.trim()) ?? 0); | |
| const top = scores.indexOf(Math.max(...scores)); | |
| rows.forEach((r, i) => { | |
| const s = scores[i]; | |
| r.fill.style.width = (s * 100).toFixed(1) + "%"; | |
| r.pct.textContent = (s * 100).toFixed(1) + "%"; | |
| r.el.classList.toggle("top", i === top && s > 0); | |
| }); | |
| const langLbl = data.lang === "ar" ? "عربي" : "English"; | |
| $("stats").textContent = `${data.ms} ms · ${data.tokens} توكن · ${cs.length} فئة · ${langLbl}`; | |
| drawWires(scores); | |
| } catch (e) { | |
| $("stats").textContent = "تعذّر الاتصال"; | |
| } finally { | |
| if (my === seq) $("orb").classList.remove("busy"); | |
| } | |
| } | |
| $("text").addEventListener("input", () => { schedule(); if (dirty) setDirty(true); }); | |
| $("add").addEventListener("click", () => { addRow("", true); setDirty(true); }); | |
| $("go").addEventListener("click", submit); | |
| window.addEventListener("resize", () => drawWires()); | |
| PRESETS.forEach((p, i) => { | |
| const b = document.createElement("button"); | |
| b.className = "preset"; b.type = "button"; b.textContent = p.name; | |
| b.addEventListener("click", () => setPreset(p, b)); | |
| $("presets").appendChild(b); | |
| if (i === 0) setTimeout(() => setPreset(p, b), 0); | |
| }); | |
| fetch("/api/ready").then(r => r.json()).then(d => { | |
| $("dot").classList.add("on"); | |
| $("nodemeta").textContent = `CPU · مسار واحد · حتى ${d.max_routes} فئات`; | |
| currentModel = d.default || currentModel; | |
| const pick = $("modelpick"); | |
| const ICON_LABEL = { | |
| "6M-BERT": "🔵 6M encoder (AR)", | |
| "52M": "🧱 52M decoder (AR)", | |
| "6M-BILINGUAL": "🌐 6M encoder (AR+EN)", | |
| }; | |
| (d.models || []).forEach(m => { | |
| const o = document.createElement("option"); | |
| o.value = m.key; | |
| o.textContent = ICON_LABEL[m.key] || m.key; | |
| o.title = m.label; | |
| if (m.key === currentModel) o.selected = true; | |
| pick.appendChild(o); | |
| }); | |
| const sizeOf = k => { | |
| const m = (d.models || []).find(x => x.key === k); | |
| return m ? (m.params / 1e6).toFixed(1) + "M" : ""; | |
| }; | |
| $("modelsize").textContent = sizeOf(currentModel); | |
| pick.addEventListener("change", () => { | |
| currentModel = pick.value; | |
| $("modelsize").textContent = sizeOf(currentModel); | |
| run(); // same text, same categories, other backbone | |
| }); | |
| }).catch(() => { $("modelsize").textContent = "غير متصل"; }); | |