Download index.html from bh-ab/Deepmind: direct link, hf CLI and curl.
- Browser
- Download file 54.9 kB
-
https://huggingface.co/spaces/bh-ab/Deepmind/resolve/main/index.html
- Command line
-
hf download hf://spaces/bh-ab/Deepmind/index.html
-
curl -L -o index.html https://huggingface.co/spaces/bh-ab/Deepmind/resolve/main/index.html
54.9 kB
| <html lang="ar" dir="rtl" data-theme="dark"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> | |
| <meta name="color-scheme" content="dark light"> | |
| <title>AI Studio — دردشة محلية</title> | |
| <style> | |
| :root,[data-theme=dark]{--bg:#0f1115;--s1:#171a21;--s2:#1f232c;--s3:#2a2f3a;--bd:#2c313c;--tx:#eceef2;--mu:#8f97a6;--ac:#e06f4f;--ok:#4ade80;--er:#fb7185;--wr:#fbbf24;--code:#0a0c10;color-scheme:dark} | |
| [data-theme=light]{--bg:#f6f6f3;--s1:#fff;--s2:#f0efeb;--s3:#e5e3dd;--bd:#dad8d1;--tx:#1f201e;--mu:#6f726b;--ac:#cc6748;--ok:#16a34a;--er:#dc2626;--wr:#d97706;--code:#f4f4f0;color-scheme:light} | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| html,body{height:100%} | |
| body{background:var(--bg);color:var(--tx);font:14.5px/1.8 "Segoe UI",Tahoma,Arial,sans-serif;display:flex;overflow:hidden} | |
| button,input,select,textarea{font:inherit;color:inherit} | |
| button{cursor:pointer;-webkit-tap-highlight-color:transparent} | |
| ::-webkit-scrollbar{width:7px;height:7px}::-webkit-scrollbar-thumb{background:var(--s3);border-radius:9px} | |
| .ib,.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px} | |
| #snd,#stp{align-items:center;justify-content:center} | |
| .ib{width:36px;height:36px;border:1px solid transparent;border-radius:10px;background:none;color:var(--mu);flex:none} | |
| .ib:hover{background:var(--s2);border-color:var(--bd);color:var(--tx)} | |
| .btn{padding:10px 16px;border:0;border-radius:11px;background:var(--ac);color:#fff;font-weight:600;font-size:13px} | |
| .btn:hover{filter:brightness(1.08)}.btn.g{background:var(--s2);border:1px solid var(--bd);color:var(--tx);font-weight:400} | |
| #ov{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:40;display:none}#ov.show{display:block} | |
| #sb{width:280px;background:var(--s1);border-left:1px solid var(--bd);display:flex;flex-direction:column;flex:none;z-index:50;transition:transform .25s} | |
| #sb .top{padding:12px}#sb .top .btn{width:100%} | |
| #ss{flex:1;overflow-y:auto;padding:4px 9px;min-height:0} | |
| .se{display:flex;align-items:center;gap:8px;padding:9px 11px;border-radius:10px;color:var(--mu);font-size:13px;margin-bottom:2px;cursor:pointer} | |
| .se:hover{background:var(--s2)}.se.on{background:var(--s3);color:var(--tx)} | |
| .se span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| .se .x{background:none;border:0;color:var(--mu);opacity:0}.se:hover .x{opacity:1}.se .x:hover{color:var(--er)} | |
| #sb .ft{padding:10px 12px;border-top:1px solid var(--bd);display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--mu)} | |
| main{flex:1;display:flex;flex-direction:column;min-width:0;position:relative} | |
| #tb{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--s1);border-bottom:1px solid var(--bd);flex-wrap:wrap} | |
| #hb{display:none} | |
| #mp{flex:0 1 320px;max-width:48vw;padding:8px 10px;border-radius:10px;border:1px solid var(--bd);background:var(--s2);font-size:12.5px} | |
| .sp{flex:1} | |
| .pill{font-size:11px;color:var(--mu);display:flex;align-items:center;gap:6px;background:var(--s2);border:1px solid var(--bd);padding:5px 10px;border-radius:20px;white-space:nowrap} | |
| .led{width:7px;height:7px;border-radius:50%;background:var(--er)}.led.on{background:var(--ok)}.led.wr{background:var(--wr);animation:pl 1s infinite} | |
| @keyframes pl{50%{opacity:.4}} | |
| #chat{flex:1;overflow-y:auto;padding:18px 14px 8px;min-height:0} | |
| #ci{max-width:820px;margin:0 auto;display:flex;flex-direction:column;gap:14px} | |
| .welcome{text-align:center;color:var(--mu);padding:70px 20px}.welcome h2{color:var(--tx);font-size:21px;margin-bottom:8px} | |
| .msg{max-width:94%;word-break:break-word} | |
| .msg.user{align-self:flex-start;background:var(--s3);padding:10px 15px;border-radius:18px 18px 18px 5px} | |
| .msg.bot{align-self:stretch;padding:2px 4px} | |
| .msg .files{font-size:11px;color:var(--mu);margin-top:5px} | |
| .msg img.chat-img{max-width:100%;max-height:280px;border-radius:10px;display:block;margin:6px 0;border:1px solid var(--bd)} | |
| .msg h1,.msg h2,.msg h3{margin:12px 0 6px;font-size:1.12em} | |
| .msg ul{margin:6px 22px 6px 0}.msg a{color:var(--ac)} | |
| .msg blockquote{border-right:3px solid var(--ac);padding-right:12px;color:var(--mu);margin:8px 0} | |
| .ma{display:flex;gap:4px;margin-top:5px;opacity:.55;flex-wrap:wrap}.ma:hover{opacity:1} | |
| .ma button{background:none;border:1px solid transparent;border-radius:8px;color:var(--mu);font-size:11px;padding:2px 9px;display:inline-flex;align-items:center;gap:4px} | |
| .ma button:hover{background:var(--s2);border-color:var(--bd);color:var(--tx)} | |
| .ma button.on{color:var(--ac);border-color:var(--ac)} | |
| .cw{margin:10px 0;border:1px solid var(--bd);border-radius:12px;overflow:hidden;background:var(--code)} | |
| .ch{display:flex;justify-content:space-between;align-items:center;padding:5px 12px;background:var(--s2);font-size:11px;color:var(--mu);direction:ltr;gap:6px;flex-wrap:wrap} | |
| .ch button{background:none;border:0;color:var(--mu);font-size:11px;display:inline-flex;align-items:center;gap:4px} | |
| .ch button.open-btn{color:var(--ac)} | |
| pre{margin:0;padding:13px;overflow-x:auto;direction:ltr;text-align:left;font:12.5px/1.65 Consolas,monospace;color:var(--tx)} | |
| code.in{background:var(--s2);padding:1px 6px;border-radius:6px;font-size:.9em;direction:ltr;display:inline-block} | |
| #cmp{padding:8px 12px calc(10px + env(safe-area-inset-bottom))} | |
| #cmp .in{max-width:820px;margin:0 auto;background:var(--s1);border:1px solid var(--bd);border-radius:20px;padding:9px 11px} | |
| #cmp .in:focus-within{border-color:var(--ac)} | |
| #chips{display:flex;gap:6px;flex-wrap:wrap}#chips:not(:empty){margin-bottom:7px} | |
| .chip{background:var(--s2);border:1px solid var(--bd);border-radius:20px;padding:3px 10px;font-size:11px;display:flex;gap:6px;align-items:center} | |
| .chip button{background:none;border:0;color:var(--mu)} | |
| #inp{width:100%;background:none;border:0;outline:0;resize:none;max-height:170px;line-height:1.6;padding:4px 2px} | |
| .cr{display:flex;align-items:center;gap:6px;margin-top:4px} | |
| #snd,#stp{width:38px;height:38px;border-radius:12px;border:0;background:var(--ac);color:#fff;font-size:16px} | |
| #stp{display:none;background:var(--s2);border:1px solid var(--bd);color:var(--er)} | |
| #pw{position:relative} | |
| #pp{position:absolute;bottom:48px;right:0;width:min(380px,calc(100vw - 30px));background:var(--s1);border:1px solid var(--bd);border-radius:16px;box-shadow:0 20px 60px rgba(0,0,0,.4);display:none;z-index:60;overflow:hidden} | |
| #pp.open{display:block}#pp .sc{max-height:min(460px,62vh);overflow-y:auto} | |
| .pi{display:flex;gap:10px;align-items:center;width:100%;padding:11px 14px;background:none;border:0;text-align:right;font-size:13px}.pi:hover{background:var(--s2)} | |
| .sw{position:relative;width:38px;height:22px;flex:none}.sw input{opacity:0;width:0;height:0} | |
| .sl{position:absolute;inset:0;background:var(--s3);border:1px solid var(--bd);border-radius:22px;cursor:pointer;transition:.2s} | |
| .sl::before{content:"";position:absolute;width:16px;height:16px;border-radius:50%;background:var(--mu);top:2px;right:2px;transition:.2s} | |
| .sw input:checked+.sl{background:var(--ac);border-color:var(--ac)}.sw input:checked+.sl::before{background:#fff;transform:translateX(-16px)} | |
| #mic.on{color:var(--er);border-color:var(--er);animation:pl 1.2s infinite} | |
| #spk.on{color:var(--ac);border-color:var(--ac)} | |
| #vcb.on{color:var(--ac);border-color:var(--ac)} | |
| #vc{position:fixed;inset:0;z-index:95;background:radial-gradient(circle at 50% 35%,var(--s2),var(--bg) 70%);display:none;flex-direction:column;align-items:center;justify-content:space-between;padding:calc(16px + env(safe-area-inset-top)) 20px calc(26px + env(safe-area-inset-bottom))} | |
| #vc.on{display:flex}.vx{align-self:flex-start} | |
| .vm{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;width:100%;max-width:560px;text-align:center;min-height:0} | |
| #orb{width:150px;height:150px;flex:none;border-radius:50%;background:radial-gradient(circle at 35% 30%,rgba(255,255,255,.35),var(--ac));position:relative;cursor:pointer;box-shadow:0 0 60px rgba(224,111,79,.4);transition:box-shadow .08s} | |
| #orb i{position:absolute;inset:0;border-radius:50%;border:2px solid var(--ac);opacity:0} | |
| #vc.listening #orb{animation:vb 1.6s ease-in-out infinite} | |
| #vc.listening #orb i{animation:vr 1.8s ease-out infinite}#vc.listening #orb i:nth-child(2){animation-delay:.6s}#vc.listening #orb i:nth-child(3){animation-delay:1.2s} | |
| #vc.thinking #orb{animation:vt 1.1s linear infinite;border-radius:42% 58% 55% 45%} | |
| #vc.speaking #orb{animation:vs .45s ease-in-out infinite alternate} | |
| #vc.muted #orb{opacity:.35;filter:grayscale(1)} | |
| @keyframes vb{50%{transform:scale(1.07)}}@keyframes vr{0%{transform:scale(1);opacity:.6}100%{transform:scale(1.9);opacity:0}}@keyframes vt{to{transform:rotate(360deg)}}@keyframes vs{to{transform:scale(1.15)}} | |
| #vS{font-size:17px;font-weight:600}#vT{color:var(--mu);min-height:26px;font-size:15px}#vR{font-size:14.5px;max-height:26vh;overflow-y:auto;line-height:1.9;width:100%} | |
| .vb{display:flex;gap:12px;align-items:center} | |
| #toasts{position:fixed;bottom:90px;left:50%;transform:translateX(-50%);z-index:100;display:flex;flex-direction:column;gap:7px;align-items:center;pointer-events:none} | |
| .toast{background:var(--s3);border:1px solid var(--bd);padding:9px 16px;border-radius:12px;font-size:13px} | |
| .md{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;z-index:90;align-items:center;justify-content:center;padding:14px}.md.open{display:flex} | |
| .card{background:var(--s1);border:1px solid var(--bd);border-radius:18px;width:min(560px,100%);max-height:92vh;overflow-y:auto;padding:18px} | |
| .card h2{font-size:16px;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center;gap:8px} | |
| .sec{border:1px solid var(--bd);border-radius:12px;padding:12px;margin-bottom:11px} | |
| .sec>b{display:block;font-size:12.5px;margin-bottom:9px;color:var(--ac)} | |
| .fd{margin-bottom:9px}.fd label{display:block;font-size:11.5px;color:var(--mu);margin-bottom:4px} | |
| .fd input,.fd select,.fd textarea{width:100%;padding:9px 11px;border-radius:10px;border:1px solid var(--bd);background:var(--s2);font-size:13px} | |
| .fd textarea{resize:vertical;min-height:70px} | |
| .g3{display:grid;grid-template-columns:repeat(3,1fr);gap:8px} | |
| .de{border:1px solid var(--bd);border-radius:10px;padding:9px;margin-bottom:7px;font-size:11px;direction:ltr;text-align:left;line-height:1.6} | |
| .de pre{background:var(--code);border-radius:6px;padding:6px;margin-top:4px;max-height:80px;font-size:10.5px} | |
| #cp{position:fixed;top:0;left:0;bottom:0;width:55vw;max-width:100vw;background:var(--s1);border-right:1px solid var(--bd);display:none;flex-direction:column;z-index:80} | |
| #cp.open{display:flex} | |
| #cp.full{width:100vw} | |
| .cph{display:flex;align-items:center;gap:5px;padding:7px 10px;border-bottom:1px solid var(--bd);flex-shrink:0;overflow-x:auto} | |
| .cph .tt{flex:1;font-size:12.5px;font-weight:600;direction:ltr;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:70px} | |
| .tabs{display:flex;background:var(--s2);border:1px solid var(--bd);border-radius:9px;overflow:hidden;flex:none} | |
| .tabs button{background:none;color:var(--mu);padding:5px 11px;font-size:11.5px}.tabs button.on{background:var(--ac);color:#fff} | |
| .cpb{flex:1;display:flex;min-height:0;position:relative} | |
| .cc,.cv{flex:1 1 0;min-width:0;min-height:0;display:none} | |
| #cp[data-v=code] .cc,#cp[data-v=preview] .cv,#cp[data-v=split] .cc,#cp[data-v=split] .cv{display:block} | |
| #cp[data-v=split] .cc{border-left:1px solid var(--bd)} | |
| .cc{overflow:auto;background:var(--code);direction:ltr}.cc pre{white-space:pre-wrap;word-break:break-word} | |
| .cv{position:relative;background:#fff}.cv iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#fff} | |
| #pe{position:absolute;bottom:0;left:0;right:0;background:#7f1d1d;color:#fecaca;font-size:11px;padding:7px 11px;direction:ltr;text-align:left;max-height:90px;overflow:auto;display:none;z-index:5;cursor:pointer} | |
| @media(max-width:768px){#sb{position:fixed;top:0;bottom:0;right:0;transform:translateX(100%)}#sb.open{transform:none}#hb{display:block}.msg{max-width:100%}.g3{grid-template-columns:1fr}#mp{flex:1 1 100%;max-width:none;order:2}#cp{width:100vw}} | |
| @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="ov" onclick="closeAll()"></div> | |
| <aside id="sb"> | |
| <div class="top"><button class="btn" onclick="newChat()">+ محادثة جديدة</button></div> | |
| <div id="ss"></div> | |
| <div class="ft"><span>محفوظة محلياً</span><button class="ib" id="thb" onclick="toggleTheme()">☀</button></div> | |
| </aside> | |
| <main> | |
| <div id="tb"> | |
| <button class="ib" id="hb" onclick="event.stopPropagation();toggleSb()">☰</button> | |
| <select id="mp" onchange="onModelChange()"></select> | |
| <div class="sp"></div> | |
| <div class="pill"><span class="led" id="led"></span><span id="ledT">جاهز</span></div> | |
| <button class="ib" id="loadBtn" onclick="doLoad()" title="تحميل النموذج">⬇</button> | |
| <button class="ib" onclick="doUnload()" title="تفريغ النموذج">🗑</button> | |
| <button class="ib" id="spk" onclick="toggleAuto()" title="النطق التلقائي"></button> | |
| <button class="ib" onclick="openDiag()" title="التشخيص">◉</button> | |
| <button class="ib" onclick="openSet()" title="الإعدادات">⚙</button> | |
| <button class="ib" onclick="exportChat()" title="تصدير">⬇</button> | |
| <button class="ib" onclick="clearChat()" title="مسح">🗑</button> | |
| </div> | |
| <div id="chat"><div id="ci"></div></div> | |
| <div id="cmp"><div class="in"> | |
| <div id="chips"></div> | |
| <textarea id="inp" rows="1" placeholder="اكتب رسالتك..." onkeydown="if(event.key==='Enter'&&!event.shiftKey&&!event.isComposing){event.preventDefault();sendMsg()}"></textarea> | |
| <div class="cr"> | |
| <div id="pw"><button class="ib" style="font-size:20px;border:1px solid var(--bd)" onclick="event.stopPropagation();$('pp').classList.toggle('open')">+</button> | |
| <div id="pp" onclick="event.stopPropagation()"><div class="sc"> | |
| <button class="pi" onclick="$('fi').click();closeAll()">📎 إرفاق صور وملفات</button> | |
| </div></div> | |
| </div> | |
| <button class="ib" id="mic" onclick="toggleMic()" title="تفريغ صوتي">🎤</button> | |
| <button class="ib" id="vcb" onclick="startVoice()" title="محادثة صوتية">〰</button> | |
| <div class="sp"></div> | |
| <button id="stp" onclick="stopGen()">■</button><button id="snd" onclick="sendMsg()">↑</button> | |
| </div> | |
| </div></div> | |
| </main> | |
| <input type="file" id="fi" multiple hidden onchange="addFiles(this.files)"> | |
| <div id="cp" data-v="code"> | |
| <div class="cph"> | |
| <button class="ib" onclick="closePanel()">✕</button><span class="tt" id="cpT"></span> | |
| <div class="tabs"><button data-v="code" class="on" onclick="setView('code')">الكود</button><button data-v="preview" onclick="setView('preview')">معاينة</button><button data-v="split" onclick="setView('split')">مقسّم</button></div> | |
| <button class="ib" onclick="reloadPrev()" title="تحديث">↻</button> | |
| <button class="ib" onclick="newTab()" title="تبويب جديد">↗</button> | |
| <button class="ib" onclick="copyArt()" title="نسخ">⧉</button> | |
| <button class="ib" onclick="dlArt()" title="تنزيل">↓</button> | |
| <button class="ib" onclick="$('cp').classList.toggle('full')" title="ملء الشاشة">⛶</button> | |
| </div> | |
| <div class="cpb"> | |
| <div class="cv"><iframe id="cf" sandbox="allow-scripts allow-modals allow-forms allow-popups"></iframe><div id="pe" onclick="this.style.display='none'"></div></div> | |
| <div class="cc" id="cpS"><pre id="cpC"></pre></div> | |
| </div> | |
| </div> | |
| <div id="vc"> | |
| <button class="ib vx" onclick="endVoice()">✕</button> | |
| <div class="vm"><div id="orb" onclick="vTap()"><i></i><i></i><i></i></div><div id="vS"></div><div id="vT"></div><div id="vR"></div></div> | |
| <div class="vb"><button class="ib" id="vMute" onclick="vMuteTog()" style="border:1px solid var(--bd);width:46px;height:46px">🎤</button><button class="btn" onclick="endVoice()">إنهاء المحادثة</button></div> | |
| </div> | |
| <div id="toasts"></div> | |
| <div id="set" class="md" onclick="if(event.target===this)this.classList.remove('open')"><div class="card"> | |
| <h2>الإعدادات <button class="ib" onclick="$('set').classList.remove('open')">✕</button></h2> | |
| <div class="sec"><b>🧠 تعليمات النظام</b><div class="fd"><textarea id="cSys" placeholder="You are a helpful assistant..."></textarea></div></div> | |
| <div class="sec"><b>🎛 التوليد</b><div class="g3"> | |
| <div class="fd"><label>Temperature</label><input id="cTemp" type="number" step="0.1" min="0" max="2"></div> | |
| <div class="fd"><label>Max Tokens (حتى 32000)</label><input id="cMax" type="number" min="32" max="32000"></div> | |
| <div class="fd"><label>Top P</label><input id="cTop" type="number" step="0.05" min="0" max="1"></div></div> | |
| <div class="fd"><label>Max Tokens: <b id="mMax">256</b></label><input id="cMaxRange" type="range" min="32" max="32000" step="32" oninput="$('cMax').value=this.value;$('mMax').textContent=this.value"></div> | |
| </div> | |
| <div class="sec"><b>🔊 النطق (إخراج الصوت)</b> | |
| <div class="fd"><label>سرعة النطق: <b id="rV">1.5</b>×</label><input id="cRate" type="range" min="0.8" max="2" step="0.1" oninput="$('rV').textContent=this.value"></div> | |
| <div class="fd"><label>صوت النطق</label><select id="cVoice"></select></div> | |
| <div class="fd"><label><input type="checkbox" id="cAutoSpeak"> النطق التلقائي لكل رد</label></div> | |
| <button class="btn g" style="width:100%" onclick="testVoice()">🔊 اختبار الصوت</button> | |
| </div> | |
| <div class="sec"><b>🎤 التفريغ الصوتي</b> | |
| <div class="fd"><label>اللغة</label><select id="cStt"> | |
| <option value="ar-SA">العربية (الفصحى)</option> | |
| <option value="ar-MA">العربية (المغرب)</option> | |
| <option value="ar-EG">العربية (مصر)</option> | |
| <option value="en-US">English</option> | |
| <option value="fr-FR">Français</option> | |
| </select></div> | |
| </div> | |
| <button class="btn" style="width:100%" onclick="saveSet()">💾 حفظ</button> | |
| </div></div> | |
| <div id="dg" class="md" onclick="if(event.target===this)this.classList.remove('open')"><div class="card"> | |
| <h2>🩺 التشخيص <span><button class="btn g" style="padding:6px 11px;font-size:11px" onclick="copyDiag()">نسخ</button> <button class="ib" onclick="$('dg').classList.remove('open')">✕</button></span></h2> | |
| <pre id="diagB" style="direction:ltr;text-align:left;white-space:pre-wrap;font-size:11px;color:var(--mu);max-height:300px;overflow:auto;background:var(--code);border-radius:8px;padding:8px"></pre> | |
| </div></div> | |
| <script> | |
| const $ = i => document.getElementById(i); | |
| const LS = "ai_studio_gradio_v3"; | |
| const esc = s => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| const uid = () => (crypto.randomUUID ? crypto.randomUUID() : "id" + Date.now() + Math.random().toString(36).slice(2)); | |
| const J = JSON.stringify; | |
| const hash = s => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; return h.toString(36); }; | |
| /* ==================== Gradio API ==================== */ | |
| let base = "/gradio_api/call"; | |
| async function call(name, args = [], onUpdate) { | |
| const post = () => fetch(`${base}/${name}`, { | |
| method: "POST", headers: { "Content-Type": "application/json" }, body: J({ data: args }) | |
| }); | |
| let r = await post(); | |
| if (r.status === 404 && base !== "/call") { base = "/call"; r = await post(); } | |
| if (!r.ok) throw new Error(`${name}: ${r.status}`); | |
| const { event_id } = await r.json(); | |
| const res = await fetch(`${base}/${name}/${event_id}`); | |
| if (!res.ok || !res.body) throw new Error(`${name}: ${res.status}`); | |
| const rd = res.body.getReader(), dec = new TextDecoder(); | |
| let buf = "", last; | |
| for (;;) { | |
| const { done, value } = await rd.read(); | |
| if (done) break; | |
| buf += dec.decode(value, { stream: true }); | |
| const parts = buf.split(/\r?\n\r?\n/); buf = parts.pop(); | |
| for (const p of parts) { | |
| let ev = "message", data = ""; | |
| for (const l of p.split(/\r?\n/)) { | |
| if (l.startsWith("event:")) ev = l.slice(6).trim(); | |
| else if (l.startsWith("data:")) data += l.slice(5).trim(); | |
| } | |
| let j = null; try { j = JSON.parse(data); } catch {} | |
| if (ev === "error") throw new Error(typeof j === "string" ? j : "خطأ من الخادم"); | |
| if (ev === "generating" && Array.isArray(j)) { last = j[0]; onUpdate?.(last); } | |
| if (ev === "complete") { const o = Array.isArray(j) ? j[0] : j; if (o !== undefined && o !== null) last = o; return last; } | |
| } | |
| } | |
| if (last === undefined) throw new Error("انتهى الاتصال دون رد"); | |
| return last; | |
| } | |
| /* ==================== Config ==================== */ | |
| let cfg = Object.assign({ | |
| sys: "You are a helpful assistant. Answer concisely, in the same language as the user.", | |
| temp: 0.7, maxTok: 256, topP: 0.9, | |
| ttsRate: 1.5, ttsVoice: "", autoSpeak: false, | |
| sttLang: "ar-SA", | |
| theme: "dark", | |
| model: "qwen25", | |
| }, JSON.parse(localStorage.getItem(LS) || "{}")); | |
| const saveCfg = () => localStorage.setItem(LS, J(cfg)); | |
| /* ==================== Theme ==================== */ | |
| function applyTheme() { | |
| document.documentElement.dataset.theme = cfg.theme; | |
| $("thb").textContent = cfg.theme === "dark" ? "☀" : "☾"; | |
| } | |
| function toggleTheme() { cfg.theme = cfg.theme === "dark" ? "light" : "dark"; applyTheme(); saveCfg(); } | |
| /* ==================== Diagnostics ==================== */ | |
| const DIAG = []; | |
| function logDiag(t) { DIAG.unshift(new Date().toLocaleTimeString("en-GB") + " " + t); if (DIAG.length > 60) DIAG.pop(); } | |
| function openDiag() { $("diagB").textContent = DIAG.join("\n") || "لا سجلات"; $("dg").classList.add("open"); } | |
| function copyDiag() { navigator.clipboard.writeText(DIAG.join("\n")).then(() => toast("📋 تم النسخ"), () => toast("❌ تعذر")); } | |
| /* ==================== Toast ==================== */ | |
| function toast(t) { const d = document.createElement("div"); d.className = "toast"; d.textContent = t; $("toasts").appendChild(d); setTimeout(() => d.remove(), Math.max(2500, t.length * 80)); } | |
| /* ==================== Sessions ==================== */ | |
| let S = JSON.parse(localStorage.getItem(LS + "_sessions") || "[]"); | |
| let cid = localStorage.getItem(LS + "_cid") || null; | |
| if (!cid || !S.find(s => s.id === cid)) { | |
| cid = uid(); | |
| S = [{ id: cid, title: "دردشة جديدة", messages: [], ts: Date.now() }]; | |
| } | |
| const cur = () => S.find(s => s.id === cid); | |
| function persist() { | |
| try { | |
| localStorage.setItem(LS + "_sessions", J(S)); | |
| localStorage.setItem(LS + "_cid", cid); | |
| } catch (e) { | |
| // Prune oldest sessions if storage full | |
| S = S.slice(0, 10); | |
| try { localStorage.setItem(LS + "_sessions", J(S)); } catch {} | |
| } | |
| } | |
| function renderSessions() { | |
| $("ss").innerHTML = S.slice().sort((a, b) => b.ts - a.ts).map(s => ` | |
| <div class="se ${s.id === cid ? "on" : ""}" onclick="openSession('${s.id}')"> | |
| 💬 <span>${esc(s.title)}</span> | |
| <button class="x" onclick="delSession(event,'${s.id}')">🗑</button> | |
| </div>`).join(""); | |
| } | |
| function openSession(id) { cid = id; persist(); renderSessions(); renderChat(); closeAll(); } | |
| function delSession(e, id) { | |
| e.stopPropagation(); | |
| if (!confirm("حذف هذه المحادثة؟")) return; | |
| S = S.filter(s => s.id !== id); | |
| if (cid === id) { cid = S[0]?.id; if (!cid) { newChat(); return; } } | |
| persist(); renderSessions(); renderChat(); | |
| } | |
| function newChat() { | |
| const s = { id: uid(), title: "دردشة جديدة", messages: [], artifacts: [], ts: Date.now() }; | |
| S.unshift(s); cid = s.id; persist(); renderSessions(); renderChat(); closeAll(); | |
| } | |
| function clearChat() { | |
| if (!confirm("مسح كل رسائل هذه المحادثة؟")) return; | |
| const s = cur(); if (!s) return; | |
| s.messages = []; s.artifacts = []; s.title = "دردشة جديدة"; | |
| persist(); renderSessions(); renderChat(); | |
| } | |
| /* ==================== Markdown + Code detection ==================== */ | |
| const qual = (l, n) => l === "html" || l === "svg" || n >= 150; | |
| const prevable = l => l === "html" || l === "svg"; | |
| function md(src, s) { | |
| if (!src) return ""; | |
| const cb = []; | |
| let t = String(src).replace(/```([\w+-]*)\n?([\s\S]*?)```/g, (_, l, c) => { | |
| cb.push({ l: (l || "text").toLowerCase(), c }); | |
| return "\u0000" + (cb.length - 1) + "\u0000"; | |
| }); | |
| let h = esc(t); | |
| h = h.replace(/`([^`\n]+)`/g, '<code class="in">$1</code>') | |
| .replace(/^### (.*)$/gm, "<h3>$1</h3>") | |
| .replace(/^## (.*)$/gm, "<h2>$1</h2>") | |
| .replace(/^# (.*)$/gm, "<h1>$1</h1>") | |
| .replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>") | |
| .replace(/^> (.*)$/gm, "<blockquote>$1</blockquote>") | |
| .replace(/^[-*] (.*)$/gm, "<li>$1</li>") | |
| .replace(/(?:<li>.*<\/li>\n?)+/g, m => "<ul>" + m.trim() + "</ul>") | |
| .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>') | |
| .replace(/\n/g, "<br>") | |
| .replace(/<br>(?=<(?:div|h\d|ul|li|pre|blockquote|button))/g, "") | |
| .replace(/(<\/(?:div|h\d|ul|li|pre|blockquote|button)>)<br>/g, "$1"); | |
| return h.replace(/(<br>)?\u0000(\d+)\u0000(<br>)?/g, (_, a, i) => { | |
| const { l, c } = cb[+i]; | |
| const isPreviewable = prevable(l); | |
| if (s && qual(l, c.length)) { | |
| const id = "a" + hash(c); | |
| let art = (s.artifacts = s.artifacts || []).find(x => x.id === id); | |
| if (!art) { | |
| art = { id, lang: l, content: c, title: l === "html" ? "index.html" : l === "svg" ? "image.svg" : "file." + l }; | |
| s.artifacts.push(art); | |
| } | |
| return `<button class="artifact" onclick="openArtifact('${id}')" style="display:flex;gap:12px;align-items:center;width:100%;margin:10px 0;padding:11px 13px;background:var(--s1);border:1px solid var(--bd);border-radius:12px;color:var(--tx);text-align:right;cursor:pointer;font:inherit"> | |
| <span style="width:38px;height:38px;border-radius:10px;background:var(--s2);display:flex;align-items:center;justify-content:center;flex:none">📄</span> | |
| <span style="flex:1;min-width:0"><b style="display:block;font-size:13px;direction:ltr;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(art.title)}</b><small style="color:var(--mu);font-size:11px">${esc(l.toUpperCase())} · ${c.length} حرف · اضغط للعرض${isPreviewable ? " والمعاينة" : ""}</small></span> | |
| </button>`; | |
| } | |
| return `<div class="cw"><div class="ch"><span>${esc(l)}</span><div style="display:flex;gap:6px"><button onclick="copyCode(this)">نسخ</button>${isPreviewable ? `<button class="open-btn" onclick="openCodePanel(this)" data-lang="${esc(l)}">📄 عرض في اللوحة</button>` : ""}</div></div><pre>${esc(c)}</pre></div>`; | |
| }); | |
| } | |
| function copyCode(b) { | |
| const code = b.closest(".cw").querySelector("pre").textContent; | |
| navigator.clipboard.writeText(code).then(() => toast("📋 تم النسخ"), () => toast("❌ تعذر")); | |
| } | |
| function openCodePanel(b) { | |
| const cw = b.closest(".cw"); | |
| const code = cw.querySelector("pre").textContent; | |
| const lang = b.dataset.lang || "html"; | |
| const s = cur(); | |
| if (s) { | |
| const id = "a" + hash(code); | |
| let art = (s.artifacts = s.artifacts || []).find(x => x.id === id); | |
| if (!art) { | |
| art = { id, lang, content: code, title: lang === "html" ? "index.html" : "image.svg" }; | |
| s.artifacts.push(art); | |
| } | |
| persist(); | |
| openArtifact(id); | |
| } | |
| } | |
| /* ==================== Render chat ==================== */ | |
| function addMsg(role, text = "", files = [], idx = null, images = []) { | |
| $("ci").querySelector(".welcome")?.remove(); | |
| const d = document.createElement("div"); | |
| d.className = "msg " + role; | |
| d.dataset.raw = text || ""; | |
| const body = role === "assistant" ? md(text, cur()) : esc(text).replace(/\n/g, "<br>"); | |
| d.innerHTML = images.map(src => `<img class="chat-img" src="${esc(src)}" alt="صورة">`).join("") | |
| + body | |
| + (files?.length ? `<div class="files">📎 ${files.map(esc).join("، ")}</div>` : "") | |
| + (idx != null && role === "assistant" ? actions(idx) : ""); | |
| $("ci").appendChild(d); | |
| scrollDown(role === "user"); | |
| return d; | |
| } | |
| function actions(idx) { | |
| return `<div class="ma"> | |
| <button onclick="copyMsg(this)">📋 نسخ</button> | |
| <button onclick="regenFrom(${idx})">🔄 إعادة</button> | |
| <button class="spb" onclick="speakMsg(this)" title="نطق">🔊 استمع</button> | |
| </div>`; | |
| } | |
| function copyMsg(b) { navigator.clipboard.writeText(b.closest(".msg").dataset.raw || "").then(() => toast("📋 تم"), () => toast("❌ تعذر")); } | |
| function renderChat() { | |
| const s = cur(); $("ci").innerHTML = ""; | |
| if (!s || !s.messages.length) { | |
| $("ci").innerHTML = `<div class="welcome"><h2>مرحباً 👋</h2><p>اكتب رسالتك. سيُحمَّل النموذج تلقائياً عند أول رسالة.</p></div>`; | |
| return; | |
| } | |
| s.messages.forEach((m, i) => { | |
| if (m.role === "user") { | |
| let txt = "", imgs = []; | |
| if (typeof m.content === "string") txt = m.content; | |
| else if (Array.isArray(m.content)) { | |
| m.content.forEach(c => { | |
| if (c.type === "text") txt = c.text; | |
| if (c.type === "image_url") imgs.push(c.image_url.url); | |
| }); | |
| } | |
| addMsg("user", txt, m._files, i, imgs); | |
| } else if (m.role === "assistant" && m.content) { | |
| addMsg("assistant", m.content, null, i); | |
| } | |
| }); | |
| scrollDown(true); | |
| } | |
| async function regenFrom(i) { | |
| if (busy) return; | |
| const s = cur(); let u = i; while (u >= 0 && s.messages[u].role !== "user") u--; | |
| if (u < 0) return; | |
| s.messages = s.messages.slice(0, u + 1); persist(); renderChat(); | |
| const m = s.messages[u]; | |
| const txt = typeof m.content === "string" ? m.content : (m.content.find?.(c => c.type === "text")?.text || ""); | |
| await ask(txt); | |
| } | |
| /* ==================== Scroll ==================== */ | |
| let follow = true; | |
| $("chat").addEventListener("scroll", () => { const c = $("chat"); follow = c.scrollTop + c.clientHeight >= c.scrollHeight - 60; }); | |
| function scrollDown(f) { const c = $("chat"); if (f || follow) c.scrollTop = c.scrollHeight; } | |
| /* ==================== Code panel + Preview ==================== */ | |
| const L = { t: 0, last: 0, code: "", lang: "html", on: false, closed: false }; | |
| let curArt = null; | |
| function wrap(code, lang) { | |
| const er = `<script>(function(){function r(m){try{parent.postMessage({__pe:1,m:String(m).slice(0,400)},'*')}catch(e){}}addEventListener('error',function(e){r((e.message||'error')+' @'+e.lineno)});addEventListener('unhandledrejection',function(e){r('promise: '+(e.reason&&e.reason.message||e.reason))})})()<\/script>`; | |
| if (lang === "svg") return '<!doctype html><meta charset="utf-8"><style>html,body{margin:0;height:100%;display:flex;align-items:center;justify-content:center;background:#fff}svg{max-width:100%;max-height:100%}</style>' + code + er; | |
| if (/<head[\s>]/i.test(code)) return code.replace(/<head[^>]*>/i, m => m + er); | |
| if (/<html[\s>]/i.test(code)) return code.replace(/<html[^>]*>/i, m => m + '<head>' + er + '</head>'); | |
| return er + code; | |
| } | |
| addEventListener("message", e => { | |
| if (e.data && e.data.__pe) { | |
| const b = $("pe"); | |
| b.textContent = "⚠ " + e.data.m + " (اضغط للإخفاء)"; | |
| b.style.display = "block"; | |
| } | |
| }); | |
| const prev = (code, lang) => { $("pe").style.display = "none"; $("cf").srcdoc = wrap(code, lang); }; | |
| function setView(v) { | |
| $("cp").dataset.v = v; | |
| document.querySelectorAll(".tabs button").forEach(b => b.classList.toggle("on", b.dataset.v === v)); | |
| } | |
| function liveWrite(lang, code) { | |
| const st = !L.on; | |
| L.on = true; L.code = code; L.lang = lang; | |
| if (st) { L.closed = false; L.last = 0; $("cp").classList.add("open"); setView(prevable(lang) ? "split" : "code"); } | |
| $("cpT").textContent = "✍ يكتب (" + lang + ")..."; | |
| const el = $("cpS"), near = el.scrollTop + el.clientHeight >= el.scrollHeight - 40; | |
| $("cpC").textContent = code; | |
| if (near) el.scrollTop = el.scrollHeight; | |
| if (L.closed || !prevable(lang)) return; | |
| clearTimeout(L.t); | |
| if (Date.now() - L.last > 800) { L.last = Date.now(); prev(code, lang); } | |
| else L.t = setTimeout(() => { L.last = Date.now(); prev(L.code, L.lang); }, 800); | |
| } | |
| function liveEnd() { | |
| clearTimeout(L.t); | |
| if (L.on && prevable(L.lang) && L.code && !L.closed) prev(L.code, L.lang); | |
| L.on = false; | |
| } | |
| function parseLive(f) { | |
| const p = f.split("```"); | |
| if (p.length % 2 === 0) { | |
| const g = p[p.length - 1], n = g.indexOf("\n"); | |
| if (n < 0) return { inside: true, lang: "text", code: "" }; | |
| return { inside: true, lang: (g.slice(0, n).trim() || "text").toLowerCase(), code: g.slice(n + 1) }; | |
| } | |
| return { inside: false }; | |
| } | |
| function openArtifact(id) { | |
| const a = cur()?.artifacts?.find(x => x.id === id); | |
| if (!a) return toast("❌ العنصر غير موجود"); | |
| liveEnd(); curArt = a; | |
| $("cpT").textContent = a.title; | |
| $("cpC").textContent = a.content; | |
| if (prevable(a.lang)) { prev(a.content, a.lang); setView("split"); } | |
| else { | |
| $("cf").srcdoc = '<body style="font-family:sans-serif;color:#888;display:flex;align-items:center;justify-content:center;height:100vh">لا معاينة لهذا النوع</body>'; | |
| setView("code"); | |
| } | |
| $("cp").classList.add("open"); | |
| } | |
| function closePanel() { $("cp").classList.remove("open", "full"); if (L.on) L.closed = true; clearTimeout(L.t); } | |
| function reloadPrev() { const c = curArt ? curArt.content : L.code, l = curArt ? curArt.lang : L.lang; c ? prev(c, l) : toast("لا محتوى"); } | |
| function newTab() { | |
| const c = curArt ? curArt.content : L.code, l = curArt ? curArt.lang : L.lang; | |
| if (!c) return toast("لا محتوى"); | |
| const w = open("about:blank", "_blank"); | |
| if (w) { w.document.write(wrap(c, l)); w.document.close(); } | |
| } | |
| function copyArt() { const c = curArt ? curArt.content : L.code; if (!c) return toast("لا كود"); navigator.clipboard.writeText(c).then(() => toast("📋 تم"), () => toast("❌ تعذر")); } | |
| function dlArt() { | |
| if (!curArt) return toast("لا ملف"); | |
| const a = document.createElement("a"); | |
| a.href = URL.createObjectURL(new Blob([curArt.content], { type: "text/plain;charset=utf-8" })); | |
| a.download = curArt.title; a.click(); | |
| toast("⬇ تم"); | |
| } | |
| /* ==================== Models ==================== */ | |
| let MODELS = {}, MODEL_LIST = [], STATUS = {}; | |
| async function initModels() { | |
| try { | |
| const r = await call("models"); | |
| MODEL_LIST = r.models || []; | |
| MODEL_LIST.forEach(m => MODELS[m.id] = m); | |
| renderModelSelect(); | |
| await refreshStatus(); | |
| } catch (e) { | |
| toast("⚠️ تعذر جلب النماذج: " + e.message); | |
| logDiag("initModels: " + e.message); | |
| } | |
| } | |
| function renderModelSelect() { | |
| const sel = $("mp"); | |
| const groups = {}; | |
| MODEL_LIST.filter(m => m.group !== "aux").forEach(m => { | |
| const g = m.group || "أخرى"; | |
| if (!groups[g]) groups[g] = []; | |
| groups[g].push(m); | |
| }); | |
| sel.innerHTML = ""; | |
| const entries = Object.entries(groups); | |
| if (entries.length === 1) { | |
| entries[0][1].forEach(m => { | |
| const o = document.createElement("option"); | |
| o.value = m.id; | |
| o.textContent = `${m.repo.split("/").pop()} (${m.size || ""})`; | |
| sel.appendChild(o); | |
| }); | |
| } else { | |
| for (const [g, list] of entries) { | |
| const og = document.createElement("optgroup"); og.label = g; | |
| list.forEach(m => { | |
| const o = document.createElement("option"); | |
| o.value = m.id; | |
| o.textContent = `${m.repo.split("/").pop()} (${m.size || ""})`; | |
| og.appendChild(o); | |
| }); | |
| sel.appendChild(og); | |
| } | |
| } | |
| const saved = MODELS[cfg.model] ? cfg.model : MODEL_LIST.find(m => m.group !== "aux")?.id || ""; | |
| sel.value = saved; | |
| onModelChange(); | |
| } | |
| async function refreshStatus() { | |
| try { | |
| STATUS = await call("status") || {}; | |
| const cur_id = $("mp").value; | |
| const st = STATUS[cur_id]; | |
| const led = $("led"), lt = $("ledT"); | |
| if (st?.state === "ready") { led.className = "led on"; lt.textContent = "جاهز"; } | |
| else if (st?.state === "loading") { led.className = "led wr"; lt.textContent = "تحميل " + Math.round(st.pct || 0) + "%"; } | |
| else if (st?.state === "error") { led.className = "led"; led.style.background = "var(--er)"; lt.textContent = "خطأ"; } | |
| else { led.className = "led"; lt.textContent = "غير محمّل"; } | |
| } catch (e) { logDiag("refreshStatus: " + e.message); } | |
| } | |
| let statusTimer = null; | |
| function startStatusPolling() { stopStatusPolling(); statusTimer = setInterval(refreshStatus, 2000); } | |
| function stopStatusPolling() { if (statusTimer) clearInterval(statusTimer); statusTimer = null; } | |
| function onModelChange() { | |
| const id = $("mp").value; if (!id) return; | |
| cfg.model = id; saveCfg(); refreshStatus(); | |
| } | |
| async function doLoad() { | |
| const id = $("mp").value; if (!id) return; | |
| toast("⏳ جارٍ تحميل النموذج..."); | |
| startStatusPolling(); | |
| try { | |
| const r = await call("load", [id]); | |
| if (!r?.ok) throw new Error(r?.error || "فشل"); | |
| toast("✅ النموذج جاهز"); | |
| await refreshStatus(); | |
| } catch (e) { toast("❌ " + e.message); logDiag("doLoad: " + e.message); } | |
| finally { setTimeout(stopStatusPolling, 3000); } | |
| } | |
| async function doUnload() { | |
| const id = $("mp").value; if (!id) return; | |
| try { await call("unload", [id]); toast("🗑️ فُرّغ"); await refreshStatus(); } | |
| catch (e) { toast("❌ " + e.message); } | |
| } | |
| async function ensureLoaded(id) { | |
| // Retry up to 3 times | |
| for (let i = 0; i < 3; i++) { | |
| try { | |
| if (STATUS[id]?.state === "ready") return true; | |
| logDiag("ensureLoaded try " + (i + 1) + " for " + id); | |
| startStatusPolling(); | |
| const r = await call("load", [id]); | |
| if (r?.ok) { await refreshStatus(); if (STATUS[id]?.state === "ready" || !STATUS[id]) return true; } | |
| // Wait a bit before retry | |
| await new Promise(r => setTimeout(r, 1500)); | |
| } catch (e) { | |
| logDiag("ensureLoaded err: " + e.message); | |
| if (i === 2) throw e; | |
| } finally { | |
| setTimeout(stopStatusPolling, 3000); | |
| } | |
| } | |
| return true; // proceed anyway, generate will retry | |
| } | |
| /* ==================== Send ==================== */ | |
| let busy = false, curReqId = null; | |
| let files = [], pendingImage = ""; | |
| function setBusy(v) { | |
| busy = v; | |
| $("snd").style.display = v ? "none" : "flex"; | |
| $("stp").style.display = v ? "flex" : "none"; | |
| $("mp").disabled = v; | |
| } | |
| function chips() { | |
| $("chips").innerHTML = files.map((f, i) => `<span class="chip">📎 ${esc(f.name)}<button onclick="files.splice(${i},1);chips()">✕</button></span>`).join(""); | |
| } | |
| function addFiles(l) { | |
| [...l].forEach(f => { | |
| const r = new FileReader(); | |
| r.onload = () => { files.push({ name: f.name, type: f.type, data: r.result }); chips(); }; | |
| if (f.type.startsWith("image/")) r.readAsDataURL(f); | |
| else r.readAsText(f); | |
| }); | |
| $("fi").value = ""; | |
| } | |
| async function sendMsg() { | |
| if (busy) return; | |
| const text = $("inp").value.trim(); | |
| if (!text && !files.length) return; | |
| const s = cur(); | |
| if (!s.messages.length) { s.title = text.slice(0, 40) || "ملفات"; renderSessions(); } | |
| const images = files.filter(f => f.type.startsWith("image/")); | |
| // If images, describe each via caption endpoint | |
| let imgDescriptions = []; | |
| if (images.length) { | |
| setBusy(true); | |
| for (const img of images) { | |
| try { | |
| const b64 = img.data.split(",")[1]; | |
| const r = await call("caption", [b64]); | |
| if (r?.ok && r.text) imgDescriptions.push(r.text); | |
| else imgDescriptions.push(""); | |
| } catch (e) { imgDescriptions.push(""); } | |
| } | |
| setBusy(false); | |
| } | |
| let content = text; | |
| if (imgDescriptions.length) { | |
| content = imgDescriptions.map((d, i) => `[صورة ${i + 1}: ${d || "بدون وصف"}]`).join("\n") + (text ? "\n\n" + text : ""); | |
| } | |
| const userMsg = { role: "user", content }; | |
| userMsg._files = files.map(f => f.name); | |
| userMsg._images = images.map(f => f.data); | |
| s.messages.push(userMsg); | |
| files = []; chips(); | |
| $("inp").value = ""; $("inp").style.height = "auto"; | |
| persist(); renderSessions(); renderChat(); | |
| await ask(content); | |
| } | |
| async function ask(text) { | |
| if (busy) return; | |
| setBusy(true); | |
| const s = cur(); | |
| const bubble = addMsg("assistant", ""); | |
| const status = document.createElement("span"); | |
| status.style.cssText = "color:var(--mu);font-size:12px;display:block;margin-bottom:4px"; | |
| status.textContent = "⏳ تحضير النموذج..."; | |
| bubble.appendChild(status); | |
| curReqId = uid(); | |
| try { | |
| await ensureLoaded(cfg.model); | |
| status.textContent = "⏳ جارٍ التوليد..."; | |
| const msgs = [{ role: "system", content: cfg.sys }, ...s.messages.map(m => ({ role: m.role, content: m.content }))]; | |
| logDiag("generate: " + cfg.model + " · maxTok=" + cfg.maxTok); | |
| const r = await call("generate", [ | |
| cfg.model, J(msgs), curReqId, | |
| parseInt(cfg.maxTok) || 256, | |
| parseFloat(cfg.temp) || 0.7 | |
| ]); | |
| status.remove(); | |
| if (!r || r.ok === false) throw new Error((r && r.error) || "فشل التوليد"); | |
| const answer = (r.text || "").trim(); | |
| if (answer) { | |
| bubble.innerHTML = md(answer, s) + actions(s.messages.length); | |
| bubble.dataset.raw = answer; | |
| s.messages.push({ role: "assistant", content: answer }); | |
| persist(); | |
| // Auto-open code panel if code found | |
| const codeMatches = [...answer.matchAll(/```([\w+-]*)\n?([\s\S]*?)```/g)]; | |
| const last = codeMatches[codeMatches.length - 1]; | |
| if (last && qual((last[1] || "text").toLowerCase(), last[2].length)) { | |
| const id = "a" + hash(last[2]); | |
| if (s.artifacts?.find(x => x.id === id)) setTimeout(() => openArtifact(id), 100); | |
| } | |
| if (cfg.autoSpeak && !micOn) ttsSpeak(answer, bubble.querySelector(".spb")); | |
| } else { | |
| bubble.innerHTML = '<span style="color:var(--mu)">(رد فارغ — جرّب مرة أخرى)</span>'; | |
| } | |
| } catch (e) { | |
| bubble.remove(); | |
| addMsg("assistant", "⚠️ " + e.message); | |
| logDiag("ask: " + e.message); | |
| } finally { | |
| status.remove(); | |
| curReqId = null; | |
| setBusy(false); | |
| $("inp").focus(); | |
| } | |
| } | |
| function stopGen() { if (curReqId) { call("cancel", [curReqId]).catch(() => {}); logDiag("cancel"); } } | |
| /* ==================== TTS ==================== */ | |
| const hasTTS = "speechSynthesis" in window; | |
| if (hasTTS) { speechSynthesis.getVoices(); speechSynthesis.onvoiceschanged = () => fillVoices(); } | |
| const TTS = { id: 0 }; | |
| function ttsClean(t) { | |
| return t.replace(/```[\s\S]*?```/g, " ").replace(/`([^`]*)`/g, "$1") | |
| .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/https?:\/\/\S+/g, "") | |
| .replace(/[#*_>~|]+/g, " ").replace(/^\s*[-•]\s+/gm, "").replace(/\s+/g, " ").trim(); | |
| } | |
| function ttsChunks(t) { | |
| const o = []; let c = ""; | |
| for (const p of (t.match(/[^.!?؟…\n]+[.!?؟…\n]*/g) || [t])) { | |
| if (c && (c + " " + p).length > 180) { o.push(c); c = p; } else c = c ? c + " " + p : p; | |
| } | |
| if (c) o.push(c); | |
| return o.flatMap(x => x.length > 220 ? x.match(/.{1,200}(\s|$)/g) : [x]); | |
| } | |
| function pickVoice(t) { | |
| const ar = (t.match(/[\u0600-\u06FF]/g) || []).length > (t.match(/[A-Za-z]/g) || []).length; | |
| const lg = ar ? "ar" : "en"; | |
| const vs = speechSynthesis.getVoices(); | |
| return { ar, v: vs.find(x => x.name === cfg.ttsVoice && x.lang.toLowerCase().startsWith(lg)) || vs.find(x => x.lang.toLowerCase().startsWith(lg)) || null }; | |
| } | |
| function ttsStop() { | |
| if (!hasTTS) return; | |
| TTS.id++; | |
| speechSynthesis.cancel(); | |
| document.querySelectorAll(".spb.on").forEach(b => { b.classList.remove("on"); b.textContent = "🔊 استمع"; }); | |
| } | |
| function ttsSpeak(text, btn) { | |
| if (!hasTTS) return toast("⚠️ المتصفح لا يدعم النطق"); | |
| ttsStop(); | |
| const t = ttsClean(text || ""); if (!t) return; | |
| const my = ++TTS.id; | |
| const { v } = pickVoice(t); | |
| const ch = ttsChunks(t); let i = 0; | |
| if (btn) { btn.classList.add("on"); btn.textContent = "⏹ إيقاف"; } | |
| const next = () => { | |
| if (my !== TTS.id) return; | |
| if (i >= ch.length) { if (btn) { btn.classList.remove("on"); btn.textContent = "🔊 استمع"; } return; } | |
| const u = new SpeechSynthesisUtterance(ch[i++]); | |
| u.rate = cfg.ttsRate || 1.5; | |
| const ar = pickVoice(ch[i - 1]).ar; | |
| u.lang = v ? v.lang : (ar ? "ar-SA" : "en-US"); | |
| if (v) u.voice = v; | |
| u.onend = next; u.onerror = next; | |
| speechSynthesis.speak(u); | |
| }; | |
| next(); | |
| } | |
| function speakMsg(b) { | |
| if (b.classList.contains("on")) return ttsStop(); | |
| ttsSpeak(b.closest(".msg").dataset.raw || "", b); | |
| } | |
| function fillVoices() { | |
| if (!hasTTS) return; | |
| const vs = speechSynthesis.getVoices(); | |
| const sel = $("cVoice"); if (!sel) return; | |
| sel.innerHTML = '<option value="">تلقائي (حسب لغة الرد)</option>' + | |
| vs.map(v => `<option value="${esc(v.name)}" ${v.name === cfg.ttsVoice ? "selected" : ""}>${esc(v.name)} (${esc(v.lang)})</option>`).join(""); | |
| } | |
| function testVoice() { | |
| cfg.ttsRate = parseFloat($("cRate").value) || 1.5; | |
| cfg.ttsVoice = $("cVoice").value; | |
| ttsSpeak("مرحباً، هذا اختبار للصوت والسرعة."); | |
| } | |
| function updSpk() { | |
| const b = $("spk"); | |
| if (!b) return; | |
| b.classList.toggle("on", !!cfg.autoSpeak); | |
| b.textContent = cfg.autoSpeak ? "🔊" : "🔇"; | |
| b.title = cfg.autoSpeak ? "النطق التلقائي: مفعل" : "النطق التلقائي: معطل"; | |
| } | |
| function toggleAuto() { | |
| if (!hasTTS) return toast("⚠️ المتصفح لا يدعم النطق"); | |
| cfg.autoSpeak = !cfg.autoSpeak; saveCfg(); updSpk(); | |
| if (cfg.autoSpeak) { const u = new SpeechSynthesisUtterance(" "); u.volume = 0; speechSynthesis.speak(u); } | |
| else ttsStop(); | |
| toast(cfg.autoSpeak ? "🔊 النطق مفعل" : "🔇 النطق معطل"); | |
| } | |
| /* ==================== STT ==================== */ | |
| const SR = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| let rec = null, micOn = false, micBase = ""; | |
| function micUI() { | |
| const b = $("mic"); | |
| b.classList.toggle("on", micOn); | |
| b.textContent = micOn ? "⏹" : "🎤"; | |
| b.title = micOn ? "إيقاف التفريغ" : "تفريغ صوتي"; | |
| $("inp").placeholder = micOn ? "🎤 أستمع..." : "اكتب رسالتك..."; | |
| } | |
| async function toggleMic() { | |
| if (!SR) return toast("⚠️ المتصفح لا يدعم التفريغ. استخدم Chrome/Edge"); | |
| if (micOn) { micOn = false; try { rec?.stop(); } catch {} micUI(); return; } | |
| try { const st = await navigator.mediaDevices.getUserMedia({ audio: true }); st.getTracks().forEach(t => t.stop()); } | |
| catch (e) { return toast("❌ تعذر الميكروفون: " + e.message); } | |
| ttsStop(); | |
| micOn = true; micUI(); | |
| micBase = $("inp").value; | |
| if (micBase && !/\s$/.test(micBase)) micBase += " "; | |
| const r = rec = new SR(); | |
| r.lang = cfg.sttLang || "ar-SA"; | |
| r.continuous = false; r.interimResults = true; r.maxAlternatives = 1; | |
| r.onresult = e => { | |
| if (rec !== r || !micOn) return; | |
| let t = ""; | |
| for (let i = 0; i < e.results.length; i++) t += e.results[i][0].transcript; | |
| $("inp").value = micBase + t; | |
| $("inp").dispatchEvent(new Event("input")); | |
| }; | |
| r.onerror = e => { | |
| if (rec !== r) return; | |
| if (e.error === "no-speech" || e.error === "aborted") return; | |
| micOn = false; micUI(); | |
| toast("❌ " + (e.error === "not-allowed" ? "الإذن مرفوض" : e.error)); | |
| }; | |
| r.onend = () => { | |
| if (rec !== r) return; | |
| if (micOn) setTimeout(() => { if (micOn && rec === r) { try { r.start(); } catch { micOn = false; micUI(); } } }, 150); | |
| else micUI(); | |
| }; | |
| try { r.start(); } catch (e) { micOn = false; micUI(); toast("❌ " + e.message); } | |
| } | |
| /* ==================== Voice mode ==================== */ | |
| const V = { on: false, state: "", rec: null, text: "", sil: 0, chk: 0, pend: 0, tok: 0, spoken: 0, done: true, muted: false }; | |
| function vSet(st) { | |
| V.state = st; | |
| $("vc").className = "on " + st; | |
| $("vS").textContent = ({ listening: "أستمع إليك...", thinking: "أفكر...", speaking: "أتحدث — المس الدائرة للمقاطعة", muted: "الميكروفون مكتوم" })[st] || ""; | |
| } | |
| async function startVoice() { | |
| if (!SR || !hasTTS) return toast("⚠️ المتصفح لا يدعم الصوت"); | |
| if (busy) return toast("⏳ انتظر انتهاء المهمة الحالية"); | |
| const u = new SpeechSynthesisUtterance(" "); u.volume = 0; speechSynthesis.speak(u); | |
| $("vc").className = "on thinking"; $("vS").textContent = "جارٍ تهيئة الميكروفون..."; | |
| try { const st = await navigator.mediaDevices.getUserMedia({ audio: true }); st.getTracks().forEach(t => t.stop()); } | |
| catch (e) { $("vc").className = ""; return toast("❌ " + e.message); } | |
| if (micOn) { micOn = false; try { rec?.stop(); } catch {} micUI(); } | |
| ttsStop(); | |
| Object.assign(V, { on: true, muted: false, done: true, pend: 0, spoken: 0, tok: V.tok + 1 }); | |
| vListen(); | |
| } | |
| function vListen() { | |
| if (!V.on || V.muted || busy) return; | |
| V.text = ""; $("vT").textContent = ""; | |
| vSet("listening"); | |
| const r = V.rec = new SR(); | |
| r.lang = cfg.sttLang || "ar-SA"; | |
| r.continuous = false; r.interimResults = true; | |
| r.onresult = e => { | |
| if (V.rec !== r || V.state !== "listening") return; | |
| let t = "", fin = false; | |
| for (let i = 0; i < e.results.length; i++) { t += e.results[i][0].transcript; if (e.results[i].isFinal) fin = true; } | |
| V.text = t; $("vT").textContent = t; | |
| clearTimeout(V.sil); | |
| if (fin) vSend(); else V.sil = setTimeout(vSend, 1200); | |
| }; | |
| r.onerror = e => { | |
| if (V.rec !== r) return; | |
| if (e.error === "no-speech" || e.error === "aborted") return; | |
| toast("❌ " + e.error); | |
| }; | |
| r.onend = () => { | |
| if (V.rec === r && V.on && V.state === "listening") setTimeout(() => { if (V.rec === r && V.on && V.state === "listening") vListen(); }, 200); | |
| }; | |
| try { r.start(); } catch {} | |
| } | |
| function vSend() { | |
| clearTimeout(V.sil); | |
| const t = (V.text || "").trim(); | |
| if (!V.on || V.state !== "listening" || !t || busy) return; | |
| const r = V.rec; V.rec = null; try { r?.abort(); } catch {} | |
| V.done = false; V.spoken = 0; $("vR").textContent = ""; | |
| vSet("thinking"); | |
| $("inp").value = t; | |
| ask(t).then(() => { V.done = true; vCheck(); }); | |
| } | |
| function vSay(t) { | |
| const tok = V.tok; | |
| for (const c of ttsChunks(t).filter(Boolean)) { | |
| const { v, ar } = pickVoice(c); | |
| const u = new SpeechSynthesisUtterance(c); | |
| u.rate = cfg.ttsRate || 1.5; | |
| u.lang = v ? v.lang : (ar ? "ar-SA" : "en-US"); | |
| if (v) u.voice = v; | |
| V.pend++; | |
| u.onstart = () => { if (tok === V.tok && V.on) vSet("speaking"); }; | |
| u.onend = u.onerror = () => { if (tok !== V.tok) return; V.pend--; vCheck(); }; | |
| speechSynthesis.speak(u); | |
| } | |
| } | |
| function vCheck() { | |
| if (V.on && V.done && V.pend <= 0 && !V.chk) V.chk = setTimeout(() => { V.chk = 0; if (V.on && V.done && V.pend <= 0) vListen(); }, 300); | |
| } | |
| function vTap() { | |
| if (!V.on) return; | |
| if (V.state === "listening") return vSend(); | |
| if (V.state === "speaking" || V.state === "thinking") { | |
| V.tok++; V.pend = 0; speechSynthesis.cancel(); | |
| if (busy && curReqId) call("cancel", [curReqId]).catch(() => {}); | |
| V.done = true; vListen(); | |
| } | |
| } | |
| function vMuteTog() { | |
| if (!V.on) return; | |
| V.muted = !V.muted; | |
| $("vMute").textContent = V.muted ? "🔇" : "🎤"; | |
| if (V.muted) { clearTimeout(V.sil); const r = V.rec; V.rec = null; try { r?.abort(); } catch {} if (V.state === "listening") vSet("muted"); } | |
| else { if (V.state === "muted") V.state = ""; if (!busy && V.pend <= 0) vListen(); } | |
| } | |
| function endVoice() { | |
| if (!V.on && !$("vc").classList.contains("on")) return; | |
| V.on = false; V.tok++; V.pend = 0; | |
| clearTimeout(V.sil); clearTimeout(V.chk); V.chk = 0; | |
| const r = V.rec; V.rec = null; try { r?.abort(); } catch {} | |
| if (hasTTS) speechSynthesis.cancel(); | |
| $("vc").className = ""; | |
| } | |
| /* ==================== Settings ==================== */ | |
| function openSet() { fillVoices(); $("set").classList.add("open"); closeAll(); } | |
| function saveSet() { | |
| cfg.sys = $("cSys").value; | |
| cfg.temp = $("cTemp").value; | |
| cfg.maxTok = Math.min(32000, Math.max(32, parseInt($("cMax").value) || 256)); | |
| cfg.topP = $("cTop").value; | |
| cfg.ttsRate = parseFloat($("cRate").value) || 1.5; | |
| cfg.ttsVoice = $("cVoice").value; | |
| cfg.autoSpeak = $("cAutoSpeak").checked; | |
| cfg.sttLang = $("cStt").value; | |
| saveCfg(); updSpk(); | |
| $("set").classList.remove("open"); | |
| toast("✅ تم الحفظ"); | |
| } | |
| /* ==================== Export ==================== */ | |
| function exportChat() { | |
| const s = cur(); if (!s.messages.length) return toast("لا رسائل"); | |
| const t = "# " + s.title + "\n\n" + s.messages.map(m => { | |
| let c = typeof m.content === "string" ? m.content : (m.content?.find?.(x => x.type === "text")?.text || ""); | |
| return "### " + (m.role === "user" ? "أنت" : "المساعد") + "\n" + c; | |
| }).join("\n\n"); | |
| const a = document.createElement("a"); | |
| a.href = URL.createObjectURL(new Blob([t], { type: "text/markdown" })); | |
| a.download = "chat-" + cid.slice(0, 8) + ".md"; a.click(); | |
| toast("⬇ تم التصدير"); | |
| } | |
| /* ==================== UI helpers ==================== */ | |
| function toggleSb() { $("sb").classList.toggle("open"); $("ov").classList.toggle("show", $("sb").classList.contains("open")); } | |
| function closeAll() { $("pp").classList.remove("open"); $("sb").classList.remove("open"); $("ov").classList.remove("show"); } | |
| document.addEventListener("click", e => { | |
| if (!$("pw").contains(e.target)) $("pp").classList.remove("open"); | |
| if (innerWidth <= 768 && $("sb").classList.contains("open") && !$("sb").contains(e.target) && e.target.id !== "hb") closeAll(); | |
| }); | |
| $("inp").addEventListener("input", () => { const i = $("inp"); i.style.height = "auto"; i.style.height = Math.min(i.scrollHeight, 170) + "px"; }); | |
| addEventListener("keydown", e => { | |
| if (e.key === "Escape") { | |
| endVoice(); closeAll(); | |
| document.querySelectorAll(".md.open").forEach(m => m.classList.remove("open")); | |
| $("cp").classList.remove("full"); | |
| } | |
| }); | |
| /* ==================== Boot ==================== */ | |
| function boot() { | |
| applyTheme(); updSpk(); fillVoices(); | |
| $("cSys").value = cfg.sys; | |
| $("cTemp").value = cfg.temp; | |
| $("cMax").value = cfg.maxTok; | |
| $("cMaxRange").value = cfg.maxTok; | |
| $("mMax").textContent = cfg.maxTok; | |
| $("cTop").value = cfg.topP; | |
| $("cRate").value = cfg.ttsRate; | |
| $("rV").textContent = cfg.ttsRate; | |
| $("cAutoSpeak").checked = !!cfg.autoSpeak; | |
| $("cStt").value = cfg.sttLang || "ar-SA"; | |
| renderSessions(); | |
| renderChat(); | |
| setBusy(false); | |
| initModels(); | |
| if (!SR) { $("mic").style.opacity = .4; $("mic").title = "المتصفح لا يدعم STT"; $("vcb").style.opacity = .4; $("vcb").title = "المتصفح لا يدعم الصوت"; } | |
| if (!hasTTS) { $("spk").style.opacity = .4; } | |
| } | |
| // Sync range <-> number | |
| document.addEventListener("DOMContentLoaded", () => { | |
| const cMax = $("cMax"), cRange = $("cMaxRange"), mMax = $("mMax"); | |
| if (cMax) cMax.oninput = () => { const v = Math.min(32000, Math.max(32, parseInt(cMax.value) || 256)); cRange.value = v; mMax.textContent = v; }; | |
| }); | |
| boot(); | |
| </script> | |
| </body> | |
| </html> |