Update index.html
Browse files- index.html +321 -377
index.html
CHANGED
|
@@ -3,17 +3,14 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
| 6 |
-
<title>دردشة ذكاء اصطناعي
|
| 7 |
<style>
|
| 8 |
:root{--bg:#0f172a;--panel:#111c33;--ai:#1e293b;--user:#0284c7;--line:#27344d;--text:#e2e8f0;--muted:#94a3b8;--ok:#22c55e;--warn:#f59e0b;--err:#ef4444;--errbg:#450a0a}
|
| 9 |
:root[data-theme=light]{--bg:#f1f5f9;--panel:#fff;--ai:#e6edf5;--line:#d3dde9;--text:#0f172a;--muted:#5b6b82;--errbg:#fee2e2}
|
| 10 |
*{box-sizing:border-box;margin:0}
|
| 11 |
html,body{height:100%}
|
| 12 |
-
body{background:var(--bg);color:var(--text);font-family:Inter,system-ui,
|
| 13 |
button{font:inherit;color:inherit;cursor:pointer;border:0;background:none}
|
| 14 |
-
:focus-visible{outline:2px solid var(--user);outline-offset:2px}
|
| 15 |
-
|
| 16 |
-
/* Sidebar */
|
| 17 |
aside{grid-area:side;background:var(--panel);border-inline-end:1px solid var(--line);padding:18px 14px;overflow:auto;position:relative;z-index:16}
|
| 18 |
aside h2{font-size:15px;margin-bottom:10px}
|
| 19 |
aside section{margin-bottom:22px}
|
|
@@ -25,13 +22,6 @@ aside section{margin-bottom:22px}
|
|
| 25 |
.dot.ready{background:var(--ok)}
|
| 26 |
.dot.error{background:var(--err)}
|
| 27 |
@keyframes pulse{50%{opacity:.4}}
|
| 28 |
-
.bar{height:5px;background:var(--line);border-radius:3px;margin-top:8px;overflow:hidden;display:none}
|
| 29 |
-
.bar i{display:block;height:100%;width:0;background:var(--user);transition:width .2s}
|
| 30 |
-
.model.loading .bar{display:block}
|
| 31 |
-
.caps{font-size:13px;color:var(--muted);line-height:1.9}
|
| 32 |
-
.caps b{color:var(--text);font-weight:600}
|
| 33 |
-
|
| 34 |
-
/* Header */
|
| 35 |
header{grid-area:head;display:flex;align-items:center;gap:10px;padding:12px 18px;border-bottom:1px solid var(--line)}
|
| 36 |
header h1{font-size:17px;flex:1}
|
| 37 |
.badge{font-size:12px;padding:4px 10px;border-radius:999px;background:var(--ai);border:1px solid var(--line)}
|
|
@@ -39,82 +29,43 @@ header h1{font-size:17px;flex:1}
|
|
| 39 |
.ghost:hover{background:var(--ai)}
|
| 40 |
#menu{display:none}
|
| 41 |
.note{grid-area:note;background:#0c4a6e;color:#e0f2fe;font-size:13px;padding:8px 18px;display:flex;gap:10px;align-items:center}
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
/* Messages */
|
| 45 |
-
main{grid-area:main;overflow-y:auto;padding:22px 18px;scroll-behavior:smooth}
|
| 46 |
.wrap{max-width:820px;margin:0 auto;display:flex;flex-direction:column;gap:14px}
|
| 47 |
.msg{max-width:85%;padding:11px 15px;border-radius:14px;line-height:1.8;white-space:pre-wrap;overflow-wrap:anywhere}
|
| 48 |
-
.msg.user{background:var(--user);color:#fff;align-self:flex-start
|
| 49 |
-
.msg.assistant{background:var(--ai);align-self:flex-end
|
| 50 |
.msg.error{background:var(--errbg);border:1px solid var(--err)}
|
| 51 |
-
.msg img{max-width:100%;max-height:240px;border-radius:8px;display:block;margin-bottom:8px}
|
| 52 |
.status{color:var(--muted);font-size:13px;display:block;margin-bottom:4px}
|
| 53 |
.empty{text-align:center;color:var(--muted);margin-top:12vh}
|
| 54 |
-
.empty h2{color:var(--text);margin-bottom:8px}
|
| 55 |
.typing{display:inline-flex;gap:4px;vertical-align:middle}
|
| 56 |
.typing i{width:6px;height:6px;border-radius:50%;background:var(--muted);animation:b 1s infinite}
|
| 57 |
.typing i:nth-child(2){animation-delay:.15s}
|
| 58 |
.typing i:nth-child(3){animation-delay:.3s}
|
| 59 |
@keyframes b{0%,60%,100%{opacity:.3}30%{opacity:1}}
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
/* Footer / input */
|
| 63 |
-
footer{grid-area:foot;padding:12px 18px calc(12px + env(safe-area-inset-bottom,0px));border-top:1px solid var(--line)}
|
| 64 |
.box{max-width:820px;margin:0 auto;display:flex;align-items:flex-end;gap:6px;background:var(--ai);border:1px solid var(--line);border-radius:14px;padding:6px;position:relative}
|
| 65 |
-
textarea{flex:1;background:none;border:0;color:var(--text);font:inherit;resize:none;max-height:160px;padding:9px 8px;outline:none
|
| 66 |
-
.ic{width:38px;height:38px;border-radius:10px;flex:none;display:grid;place-items:center
|
| 67 |
.ic:hover{background:var(--line)}
|
| 68 |
-
.ic.rec{background:var(--err);color:#fff;animation:b 1s infinite}
|
| 69 |
-
.ic.ok{color:var(--ok)}
|
| 70 |
#send{background:var(--user);color:#fff}
|
| 71 |
-
#send .i{transform:scaleX(-1)}
|
| 72 |
#send:disabled{opacity:.4;cursor:not-allowed}
|
| 73 |
#stop{background:var(--err);color:#fff;display:none}
|
| 74 |
-
.
|
| 75 |
-
|
| 76 |
-
/* Icons, popover */
|
| 77 |
-
.i{width:20px;height:20px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
|
| 78 |
-
[hidden]{display:none!important}
|
| 79 |
-
.pop{position:absolute;bottom:calc(100% + 8px);inset-inline-start:0;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:6px;min-width:200px;box-shadow:0 8px 24px #0005;z-index:5}
|
| 80 |
-
.pop button{display:flex;align-items:center;gap:10px;width:100%;padding:10px 12px;border-radius:8px;font-size:14px;text-align:start}
|
| 81 |
.pop button:hover{background:var(--ai)}
|
| 82 |
select{width:100%;background:var(--ai);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:9px;font:inherit;font-size:13px;margin-bottom:8px}
|
| 83 |
-
|
| 84 |
-
.wide{width:100%;justify-content:center}
|
| 85 |
-
.sub{font-size:12px;color:var(--muted);line-height:1.7;margin-bottom:8px}
|
| 86 |
-
.acts{display:flex;gap:4px;margin-top:6px}
|
| 87 |
-
.acts button{padding:5px 7px;border-radius:6px;color:var(--muted);display:grid}
|
| 88 |
-
.acts button:hover{background:var(--line)}
|
| 89 |
-
.acts button.ok{color:var(--ok)}
|
| 90 |
-
.diag{font-size:11px;color:var(--muted);white-space:pre-wrap;direction:ltr;text-align:left;max-height:180px;overflow:auto;background:var(--ai);border-radius:8px;padding:8px}
|
| 91 |
-
.history-item{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-radius:8px;cursor:pointer;font-size:12px;margin-bottom:4px;background:var(--ai);color:var(--text)}
|
| 92 |
.history-item:hover{background:var(--line)}
|
| 93 |
.history-item.active{border:1px solid var(--user)}
|
| 94 |
-
.history-item .title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap
|
| 95 |
-
.
|
| 96 |
-
.
|
| 97 |
-
.
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
#overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:15;backdrop-filter:blur(2px)}
|
| 101 |
-
#overlay.show{display:block}
|
| 102 |
-
@media (max-width:800px){
|
| 103 |
-
body{grid-template-columns:1fr;grid-template-areas:"head" "note" "main" "foot"}
|
| 104 |
-
aside{position:fixed;inset:0 0 0 auto;width:280px;max-width:85vw;z-index:20;transform:translateX(100%);transition:transform .25s}
|
| 105 |
-
aside.open{transform:none}
|
| 106 |
-
#menu{display:inline-flex}
|
| 107 |
-
.msg{max-width:94%}
|
| 108 |
-
}
|
| 109 |
</style>
|
| 110 |
-
<script type="module">
|
| 111 |
-
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
|
| 112 |
-
window.GradioClient = Client;
|
| 113 |
-
window.dispatchEvent(new Event("gradio-client-ready"));
|
| 114 |
-
</script>
|
| 115 |
</head>
|
| 116 |
<body>
|
| 117 |
-
<!-- SVG icon sprite -->
|
| 118 |
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
| 119 |
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
|
| 120 |
<symbol id="i-img" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 8"/></symbol>
|
|
@@ -138,60 +89,56 @@ select:disabled,.ghost:disabled{opacity:.5;cursor:not-allowed}
|
|
| 138 |
<aside id="side">
|
| 139 |
<section>
|
| 140 |
<h2>المحادثات</h2>
|
| 141 |
-
<button class="ghost
|
| 142 |
<div id="historyList" style="max-height:200px;overflow-y:auto"></div>
|
| 143 |
</section>
|
| 144 |
<section>
|
| 145 |
<h2>نموذج الدردشة</h2>
|
| 146 |
-
<select id="modelSel"
|
| 147 |
-
<p
|
| 148 |
-
<button class="ghost
|
|
|
|
| 149 |
</section>
|
| 150 |
<section>
|
| 151 |
<h2>حالة النماذج</h2>
|
| 152 |
-
<div class="model" id="m-chat"><b><span class="dot"></span>الدردشة</b><small id="chatName">—</small><
|
| 153 |
-
<div class="model" id="m-asr"><b><span class="dot"></span>
|
| 154 |
-
<div class="model" id="m-cap"><b><span class="dot"></span>
|
| 155 |
-
</section>
|
| 156 |
-
<section>
|
| 157 |
-
<h2>قدرات جهازك</h2>
|
| 158 |
-
<div class="caps" id="caps"></div>
|
| 159 |
</section>
|
| 160 |
<section>
|
| 161 |
<h2>سجل المحرّك</h2>
|
| 162 |
-
<pre id="diag"
|
| 163 |
</section>
|
| 164 |
</aside>
|
| 165 |
|
| 166 |
<header>
|
| 167 |
<button class="ghost" id="menu" aria-label="القائمة"><svg class="i"><use href="#i-menu"/></svg></button>
|
| 168 |
-
<h1>دردشة ذكاء اصطناعي
|
| 169 |
-
<span class="badge" id="backend">
|
| 170 |
-
<button class="ghost" id="theme" aria-label="
|
| 171 |
-
<button class="ghost" id="clear"><svg class="i"><use href="#i-trash"/></svg>مسح</button>
|
| 172 |
</header>
|
| 173 |
|
| 174 |
<div class="note" id="note">
|
| 175 |
-
<span>تُعالَج محادثاتك على خادم Hugging Face Space.
|
| 176 |
-
<button id="noteX"
|
| 177 |
</div>
|
| 178 |
|
| 179 |
<main id="main"><div class="wrap" id="log"></div></main>
|
| 180 |
|
| 181 |
<footer>
|
| 182 |
<div class="box">
|
| 183 |
-
<button class="ic" id="speak" title="قراءة
|
| 184 |
-
<button class="ic" id="plus" title="إرفاق"
|
| 185 |
<div class="pop" id="pop" hidden>
|
| 186 |
-
<button id="img"><svg class="i"><use href="#i-img"/></svg>إرفاق صورة</button>
|
| 187 |
-
<button id="aud"><svg class="i"><use href="#i-aud"/></svg>رفع
|
| 188 |
-
<button id="mic"><svg class="i"><use href="#i-mic"/></svg>تسجيل
|
| 189 |
</div>
|
| 190 |
-
<textarea id="input" rows="1" placeholder="اكتب رسالتك… (Enter للإرسال
|
| 191 |
-
<button class="ic" id="stop" title="إيقاف
|
| 192 |
-
<button class="ic" id="send" title="إرسال"
|
| 193 |
</div>
|
| 194 |
-
<p class="hint">النماذج تُشغَّل على الخادم (ZeroGPU). أول طلب قد يستغرق وقتاً لتحميل النموذج.</p>
|
| 195 |
<input type="file" id="imgFile" accept="image/*" hidden>
|
| 196 |
<input type="file" id="audFile" accept="audio/*" hidden>
|
| 197 |
</footer>
|
|
@@ -199,52 +146,88 @@ select:disabled,.ghost:disabled{opacity:.5;cursor:not-allowed}
|
|
| 199 |
<script>
|
| 200 |
const $ = (s) => document.querySelector(s);
|
| 201 |
const log = $("#log"), main = $("#main"), input = $("#input");
|
| 202 |
-
const sendBtn = $("#send"), stopBtn = $("#stop"), micBtn = $("#mic");
|
| 203 |
const icon = (n) => `<svg class="i"><use href="#i-${n}"/></svg>`;
|
| 204 |
-
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : 'id-' + Date.now() +
|
| 205 |
|
| 206 |
// ===== Theme =====
|
| 207 |
-
|
| 208 |
-
let theme = null;
|
| 209 |
-
try { theme = localStorage.getItem("theme"); } catch (e) {}
|
| 210 |
-
if (!theme) theme = matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
| 211 |
function applyTheme(t) {
|
| 212 |
theme = t;
|
| 213 |
document.documentElement.dataset.theme = t;
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
try { localStorage.setItem("theme", t); } catch (e) {}
|
| 217 |
}
|
| 218 |
-
|
| 219 |
applyTheme(theme);
|
| 220 |
|
| 221 |
-
// =====
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
}
|
| 238 |
-
|
| 239 |
-
clientPromise = window.GradioClient.connect(window.location.origin);
|
| 240 |
}
|
| 241 |
-
return clientPromise;
|
| 242 |
-
}
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
}
|
| 249 |
|
| 250 |
// ===== State =====
|
|
@@ -258,25 +241,13 @@ function onLog(t) {
|
|
| 258 |
}
|
| 259 |
|
| 260 |
// ===== Sessions =====
|
| 261 |
-
let sessions = {};
|
| 262 |
-
let
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
function saveSessions() {
|
| 270 |
-
try {
|
| 271 |
-
localStorage.setItem("chatSessions", JSON.stringify(sessions));
|
| 272 |
-
localStorage.setItem("currentSessionId", currentSessionId);
|
| 273 |
-
} catch (e) {
|
| 274 |
-
const keys = Object.keys(sessions);
|
| 275 |
-
if (keys.length > 1) {
|
| 276 |
-
delete sessions[keys[0]];
|
| 277 |
-
try { localStorage.setItem("chatSessions", JSON.stringify(sessions)); } catch (e2) {}
|
| 278 |
-
}
|
| 279 |
-
}
|
| 280 |
}
|
| 281 |
|
| 282 |
function renderHistory() {
|
|
@@ -285,143 +256,186 @@ function renderHistory() {
|
|
| 285 |
Object.keys(sessions).reverse().forEach(id => {
|
| 286 |
const s = sessions[id];
|
| 287 |
const div = document.createElement("div");
|
| 288 |
-
div.className = `history-item ${id ===
|
| 289 |
const title = document.createElement("span");
|
| 290 |
-
title.className = "title";
|
| 291 |
-
title.textContent = s.title;
|
| 292 |
const del = document.createElement("button");
|
| 293 |
-
del.
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
if (
|
| 297 |
-
|
| 298 |
-
if (
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
currentSessionId = uid();
|
| 302 |
-
sessions[currentSessionId] = { title: "دردشة جديدة", messages: [] };
|
| 303 |
-
}
|
| 304 |
-
saveSessions(); renderHistory(); loadSession(currentSessionId);
|
| 305 |
}
|
| 306 |
-
|
| 307 |
}
|
| 308 |
-
currentSessionId = id;
|
| 309 |
-
saveSessions(); renderHistory(); loadSession(id);
|
| 310 |
-
closeSidebar();
|
| 311 |
};
|
|
|
|
|
|
|
| 312 |
list.appendChild(div);
|
| 313 |
});
|
| 314 |
}
|
| 315 |
|
| 316 |
-
function updateSessionTitle(text) {
|
| 317 |
-
if (sessions[currentSessionId].title === "دردشة جديدة") {
|
| 318 |
-
sessions[currentSessionId].title = text.slice(0, 25) + (text.length > 25 ? "..." : "");
|
| 319 |
-
renderHistory();
|
| 320 |
-
}
|
| 321 |
-
}
|
| 322 |
-
|
| 323 |
-
// ===== Model UI =====
|
| 324 |
-
function setModel(key, state, pct) {
|
| 325 |
-
const el = document.getElementById("m-" + key);
|
| 326 |
-
if (!el) return;
|
| 327 |
-
el.className = "model " + (state === "loading" ? "loading" : "");
|
| 328 |
-
el.querySelector(".dot").className = "dot " + state;
|
| 329 |
-
if (pct != null) el.querySelector(".bar i").style.width = pct + "%";
|
| 330 |
-
}
|
| 331 |
-
|
| 332 |
// ===== Messages =====
|
| 333 |
-
function addMsg(role, text = "",
|
| 334 |
$(".empty")?.remove();
|
| 335 |
const d = document.createElement("div");
|
| 336 |
-
d.className = `msg ${role} ${
|
| 337 |
if (text) d.textContent = text;
|
| 338 |
log.appendChild(d);
|
| 339 |
-
|
| 340 |
return d;
|
| 341 |
}
|
| 342 |
-
|
| 343 |
-
function typing() {
|
| 344 |
-
const s = document.createElement("span");
|
| 345 |
-
s.className = "typing"; s.innerHTML = "<i></i><i></i><i></i>";
|
| 346 |
-
return s;
|
| 347 |
-
}
|
| 348 |
function welcome() {
|
| 349 |
-
log.innerHTML = '<div class="empty"><h2>مرحباً 👋</h2><p>ا
|
| 350 |
-
}
|
| 351 |
-
function displayContent(m) {
|
| 352 |
-
let t = m.content;
|
| 353 |
-
if (m.role === "user" && t.startsWith("[Image description:")) {
|
| 354 |
-
t = t.replace(/^\[Image description:[^\]]*\]\n?/, "").trim() || "(صورة)";
|
| 355 |
-
}
|
| 356 |
-
return t;
|
| 357 |
}
|
|
|
|
| 358 |
function loadSession(id) {
|
| 359 |
log.innerHTML = "";
|
| 360 |
const msgs = sessions[id].messages;
|
| 361 |
-
if (msgs.length
|
| 362 |
msgs.forEach(m => {
|
| 363 |
-
const d = addMsg(m.role,
|
| 364 |
if (m.role === "assistant") addActions(d, m.content);
|
| 365 |
});
|
| 366 |
}
|
|
|
|
| 367 |
function setBusy(v) {
|
| 368 |
busy = v;
|
| 369 |
-
|
| 370 |
-
$("#
|
| 371 |
-
|
| 372 |
-
sendBtn.style.display = v ? "none" : "grid";
|
| 373 |
}
|
| 374 |
-
function showError(msg) { addMsg("assistant", "⚠️ " + msg, "error"); }
|
| 375 |
|
| 376 |
-
// =====
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
sessions[currentSessionId].messages.push({ role: "user", content: prompt });
|
| 390 |
-
updateSessionTitle(text);
|
| 391 |
-
saveSessions();
|
| 392 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
|
| 394 |
const bubble = addMsg("assistant");
|
| 395 |
const status = document.createElement("span");
|
| 396 |
-
status.className = "status";
|
| 397 |
-
status.textContent = "جارٍ التوليد…";
|
| 398 |
const out = document.createElement("span");
|
| 399 |
-
bubble.append(status,
|
| 400 |
|
| 401 |
currentReqId = uid();
|
| 402 |
|
| 403 |
try {
|
| 404 |
-
|
| 405 |
-
onLog("طلب توليد: " + modelSel.value);
|
| 406 |
-
const r = await api("generate", {
|
| 407 |
model_id: modelSel.value,
|
| 408 |
-
messages: JSON.stringify(sessions[
|
| 409 |
req_id: currentReqId,
|
| 410 |
});
|
| 411 |
-
setModel("chat", "ready", 100);
|
| 412 |
|
| 413 |
-
if (!r || r.ok === false) throw new Error((r && r.error) || "فشل التوليد");
|
| 414 |
status.remove();
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
}
|
| 422 |
} catch (e) {
|
| 423 |
bubble.remove();
|
| 424 |
-
|
| 425 |
onLog("خطأ: " + e.message);
|
| 426 |
} finally {
|
| 427 |
status.remove();
|
|
@@ -432,91 +446,46 @@ async function ask(text, regen = false) {
|
|
| 432 |
|
| 433 |
function addActions(bubble, text) {
|
| 434 |
const row = document.createElement("div"); row.className = "acts";
|
| 435 |
-
const copy = document.createElement("button"); copy.
|
| 436 |
copy.onclick = async () => {
|
| 437 |
-
try { await navigator.clipboard.writeText(text); }
|
| 438 |
-
|
| 439 |
-
copy.title = "تم النسخ"; copy.classList.add("ok"); setTimeout(() => copy.classList.remove("ok"), 1200);
|
| 440 |
};
|
| 441 |
-
const re = document.createElement("button"); re.className = "regen"; re.
|
| 442 |
re.onclick = () => {
|
| 443 |
if (busy) return;
|
| 444 |
-
const msgs = sessions[
|
| 445 |
if (msgs.length && msgs[msgs.length - 1].role === "assistant") msgs.pop();
|
| 446 |
-
|
| 447 |
};
|
| 448 |
row.append(copy, re); bubble.appendChild(row);
|
| 449 |
}
|
| 450 |
|
| 451 |
-
// ===== Models list =====
|
| 452 |
-
const CHAT_MODELS = {
|
| 453 |
-
qwen25: { name: "Qwen2.5-0.5B-Instruct", kind: "chat", size: "~500MB" },
|
| 454 |
-
qwen3: { name: "Qwen3-0.6B", kind: "chat", size: "~600MB" },
|
| 455 |
-
smol360: { name: "SmolLM2-360M-Instruct", kind: "chat", size: "~360MB" },
|
| 456 |
-
smol135: { name: "SmolLM2-135M-Instruct", kind: "chat", size: "~140MB" },
|
| 457 |
-
flansmall:{ name: "Flan-T5-Small", kind: "t5", size: "~60MB" },
|
| 458 |
-
};
|
| 459 |
-
const modelSel = $("#modelSel");
|
| 460 |
-
for (const [k, m] of Object.entries(CHAT_MODELS)) {
|
| 461 |
-
const o = document.createElement("option");
|
| 462 |
-
o.value = k;
|
| 463 |
-
o.textContent = `${m.name} (${m.size})`;
|
| 464 |
-
modelSel.appendChild(o);
|
| 465 |
-
}
|
| 466 |
-
modelSel.value = "qwen25";
|
| 467 |
-
function syncModelUI() {
|
| 468 |
-
const m = CHAT_MODELS[modelSel.value];
|
| 469 |
-
$("#chatName").textContent = m.name;
|
| 470 |
-
$("#modelNote").textContent = m.kind === "chat"
|
| 471 |
-
? "يتذكر آخر الرسائل. يُحمَّل على الخادم عند أول طلب."
|
| 472 |
-
: "يجيب على كل سؤال مستقلاً. صغير وسريع.";
|
| 473 |
-
}
|
| 474 |
-
syncModelUI();
|
| 475 |
-
modelSel.onchange = () => {
|
| 476 |
-
if (sessions[currentSessionId].messages.length && !confirm("تغيير النموذج سيمسح المحادثة. متابعة؟")) {
|
| 477 |
-
modelSel.value = modelSel.value; return;
|
| 478 |
-
}
|
| 479 |
-
sessions[currentSessionId].messages = [];
|
| 480 |
-
saveSessions(); loadSession(currentSessionId); syncModelUI();
|
| 481 |
-
};
|
| 482 |
-
|
| 483 |
// ===== Input =====
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
};
|
| 490 |
input.addEventListener("keydown", (e) => {
|
| 491 |
-
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
|
|
|
|
|
|
|
|
| 492 |
});
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
// ===== Unload =====
|
| 497 |
-
$("#unload").onclick = async () => {
|
| 498 |
-
if (busy) return;
|
| 499 |
-
try {
|
| 500 |
-
await api("unload", { model_id: modelSel.value });
|
| 501 |
-
["chat","asr","cap"].forEach(k => setModel(k, "", 0));
|
| 502 |
-
addMsg("assistant", "تم تفريغ النماذج من ذاكرة الخادم.");
|
| 503 |
-
} catch (e) { showError("فشل التفريغ: " + e.message); }
|
| 504 |
};
|
| 505 |
|
| 506 |
-
// ===== Audio
|
| 507 |
let rec = null, chunks = [];
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
e.stopPropagation();
|
| 512 |
-
if (rec) { rec.stop(); return; }
|
| 513 |
-
pop.hidden = !pop.hidden;
|
| 514 |
-
plusBtn.setAttribute("aria-expanded", String(!pop.hidden));
|
| 515 |
-
};
|
| 516 |
-
pop.addEventListener("click", closePop);
|
| 517 |
-
document.addEventListener("click", (e) => { if (!pop.contains(e.target)) closePop(); });
|
| 518 |
|
| 519 |
-
|
| 520 |
if (busy || rec) return;
|
| 521 |
try {
|
| 522 |
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
@@ -524,36 +493,24 @@ micBtn.onclick = async () => {
|
|
| 524 |
rec.ondataavailable = (e) => chunks.push(e.data);
|
| 525 |
rec.onstop = async () => {
|
| 526 |
stream.getTracks().forEach((t) => t.stop());
|
| 527 |
-
plusBtn.classList.remove("rec"); plusBtn.innerHTML = icon("plus"); plusBtn.title = "إرفاق";
|
| 528 |
rec = null;
|
| 529 |
await transcribe(await new Blob(chunks).arrayBuffer());
|
| 530 |
};
|
| 531 |
rec.start();
|
| 532 |
-
|
| 533 |
-
} catch (e) {
|
| 534 |
};
|
| 535 |
|
| 536 |
-
async function transcribe(
|
| 537 |
-
if (busy) return;
|
| 538 |
setBusy(true);
|
| 539 |
-
const note = addMsg("assistant");
|
| 540 |
-
const status = document.createElement("span");
|
| 541 |
-
status.className = "status"; status.textContent = "جارٍ تفريغ الصوت…";
|
| 542 |
-
note.append(status, typing());
|
| 543 |
-
|
| 544 |
try {
|
| 545 |
-
const b64 = btoa(String.fromCharCode(...new Uint8Array(
|
| 546 |
-
|
| 547 |
-
const r = await api("asr", { audio_b64: b64 });
|
| 548 |
-
setModel("asr", "ready", 100);
|
| 549 |
-
note.remove();
|
| 550 |
-
setBusy(false);
|
| 551 |
-
if (r && r.text) ask(r.text);
|
| 552 |
-
else showError("لم يُكتشف أي كلام.");
|
| 553 |
-
} catch (e) {
|
| 554 |
note.remove(); setBusy(false);
|
| 555 |
-
|
| 556 |
-
|
|
|
|
| 557 |
}
|
| 558 |
|
| 559 |
$("#aud").onclick = () => $("#audFile").click();
|
|
@@ -562,76 +519,63 @@ $("#audFile").onchange = async (e) => {
|
|
| 562 |
if (f) await transcribe(await f.arrayBuffer());
|
| 563 |
};
|
| 564 |
|
| 565 |
-
// ===== Images =====
|
| 566 |
$("#img").onclick = () => $("#imgFile").click();
|
| 567 |
-
$("#imgFile").onchange = (e) => {
|
| 568 |
const f = e.target.files[0]; e.target.value = "";
|
| 569 |
-
if (!f
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
const
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
if (r && r.text) addMsg("assistant", "وصف الصورة: " + r.text);
|
| 584 |
-
else showError("فشل وصف الصورة.");
|
| 585 |
-
} catch (err) {
|
| 586 |
-
note.remove(); setBusy(false);
|
| 587 |
-
showError("فشل وصف الصورة: " + err.message);
|
| 588 |
-
}
|
| 589 |
-
};
|
| 590 |
-
reader.readAsDataURL(f);
|
| 591 |
};
|
| 592 |
|
| 593 |
-
// ===== Header
|
| 594 |
$("#clear").onclick = () => {
|
| 595 |
-
|
| 596 |
-
sessions[
|
| 597 |
-
|
| 598 |
-
saveSessions(); renderHistory(); loadSession(currentSessionId);
|
| 599 |
};
|
| 600 |
$("#newChatBtn").onclick = () => {
|
| 601 |
-
|
| 602 |
-
sessions[
|
| 603 |
-
|
| 604 |
-
closeSidebar();
|
| 605 |
};
|
| 606 |
$("#noteX").onclick = () => $("#note").remove();
|
| 607 |
-
|
| 608 |
-
function openSidebar() { $("#side").classList.add("open"); $("#overlay").classList.add("show"); }
|
| 609 |
-
function closeSidebar() { $("#side").classList.remove("open"); $("#overlay").classList.remove("show"); }
|
| 610 |
$("#menu").onclick = () => {
|
| 611 |
-
$("#side")
|
|
|
|
|
|
|
| 612 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
$("#overlay").onclick = closeSidebar;
|
| 614 |
-
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSidebar(); });
|
| 615 |
|
| 616 |
// ===== Speech =====
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
if (!('speechSynthesis' in window)) { showError("المتصفح لا يدعم تحويل النص إلى كلام."); return; }
|
| 620 |
window.speechSynthesis.cancel();
|
| 621 |
-
const
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
window.speechSynthesis.speak(utter);
|
| 627 |
-
speakBtn.classList.add("ok");
|
| 628 |
-
utter.onend = () => speakBtn.classList.remove("ok");
|
| 629 |
};
|
| 630 |
|
| 631 |
-
setBusy(false);
|
| 632 |
renderHistory();
|
| 633 |
-
loadSession(
|
| 634 |
-
|
|
|
|
| 635 |
</script>
|
| 636 |
</body>
|
| 637 |
</html>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
| 6 |
+
<title>دردشة ذكاء اصطناعي</title>
|
| 7 |
<style>
|
| 8 |
:root{--bg:#0f172a;--panel:#111c33;--ai:#1e293b;--user:#0284c7;--line:#27344d;--text:#e2e8f0;--muted:#94a3b8;--ok:#22c55e;--warn:#f59e0b;--err:#ef4444;--errbg:#450a0a}
|
| 9 |
:root[data-theme=light]{--bg:#f1f5f9;--panel:#fff;--ai:#e6edf5;--line:#d3dde9;--text:#0f172a;--muted:#5b6b82;--errbg:#fee2e2}
|
| 10 |
*{box-sizing:border-box;margin:0}
|
| 11 |
html,body{height:100%}
|
| 12 |
+
body{background:var(--bg);color:var(--text);font-family:Inter,system-ui,Tahoma,sans-serif;display:grid;grid-template-columns:270px 1fr;grid-template-rows:auto auto 1fr auto;grid-template-areas:"side head" "side note" "side main" "side foot";overflow:hidden}
|
| 13 |
button{font:inherit;color:inherit;cursor:pointer;border:0;background:none}
|
|
|
|
|
|
|
|
|
|
| 14 |
aside{grid-area:side;background:var(--panel);border-inline-end:1px solid var(--line);padding:18px 14px;overflow:auto;position:relative;z-index:16}
|
| 15 |
aside h2{font-size:15px;margin-bottom:10px}
|
| 16 |
aside section{margin-bottom:22px}
|
|
|
|
| 22 |
.dot.ready{background:var(--ok)}
|
| 23 |
.dot.error{background:var(--err)}
|
| 24 |
@keyframes pulse{50%{opacity:.4}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
header{grid-area:head;display:flex;align-items:center;gap:10px;padding:12px 18px;border-bottom:1px solid var(--line)}
|
| 26 |
header h1{font-size:17px;flex:1}
|
| 27 |
.badge{font-size:12px;padding:4px 10px;border-radius:999px;background:var(--ai);border:1px solid var(--line)}
|
|
|
|
| 29 |
.ghost:hover{background:var(--ai)}
|
| 30 |
#menu{display:none}
|
| 31 |
.note{grid-area:note;background:#0c4a6e;color:#e0f2fe;font-size:13px;padding:8px 18px;display:flex;gap:10px;align-items:center}
|
| 32 |
+
main{grid-area:main;overflow-y:auto;padding:22px 18px}
|
|
|
|
|
|
|
|
|
|
| 33 |
.wrap{max-width:820px;margin:0 auto;display:flex;flex-direction:column;gap:14px}
|
| 34 |
.msg{max-width:85%;padding:11px 15px;border-radius:14px;line-height:1.8;white-space:pre-wrap;overflow-wrap:anywhere}
|
| 35 |
+
.msg.user{background:var(--user);color:#fff;align-self:flex-start}
|
| 36 |
+
.msg.assistant{background:var(--ai);align-self:flex-end}
|
| 37 |
.msg.error{background:var(--errbg);border:1px solid var(--err)}
|
|
|
|
| 38 |
.status{color:var(--muted);font-size:13px;display:block;margin-bottom:4px}
|
| 39 |
.empty{text-align:center;color:var(--muted);margin-top:12vh}
|
|
|
|
| 40 |
.typing{display:inline-flex;gap:4px;vertical-align:middle}
|
| 41 |
.typing i{width:6px;height:6px;border-radius:50%;background:var(--muted);animation:b 1s infinite}
|
| 42 |
.typing i:nth-child(2){animation-delay:.15s}
|
| 43 |
.typing i:nth-child(3){animation-delay:.3s}
|
| 44 |
@keyframes b{0%,60%,100%{opacity:.3}30%{opacity:1}}
|
| 45 |
+
footer{grid-area:foot;padding:12px 18px;border-top:1px solid var(--line)}
|
|
|
|
|
|
|
|
|
|
| 46 |
.box{max-width:820px;margin:0 auto;display:flex;align-items:flex-end;gap:6px;background:var(--ai);border:1px solid var(--line);border-radius:14px;padding:6px;position:relative}
|
| 47 |
+
textarea{flex:1;background:none;border:0;color:var(--text);font:inherit;resize:none;max-height:160px;padding:9px 8px;outline:none}
|
| 48 |
+
.ic{width:38px;height:38px;border-radius:10px;flex:none;display:grid;place-items:center}
|
| 49 |
.ic:hover{background:var(--line)}
|
|
|
|
|
|
|
| 50 |
#send{background:var(--user);color:#fff}
|
|
|
|
| 51 |
#send:disabled{opacity:.4;cursor:not-allowed}
|
| 52 |
#stop{background:var(--err);color:#fff;display:none}
|
| 53 |
+
.pop{position:absolute;bottom:calc(100% + 8px);inset-inline-start:0;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:6px;min-width:180px;z-index:5}
|
| 54 |
+
.pop button{display:flex;align-items:center;gap:8px;width:100%;padding:10px 12px;border-radius:8px;font-size:14px}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
.pop button:hover{background:var(--ai)}
|
| 56 |
select{width:100%;background:var(--ai);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:9px;font:inherit;font-size:13px;margin-bottom:8px}
|
| 57 |
+
.history-item{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-radius:8px;cursor:pointer;font-size:12px;margin-bottom:4px;background:var(--ai)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
.history-item:hover{background:var(--line)}
|
| 59 |
.history-item.active{border:1px solid var(--user)}
|
| 60 |
+
.history-item .title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
| 61 |
+
.acts{display:flex;gap:4px;margin-top:6px}
|
| 62 |
+
.acts button{padding:5px 7px;border-radius:6px;color:var(--muted)}
|
| 63 |
+
.acts button:hover{background:var(--line)}
|
| 64 |
+
.i{width:20px;height:20px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
|
| 65 |
+
@media(max-width:800px){body{grid-template-columns:1fr;grid-template-areas:"head" "note" "main" "foot"}aside{position:fixed;inset:0 0 0 auto;width:280px;z-index:20;transform:translateX(100%);transition:.25s}aside.open{transform:none}#menu{display:inline-flex}.msg{max-width:94%}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
</head>
|
| 68 |
<body>
|
|
|
|
| 69 |
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
| 70 |
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
|
| 71 |
<symbol id="i-img" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 8"/></symbol>
|
|
|
|
| 89 |
<aside id="side">
|
| 90 |
<section>
|
| 91 |
<h2>المحادثات</h2>
|
| 92 |
+
<button class="ghost" id="newChatBtn" style="width:100%;justify-content:center;margin-bottom:10px"><svg class="i"><use href="#i-plus"/></svg> محادثة جديدة</button>
|
| 93 |
<div id="historyList" style="max-height:200px;overflow-y:auto"></div>
|
| 94 |
</section>
|
| 95 |
<section>
|
| 96 |
<h2>نموذج الدردشة</h2>
|
| 97 |
+
<select id="modelSel" dir="ltr"></select>
|
| 98 |
+
<p style="font-size:12px;color:var(--muted)" id="modelNote"></p>
|
| 99 |
+
<button class="ghost" id="loadBtn" style="width:100%;justify-content:center;margin-top:6px"><svg class="i"><use href="#i-chip"/></svg> تحميل النموذج</button>
|
| 100 |
+
<button class="ghost" id="unload" style="width:100%;justify-content:center;margin-top:6px">تفريغ النموذج</button>
|
| 101 |
</section>
|
| 102 |
<section>
|
| 103 |
<h2>حالة النماذج</h2>
|
| 104 |
+
<div class="model" id="m-chat"><b><span class="dot"></span>الدردشة</b><small id="chatName">—</small></div>
|
| 105 |
+
<div class="model" id="m-asr"><b><span class="dot"></span>الصوت</b><small>whisper-tiny.en</small></div>
|
| 106 |
+
<div class="model" id="m-cap"><b><span class="dot"></span>الصور</b><small>vit-gpt2</small></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
</section>
|
| 108 |
<section>
|
| 109 |
<h2>سجل المحرّك</h2>
|
| 110 |
+
<pre id="diag" style="font-size:11px;color:var(--muted);white-space:pre-wrap;direction:ltr;text-align:left;max-height:180px;overflow:auto;background:var(--ai);border-radius:8px;padding:8px"></pre>
|
| 111 |
</section>
|
| 112 |
</aside>
|
| 113 |
|
| 114 |
<header>
|
| 115 |
<button class="ghost" id="menu" aria-label="القائمة"><svg class="i"><use href="#i-menu"/></svg></button>
|
| 116 |
+
<h1>دردشة ذكاء اصطناعي</h1>
|
| 117 |
+
<span class="badge" id="backend">GPU</span>
|
| 118 |
+
<button class="ghost" id="theme" aria-label="المظهر"></button>
|
| 119 |
+
<button class="ghost" id="clear"><svg class="i"><use href="#i-trash"/></svg> مسح</button>
|
| 120 |
</header>
|
| 121 |
|
| 122 |
<div class="note" id="note">
|
| 123 |
+
<span>تُعالَج محادثاتك على خادم Hugging Face Space.</span>
|
| 124 |
+
<button id="noteX" style="margin-inline-start:auto;display:grid"><svg class="i"><use href="#i-x"/></svg></button>
|
| 125 |
</div>
|
| 126 |
|
| 127 |
<main id="main"><div class="wrap" id="log"></div></main>
|
| 128 |
|
| 129 |
<footer>
|
| 130 |
<div class="box">
|
| 131 |
+
<button class="ic" id="speak" title="قراءة"><svg class="i"><use href="#i-speaker"/></svg></button>
|
| 132 |
+
<button class="ic" id="plus" title="إرفاق"><svg class="i"><use href="#i-plus"/></svg></button>
|
| 133 |
<div class="pop" id="pop" hidden>
|
| 134 |
+
<button id="img"><svg class="i"><use href="#i-img"/></svg> إرفاق صورة</button>
|
| 135 |
+
<button id="aud"><svg class="i"><use href="#i-aud"/></svg> رفع صوت</button>
|
| 136 |
+
<button id="mic"><svg class="i"><use href="#i-mic"/></svg> تسجيل</button>
|
| 137 |
</div>
|
| 138 |
+
<textarea id="input" rows="1" placeholder="اكتب رسالتك… (Enter للإرسال)"></textarea>
|
| 139 |
+
<button class="ic" id="stop" title="إيقاف"><svg class="i"><use href="#i-stop"/></svg></button>
|
| 140 |
+
<button class="ic" id="send" title="إرسال"><svg class="i"><use href="#i-send"/></svg></button>
|
| 141 |
</div>
|
|
|
|
| 142 |
<input type="file" id="imgFile" accept="image/*" hidden>
|
| 143 |
<input type="file" id="audFile" accept="audio/*" hidden>
|
| 144 |
</footer>
|
|
|
|
| 146 |
<script>
|
| 147 |
const $ = (s) => document.querySelector(s);
|
| 148 |
const log = $("#log"), main = $("#main"), input = $("#input");
|
|
|
|
| 149 |
const icon = (n) => `<svg class="i"><use href="#i-${n}"/></svg>`;
|
| 150 |
+
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : 'id-' + Date.now() + Math.random());
|
| 151 |
|
| 152 |
// ===== Theme =====
|
| 153 |
+
let theme = localStorage.getItem("theme") || "dark";
|
|
|
|
|
|
|
|
|
|
| 154 |
function applyTheme(t) {
|
| 155 |
theme = t;
|
| 156 |
document.documentElement.dataset.theme = t;
|
| 157 |
+
$("#theme").textContent = t === "dark" ? "☀️" : "🌙";
|
| 158 |
+
localStorage.setItem("theme", t);
|
|
|
|
| 159 |
}
|
| 160 |
+
$("#theme").onclick = () => applyTheme(theme === "dark" ? "light" : "dark");
|
| 161 |
applyTheme(theme);
|
| 162 |
|
| 163 |
+
// ===== Gradio API عبر SSE (الطريقة الصحيحة) =====
|
| 164 |
+
// Gradio 6: POST /gradio_api/call/{fn} -> {event_id}
|
| 165 |
+
// ثم GET /gradio_api/call/{fn}/{event_id} -> SSE stream
|
| 166 |
+
|
| 167 |
+
async function apiCall(name, body = {}) {
|
| 168 |
+
// 1) إرسال الطلب
|
| 169 |
+
const r1 = await fetch(`/gradio_api/call/${name}`, {
|
| 170 |
+
method: "POST",
|
| 171 |
+
headers: { "Content-Type": "application/json" },
|
| 172 |
+
body: JSON.stringify({ data: [body] }),
|
| 173 |
+
});
|
| 174 |
+
if (!r1.ok) throw new Error(`POST ${name}: ${r1.status} ${await r1.text()}`);
|
| 175 |
+
const { event_id } = await r1.json();
|
| 176 |
+
if (!event_id) throw new Error("لا يوجد event_id في الرد");
|
| 177 |
+
|
| 178 |
+
// 2) قراءة SSE
|
| 179 |
+
const r2 = await fetch(`/gradio_api/call/${name}/${event_id}`);
|
| 180 |
+
if (!r2.ok) throw new Error(`GET ${name}: ${r2.status}`);
|
| 181 |
+
|
| 182 |
+
const reader = r2.body.getReader();
|
| 183 |
+
const decoder = new TextDecoder();
|
| 184 |
+
let buffer = "";
|
| 185 |
+
let result = null;
|
| 186 |
+
let errorMsg = null;
|
| 187 |
+
|
| 188 |
+
while (true) {
|
| 189 |
+
const { done, value } = await reader.read();
|
| 190 |
+
if (done) break;
|
| 191 |
+
buffer += decoder.decode(value, { stream: true });
|
| 192 |
+
|
| 193 |
+
// SSE: events مفصولة بـ \n\n
|
| 194 |
+
const parts = buffer.split("\n\n");
|
| 195 |
+
buffer = parts.pop(); // آخر جزء قد يكون ناقصاً
|
| 196 |
+
|
| 197 |
+
for (const part of parts) {
|
| 198 |
+
const lines = part.split("\n");
|
| 199 |
+
let event = "message";
|
| 200 |
+
let data = "";
|
| 201 |
+
for (const line of lines) {
|
| 202 |
+
if (line.startsWith("event: ")) event = line.slice(7).trim();
|
| 203 |
+
else if (line.startsWith("data: ")) data += line.slice(6);
|
| 204 |
+
}
|
| 205 |
+
if (!data) continue;
|
| 206 |
+
let parsed;
|
| 207 |
+
try { parsed = JSON.parse(data); } catch { continue; }
|
| 208 |
+
|
| 209 |
+
if (event === "error") {
|
| 210 |
+
errorMsg = parsed;
|
| 211 |
+
break;
|
| 212 |
+
} else if (event === "complete") {
|
| 213 |
+
result = parsed;
|
| 214 |
+
break;
|
| 215 |
+
}
|
| 216 |
+
// event === "generating" أو "heartbeat" — نتجاهلها
|
| 217 |
}
|
| 218 |
+
if (result !== null || errorMsg) break;
|
|
|
|
| 219 |
}
|
|
|
|
|
|
|
| 220 |
|
| 221 |
+
if (errorMsg) {
|
| 222 |
+
// Gradio يرسل الخطأ كنص
|
| 223 |
+
if (typeof errorMsg === "string") throw new Error(errorMsg);
|
| 224 |
+
throw new Error(JSON.stringify(errorMsg));
|
| 225 |
+
}
|
| 226 |
+
if (result === null) throw new Error("لم يصل أي رد من الخادم");
|
| 227 |
+
|
| 228 |
+
// النتيجة عادة { data: [ {...} ] }
|
| 229 |
+
const out = result.data?.[0] ?? result;
|
| 230 |
+
return out;
|
| 231 |
}
|
| 232 |
|
| 233 |
// ===== State =====
|
|
|
|
| 241 |
}
|
| 242 |
|
| 243 |
// ===== Sessions =====
|
| 244 |
+
let sessions = JSON.parse(localStorage.getItem("chatSessions") || "{}");
|
| 245 |
+
let currentId = localStorage.getItem("currentSessionId") || uid();
|
| 246 |
+
if (!sessions[currentId]) sessions[currentId] = { title: "دردشة جديدة", messages: [] };
|
| 247 |
+
|
| 248 |
+
function save() {
|
| 249 |
+
localStorage.setItem("chatSessions", JSON.stringify(sessions));
|
| 250 |
+
localStorage.setItem("currentSessionId", currentId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
}
|
| 252 |
|
| 253 |
function renderHistory() {
|
|
|
|
| 256 |
Object.keys(sessions).reverse().forEach(id => {
|
| 257 |
const s = sessions[id];
|
| 258 |
const div = document.createElement("div");
|
| 259 |
+
div.className = `history-item ${id === currentId ? "active" : ""}`;
|
| 260 |
const title = document.createElement("span");
|
| 261 |
+
title.className = "title"; title.textContent = s.title;
|
|
|
|
| 262 |
const del = document.createElement("button");
|
| 263 |
+
del.innerHTML = icon("trash");
|
| 264 |
+
del.onclick = (e) => {
|
| 265 |
+
e.stopPropagation();
|
| 266 |
+
if (confirm("حذف؟")) {
|
| 267 |
+
delete sessions[id];
|
| 268 |
+
if (currentId === id) {
|
| 269 |
+
currentId = uid();
|
| 270 |
+
sessions[currentId] = { title: "دردشة جديدة", messages: [] };
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
}
|
| 272 |
+
save(); renderHistory(); loadSession(currentId);
|
| 273 |
}
|
|
|
|
|
|
|
|
|
|
| 274 |
};
|
| 275 |
+
div.append(title, del);
|
| 276 |
+
div.onclick = () => { currentId = id; save(); renderHistory(); loadSession(id); closeSidebar(); };
|
| 277 |
list.appendChild(div);
|
| 278 |
});
|
| 279 |
}
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
// ===== Messages =====
|
| 282 |
+
function addMsg(role, text = "", cls = "") {
|
| 283 |
$(".empty")?.remove();
|
| 284 |
const d = document.createElement("div");
|
| 285 |
+
d.className = `msg ${role} ${cls}`;
|
| 286 |
if (text) d.textContent = text;
|
| 287 |
log.appendChild(d);
|
| 288 |
+
main.scrollTop = main.scrollHeight;
|
| 289 |
return d;
|
| 290 |
}
|
| 291 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
function welcome() {
|
| 293 |
+
log.innerHTML = '<div class="empty"><h2>مرحباً 👋</h2><p>اختر نموذجاً من القائمة، اضغط "تحميل النموذج"، ثم اكتب.</p></div>';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
}
|
| 295 |
+
|
| 296 |
function loadSession(id) {
|
| 297 |
log.innerHTML = "";
|
| 298 |
const msgs = sessions[id].messages;
|
| 299 |
+
if (!msgs.length) { welcome(); return; }
|
| 300 |
msgs.forEach(m => {
|
| 301 |
+
const d = addMsg(m.role, m.content);
|
| 302 |
if (m.role === "assistant") addActions(d, m.content);
|
| 303 |
});
|
| 304 |
}
|
| 305 |
+
|
| 306 |
function setBusy(v) {
|
| 307 |
busy = v;
|
| 308 |
+
$("#send").style.display = v ? "none" : "grid";
|
| 309 |
+
$("#stop").style.display = v ? "grid" : "none";
|
| 310 |
+
$("#modelSel").disabled = v;
|
|
|
|
| 311 |
}
|
|
|
|
| 312 |
|
| 313 |
+
// ===== Models — القائمة الكاملة =====
|
| 314 |
+
const MODELS = {
|
| 315 |
+
// Chat / Instruct
|
| 316 |
+
qwen: { name: "Xenova/Qwen1.5-0.5B-Chat", kind: "chat", size: "~500MB", group: "💬 دردشة" },
|
| 317 |
+
qwen25: { name: "onnx-community/Qwen2.5-0.5B-Instruct", kind: "chat", size: "~500MB", group: "💬 دردشة" },
|
| 318 |
+
qwen3: { name: "onnx-community/Qwen3-0.6B-ONNX", kind: "chat", size: "~600MB", group: "💬 دردشة" },
|
| 319 |
+
lfm2: { name: "onnx-community/LFM2-350M-ONNX", kind: "chat", size: "~350MB", group: "💬 دردشة" },
|
| 320 |
+
smol360: { name: "onnx-community/SmolLM2-360M-Instruct", kind: "chat", size: "~360MB", group: "💬 دردشة" },
|
| 321 |
+
smol135: { name: "onnx-community/SmolLM2-135M-Instruct", kind: "chat", size: "~140MB", group: "💬 دردشة" },
|
| 322 |
+
// T5
|
| 323 |
+
lamini: { name: "Xenova/LaMini-Flan-T5-77M", kind: "t5", size: "~80MB", group: "⚡ سريعة" },
|
| 324 |
+
lamini248: { name: "Xenova/LaMini-Flan-T5-248M", kind: "t5", size: "~250MB", group: "⚡ سريعة" },
|
| 325 |
+
flansmall: { name: "Xenova/flan-t5-small", kind: "t5", size: "~60MB", group: "⚡ سريعة" },
|
| 326 |
+
flanbase: { name: "Xenova/flan-t5-base", kind: "t5", size: "~250MB", group: "⚡ سريعة" },
|
| 327 |
+
t5small: { name: "Xenova/t5-small", kind: "t5", size: "~60MB", group: "⚡ سريعة" },
|
| 328 |
+
// Completion
|
| 329 |
+
distilgpt2:{ name: "Xenova/distilgpt2", kind: "alpaca", size: "~82MB", group: "🐣 إكمال" },
|
| 330 |
+
gpt2: { name: "Xenova/gpt2", kind: "alpaca", size: "~125MB", group: "🐣 إكمال" },
|
| 331 |
+
opt125: { name: "Xenova/opt-125m", kind: "alpaca", size: "~130MB", group: "🐣 إكمال" },
|
| 332 |
+
lgpt: { name: "Xenova/LaMini-GPT-124M", kind: "alpaca", size: "~125MB", group: "🐣 إكمال" },
|
| 333 |
+
};
|
| 334 |
|
| 335 |
+
const modelSel = $("#modelSel");
|
| 336 |
+
const groups = {};
|
| 337 |
+
for (const [k, m] of Object.entries(MODELS)) {
|
| 338 |
+
if (!groups[m.group]) {
|
| 339 |
+
groups[m.group] = document.createElement("optgroup");
|
| 340 |
+
groups[m.group].label = m.group;
|
| 341 |
+
modelSel.appendChild(groups[m.group]);
|
|
|
|
|
|
|
|
|
|
| 342 |
}
|
| 343 |
+
const o = document.createElement("option");
|
| 344 |
+
o.value = k;
|
| 345 |
+
o.textContent = `${m.name} (${m.size})`;
|
| 346 |
+
groups[m.group].appendChild(o);
|
| 347 |
+
}
|
| 348 |
+
modelSel.value = "qwen25";
|
| 349 |
+
|
| 350 |
+
function syncModelUI() {
|
| 351 |
+
const m = MODELS[modelSel.value];
|
| 352 |
+
$("#chatName").textContent = m.name;
|
| 353 |
+
$("#modelNote").textContent = m.kind === "chat"
|
| 354 |
+
? "يتذكر السياق — اضغط تحميل أولاً."
|
| 355 |
+
: m.kind === "t5" ? "سؤال وجواب مستقل." : "إكمال نصي.";
|
| 356 |
+
// إعادة ضبط حالة المؤشر
|
| 357 |
+
$("#m-chat .dot").className = "dot";
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
modelSel.onchange = () => {
|
| 361 |
+
if (sessions[currentId].messages.length && !confirm("تغيير النموذج سيمسح المحادثة. متابعة؟")) {
|
| 362 |
+
return;
|
| 363 |
+
}
|
| 364 |
+
sessions[currentId].messages = [];
|
| 365 |
+
save(); loadSession(currentId); syncModelUI();
|
| 366 |
+
};
|
| 367 |
+
|
| 368 |
+
// ===== Load button =====
|
| 369 |
+
$("#loadBtn").onclick = async () => {
|
| 370 |
+
const id = modelSel.value;
|
| 371 |
+
const el = $("#m-chat");
|
| 372 |
+
el.querySelector(".dot").className = "dot loading";
|
| 373 |
+
try {
|
| 374 |
+
onLog("تحميل: " + id);
|
| 375 |
+
const r = await apiCall("load", { model_id: id });
|
| 376 |
+
if (r && r.ok) {
|
| 377 |
+
el.querySelector(".dot").className = "dot ready";
|
| 378 |
+
onLog("جاهز: " + id);
|
| 379 |
+
} else {
|
| 380 |
+
throw new Error(r?.error || "فشل التحميل");
|
| 381 |
+
}
|
| 382 |
+
} catch (e) {
|
| 383 |
+
el.querySelector(".dot").className = "dot error";
|
| 384 |
+
addMsg("assistant", "⚠️ فشل تحميل النموذج: " + e.message, "error");
|
| 385 |
+
}
|
| 386 |
+
};
|
| 387 |
+
|
| 388 |
+
$("#unload").onclick = async () => {
|
| 389 |
+
try {
|
| 390 |
+
await apiCall("unload", { model_id: modelSel.value });
|
| 391 |
+
$("#m-chat .dot").className = "dot";
|
| 392 |
+
addMsg("assistant", "تم تفريغ النموذج.");
|
| 393 |
+
} catch (e) { addMsg("assistant", "فشل: " + e.message, "error"); }
|
| 394 |
+
};
|
| 395 |
+
|
| 396 |
+
// ===== Chat =====
|
| 397 |
+
async function ask(text) {
|
| 398 |
+
if (!text.trim()) return;
|
| 399 |
+
addMsg("user", text);
|
| 400 |
+
sessions[currentId].messages.push({ role: "user", content: text });
|
| 401 |
+
if (sessions[currentId].title === "دردشة جديدة") {
|
| 402 |
+
sessions[currentId].title = text.slice(0, 25);
|
| 403 |
+
renderHistory();
|
| 404 |
+
}
|
| 405 |
+
save();
|
| 406 |
+
setBusy(true);
|
| 407 |
|
| 408 |
const bubble = addMsg("assistant");
|
| 409 |
const status = document.createElement("span");
|
| 410 |
+
status.className = "status"; status.textContent = "جارٍ التوليد…";
|
|
|
|
| 411 |
const out = document.createElement("span");
|
| 412 |
+
bubble.append(status, out);
|
| 413 |
|
| 414 |
currentReqId = uid();
|
| 415 |
|
| 416 |
try {
|
| 417 |
+
const r = await apiCall("generate", {
|
|
|
|
|
|
|
| 418 |
model_id: modelSel.value,
|
| 419 |
+
messages: JSON.stringify(sessions[currentId].messages),
|
| 420 |
req_id: currentReqId,
|
| 421 |
});
|
|
|
|
| 422 |
|
|
|
|
| 423 |
status.remove();
|
| 424 |
+
|
| 425 |
+
if (!r || r.ok === false) throw new Error(r?.error || "فشل التوليد");
|
| 426 |
+
|
| 427 |
+
const answer = (r.text || "").trim();
|
| 428 |
+
if (answer) {
|
| 429 |
+
out.textContent = answer;
|
| 430 |
+
sessions[currentId].messages.push({ role: "assistant", content: answer });
|
| 431 |
+
save();
|
| 432 |
+
addActions(bubble, answer);
|
| 433 |
+
} else {
|
| 434 |
+
out.textContent = "(رد فارغ من النموذج)";
|
| 435 |
}
|
| 436 |
} catch (e) {
|
| 437 |
bubble.remove();
|
| 438 |
+
addMsg("assistant", "⚠️ " + e.message, "error");
|
| 439 |
onLog("خطأ: " + e.message);
|
| 440 |
} finally {
|
| 441 |
status.remove();
|
|
|
|
| 446 |
|
| 447 |
function addActions(bubble, text) {
|
| 448 |
const row = document.createElement("div"); row.className = "acts";
|
| 449 |
+
const copy = document.createElement("button"); copy.innerHTML = icon("copy");
|
| 450 |
copy.onclick = async () => {
|
| 451 |
+
try { await navigator.clipboard.writeText(text); } catch {}
|
| 452 |
+
copy.style.color = "var(--ok)"; setTimeout(() => copy.style.color = "", 1200);
|
|
|
|
| 453 |
};
|
| 454 |
+
const re = document.createElement("button"); re.className = "regen"; re.innerHTML = icon("refresh");
|
| 455 |
re.onclick = () => {
|
| 456 |
if (busy) return;
|
| 457 |
+
const msgs = sessions[currentId].messages;
|
| 458 |
if (msgs.length && msgs[msgs.length - 1].role === "assistant") msgs.pop();
|
| 459 |
+
save(); bubble.remove(); ask(msgs[msgs.length - 1]?.content || "");
|
| 460 |
};
|
| 461 |
row.append(copy, re); bubble.appendChild(row);
|
| 462 |
}
|
| 463 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
// ===== Input =====
|
| 465 |
+
$("#send").onclick = () => {
|
| 466 |
+
const t = input.value.trim();
|
| 467 |
+
if (!t) return;
|
| 468 |
+
input.value = "";
|
| 469 |
+
ask(t);
|
| 470 |
};
|
| 471 |
input.addEventListener("keydown", (e) => {
|
| 472 |
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
| 473 |
+
e.preventDefault();
|
| 474 |
+
$("#send").click();
|
| 475 |
+
}
|
| 476 |
});
|
| 477 |
+
$("#stop").onclick = async () => {
|
| 478 |
+
if (!currentReqId) return;
|
| 479 |
+
try { await apiCall("cancel", { req_id: currentReqId }); } catch {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
};
|
| 481 |
|
| 482 |
+
// ===== Audio / Images =====
|
| 483 |
let rec = null, chunks = [];
|
| 484 |
+
$("#plus").onclick = (e) => { e.stopPropagation(); $("#pop").hidden = !$("#pop").hidden; };
|
| 485 |
+
document.addEventListener("click", () => $("#pop").hidden = true);
|
| 486 |
+
$("#pop").addEventListener("click", (e) => e.stopPropagation());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
|
| 488 |
+
$("#mic").onclick = async () => {
|
| 489 |
if (busy || rec) return;
|
| 490 |
try {
|
| 491 |
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
| 493 |
rec.ondataavailable = (e) => chunks.push(e.data);
|
| 494 |
rec.onstop = async () => {
|
| 495 |
stream.getTracks().forEach((t) => t.stop());
|
|
|
|
| 496 |
rec = null;
|
| 497 |
await transcribe(await new Blob(chunks).arrayBuffer());
|
| 498 |
};
|
| 499 |
rec.start();
|
| 500 |
+
addMsg("assistant", "🎤 جارٍ التسجيل…");
|
| 501 |
+
} catch (e) { addMsg("assistant", "⚠️ ميكروفون: " + e.message, "error"); }
|
| 502 |
};
|
| 503 |
|
| 504 |
+
async function transcribe(buf) {
|
|
|
|
| 505 |
setBusy(true);
|
| 506 |
+
const note = addMsg("assistant", "جارٍ تفريغ الصوت…");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
try {
|
| 508 |
+
const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
|
| 509 |
+
const r = await apiCall("asr", { audio_b64: b64 });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
note.remove(); setBusy(false);
|
| 511 |
+
if (r?.ok && r.text) ask(r.text);
|
| 512 |
+
else addMsg("assistant", "⚠️ " + (r?.error || "لا كلام"), "error");
|
| 513 |
+
} catch (e) { note.remove(); setBusy(false); addMsg("assistant", "⚠️ " + e.message, "error"); }
|
| 514 |
}
|
| 515 |
|
| 516 |
$("#aud").onclick = () => $("#audFile").click();
|
|
|
|
| 519 |
if (f) await transcribe(await f.arrayBuffer());
|
| 520 |
};
|
| 521 |
|
|
|
|
| 522 |
$("#img").onclick = () => $("#imgFile").click();
|
| 523 |
+
$("#imgFile").onchange = async (e) => {
|
| 524 |
const f = e.target.files[0]; e.target.value = "";
|
| 525 |
+
if (!f) return;
|
| 526 |
+
setBusy(true);
|
| 527 |
+
const note = addMsg("assistant", "جارٍ وصف الصورة…");
|
| 528 |
+
try {
|
| 529 |
+
const b64 = await new Promise((res) => {
|
| 530 |
+
const reader = new FileReader();
|
| 531 |
+
reader.onload = () => res(reader.result.split(",")[1]);
|
| 532 |
+
reader.readAsDataURL(f);
|
| 533 |
+
});
|
| 534 |
+
const r = await apiCall("caption", { image_b64: b64 });
|
| 535 |
+
note.remove(); setBusy(false);
|
| 536 |
+
if (r?.ok && r.text) addMsg("assistant", "🖼️ وصف الصورة: " + r.text);
|
| 537 |
+
else addMsg("assistant", "⚠️ " + (r?.error || "فشل"), "error");
|
| 538 |
+
} catch (err) { note.remove(); setBusy(false); addMsg("assistant", "⚠️ " + err.message, "error"); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
};
|
| 540 |
|
| 541 |
+
// ===== Header =====
|
| 542 |
$("#clear").onclick = () => {
|
| 543 |
+
sessions[currentId].messages = [];
|
| 544 |
+
sessions[currentId].title = "دردشة جديدة";
|
| 545 |
+
save(); renderHistory(); loadSession(currentId);
|
|
|
|
| 546 |
};
|
| 547 |
$("#newChatBtn").onclick = () => {
|
| 548 |
+
currentId = uid();
|
| 549 |
+
sessions[currentId] = { title: "دردشة جديدة", messages: [] };
|
| 550 |
+
save(); renderHistory(); loadSession(currentId); closeSidebar();
|
|
|
|
| 551 |
};
|
| 552 |
$("#noteX").onclick = () => $("#note").remove();
|
|
|
|
|
|
|
|
|
|
| 553 |
$("#menu").onclick = () => {
|
| 554 |
+
const s = $("#side");
|
| 555 |
+
s.classList.toggle("open");
|
| 556 |
+
$("#overlay").classList.toggle("show", s.classList.contains("open"));
|
| 557 |
};
|
| 558 |
+
function closeSidebar() {
|
| 559 |
+
$("#side").classList.remove("open");
|
| 560 |
+
$("#overlay").classList.remove("show");
|
| 561 |
+
}
|
| 562 |
$("#overlay").onclick = closeSidebar;
|
|
|
|
| 563 |
|
| 564 |
// ===== Speech =====
|
| 565 |
+
$("#speak").onclick = () => {
|
| 566 |
+
if (!("speechSynthesis" in window)) return;
|
|
|
|
| 567 |
window.speechSynthesis.cancel();
|
| 568 |
+
const last = sessions[currentId].messages.filter(m => m.role === "assistant").pop();
|
| 569 |
+
if (!last) return;
|
| 570 |
+
const u = new SpeechSynthesisUtterance(last.content);
|
| 571 |
+
u.lang = /[\u0600-\u06FF]/.test(last.content) ? "ar" : "en";
|
| 572 |
+
window.speechSynthesis.speak(u);
|
|
|
|
|
|
|
|
|
|
| 573 |
};
|
| 574 |
|
|
|
|
| 575 |
renderHistory();
|
| 576 |
+
loadSession(currentId);
|
| 577 |
+
syncModelUI();
|
| 578 |
+
setBusy(false);
|
| 579 |
</script>
|
| 580 |
</body>
|
| 581 |
</html>
|