bh-ab commited on
Commit
7bf2e99
·
verified ·
1 Parent(s): 848553f

Update index.html

Browse files
Files changed (1) hide show
  1. 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>دردشة ذكاء اصطناعي محلية</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,-apple-system,"Segoe 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
- :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
- .note button{margin-inline-start:auto;display:grid}
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;border-end-start-radius:4px}
49
- .msg.assistant{background:var(--ai);align-self:flex-end;border-end-end-radius:4px}
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
- @media (prefers-reduced-motion:reduce){.typing i{animation:none}main{scroll-behavior:auto}}
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;line-height:1.6}
66
- .ic{width:38px;height:38px;border-radius:10px;flex:none;display:grid;place-items:center;transition:background .15s}
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
- .hint{max-width:820px;margin:6px auto 0;font-size:12px;color:var(--muted);text-align:center}
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
- select:disabled,.ghost:disabled{opacity:.5;cursor:not-allowed}
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;margin-inline-end:6px}
95
- .history-item .del-btn{color:var(--muted);display:none}
96
- .history-item:hover .del-btn{display:block}
97
- .history-item .del-btn:hover{color:var(--err)}
98
-
99
- /* Mobile */
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 wide" id="newChatBtn" style="margin-bottom:10px"><svg class="i"><use href="#i-plus"/></svg> محادثة جديدة</button>
142
  <div id="historyList" style="max-height:200px;overflow-y:auto"></div>
143
  </section>
144
  <section>
145
  <h2>نموذج الدردشة</h2>
146
- <select id="modelSel" aria-label="اختيار النموذج" dir="ltr"></select>
147
- <p class="sub" id="modelNote"></p>
148
- <button class="ghost wide" id="unload"><svg class="i"><use href="#i-chip"/></svg>تفريغ النموذج من الذاكرة</button>
 
149
  </section>
150
  <section>
151
  <h2>حالة النماذج</h2>
152
- <div class="model" id="m-chat"><b><span class="dot"></span>الدردشة</b><small id="chatName">—</small><div class="bar"><i></i></div></div>
153
- <div class="model" id="m-asr"><b><span class="dot"></span>تحويل الصوت</b><small>openai/whisper-tiny.en</small><div class="bar"><i></i></div></div>
154
- <div class="model" id="m-cap"><b><span class="dot"></span>وصف الصور</b><small>nlpconnect/vit-gpt2</small><div class="bar"><i></i></div></div>
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" class="diag"></pre>
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>دردشة ذكاء اصطناعي محلية</h1>
169
- <span class="badge" id="backend">…</span>
170
- <button class="ghost" id="theme" aria-label="تبديل المظهر"></button>
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. الواجهة محلية في متصفحك.</span>
176
- <button id="noteX" aria-label="إغلاق"><svg class="i"><use href="#i-x"/></svg></button>
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="قراءة آخر رد بصوت" aria-label="قراءة"><svg class="i"><use href="#i-speaker"/></svg></button>
184
- <button class="ic" id="plus" title="إرفاق" aria-label="إرفاق" aria-haspopup="true" aria-expanded="false"><svg class="i"><use href="#i-plus"/></svg></button>
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>رفع ملف صوتي</button>
188
- <button id="mic"><svg class="i"><use href="#i-mic"/></svg>تسجيل صوتي</button>
189
  </div>
190
- <textarea id="input" rows="1" placeholder="اكتب رسالتك… (Enter للإرسال، Shift+Enter لسطر جديد)"></textarea>
191
- <button class="ic" id="stop" title="إيقاف التوليد" aria-label="إيقاف"><svg class="i"><use href="#i-stop"/></svg></button>
192
- <button class="ic" id="send" title="إرسال" aria-label="إرسال"><svg class="i"><use href="#i-send"/></svg></button>
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() + '-' + Math.random().toString(36).slice(2));
205
 
206
  // ===== Theme =====
207
- const themeBtn = $("#theme");
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
- themeBtn.innerHTML = icon(t === "dark" ? "sun" : "moon");
215
- themeBtn.title = t === "dark" ? "الوضع النهاري" : "الوضع الليلي";
216
- try { localStorage.setItem("theme", t); } catch (e) {}
217
  }
218
- themeBtn.onclick = () => applyTheme(theme === "dark" ? "light" : "dark");
219
  applyTheme(theme);
220
 
