Deepmind / index.html
bh-ab's picture
Update index.html
8fdb176 verified
Raw History Blame Contribute Delete
54.9 kB
<!DOCTYPE html>
<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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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(/^&gt; (.*)$/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>