221
- // ===== Capability =====
222
- const gpu = !!navigator.gpu;
223
- const mem = navigator.deviceMemory ? navigator.deviceMemory + " GB" : "غير معروفة";
224
- $("#caps").innerHTML =
225
- `WebGPU: <b>${gpu ? "متاح" : "غير متاح"}</b><br>الذاكرة: <b>${mem}</b><br>أنوية المعالج: <b>${navigator.hardwareConcurrency || "؟"}</b><br>المعالجة: <b>على الخادم (ZeroGPU)</b>`;
226
- $("#backend").textContent = "GPU";
227
-
228
- // ===== Gradio Client =====
229
- let clientPromise = null;
230
- async function getClient() {
231
- if (!clientPromise) {
232
- if (!window.GradioClient) {
233
- await new Promise((res) => {
234
- if (window.GradioClient) return res();
235
- window.addEventListener("gradio-client-ready", res, { once: true });
236
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  }
238
- // ✅ التصحيح: استخدام Client.connect بدلاً من استدعاء الكلاس مباشرة
239
- clientPromise = window.GradioClient.connect(window.location.origin);
240
  }
241
- return clientPromise;
242
- }
243
 
244
- async function api(name, body = {}) {
245
- const client = await getClient();
246
- const result = await client.predict(`/${name}`, body);
247
- return Array.isArray(result?.data) ? result.data[0] : result;
 
 
 
 
 
 
248
  }
249
 
250
  // ===== State =====
@@ -258,25 +241,13 @@ function onLog(t) {
258
  }
259
 
260
  // ===== Sessions =====
261
- let sessions = {};
262
- let currentSessionId = "";
263
- try {
264
- sessions = JSON.parse(localStorage.getItem("chatSessions") || "{}");
265
- currentSessionId = localStorage.getItem("currentSessionId") || uid();
266
- } catch (e) { sessions = {}; currentSessionId = uid(); }
267
- if (!sessions[currentSessionId]) sessions[currentSessionId] = { title: "دردشة جديدة", messages: [] };
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 === currentSessionId ? "active" : ""}`;
289
  const title = document.createElement("span");
290
- title.className = "title";
291
- title.textContent = s.title;
292
  const del = document.createElement("button");
293
- del.className = "del-btn"; del.title = "حذف"; del.innerHTML = icon("trash");
294
- div.append(title, del);
295
- div.onclick = (e) => {
296
- if (e.target.closest(".del-btn")) {
297
- e.stopPropagation();
298
- if (confirm("حذف هذه المحادثة؟")) {
299
- delete sessions[id];
300
- if (currentSessionId === id) {
301
- currentSessionId = uid();
302
- sessions[currentSessionId] = { title: "دردشة جديدة", messages: [] };
303
- }
304
- saveSessions(); renderHistory(); loadSession(currentSessionId);
305
  }
306
- return;
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 = "", extraClass = "") {
334
  $(".empty")?.remove();
335
  const d = document.createElement("div");
336
- d.className = `msg ${role} ${extraClass}`;
337
  if (text) d.textContent = text;
338
  log.appendChild(d);
339
- scroll();
340
  return d;
341
  }
342
- const scroll = () => { main.scrollTop = main.scrollHeight; };
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>اكتب رسالتك للبدء. سيُحمَّل النموذج عند أول رسالة.</p></div>';
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 === 0) { welcome(); return; }
362
  msgs.forEach(m => {
363
- const d = addMsg(m.role, displayContent(m));
364
  if (m.role === "assistant") addActions(d, m.content);
365
  });
366
  }
 
367
  function setBusy(v) {
368
  busy = v;
369
- sendBtn.disabled = v;
370
- $("#unload").disabled = v; $("#modelSel").disabled = v;
371
- stopBtn.style.display = v ? "grid" : "none";
372
- sendBtn.style.display = v ? "none" : "grid";
373
  }
374
- function showError(msg) { addMsg("assistant", "⚠️ " + msg, "error"); }
375
 
376
- // ===== Chat =====
377
- async function ask(text, regen = false) {
378
- if (busy || (!regen && !text.trim())) return;
379
- document.querySelectorAll(".regen").forEach((b) => b.remove());
380
- setBusy(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
 
382
- let prompt;
383
- if (regen) {
384
- const msgs = sessions[currentSessionId].messages;
385
- prompt = msgs.length ? msgs[msgs.length - 1].content : "";
386
- } else {
387
- addMsg("user", text);
388
- prompt = text;
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, typing());
400
 
401
  currentReqId = uid();
402
 
403
  try {
404
- setModel("chat", "loading", 0);
405
- onLog("طلب توليد: " + modelSel.value);
406
- const r = await api("generate", {
407
  model_id: modelSel.value,
408
- messages: JSON.stringify(sessions[currentSessionId].messages),
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
- bubble.querySelector(".typing")?.remove();
416
- out.textContent = r.text || "(لا يوجد رد)";
417
- if (r.text) {
418
- sessions[currentSessionId].messages.push({ role: "assistant", content: r.text });
419
- saveSessions();
420
- addActions(bubble, r.text);
 
 
 
 
 
421
  }
422
  } catch (e) {
423
  bubble.remove();
424
- showError("فشل الاتصال بالخادم: " + e.message);
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.title = "نسخ"; copy.innerHTML = icon("copy");
436
  copy.onclick = async () => {
437
- try { await navigator.clipboard.writeText(text); }
438
- catch (e) { const ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); }
439
- copy.title = "تم النسخ"; copy.classList.add("ok"); setTimeout(() => copy.classList.remove("ok"), 1200);
440
  };
441
- const re = document.createElement("button"); re.className = "regen"; re.title = "إعادة التوليد"; re.innerHTML = icon("refresh");
442
  re.onclick = () => {
443
  if (busy) return;
444
- const msgs = sessions[currentSessionId].messages;
445
  if (msgs.length && msgs[msgs.length - 1].role === "assistant") msgs.pop();
446
- saveSessions(); bubble.remove(); ask("", true);
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
- function submit() { const t = input.value.trim(); if (!t) return; input.value = ""; fit(); ask(t); }
485
- sendBtn.onclick = submit;
486
- stopBtn.onclick = async () => {
487
- if (!currentReqId) return;
488
- try { await api("cancel", { req_id: currentReqId }); } catch (e) {}
489
  };
490
  input.addEventListener("keydown", (e) => {
491
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit(); }
 
 
 
492
  });
493
- const fit = () => { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 160) + "px"; };
494
- input.addEventListener("input", fit);
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 recording =====
507
  let rec = null, chunks = [];
508
- const plusBtn = $("#plus"), pop = $("#pop");
509
- function closePop() { pop.hidden = true; plusBtn.setAttribute("aria-expanded", "false"); }
510
- plusBtn.onclick = (e) => {
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
- micBtn.onclick = async () => {
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
- plusBtn.classList.add("rec"); plusBtn.innerHTML = icon("stop"); plusBtn.title = "إيقاف التسجيل";
533
- } catch (e) { showError("لا يمكن الوصول إلى الميكروفون (يتطلب https)."); }
534
  };
535
 
536
- async function transcribe(arrayBuffer) {
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(arrayBuffer)));
546
- setModel("asr", "loading", 0);
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
- showError("فشل تفريغ الصوت: " + e.message);
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 || busy) return;
570
- const reader = new FileReader();
571
- reader.onload = async () => {
572
- const b64 = reader.result.split(",")[1];
573
- const u = addMsg("user"); const im = new Image(); im.src = reader.result; u.appendChild(im);
574
- const note = addMsg("assistant");
575
- const status = document.createElement("span");
576
- status.className = "status"; status.textContent = "جارٍ وصف الصورة…";
577
- note.append(status, typing());
578
- setBusy(true); setModel("cap", "loading", 0);
579
- try {
580
- const r = await api("caption", { image_b64: b64 });
581
- setModel("cap", "ready", 100);
582
- note.remove(); setBusy(false);
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 / sidebar =====
594
  $("#clear").onclick = () => {
595
- if (busy) stopBtn.click();
596
- sessions[currentSessionId].messages = [];
597
- sessions[currentSessionId].title = "دردشة جديدة";
598
- saveSessions(); renderHistory(); loadSession(currentSessionId);
599
  };
600
  $("#newChatBtn").onclick = () => {
601
- currentSessionId = uid();
602
- sessions[currentSessionId] = { title: "دردشة جديدة", messages: [] };
603
- saveSessions(); renderHistory(); loadSession(currentSessionId);
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").classList.contains("open") ? closeSidebar() : openSidebar();
 
 
612
  };
 
 
 
 
613
  $("#overlay").onclick = closeSidebar;
614
- document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSidebar(); });
615
 
616
  // ===== Speech =====
617
- const speakBtn = $("#speak");
618
- speakBtn.onclick = () => {
619
- if (!('speechSynthesis' in window)) { showError("المتصفح لا يدعم تحويل النص إلى كلام."); return; }
620
  window.speechSynthesis.cancel();
621
- const msgs = sessions[currentSessionId]?.messages;
622
- const lastAssistant = msgs?.filter(m => m.role === "assistant").pop();
623
- if (!lastAssistant || !lastAssistant.content) { showError("لا يوجد رد لقراءته."); return; }
624
- const utter = new SpeechSynthesisUtterance(lastAssistant.content);
625
- utter.lang = /[\u0600-\u06FF]/.test(lastAssistant.content) ? "ar" : "en";
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(currentSessionId);
634
- input.focus();
 
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>