gemma / index.html
gijl's picture
Rename index (3).html to index.html
9f1bd35 verified
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>BrainMap OS – Sovereign Medical Engine V25</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://unpkg.com/dexie@3.2.4/dist/dexie.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mammoth@1.6.0/mammoth.browser.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Tajawal:wght@300;400;500;700;900&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
@import url('https://fonts.googleapis.com/css2?family=Tajawal:wght@400;500;700;900&display=swap');
*, *::before, *::after { box-sizing: border-box; }
body { font-family: 'Tajawal', sans-serif; -webkit-tap-highlight-color: transparent; overflow: hidden; margin: 0; padding: 0; background: #020617; color: #f8fafc; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(100,100,100,0.3); border-radius: 10px; }
@keyframes slideUp { from { transform: translateY(40px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes scaleIn { from { transform: scale(0.93); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes pulseRing { 0% { box-shadow: 0 0 0 0 rgba(37,99,235,0.8); } 70% { box-shadow: 0 0 0 24px rgba(37,99,235,0); } 100% { box-shadow: 0 0 0 0 rgba(37,99,235,0); } }
@keyframes audioWave { 0%, 100% { height: 8px; } 50% { height: 32px; } }
@keyframes typingDot { 0%, 100% { transform: translateY(0); opacity: 0.4; } 50% { transform: translateY(-4px); opacity: 1; } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes shimmer { 0% { background-position: -200% center; } 100% { background-position: 200% center; } }
@keyframes ragPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
@keyframes imgLoad { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } }
@keyframes toastIn { from { transform: translateY(80px) scale(0.9); opacity: 0; } to { transform: translateY(0) scale(1); opacity: 1; } }
@keyframes toastOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.9); } }
@keyframes ttsWave { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } }
.animate-slide-up { animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-fade-in { animation: fadeIn 0.3s ease forwards; }
.animate-scale-in { animation: scaleIn 0.3s cubic-bezier(0.16,1,0.3,1) forwards; }
.voice-pulse { animation: pulseRing 1.5s infinite; }
.wave-bar { animation: audioWave 1s ease-in-out infinite; }
.typing-dot { animation: typingDot 1s infinite; }
.spin { animation: spin 1s linear infinite; }
.rag-pulse { animation: ragPulse 2s ease-in-out infinite; }
.img-load { animation: imgLoad 0.4s ease forwards; }
.toast-enter { animation: toastIn 0.4s cubic-bezier(0.16,1,0.3,1) forwards; }
.toast-exit { animation: toastOut 0.3s ease forwards; }
.tts-bar { animation: ttsWave 0.8s ease-in-out infinite; }
.glass { background: rgba(255,255,255,0.04); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.08); }
.prose table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 0.78rem; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
.prose th { background: rgba(59,130,246,0.25); padding: 10px 12px; border: 1px solid rgba(255,255,255,0.1); font-weight: 900; text-align: right; }
.prose td { padding: 9px 12px; border: 1px solid rgba(255,255,255,0.06); text-align: right; }
.prose tr:nth-child(even) { background: rgba(0,0,0,0.15); }
.prose img { border-radius: 12px; max-width: 100%; margin: 10px 0; box-shadow: 0 8px 20px rgba(0,0,0,0.4); }
.prose strong { color: #60a5fa; }
.prose em { color: #94a3b8; }
.prose pre { background: rgba(0,0,0,0.4); padding: 12px; border-radius: 8px; overflow-x: auto; font-size: 0.75rem; font-family: 'JetBrains Mono','Courier New', monospace; }
.prose code { background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-family: 'JetBrains Mono', monospace; }
.prose blockquote { border-right: 3px solid #3b82f6; padding-right: 12px; margin: 8px 0; color: #94a3b8; background: rgba(59,130,246,0.06); padding: 8px 12px; border-radius: 0 8px 8px 0; }
.prose h1, .prose h2, .prose h3 { font-weight: 900; margin: 12px 0 6px; }
.prose ul, .prose ol { padding-right: 18px; }
.prose li { margin: 3px 0; line-height: 1.7; }
.prose a { color: #60a5fa; text-decoration: underline; }
.prose hr { border-color: rgba(255,255,255,0.08); margin: 12px 0; }
input[type="file"] { display: none; }
textarea { resize: none; }
input[type="range"] { accent-color: var(--primary, #3b82f6); }
.nb-card { transition: all 0.2s; }
.nb-card:hover { transform: translateY(-2px); }
.voice-selector option { background: #0f172a; color: #f1f5f9; }
.search-result { border-bottom: 1px solid rgba(255,255,255,0.06); }
.search-result:last-child { border-bottom: none; }
.citation-badge { display: inline-flex; align-items: center; gap: 3px; padding: 2px 7px; border-radius: 20px; font-size: 9px; font-weight: 900; cursor: pointer; transition: all 0.15s; border: 1px solid; margin: 2px 1px; }
.citation-badge:hover { transform: translateY(-1px); box-shadow: 0 3px 10px rgba(0,0,0,0.3); }
.intent-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 20px; font-size: 9px; font-weight: 900; }
.rag-indicator { display: inline-flex; align-items: center; gap: 3px; font-size: 9px; font-weight: 900; padding: 2px 7px; border-radius: 20px; }
.note-card { transition: all 0.2s; border-radius: 14px; }
.note-card:hover { transform: translateY(-1px); }
.source-row { transition: all 0.15s; }
.source-row:hover { background: rgba(255,255,255,0.04); }
.toggle-switch { position: relative; width: 36px; height: 20px; border-radius: 20px; cursor: pointer; transition: background 0.2s; flex-shrink: 0; }
.toggle-thumb { position: absolute; top: 2px; width: 16px; height: 16px; border-radius: 50%; background: white; box-shadow: 0 1px 4px rgba(0,0,0,0.3); transition: all 0.2s; }
/* ── INLINE IMAGE SEARCH ── */
.search-images-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; margin-top: 10px; }
.search-img-item { border-radius: 10px; overflow: hidden; cursor: pointer; position: relative; aspect-ratio: 4/3; background: rgba(0,0,0,0.2); }
.search-img-item img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.2s; }
.search-img-item:hover img { transform: scale(1.06); }
.search-img-item .img-caption { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,0.75)); padding: 5px 6px; font-size: 8px; font-weight: 700; color: white; }
.search-img-item .img-source { position: absolute; top: 4px; right: 4px; background: rgba(0,0,0,0.6); color: white; font-size: 7px; font-weight: 900; padding: 1px 4px; border-radius: 4px; }
/* ── FILE CONTENT PREVIEW ── */
.file-preview-card { border-radius: 12px; overflow: hidden; margin-top: 8px; }
.file-preview-image { width: 100%; max-height: 200px; object-fit: cover; cursor: zoom-in; border-radius: 10px; }
/* ── BACKGROUND SEARCH BADGE ── */
.bg-search-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px; border-radius: 20px; font-size: 9px; font-weight: 900; animation: ragPulse 1.5s ease-in-out infinite; }
/* ── QUICK PROMPTS ── */
.quick-prompt-btn { transition: all 0.2s; }
.quick-prompt-btn:hover { transform: translateY(-2px); }
.quick-prompt-btn:active { transform: scale(0.96); }
/* ── TOAST ── */
.toast-wrap { position: fixed; bottom: 90px; left: 50%; transform: translateX(-50%); z-index: 999; display: flex; flex-direction: column; gap: 6px; align-items: center; width: 100%; max-width: 320px; padding: 0 16px; pointer-events: none; }
/* Skeleton shimmer */
.skeleton { background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.09) 50%, rgba(255,255,255,0.04) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 8px; }
/* msg hover actions */
.msg-actions { opacity: 0; transition: opacity 0.15s; }
.msg-wrapper:hover .msg-actions { opacity: 1; }
/* Lightbox */
.lightbox-overlay { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.94); display: flex; align-items: center; justify-content: center; cursor: zoom-out; flex-direction: column; }
.lightbox-overlay img { max-width: 94vw; max-height: 85vh; object-fit: contain; border-radius: 12px; box-shadow: 0 24px 60px rgba(0,0,0,0.6); }
/* TTS Playing Indicator */
.tts-playing-indicator { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 20px; }
.tts-playing-indicator .bar { width: 3px; border-radius: 2px; display: inline-block; }
/* Edge TTS voice badge */
.edge-voice-badge { display: inline-flex; align-items: center; gap: 3px; padding: 1px 6px; border-radius: 20px; font-size: 8px; font-weight: 900; background: linear-gradient(135deg, #0078d4, #106ebe); color: white; }
/* Voice category tabs */
.voice-tab { padding: 4px 10px; border-radius: 20px; font-size: 10px; font-weight: 900; cursor: pointer; transition: all 0.15s; border: 1px solid transparent; }
.voice-tab.active { color: white; }
/* ── RED FLAG ── */
@keyframes redAlert { 0%,100% { box-shadow:0 0 0 0 rgba(239,68,68,0); } 50% { box-shadow:0 0 0 10px rgba(239,68,68,0.35); } }
.red-flag-alert { animation: redAlert 1.2s ease-in-out infinite; }
/* ── SOAP NOTES ── */
.soap-section { border-right: 3px solid; padding: 10px 12px; border-radius: 0 10px 10px 0; margin-bottom: 8px; }
.soap-s { border-color:#3b82f6; background:rgba(59,130,246,0.06); }
.soap-o { border-color:#10b981; background:rgba(16,185,129,0.06); }
.soap-a { border-color:#f59e0b; background:rgba(245,158,11,0.06); }
.soap-p { border-color:#a855f7; background:rgba(168,85,247,0.06); }
/* ── DRUG INTERACTION ── */
.drug-major { background:rgba(239,68,68,0.1); border-left:3px solid #ef4444; padding:8px 10px; border-radius:0 8px 8px 0; margin-bottom:6px; }
.drug-moderate { background:rgba(245,158,11,0.1); border-left:3px solid #f59e0b; padding:8px 10px; border-radius:0 8px 8px 0; margin-bottom:6px; }
.drug-minor { background:rgba(16,185,129,0.1); border-left:3px solid #10b981; padding:8px 10px; border-radius:0 8px 8px 0; margin-bottom:6px; }
/* ── AMBIENT SCRIBE ── */
@keyframes ambientPulse { 0%,100%{transform:scale(1);opacity:1;} 50%{transform:scale(1.15);opacity:0.7;} }
.ambient-active { animation: ambientPulse 2s ease-in-out infinite; }
/* ── AGENT ── */
@keyframes agentThink { 0%,100%{opacity:0.3;transform:translateY(0);} 50%{opacity:1;transform:translateY(-3px);} }
.agent-thinking { animation: agentThink 1.5s ease-in-out infinite; }
/* ── CALC RESULTS ── */
.calc-result-normal { color:#10b981; }
.calc-result-warn { color:#f59e0b; }
.calc-result-danger { color:#ef4444; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback, useMemo } = React;
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
// ─────────────────────────────────────────────
// CONSTANTS
// ─────────────────────────────────────────────
const DEV_CODE = "AaAa@3652";
const EDU_DOMAIN = ".m-mail.edu.eg";
// ─────────────────────────────────────────────
// ══ إعدادات ngrok + Ollama ══
// يمكنك الآن تغيير الرابط من الإعدادات في الواجهة مباشرةً
// أو ضعه هنا كقيمة افتراضية
// ─────────────────────────────────────────────
// ✅ HF Space Fix: استخدام نفس الخادم (llama.cpp) تلقائياً
const NGROK_URL_DEFAULT = window.location.origin;
// ─── متغيّر قابل للتعديل من الواجهة ───
// يُحمَّل من localStorage عند الفتح، أو يستخدم القيمة الافتراضية
let ACTIVE_NGROK_URL = (() => {
try { return localStorage.getItem('brainmap_ngrok_url') || NGROK_URL_DEFAULT; }
catch(_) { return NGROK_URL_DEFAULT; }
})();
// ✅ إصلاح: بدلاً من const ثابت يحتفظ بالقيمة الأولى فقط،
// نستخدم getter يُعيد قيمة ACTIVE_NGROK_URL الحالية في وقت الاستدعاء
// هذا يضمن أن أي كود قديم يستخدم NGROK_URL سيحصل دائماً على الرابط المحدَّث
const getNgrokUrl = () => ACTIVE_NGROK_URL;
// مُعرَّف كـ getter وليس ثابتاً لتجنّب تجميد القيمة الأولية
Object.defineProperty(window, 'NGROK_URL', { get: () => ACTIVE_NGROK_URL, configurable: true });
// ── V23: Global patient context — updated from React state ──
let ACTIVE_PATIENT_CONTEXT = (() => {
try { return localStorage.getItem('bm_patient_ctx') || ''; } catch(_) { return ''; }
})();
Object.defineProperty(window, 'PATIENT_CTX', { get: () => ACTIVE_PATIENT_CONTEXT, configurable: true });
// إذا كنت تستخدم Claude API أو Gemini API ضع مفتاحك هنا
const GROQ_KEYS = [
"ollama"
];
// ─────────────────────────────────────────────
// ══ ACTIVE MODEL REFERENCE ══
// متغيّر عالمي يُحدَّث من React state عند تغيير النموذج النشط
// يُستخدم داخل callGroqText لتحديد المزوّد والـ endpoint الصحيح
// ─────────────────────────────────────────────
let ACTIVE_MODEL_KEY = (() => {
try { return localStorage.getItem('brainmap_active_model') || "gemma-4-E2B-it-Q8_0"; }
catch(_) { return "gemma-4-E2B-it-Q8_0"; }
})();
// ─────────────────────────────────────────────
// ══ API KEYS STORE ══
// مفاتيح API لجميع المزوّدين — تُحمَّل من localStorage
// تُحدَّث من الواجهة عند إدخال المستخدم لها في الإعدادات
// ─────────────────────────────────────────────
let API_KEYS = {
groq: (() => { try { return localStorage.getItem('brainmap_key_groq') || ''; } catch(_) { return ''; } })(),
openai: (() => { try { return localStorage.getItem('brainmap_key_openai') || ''; } catch(_) { return ''; } })(),
anthropic: (() => { try { return localStorage.getItem('brainmap_key_anthropic') || ''; } catch(_) { return ''; } })(),
google: (() => { try { return localStorage.getItem('brainmap_key_google') || ''; } catch(_) { return ''; } })(),
mistral: (() => { try { return localStorage.getItem('brainmap_key_mistral') || ''; } catch(_) { return ''; } })(),
openrouter: (() => { try { return localStorage.getItem('brainmap_key_openrouter') || ''; } catch(_) { return ''; } })(),
together: (() => { try { return localStorage.getItem('brainmap_key_together') || ''; } catch(_) { return ''; } })(),
};
// ─────────────────────────────────────────────
// ══ PER-MODEL KEY STORE ══
// يخزّن مفاتيح API وروابط URL مخصّصة لكل نموذج
// مع دوران تلقائي عند فشل المفتاح (401 / 429)
// البنية: { [modelName]: { keys: string[], urls: string[], ki: number, ui: number } }
// ─────────────────────────────────────────────
let MODEL_KEY_STORE = (() => {
try { return JSON.parse(localStorage.getItem('bm_mkstore_v1') || '{}'); }
catch(_) { return {}; }
})();
const persistModelKeyStore = () => {
try { localStorage.setItem('bm_mkstore_v1', JSON.stringify(MODEL_KEY_STORE)); }
catch(_) {}
};
const ensureModelEntry = (modelName) => {
if (!MODEL_KEY_STORE[modelName]) {
MODEL_KEY_STORE[modelName] = { keys: [], urls: [], ki: 0, ui: 0 };
}
return MODEL_KEY_STORE[modelName];
};
const addKeyForModel = (modelName, key) => {
const entry = ensureModelEntry(modelName);
const trimmed = (key || '').trim();
if (trimmed && !entry.keys.includes(trimmed)) {
entry.keys.push(trimmed);
persistModelKeyStore();
return true;
}
return false;
};
const removeKeyForModel = (modelName, idx) => {
const entry = ensureModelEntry(modelName);
if (idx >= 0 && idx < entry.keys.length) {
entry.keys.splice(idx, 1);
if (entry.ki >= entry.keys.length && entry.ki > 0) entry.ki = entry.keys.length - 1;
persistModelKeyStore();
}
};
const addUrlForModel = (modelName, url) => {
const entry = ensureModelEntry(modelName);
const trimmed = (url || '').trim().replace(/\/$/, '');
if (trimmed && !entry.urls.includes(trimmed)) {
entry.urls.push(trimmed);
persistModelKeyStore();
return true;
}
return false;
};
const removeUrlForModel = (modelName, idx) => {
const entry = ensureModelEntry(modelName);
if (idx >= 0 && idx < entry.urls.length) {
entry.urls.splice(idx, 1);
if (entry.ui >= entry.urls.length && entry.ui > 0) entry.ui = entry.urls.length - 1;
persistModelKeyStore();
}
};
const rotateKeyForModel = (modelName) => {
const entry = MODEL_KEY_STORE[modelName];
if (entry && entry.keys.length > 1) {
entry.ki = (entry.ki + 1) % entry.keys.length;
persistModelKeyStore();
}
};
const rotateUrlForModel = (modelName) => {
const entry = MODEL_KEY_STORE[modelName];
if (entry && entry.urls.length > 1) {
entry.ui = (entry.ui + 1) % entry.urls.length;
persistModelKeyStore();
}
};
// يُعيد المفتاح الحالي للنموذج من المخزن المحلي
// إذا لم يوجد يعود إلى null (سيُستخدم المفتاح الافتراضي للمزوّد)
const getCurrentKeyForModel = (modelName) => {
const entry = MODEL_KEY_STORE[modelName];
if (entry && entry.keys && entry.keys.length > 0) {
const idx = (entry.ki || 0) % entry.keys.length;
return entry.keys[idx] || null;
}
return null;
};
// يُعيد رابط URL الحالي للنموذج من المخزن المحلي
// إذا لم يوجد يعود إلى null (سيُستخدم ACTIVE_NGROK_URL)
const getCurrentUrlForModel = (modelName) => {
const entry = MODEL_KEY_STORE[modelName];
if (entry && entry.urls && entry.urls.length > 0) {
const idx = (entry.ui || 0) % entry.urls.length;
return entry.urls[idx] || null;
}
return null;
};
// ─────────────────────────────────────────────
// النماذج المتاحة في Ollama — عدّل حسب النماذج المثبتة عندك
// ── قائمة شاملة ومحدَّثة بجميع نماذج AI المحلية (Ollama) ──
const OLLAMA_MODELS = [
// ── llama.cpp HF Space Model ──
"gemma-4-E2B-it-Q8_0",
// ── Google Gemma 4 ──
"gemma4:e2b","gemma4:e4b","gemma4:2b","gemma4:9b","gemma4:27b",
// ── Google Gemma 3 ──
"gemma3:1b","gemma3:4b","gemma4:31b","gemma3:27b",
"gemma3n:e2b","gemma3n:e4b",
// ── Google Gemma 2 ──
"gemma2:2b","gemma2:9b","gemma2:27b",
// ── Google Gemma 1 ──
"gemma:2b","gemma:7b",
// ── Meta Llama 4 ──
"llama4:scout","llama4:maverick","llama4:17b","llama4:109b",
// ── Meta Llama 3.3 ──
"llama3.3:70b","llama3.3:70b-instruct-q2_K","llama3.3:70b-instruct-q4_K_M",
"llama3.3:70b-instruct-q6_K","llama3.3:70b-instruct-fp16",
// ── Meta Llama 3.2 ──
"llama3.2:1b","llama3.2:3b","llama3.2:11b","llama3.2:90b",
"llama3.2:1b-instruct-q4_K_M","llama3.2:3b-instruct-q4_K_M",
"llama3.2:11b-vision-instruct","llama3.2:90b-vision-instruct",
// ── Meta Llama 3.1 ──
"llama3.1:8b","llama3.1:70b","llama3.1:405b",
"llama3.1:8b-instruct-q4_K_M","llama3.1:8b-instruct-q8_0","llama3.1:8b-instruct-fp16",
"llama3.1:70b-instruct-q4_K_M","llama3.1:70b-instruct-q6_K",
"llama3.1:405b-instruct-q4_K_M",
// ── Meta Llama 3 ──
"llama3:8b","llama3:70b",
"llama3:8b-instruct-q4_K_M","llama3:8b-instruct-fp16",
"llama3:70b-instruct-q4_K_M",
// ── Meta Llama 2 ──
"llama2:7b","llama2:13b","llama2:70b",
"llama2:7b-chat","llama2:13b-chat","llama2:70b-chat",
"llama2-uncensored:7b","llama2-uncensored:70b",
// ── Mistral AI ──
"mistral:7b","mistral:7b-instruct","mistral:7b-instruct-v0.3",
"mistral-nemo:12b","mistral-nemo:12b-instruct-2407",
"mistral-small:22b","mistral-small:22b-instruct-2409",
"mistral-medium:22b",
"mistral-large:123b","mistral-large:123b-instruct-2407",
"mistral-openorca:7b",
// ── Mixtral ──
"mixtral:8x7b","mixtral:8x7b-instruct-v0.1",
"mixtral:8x22b","mixtral:8x22b-instruct-v0.1",
// ── Qwen 3 (Alibaba) ──
"qwen3:0.6b","qwen3:1.7b","qwen3:4b","qwen3:8b",
"qwen3:14b","qwen3:32b","qwen3:72b","qwen3:235b",
"qwen3:0.6b-q4_K_M","qwen3:1.7b-q4_K_M","qwen3:4b-q4_K_M","qwen3:8b-q4_K_M",
"qwen3:14b-q4_K_M","qwen3:32b-q4_K_M","qwen3:72b-q4_K_M",
// ── Qwen 2.5 ──
"qwen2.5:0.5b","qwen2.5:1.5b","qwen2.5:3b","qwen2.5:7b",
"qwen2.5:14b","qwen2.5:32b","qwen2.5:72b",
"qwen2.5:7b-instruct","qwen2.5:14b-instruct","qwen2.5:72b-instruct",
// ── Qwen 2.5 Coder ──
"qwen2.5-coder:0.5b","qwen2.5-coder:1.5b","qwen2.5-coder:3b",
"qwen2.5-coder:7b","qwen2.5-coder:14b","qwen2.5-coder:32b",
"qwen2.5-coder:7b-instruct","qwen2.5-coder:14b-instruct","qwen2.5-coder:32b-instruct",
// ── Qwen 2 ──
"qwen2:0.5b","qwen2:1.5b","qwen2:7b","qwen2:72b",
"qwen2:7b-instruct","qwen2:72b-instruct",
// ── Qwen 2 VL (Vision-Language) ──
"qwen2-vl:2b","qwen2-vl:7b","qwen2-vl:72b",
// ── DeepSeek R1 ──
"deepseek-r1:1.5b","deepseek-r1:7b","deepseek-r1:8b",
"deepseek-r1:14b","deepseek-r1:32b","deepseek-r1:70b","deepseek-r1:671b",
// ── DeepSeek R2 ──
"deepseek-r2:1.5b","deepseek-r2:7b","deepseek-r2:14b","deepseek-r2:32b","deepseek-r2:70b",
// ── DeepSeek V3 / V2 ──
"deepseek-v3","deepseek-v3:671b",
"deepseek-v2:16b","deepseek-v2:236b",
// ── DeepSeek Coder V2 ──
"deepseek-coder-v2:16b","deepseek-coder-v2:236b",
"deepseek-coder:1.3b","deepseek-coder:6.7b","deepseek-coder:33b",
// ── Microsoft Phi 4 ──
"phi4:14b","phi4-mini:3.8b","phi4-reasoning:14b",
"phi4:14b-q4_K_M","phi4:14b-q8_0","phi4:14b-fp16",
// ── Microsoft Phi 3 ──
"phi3:3.8b","phi3:14b","phi3-mini:3.8b","phi3-medium:14b",
"phi3.5:3.8b","phi3.5-mini:3.8b","phi3.5-moe:41b",
// ── Microsoft Phi 2 ──
"phi:2.7b","phi2:2.7b",
// ── NVIDIA Nemotron ──
"nemotron-mini:4b","nemotron:70b","nemotron:70b-instruct",
"llama-3.1-nemotron-70b-instruct",
// ── Cohere Command R ──
"command-r:35b","command-r-plus:104b",
"command-r7b:7b","command-r7b-arabic:7b",
// ── Cohere Aya ──
"aya:8b","aya:35b",
"aya-expanse:8b","aya-expanse:32b",
// ── Hermes 3 (NousResearch) ──
"hermes3:3b","hermes3:8b","hermes3:70b","hermes3:405b",
// ── Nous Hermes 2 / Hermes ──
"nous-hermes:7b","nous-hermes:13b",
"nous-hermes2:10.7b","nous-hermes2:34b",
"nous-hermes2-mixtral:8x7b",
// ── SmolLM 2 (HuggingFace) ──
"smollm2:135m","smollm2:360m","smollm2:1.7b",
"smollm2:135m-instruct","smollm2:360m-instruct","smollm2:1.7b-instruct",
// ── SmolLM 1 ──
"smollm:135m","smollm:360m","smollm:1.7b",
// ── Code Models ──
"codellama:7b","codellama:13b","codellama:34b","codellama:70b",
"codellama:7b-code","codellama:13b-code","codellama:34b-code",
"codellama:7b-instruct","codellama:13b-instruct","codellama:34b-instruct",
"codegemma:2b","codegemma:7b",
"starcoder:1b","starcoder:3b","starcoder:7b","starcoder:15b",
"starcoder2:3b","starcoder2:7b","starcoder2:15b",
"wizardcoder:7b","wizardcoder:13b","wizardcoder:34b",
// ── Vision Models ──
"llava:7b","llava:13b","llava:34b",
"llava-llama3:8b","llava-phi3:3.8b",
"bakllava:7b","moondream:1.8b",
"minicpm-v:8b","minicpm-v:8b-2.6",
"llava-next:7b","llava-next:13b",
"idefics3:8b",
// ── Medical / BioMedical Models ──
"meditron:7b","meditron:70b",
"medllama2:7b",
"med42:8b","med42:70b",
"biomistral:7b",
"clinical-camel:70b",
// ── Orca / Wizard ──
"orca-mini:3b","orca-mini:7b","orca-mini:13b","orca-mini:70b",
"orca2:7b","orca2:13b",
"wizard-vicuna:7b","wizard-vicuna:13b",
"wizardlm2:7b","wizardlm2:8x22b",
"wizardlm:7b","wizardlm:13b","wizardlm:70b",
// ── Yi Models (01.AI) ──
"yi:6b","yi:9b","yi:34b",
"yi-coder:1.5b","yi-coder:9b",
// ── Solar (Upstage) ──
"solar:10.7b","solar-pro:22b",
// ── Falcon ──
"falcon:7b","falcon:40b","falcon:180b","falcon2:11b",
// ── Vicuna ──
"vicuna:7b","vicuna:13b","vicuna:33b",
// ── OpenChat ──
"openchat:7b","openchat:8b",
// ── Zephyr ──
"zephyr:7b","zephyr:141b",
// ── InternLM ──
"internlm2:1.8b","internlm2:7b","internlm2:20b",
"internlm2.5:7b","internlm2.5:20b",
// ── TinyLlama ──
"tinyllama:1.1b",
// ── Dolphin ──
"dolphin-phi:2.7b","dolphin-mistral:7b",
"dolphin-llama3:8b","dolphin-llama3:70b",
"dolphin3:8b",
// ── DBRX (Databricks) ──
"dbrx:132b",
// ── Granite (IBM) ──
"granite-code:3b","granite-code:8b","granite-code:20b","granite-code:34b",
"granite3-dense:2b","granite3-dense:8b",
"granite3-moe:1b","granite3-moe:3b",
"granite3.1-dense:2b","granite3.1-dense:8b",
"granite3.1-moe:1b","granite3.1-moe:3b",
// ── Goliath ──
"goliath:120b",
// ── SQLCoder (Defog) ──
"sqlcoder:7b","sqlcoder:15b",
// ── StarLing ──
"starling-lm:7b",
// ── Neural Chat (Intel) ──
"neural-chat:7b",
// ── Llama Pro ──
"llama-pro:8b",
// ── EverythingLM ──
"everythinglm:13b",
// ── Samantha ──
"samantha-mistral:7b",
// ── Stable LM (Stability AI) ──
"stablelm2:1.6b","stablelm2:12b",
"stablelm-zephyr:3b",
// ── Marco-o1 ──
"marco-o1:7b",
// ── Reflection ──
"reflection:70b",
// ── GLM (Zhipu AI) ──
"glm4:9b",
// ── Exaone (LG AI) ──
"exaone3.5:2.4b","exaone3.5:7.8b","exaone3.5:32b",
// ── Tulu 3 (Allen AI) ──
"tulu3:8b","tulu3:70b",
// ── OLMo (Allen AI) ──
"olmo:7b","olmo2:7b","olmo2:13b",
// ── Bespoke Stratos ──
"bespoke-stratos:7b",
// ── Athene-V2 ──
"athene-v2:72b",
// ── QwQ ──
"qwq:32b","qwq:32b-preview",
// ── R1 Distill ──
"deepseek-r1:7b-qwen-distill","deepseek-r1:8b-llama-distill",
"deepseek-r1:14b-qwen-distill","deepseek-r1:32b-qwen-distill",
"deepseek-r1:70b-llama-distill",
];
// ── نماذج API السحابية (Closed Source) ──
const CLOSED_MODELS = {
// ── Groq Cloud ──
groq: [
// Llama 4 (Meta via Groq)
"meta-llama/llama-4-scout-17b-16e-instruct",
"meta-llama/llama-4-maverick-17b-128e-instruct",
// Llama 3.3
"llama-3.3-70b-versatile",
"llama-3.3-70b-specdec",
// Llama 3.2 Vision
"llama-3.2-90b-vision-preview",
"llama-3.2-11b-vision-preview",
// Llama 3.2 Text
"llama-3.2-3b-preview",
"llama-3.2-1b-preview",
// Llama 3.1
"llama-3.1-70b-versatile",
"llama-3.1-8b-instant",
// Llama 3
"llama3-70b-8192",
"llama3-8b-8192",
"llama3-groq-70b-8192-tool-use-preview",
"llama3-groq-8b-8192-tool-use-preview",
// Mixtral
"mixtral-8x7b-32768",
// Gemma
"gemma2-9b-it",
"gemma-7b-it",
// DeepSeek
"deepseek-r1-distill-llama-70b",
"deepseek-r1-distill-qwen-32b",
// Qwen
"qwen-qwq-32b",
"qwen-2.5-coder-32b",
"qwen-2.5-72b",
// Compound
"compound-beta",
"compound-beta-mini",
// Playgrounds
"llama-guard-3-8b",
"llama-3.1-70b-versatile",
],
// ── OpenAI ──
openai: [
// GPT-4o
"gpt-4o",
"gpt-4o-mini",
"gpt-4o-2024-11-20",
"gpt-4o-2024-08-06",
"gpt-4o-2024-05-13",
"gpt-4o-mini-2024-07-18",
// GPT-4 Turbo
"gpt-4-turbo",
"gpt-4-turbo-preview",
"gpt-4-turbo-2024-04-09",
// GPT-4
"gpt-4",
"gpt-4-0613",
"gpt-4-32k",
// GPT-3.5
"gpt-3.5-turbo",
"gpt-3.5-turbo-16k",
"gpt-3.5-turbo-0125",
// o-series (reasoning)
"o1",
"o1-2024-12-17",
"o1-mini",
"o1-mini-2024-09-12",
"o1-preview",
"o1-preview-2024-09-12",
"o3",
"o3-mini",
"o3-mini-2025-01-31",
"o4-mini",
"o4-mini-2025-04-16",
],
claude: [
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-haiku-4-5-20251001",
"claude-opus-4-5-20251101",
"claude-sonnet-4-5-20251101",
"claude-3-7-sonnet-20250219",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-5-sonnet-20240620",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
],
// ── Google Gemini ──
google: [
// Gemini 2.5
"gemini-2.5-pro",
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-flash",
"gemini-2.5-flash-preview-05-20",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-lite-preview-06-17",
// Gemini 2.0
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-2.0-flash-exp",
"gemini-2.0-flash-thinking-exp",
"gemini-2.0-pro-exp",
"gemini-2.0-pro-exp-02-05",
// Gemini 1.5
"gemini-1.5-pro",
"gemini-1.5-pro-002",
"gemini-1.5-pro-001",
"gemini-1.5-flash",
"gemini-1.5-flash-002",
"gemini-1.5-flash-001",
"gemini-1.5-flash-8b",
"gemini-1.5-flash-8b-001",
// Gemini 1.0
"gemini-1.0-pro",
],
// ── Mistral AI ──
mistral: [
// Mistral Large
"mistral-large-latest",
"mistral-large-2411",
"mistral-large-2407",
// Mistral Medium
"mistral-medium-latest",
"mistral-medium-2505",
// Mistral Small
"mistral-small-latest",
"mistral-small-2501",
"mistral-small-2409",
// Mistral Saba (Arabic-focused)
"mistral-saba-latest",
"mistral-saba-2502",
// Codestral
"codestral-latest",
"codestral-2501",
"codestral-mamba-latest",
// Pixtral (Vision)
"pixtral-large-latest",
"pixtral-large-2411",
"pixtral-12b-2409",
// Ministral
"ministral-3b-latest",
"ministral-3b-2410",
"ministral-8b-latest",
"ministral-8b-2410",
// Open models (free tier)
"open-mistral-nemo",
"open-mistral-7b",
"open-mixtral-8x7b",
"open-mixtral-8x22b",
// Devstral (coding)
"devstral-small-latest",
"devstral-small-2505",
],
};
// النماذج النشطة الافتراضية في الشريط السريع (Ollama)
const GROQ_MODELS = [
"gemma4:e4b","gemma4:9b","gemma3:4b","gemma4:31b","gemma2:9b","gemma4:27b",
"llama4:scout","llama4:maverick","llama4:17b","llama4:109b",
"llama3.3:70b","llama3.2:3b","llama3.2:11b","llama3.1:8b","llama3.1:70b",
"mistral:7b","mistral-nemo:12b","mistral-small:22b","mistral-small3.1:24b",
"qwen3:8b","qwen3:14b","qwen3:32b","qwen3:30b","qwen3:235b","qwen2.5:7b","qwen2.5:14b","qwen2.5:32b","qwen2.5:72b",
"deepseek-r1:8b","deepseek-r1:14b","deepseek-r1:32b","deepseek-r1:70b","deepseek-r1:671b",
"deepseek-v3","deepseek-v3:671b","phi4:14b","phi4-mini:3.8b","phi4-reasoning:14b","phi4-reasoning-plus:14b",
"llava:13b","llava:34b","moondream:1.8b","minicpm-v:8b","llava-llama3:8b",
"meditron:7b","meditron:70b","med42:8b","biomistral:7b","medllama3:8b",
"codellama:7b","codellama:13b","codegemma:7b","starcoder2:7b",
"command-r:35b","command-r-plus:104b",
"hermes3:8b","hermes3:70b",
"nemotron-mini:4b","nemotron:70b",
"aya:8b","aya:35b","aya-expanse:8b","aya-expanse:32b",
"smollm2:135m","smollm2:360m","smollm2:1.7b",
"internlm2:7b","internlm2:20b",
"solar:10.7b","openchat:7b","neural-chat:7b",
];
// ─────────────────────────────────────────────
// ══ MODEL REGISTRY ══
// كل نموذج له قائمة بمصادر توفّره + نمط اسمه في كل مصدر
// يُستخدم للتبديل التلقائي بين المصادر عند الاختيار التلقائي
// ─────────────────────────────────────────────
const MODEL_REGISTRY = {
// ── Google Gemma ──
"gemma": {
label: "Google Gemma",
family: "gemma",
sources: [
{ provider: "groq", models: ["gemma2-9b-it","gemma-7b-it"] },
{ provider: "google", models: ["gemini-1.5-flash","gemini-2.0-flash"] },
{ provider: "ollama", models: ["gemma4:e4b","gemma4:9b","gemma4:31b","gemma3:4b","gemma2:9b","gemma2:2b"] },
{ provider: "openrouter", models: ["google/gemma-2-9b-it","google/gemma-4-31b-it","google/gemma-3-27b-it"] },
{ provider: "together", models: ["google/gemma-2-9b-it","google/gemma-2-27b-it"] },
]
},
// ── Meta Llama ──
"llama": {
label: "Meta Llama",
family: "llama",
sources: [
{ provider: "groq", models: ["llama-3.3-70b-versatile","llama-3.1-70b-versatile","llama-3.1-8b-instant","meta-llama/llama-4-scout-17b-16e-instruct"] },
{ provider: "ollama", models: ["llama4:scout","llama4:maverick","llama3.3:70b","llama3.2:3b","llama3.1:8b","llama3.1:70b"] },
{ provider: "openrouter", models: ["meta-llama/llama-4-scout","meta-llama/llama-3.3-70b-instruct","meta-llama/llama-3.1-8b-instruct:free"] },
{ provider: "together", models: ["meta-llama/Llama-3.3-70B-Instruct-Turbo","meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo"] },
{ provider: "cloudflare", models: ["@cf/meta/llama-3.3-70b-instruct-fp8-fast","@cf/meta/llama-3.1-8b-instruct"] },
]
},
// ── Mistral / Mixtral ──
"mistral": {
label: "Mistral AI",
family: "mistral",
sources: [
{ provider: "mistral", models: ["mistral-large-latest","mistral-small-latest","open-mistral-nemo"] },
{ provider: "groq", models: ["mixtral-8x7b-32768"] },
{ provider: "ollama", models: ["mistral:7b","mistral-nemo:12b","mistral-small:22b","mixtral:8x7b"] },
{ provider: "openrouter", models: ["mistralai/mistral-7b-instruct:free","mistralai/mixtral-8x7b-instruct"] },
{ provider: "together", models: ["mistralai/Mistral-7B-Instruct-v0.3","mistralai/Mixtral-8x7B-Instruct-v0.1"] },
]
},
// ── Qwen ──
"qwen": {
label: "Alibaba Qwen",
family: "qwen",
sources: [
{ provider: "groq", models: ["qwen-qwq-32b","qwen-2.5-72b","qwen-2.5-coder-32b"] },
{ provider: "ollama", models: ["qwen3:8b","qwen3:14b","qwen3:32b","qwen2.5:7b","qwen2.5:14b","qwen2.5:72b"] },
{ provider: "openrouter", models: ["qwen/qwen3-235b-a22b","qwen/qwen3-30b-a3b:free","qwen/qwen-2.5-72b-instruct"] },
{ provider: "together", models: ["Qwen/Qwen2.5-72B-Instruct-Turbo","Qwen/Qwen2.5-7B-Instruct-Turbo"] },
]
},
// ── DeepSeek ──
"deepseek": {
label: "DeepSeek",
family: "deepseek",
sources: [
{ provider: "groq", models: ["deepseek-r1-distill-llama-70b","deepseek-r1-distill-qwen-32b"] },
{ provider: "ollama", models: ["deepseek-r1:8b","deepseek-r1:14b","deepseek-r1:32b","deepseek-r1:70b"] },
{ provider: "openrouter", models: ["deepseek/deepseek-r1","deepseek/deepseek-chat","deepseek/deepseek-r1:free"] },
{ provider: "together", models: ["deepseek-ai/DeepSeek-R1","deepseek-ai/DeepSeek-V3"] },
]
},
// ── Microsoft Phi ──
"phi": {
label: "Microsoft Phi",
family: "phi",
sources: [
{ provider: "ollama", models: ["phi4:14b","phi4-mini:3.8b","phi3:14b","phi3-mini:3.8b"] },
{ provider: "openrouter", models: ["microsoft/phi-4","microsoft/phi-3.5-mini-128k-instruct:free"] },
{ provider: "cloudflare", models: ["@cf/microsoft/phi-2"] },
]
},
// ── Gemini ──
"gemini": {
label: "Google Gemini",
family: "gemini",
sources: [
{ provider: "google", models: ["gemini-2.5-flash","gemini-2.0-flash","gemini-1.5-flash","gemini-2.5-pro","gemini-1.5-pro"] },
{ provider: "openrouter", models: ["google/gemini-2.0-flash-exp:free","google/gemini-flash-1.5","google/gemini-pro-1.5"] },
]
},
// ── Claude ──
"claude": {
label: "Anthropic Claude",
family: "claude",
sources: [
{ provider: "claude", models: ["claude-sonnet-4-6","claude-haiku-4-5-20251001","claude-3-5-sonnet-20241022","claude-3-haiku-20240307"] },
{ provider: "openrouter", models: ["anthropic/claude-sonnet-4-5","anthropic/claude-3-haiku","anthropic/claude-3.5-sonnet"] },
]
},
// ── GPT / OpenAI ──
"gpt": {
label: "OpenAI GPT",
family: "gpt",
sources: [
{ provider: "openai", models: ["gpt-4o-mini","gpt-4o","gpt-3.5-turbo","o3-mini","o4-mini"] },
{ provider: "openrouter", models: ["openai/gpt-4o-mini","openai/gpt-4o","openai/gpt-3.5-turbo"] },
]
},
// ── Code Llama ──
"codellama": {
label: "Code Llama",
family: "codellama",
sources: [
{ provider: "ollama", models: ["codellama:7b","codellama:13b","codellama:34b","codellama:70b"] },
{ provider: "openrouter", models: ["meta-llama/codellama-70b-instruct"] },
{ provider: "together", models: ["codellama/CodeLlama-70b-Instruct-hf"] },
]
},
// ── Yi ──
"yi": {
label: "01.AI Yi",
family: "yi",
sources: [
{ provider: "ollama", models: ["yi:9b","yi:34b"] },
{ provider: "openrouter", models: ["01-ai/yi-large","01-ai/yi-34b-chat"] },
{ provider: "together", models: ["zero-one-ai/Yi-34B-Chat"] },
]
},
};
// ─────────────────────────────────────────────
// ══ PROVIDER URLS MAP ══
// روابط API الافتراضية لكل مزوّد OpenAI-compatible
// ─────────────────────────────────────────────
const PROVIDER_URLS = {
groq: 'https://api.groq.com/openai/v1',
openai: 'https://api.openai.com/v1',
claude: 'https://api.anthropic.com/v1',
google: 'https://generativelanguage.googleapis.com/v1beta/openai',
mistral: 'https://api.mistral.ai/v1',
openrouter: 'https://openrouter.ai/api/v1',
together: 'https://api.together.xyz/v1',
cloudflare: 'https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1',
anyscale: 'https://api.endpoints.anyscale.com/v1',
fireworks: 'https://api.fireworks.ai/inference/v1',
deepinfra: 'https://api.deepinfra.com/v1/openai',
novita: 'https://api.novita.ai/v3/openai',
replicate: 'https://api.replicate.com/v1',
};
// ─────────────────────────────────────────────
// ══ AUTO MODEL SELECTOR ══
// يختار أفضل نموذج متاح بناءً على المفاتيح المضافة
// الأولوية: مفاتيح النماذج المخصّصة > مفاتيح المزوّدين العامة > Ollama
// ─────────────────────────────────────────────
const getAutoModel = (preferFamily = null) => {
// 1. أولاً: ابحث عن نموذج له مفتاح مخصّص
const customModels = Object.entries(MODEL_KEY_STORE)
.filter(([m, e]) => e.keys && e.keys.length > 0)
.map(([m]) => m);
if (customModels.length > 0) {
// فضّل العائلة المطلوبة إن وُجدت
if (preferFamily) {
const fam = customModels.find(m => m.toLowerCase().includes(preferFamily.toLowerCase()));
if (fam) return fam;
}
return customModels[0];
}
// 2. ثانياً: ابحث عن مفاتيح مزوّدين عامة
const vendorPriority = [
{ key: API_KEYS.groq, models: CLOSED_MODELS.groq, family: null },
{ key: API_KEYS.google, models: CLOSED_MODELS.google, family: 'gemini' },
{ key: API_KEYS.openai, models: CLOSED_MODELS.openai, family: 'gpt' },
{ key: API_KEYS.anthropic, models: CLOSED_MODELS.claude, family: 'claude' },
{ key: API_KEYS.mistral, models: CLOSED_MODELS.mistral, family: 'mistral' },
];
for (const v of vendorPriority) {
if (v.key && v.key.trim()) {
// اختر أول نموذج متاح (أو من العائلة المطلوبة)
if (preferFamily) {
const m = v.models.find(m => m.toLowerCase().includes(preferFamily.toLowerCase()));
if (m) return m;
}
return v.models[0];
}
}
// 3. أخيراً: Ollama مع رابط ngrok
if (ACTIVE_NGROK_URL.trim()) {
if (preferFamily) {
const m = OLLAMA_MODELS.find(m => m.toLowerCase().includes(preferFamily.toLowerCase()));
if (m) return m;
}
return OLLAMA_MODELS[0];
}
// لا يوجد شيء متاح
return null;
};
// يُعيد كل النماذج المتاحة حالياً (لها مفتاح أو رابط)
const getAvailableModels = () => {
const available = [];
// نماذج بمفاتيح مخصّصة
Object.entries(MODEL_KEY_STORE).forEach(([m, e]) => {
if ((e.keys && e.keys.length > 0) || (e.urls && e.urls.length > 0)) {
available.push({ model: m, source: 'custom', ready: true });
}
});
// نماذج Groq Cloud
if (API_KEYS.groq && API_KEYS.groq.trim()) {
CLOSED_MODELS.groq.forEach(m => available.push({ model: m, source: 'groq', ready: true }));
}
// نماذج OpenAI
if (API_KEYS.openai && API_KEYS.openai.trim()) {
CLOSED_MODELS.openai.forEach(m => available.push({ model: m, source: 'openai', ready: true }));
}
// نماذج Anthropic
if (API_KEYS.anthropic && API_KEYS.anthropic.trim()) {
CLOSED_MODELS.claude.forEach(m => available.push({ model: m, source: 'anthropic', ready: true }));
}
// نماذج Google
if (API_KEYS.google && API_KEYS.google.trim()) {
CLOSED_MODELS.google.forEach(m => available.push({ model: m, source: 'google', ready: true }));
}
// نماذج Mistral
if (API_KEYS.mistral && API_KEYS.mistral.trim()) {
CLOSED_MODELS.mistral.forEach(m => available.push({ model: m, source: 'mistral', ready: true }));
}
// Ollama
if (ACTIVE_NGROK_URL.trim()) {
OLLAMA_MODELS.forEach(m => available.push({ model: m, source: 'ollama', ready: true }));
}
return available;
};
// ✅ HF Space: نفس النموذج يدعم الرؤية عبر mmproj
const VISION_MODEL = "gemma-4-E2B-it-Q8_0";
// نموذج Whisper لتفريغ الصوت — يعمل عبر Ollama إذا دعمه
// إذا لم يتوفر Whisper في Ollama يمكن تعطيل هذه الخاصية
const WHISPER_MODEL = "whisper-large-v3";
// ── RAG CONSTANTS ──
const CHUNK_SIZE_WORDS = 400;
const CHUNK_OVERLAP_WORDS = 80;
const MAX_RAG_CONTEXT_TOKENS = 5000;
const TOP_K_CHUNKS = 6;
const MIN_CHUNK_WORDS = 20;
const BM25_K1 = 1.5;
const BM25_B = 0.75;
// ── IMAGE SEARCH TRIGGERS ──
const IMAGE_TRIGGER_PATTERNS = [
/صورة|صور|أرِني|اعرض|انظر|شكل|مظهر|كيف يبدو|تشريح|خريطة|مخطط|رسم|diagram|image|photo|show|picture|anatomy|chart|map|figure|وجه|جسم|عضو|قلب|دماغ|رئة|كلية|كبد|brain|heart|lung|liver|kidney/i
];
// ─────────────────────────────────────────────
// ══ MICROSOFT EDGE TTS VOICES ══
// Lists categorized Edge voices for Arabic, English, Multilingual
// ─────────────────────────────────────────────
const EDGE_TTS_VOICES = {
arabic: [
{ name: 'ar-EG-SalmaNeural', label: 'سلمى (مصر)', lang: 'ar-EG', gender: '♀' },
{ name: 'ar-EG-ShakirNeural', label: 'شاكر (مصر)', lang: 'ar-EG', gender: '♂' },
{ name: 'ar-SA-ZariyahNeural', label: 'زارية (السعودية)', lang: 'ar-SA', gender: '♀' },
{ name: 'ar-SA-HamedNeural', label: 'حامد (السعودية)', lang: 'ar-SA', gender: '♂' },
{ name: 'ar-AE-FatimaNeural', label: 'فاطمة (الإمارات)', lang: 'ar-AE', gender: '♀' },
{ name: 'ar-AE-HamdanNeural', label: 'حمدان (الإمارات)', lang: 'ar-AE', gender: '♂' },
{ name: 'ar-KW-FahedNeural', label: 'فهد (الكويت)', lang: 'ar-KW', gender: '♂' },
{ name: 'ar-KW-NouraNeural', label: 'نورة (الكويت)', lang: 'ar-KW', gender: '♀' },
{ name: 'ar-MA-JamalNeural', label: 'جمال (المغرب)', lang: 'ar-MA', gender: '♂' },
{ name: 'ar-MA-MounaNeural', label: 'مونى (المغرب)', lang: 'ar-MA', gender: '♀' },
{ name: 'ar-LY-ImanNeural', label: 'إيمان (ليبيا)', lang: 'ar-LY', gender: '♀' },
{ name: 'ar-LY-OmarNeural', label: 'عمر (ليبيا)', lang: 'ar-LY', gender: '♂' },
{ name: 'ar-IQ-BasselNeural', label: 'باسل (العراق)', lang: 'ar-IQ', gender: '♂' },
{ name: 'ar-IQ-RanaNeural', label: 'رنا (العراق)', lang: 'ar-IQ', gender: '♀' },
{ name: 'ar-SY-AmanyNeural', label: 'أماني (سوريا)', lang: 'ar-SY', gender: '♀' },
{ name: 'ar-SY-LaithNeural', label: 'ليث (سوريا)', lang: 'ar-SY', gender: '♂' },
{ name: 'ar-DZ-AminaNeural', label: 'أمينة (الجزائر)', lang: 'ar-DZ', gender: '♀' },
{ name: 'ar-DZ-IsmaelNeural', label: 'إسماعيل (الجزائر)',lang: 'ar-DZ', gender: '♂' },
],
english: [
{ name: 'en-US-JennyNeural', label: 'Jenny (US)', lang: 'en-US', gender: '♀' },
{ name: 'en-US-GuyNeural', label: 'Guy (US)', lang: 'en-US', gender: '♂' },
{ name: 'en-US-AriaNeural', label: 'Aria (US)', lang: 'en-US', gender: '♀' },
{ name: 'en-US-DavisNeural', label: 'Davis (US)', lang: 'en-US', gender: '♂' },
{ name: 'en-GB-SoniaNeural', label: 'Sonia (UK)', lang: 'en-GB', gender: '♀' },
{ name: 'en-GB-RyanNeural', label: 'Ryan (UK)', lang: 'en-GB', gender: '♂' },
{ name: 'en-AU-NatashaNeural', label: 'Natasha (AU)', lang: 'en-AU', gender: '♀' },
{ name: 'en-CA-ClaraNeural', label: 'Clara (CA)', lang: 'en-CA', gender: '♀' },
{ name: 'en-IN-NeerjaNeural', label: 'Neerja (IN)', lang: 'en-IN', gender: '♀' },
],
multilingual: [
{ name: 'fr-FR-DeniseNeural', label: 'Denise (FR)', lang: 'fr-FR', gender: '♀' },
{ name: 'de-DE-KatjaNeural', label: 'Katja (DE)', lang: 'de-DE', gender: '♀' },
{ name: 'es-ES-ElviraNeural', label: 'Elvira (ES)', lang: 'es-ES', gender: '♀' },
{ name: 'it-IT-ElsaNeural', label: 'Elsa (IT)', lang: 'it-IT', gender: '♀' },
{ name: 'tr-TR-EmelNeural', label: 'Emel (TR)', lang: 'tr-TR', gender: '♀' },
{ name: 'zh-CN-XiaoxiaoNeural', label: 'Xiaoxiao (ZH)', lang: 'zh-CN', gender: '♀' },
{ name: 'ja-JP-NanamiNeural', label: 'Nanami (JA)', lang: 'ja-JP', gender: '♀' },
{ name: 'hi-IN-SwaraNeural', label: 'Swara (HI)', lang: 'hi-IN', gender: '♀' },
{ name: 'pt-BR-FranciscaNeural', label: 'Francisca (PT)', lang: 'pt-BR', gender: '♀' },
{ name: 'ru-RU-SvetlanaNeural', label: 'Svetlana (RU)', lang: 'ru-RU', gender: '♀' },
]
};
const ALL_EDGE_VOICES = [
...EDGE_TTS_VOICES.arabic,
...EDGE_TTS_VOICES.english,
...EDGE_TTS_VOICES.multilingual
];
const QUICK_PROMPTS = [
{ icon:'🩺', label:'تشخيص تفاضلي', text:'اشرح خطوات التشخيص التفاضلي للألم الصدري الحاد مع جداول مقارنة مفصلة' },
{ icon:'💊', label:'جرعات دوائية', text:'قدم جدول الجرعات القياسية للمضادات الحيوية الشائعة مع الآثار الجانبية' },
{ icon:'🔬', label:'قراءة تحاليل', text:'كيف أفسر نتائج CBC؟ اشرح القيم المرجعية والتفسير السريري' },
{ icon:'🧠', label:'علم التشريح', text:'اشرح تشريح الجهاز العصبي المركزي بخريطة ذهنية هرمية مفصلة' },
{ icon:'🏥', label:'بروتوكول علاج', text:'ما بروتوكول علاج الجلطة الدماغية الإقفارية خلال نافذة 4.5 ساعة؟' },
{ icon:'📊', label:'مقارنة فحوص', text:'قارن CT scan مقابل MRI في تشخيص أمراض الدماغ في جدول مقارنة شامل' },
{ icon:'🫀', label:'أمراض القلب', text:'اشرح التصنيف والعلاج الحديث لقصور القلب الاحتقاني وفق إرشادات ESC 2023' },
{ icon:'🧪', label:'تحليل دوائي', text:'ما التفاعلات الدوائية الخطرة للوارفارين؟ قدم جدولاً شاملاً مع التعديلات' },
{ icon:'👶', label:'طب الأطفال', text:'ما الجرعات الصحيحة للمضادات الحيوية في الأطفال؟ جدول حسب الوزن والعمر' },
];
const THEMES = [
{ id:'t1', name:'Dark Mode', bg:'#020617', text:'#f8fafc', card:'#0f172a', border:'#1e293b', primary:'#3b82f6' },
{ id:'t2', name:'Medical White', bg:'#f8fafc', text:'#0f172a', card:'#ffffff', border:'#e2e8f0', primary:'#2563eb' },
{ id:'t3', name:'Surgical Green', bg:'#ecfdf5', text:'#064e3b', card:'#ffffff', border:'#d1fae5', primary:'#059669' },
{ id:'t4', name:'Neural Violet', bg:'#2e1065', text:'#f5f3ff', card:'#4c1d95', border:'#5b21b6', primary:'#a855f7' },
{ id:'t5', name:'Clinical Blue', bg:'#eff6ff', text:'#1e3a8a', card:'#dbeafe', border:'#bfdbfe', primary:'#2563eb' },
{ id:'t6', name:'Radiology Gray', bg:'#111827', text:'#f3f4f6', card:'#1f2937', border:'#374151', primary:'#9ca3af' },
{ id:'t7', name:'Emergency Red', bg:'#450a0a', text:'#fef2f2', card:'#7f1d1d', border:'#991b1b', primary:'#ef4444' },
{ id:'t8', name:'Pediatric Yellow', bg:'#fffbeb', text:'#78350f', card:'#fef3c7', border:'#fde68a', primary:'#d97706' },
{ id:'t9', name:'Oncology Black', bg:'#000000', text:'#e5e7eb', card:'#111827', border:'#374151', primary:'#10b981' },
{ id:'t10', name:'Pharmacy Teal', bg:'#f0fdfa', text:'#134e4a', card:'#ccfbf1', border:'#99f6e4', primary:'#0d9488' },
{ id:'t11', name:'Genetics Rose', bg:'#fff1f2', text:'#881337', card:'#ffe4e6', border:'#fecdd3', primary:'#e11d48' },
{ id:'t12', name:'Immunology Cyan', bg:'#ecfeff', text:'#164e63', card:'#cffafe', border:'#a5f3fc', primary:'#0891b2' },
{ id:'t13', name:'Psychiatry Indigo',bg:'#eef2ff', text:'#312e81', card:'#e0e7ff', border:'#c7d2fe', primary:'#4f46e5' },
{ id:'t14', name:'BioMistral Neo', bg:'#0a0a0a', text:'#facc15', card:'#171717', border:'#292524', primary:'#eab308' },
{ id:'t15', name:'Surgery Purple', bg:'#1e0040', text:'#e9d5ff', card:'#2d0060', border:'#4c1d95', primary:'#c084fc' }
];
// ─────────────────────────────────────────────
// DATABASE
// ─────────────────────────────────────────────
const db = new Dexie("BrainMapOS_V25");
db.version(2).stores({
users: '++id, name, email, type',
sessions: '++id, userId, title, timestamp',
messages: '++id, sessionId, role, text, timestamp, mediaType',
files: '++id, userId, sessionId, name, fileType, content, base64, timestamp',
injected_html: '++id, code, timestamp',
settings: '++id, userId, themeId, voiceName, voiceRate, voicePitch'
});
db.version(3).stores({
chunks: '++id, fileId, userId, text, chunkIndex, timestamp',
notes: '++id, userId, sessionId, title, content, isAiGenerated, timestamp',
favorites: '++id, msgId, text, timestamp'
});
db.version(4).stores({
patients: '++id, userId, name, dob, gender, mrn, timestamp',
soap_notes: '++id, userId, patientId, sessionId, subjective, objective, assessment, plan, timestamp',
drug_checks: '++id, userId, drugs, results, timestamp',
pubmed_cache: '++id, query, results, timestamp',
});
db.version(5).stores({
patient_context: '++id, userId, context, timestamp',
});
const allocateArchive = () => {
try {
const req = indexedDB.open("BrainMap_Archive_V2", 1);
req.onupgradeneeded = e => {
const idb = e.target.result;
if (!idb.objectStoreNames.contains("archive")) {
idb.createObjectStore("archive", { keyPath: "fid" });
}
};
req.onsuccess = e => {
// Archive DB allocated. No padding blocks written — IndexedDB quota is managed by the browser.
e.target.result.close();
};
} catch(_) {}
};
// ─────────────────────────────────────────────
// ══ INGESTION LAYER — CHUNKING ENGINE ══
// ─────────────────────────────────────────────
const detectSectionBoundaries = (text) => {
const headingPattern = /^(#{1,6}\s+.+|[^\n]{1,80}:\s*$|\d+[\.\)]\s+[^\n]{5,60})/gm;
const boundaries = [];
let match;
while ((match = headingPattern.exec(text)) !== null) {
boundaries.push({ index: match.index, heading: match[0].trim().substring(0, 60) });
}
return boundaries;
};
const chunkText = (text, sourceName = '', chunkSize = CHUNK_SIZE_WORDS, overlap = CHUNK_OVERLAP_WORDS) => {
if (!text || text.trim().length < 10) return [];
const normalizedText = text
.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\u200B/g, '')
.replace(/\t/g, ' ').replace(/[ ]{3,}/g, ' ').trim();
const paragraphs = normalizedText.split(/\n{2,}/).filter(p => {
const wordCount = p.trim().split(/\s+/).filter(w => w.length > 0).length;
return wordCount >= MIN_CHUNK_WORDS;
});
const chunks = [];
let globalWordOffset = 0;
let chunkIndex = 0;
if (paragraphs.length > 0) {
let currentChunkWords = [];
let currentSectionHints = [];
for (const para of paragraphs) {
const paraWords = para.trim().split(/\s+/).filter(w => w.length > 0);
const sectionHint = para.trim().substring(0, 70);
if (currentChunkWords.length + paraWords.length > chunkSize && currentChunkWords.length >= MIN_CHUNK_WORDS) {
chunks.push({
text: currentChunkWords.join(' '),
chunkIndex: chunkIndex++,
metadata: {
sourceName, section: currentSectionHints[0] || sectionHint,
startWord: globalWordOffset - currentChunkWords.length,
endWord: globalWordOffset,
pageHint: Math.floor((globalWordOffset - currentChunkWords.length) / 300) + 1
}
});
const overlapSlice = currentChunkWords.slice(-overlap);
currentChunkWords = [...overlapSlice, ...paraWords];
currentSectionHints = [sectionHint];
} else {
currentChunkWords = [...currentChunkWords, ...paraWords];
currentSectionHints.push(sectionHint);
}
globalWordOffset += paraWords.length;
}
if (currentChunkWords.length >= MIN_CHUNK_WORDS) {
chunks.push({
text: currentChunkWords.join(' '),
chunkIndex: chunkIndex++,
metadata: {
sourceName, section: currentSectionHints[0] || sourceName,
startWord: globalWordOffset - currentChunkWords.length,
endWord: globalWordOffset,
pageHint: Math.floor((globalWordOffset - currentChunkWords.length) / 300) + 1
}
});
}
}
if (chunks.length === 0) {
const allWords = normalizedText.split(/\s+/).filter(w => w.length > 0);
for (let i = 0; i < allWords.length; i += (chunkSize - overlap)) {
const sliceWords = allWords.slice(i, i + chunkSize);
if (sliceWords.length < MIN_CHUNK_WORDS) break;
chunks.push({
text: sliceWords.join(' '),
chunkIndex: chunkIndex++,
metadata: {
sourceName,
section: sliceWords.slice(0, 8).join(' '),
startWord: i, endWord: Math.min(i + chunkSize, allWords.length),
pageHint: Math.floor(i / 300) + 1
}
});
if (i + chunkSize >= allWords.length) break;
}
}
return chunks;
};
// ─────────────────────────────────────────────
// ══ BM25 RETRIEVAL ENGINE ══
// ─────────────────────────────────────────────
const ARABIC_STOPWORDS = new Set([
'في','من','على','إلى','عن','مع','هذا','هذه','التي','الذي','وهو','وهي','أن','إن',
'لا','ما','كان','كانت','يكون','يكن','هو','هي','نحن','أنت','ذلك','تلك','ولا','أو',
'وأن','كما','ثم','بين','قد','لم','لن','حتى','عند','منه','منها','فيه','فيها','له',
'لها','بها','به','هم','هن','أنتم','وقد','فقد','كل','بعض','غير','حين','بعد','قبل',
'عليه','عليها','وكان','the','and','or','of','to','in','is','are','was','were','a','an'
]);
const tokenizeForRetrieval = (text) => {
return text.toLowerCase()
.replace(/[^\u0600-\u06FF\u0750-\u077F\w\s]/g, ' ')
.split(/\s+/)
.filter(t => t.length > 2 && !ARABIC_STOPWORDS.has(t));
};
const buildDocFrequency = (chunks) => {
const docFreq = {};
chunks.forEach(chunk => {
const uniqueTokens = new Set(tokenizeForRetrieval(chunk.text));
uniqueTokens.forEach(t => { docFreq[t] = (docFreq[t] || 0) + 1; });
});
return docFreq;
};
const computeBM25Score = (queryTokens, docText, avgDocLength, docFreq, corpusSize) => {
const docTokens = tokenizeForRetrieval(docText);
const docLength = docTokens.length;
if (docLength === 0 || queryTokens.length === 0) return 0;
const termFreqMap = {};
docTokens.forEach(t => { termFreqMap[t] = (termFreqMap[t] || 0) + 1; });
let score = 0;
queryTokens.forEach(term => {
const tf = termFreqMap[term] || 0;
if (tf === 0) return;
const df = docFreq[term] || 0;
const idf = Math.log((corpusSize - df + 0.5) / (df + 0.5) + 1);
const normDenom = tf + BM25_K1 * (1 - BM25_B + BM25_B * docLength / Math.max(avgDocLength, 1));
const tfNorm = (tf * (BM25_K1 + 1)) / normDenom;
score += idf * tfNorm;
});
return score;
};
const retrieveTopChunks = (query, allChunks, topK = TOP_K_CHUNKS, enabledFileIds = null) => {
if (!allChunks || allChunks.length === 0) return [];
if (!query || query.trim().length === 0) return allChunks.slice(0, topK);
const filteredChunks = enabledFileIds !== null
? allChunks.filter(c => enabledFileIds.has(String(c.fileId)))
: allChunks;
if (filteredChunks.length === 0) return [];
const queryTokens = tokenizeForRetrieval(query);
if (queryTokens.length === 0) return filteredChunks.slice(0, topK);
const corpusSize = filteredChunks.length;
const docFreq = buildDocFrequency(filteredChunks);
const avgDocLength = filteredChunks.reduce((sum, c) => sum + tokenizeForRetrieval(c.text).length, 0) / corpusSize;
const scored = filteredChunks.map(chunk => ({
...chunk,
bm25Score: computeBM25Score(queryTokens, chunk.text, avgDocLength, docFreq, corpusSize)
}));
scored.sort((a, b) => b.bm25Score - a.bm25Score);
return scored.slice(0, topK).filter(c => c.bm25Score > 0);
};
const estimateTokens = (text) => Math.ceil(text.split(/\s+/).length * 1.35);
const buildRAGContext = (topChunks, maxTokens = MAX_RAG_CONTEXT_TOKENS) => {
if (!topChunks || topChunks.length === 0) return { context: '', citations: [], sourcesUsed: [] };
const selected = [];
let usedTokens = 0;
const seenSources = new Set();
for (const chunk of topChunks) {
const chunkTokens = estimateTokens(chunk.text);
const isNewSource = !seenSources.has(chunk.metadata?.sourceName);
const budgetLeft = maxTokens - usedTokens;
if (budgetLeft <= 0) break;
if (chunkTokens > budgetLeft && !isNewSource) continue;
selected.push(chunk);
usedTokens += Math.min(chunkTokens, budgetLeft);
seenSources.add(chunk.metadata?.sourceName || 'unknown');
}
if (selected.length === 0) return { context: '', citations: [], sourcesUsed: [] };
const citations = [];
const contextParts = selected.map((chunk, idx) => {
const citId = idx + 1;
const sourceName = chunk.metadata?.sourceName || 'مصدر';
const section = chunk.metadata?.section ? chunk.metadata.section.substring(0, 50) : '';
const page = chunk.metadata?.pageHint ? `ص.${chunk.metadata.pageHint}` : '';
const score = chunk.bm25Score ? chunk.bm25Score.toFixed(2) : '–';
citations.push({ id: citId, source: sourceName, section, page, fileId: String(chunk.fileId), score });
return `[📎 مرجع ${citId}: ${sourceName}${page ? ` — ${page}` : ''}]\n${chunk.text}`;
});
return { context: contextParts.join('\n\n━━━\n\n'), citations, sourcesUsed: [...seenSources] };
};
// ─────────────────────────────────────────────
// ══ QUERY UNDERSTANDING MODULE ══
// ─────────────────────────────────────────────
const INTENT_PATTERNS = {
pharmacological: /دواء|علاج|جرعة|دوائي|صيدلة|مضاد|حيوي|تأثير|آلية عمل|pharmacol|drug|dose|medication|treatment|antibiotic|receptor/i,
diagnostic: /تشخيص|أعراض|مرض|حالة|سريري|نتيجة|فحص|diagnosis|symptom|clinical|test|result|differential/i,
procedural: /عملية|إجراء|خطوات|طريقة|تقنية|procedure|surgery|technique|step|protocol/i,
summarization: /ملخص|لخص|اشرح|وضح|اختصر|summary|explain|describe|overview|what is|ما هو/i,
comparative: /مقارنة|الفرق|فرق|مقابل|compare|vs\b|versus|difference|contrast|أيهما/i,
laboratory: /مخبر|مختبر|تحليل|قيم|مرجعية|نتائج|CBC|RBC|WBC|hemoglobin|خضاب|كريات/i
};
const INTENT_META = {
pharmacological: { ar:'دوائي', color:'#10b981', bg:'#10b98118', icon:'💊', depth:'deep' },
diagnostic: { ar:'تشخيصي', color:'#3b82f6', bg:'#3b82f618', icon:'🔬', depth:'deep' },
procedural: { ar:'إجرائي', color:'#f59e0b', bg:'#f59e0b18', icon:'🔧', depth:'medium' },
summarization: { ar:'تلخيص', color:'#8b5cf6', bg:'#8b5cf618', icon:'📋', depth:'medium' },
comparative: { ar:'مقارنة', color:'#ec4899', bg:'#ec489918', icon:'⚖️', depth:'deep' },
laboratory: { ar:'مخبري', color:'#06b6d4', bg:'#06b6d418', icon:'🧪', depth:'deep' },
general: { ar:'عام', color:'#6b7280', bg:'#6b728018', icon:'💬', depth:'normal' }
};
const classifyQueryIntent = (query) => {
for (const [intent, pattern] of Object.entries(INTENT_PATTERNS)) {
if (pattern.test(query)) return { intent, ...INTENT_META[intent] };
}
return { intent: 'general', ...INTENT_META.general };
};
const shouldFetchImages = (query) => {
return IMAGE_TRIGGER_PATTERNS.some(p => p.test(query));
};
// ─────────────────────────────────────────────
// ══ ENHANCED IMAGE SEARCH ENGINE ══
// Uses Wikimedia Commons API for free medical images
// Falls back to DuckDuckGo
// ─────────────────────────────────────────────
const searchImagesWikimedia = async (query) => {
try {
// Wikimedia Commons API - free, no key needed, good medical content
const searchTerm = encodeURIComponent(query + ' medical anatomy');
const res = await fetch(
`https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrsearch=${searchTerm}&gsrnamespace=6&gsrlimit=8&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=400&format=json&origin=*`
);
const data = await res.json();
const images = [];
if (data.query && data.query.pages) {
Object.values(data.query.pages).forEach(page => {
if (page.imageinfo && page.imageinfo[0]) {
const info = page.imageinfo[0];
const thumbUrl = info.thumburl || info.url;
const title = page.title.replace('File:', '').replace(/\.[^.]+$/, '');
const license = info.extmetadata?.LicenseShortName?.value || 'CC';
if (thumbUrl && !thumbUrl.includes('.ogg') && !thumbUrl.includes('.svg')) {
images.push({
url: thumbUrl,
fullUrl: info.url,
title: title.substring(0, 60),
source: 'Wikimedia Commons',
license,
sourceUrl: `https://commons.wikimedia.org/wiki/${page.title}`
});
}
}
});
}
return images.slice(0, 4);
} catch(_) { return []; }
};
const searchImagesOpenVerse = async (query) => {
try {
// Openverse API - free creative commons images
const res = await fetch(
`https://api.openverse.org/v1/images/?q=${encodeURIComponent(query + ' medical')}&license_type=commercial,modification&page_size=4&mature=false`,
{ headers: { 'Accept': 'application/json' } }
);
if (!res.ok) return [];
const data = await res.json();
const images = [];
if (data.results) {
data.results.forEach(img => {
if (img.url) {
images.push({
url: img.thumbnail || img.url,
fullUrl: img.url,
title: (img.title || query).substring(0, 60),
source: img.source || 'Openverse',
license: img.license || 'CC',
sourceUrl: img.foreign_landing_url || img.url
});
}
});
}
return images.slice(0, 4);
} catch(_) { return []; }
};
const searchImagesDuckDuckGo = async (query) => {
try {
// ✅ إصلاح: استخدام بروكسي CORS لأن DuckDuckGo لا يسمح بطلبات المتصفح المباشرة
const proxyUrl = `https://corsproxy.io/?url=${encodeURIComponent(`https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`)}`;
const res = await fetch(proxyUrl);
const data = await res.json();
const images = [];
if (data.Image && data.Image.trim()) {
images.push({ url: data.Image, fullUrl: data.Image, title: data.Heading || query, source: 'DuckDuckGo', license: '' });
}
if (data.RelatedTopics) {
data.RelatedTopics.slice(0, 6).forEach(t => {
if (t.Icon && t.Icon.URL && t.Icon.URL.trim() && !t.Icon.URL.includes('noresult') && !t.Icon.URL.includes('duckduckgo.com/i')) {
images.push({ url: t.Icon.URL, fullUrl: t.Icon.URL, title: (t.Text||query).substring(0, 50), source: 'DuckDuckGo', license: '' });
}
});
}
return images.filter(img => img.url && img.url.startsWith('http')).slice(0, 4);
} catch(_) { return []; }
};
const searchImages = async (query) => {
// Try Wikimedia first (best for medical content), then Openverse, then DDG
const wikimediaResults = await searchImagesWikimedia(query);
if (wikimediaResults.length >= 2) return wikimediaResults;
const openverseResults = await searchImagesOpenVerse(query);
if (openverseResults.length >= 2) return openverseResults;
return await searchImagesDuckDuckGo(query);
};
// ─────────────────────────────────────────────
// ══ MICROSOFT EDGE TTS ENGINE ══
// Uses SpeechSynthesis with Edge voices detected, plus
// fallback to standard voices. Also supports Edge TTS API via SSML.
// ─────────────────────────────────────────────
let systemVoicesCache = [];
// (V25: _ttsKATimer removed — Keep-Alive hack eliminated, smart sentence chunking used instead)
let _audioCtx = null;
const unlockAudioContext = () => {
if (_audioCtx) return;
try {
_audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') _audioCtx.resume().catch(()=>{});
const buf = _audioCtx.createBuffer(1, 1, 22050);
const src = _audioCtx.createBufferSource();
src.buffer = buf;
src.connect(_audioCtx.destination);
src.start(0);
} catch(_) {}
};
['touchstart','touchend','mousedown','keydown','click'].forEach(ev =>
document.addEventListener(ev, function _u() {
unlockAudioContext();
document.removeEventListener(ev, _u);
}, { once: true, passive: true })
);
const loadSystemVoices = () => {
systemVoicesCache = window.speechSynthesis?.getVoices() || [];
};
if ('speechSynthesis' in window) {
window.speechSynthesis.addEventListener('voiceschanged', loadSystemVoices);
loadSystemVoices();
}
// Find a system voice matching an Edge voice name (by partial match)
const findSystemVoiceForEdge = (edgeVoiceName, lang) => {
const voices = window.speechSynthesis?.getVoices() || [];
// Try exact name match first
const exact = voices.find(v => v.name === edgeVoiceName);
if (exact) return exact;
// Try partial match on voice name (Edge installs use different naming)
const shortName = edgeVoiceName.replace('Neural', '').replace(/-[A-Z]{2}-/g, ' ').trim();
const partial = voices.find(v => v.name.includes(shortName.split(' ')[0]));
if (partial) return partial;
// Try language match
const langMatch = voices.find(v => v.lang.startsWith(lang.substring(0, 5)));
if (langMatch) return langMatch;
// Last resort: any Arabic voice
if (lang.startsWith('ar')) return voices.find(v => v.lang.startsWith('ar')) || voices[0];
return voices.find(v => v.lang.startsWith(lang.substring(0, 2))) || voices[0];
};
// Get all available system voices categorized
const getSystemVoicesByCategory = () => {
const voices = window.speechSynthesis?.getVoices() || [];
const arabic = voices.filter(v => v.lang.startsWith('ar'));
const english = voices.filter(v => v.lang.startsWith('en'));
const other = voices.filter(v => !v.lang.startsWith('ar') && !v.lang.startsWith('en'));
// Mark Edge voices
const markEdge = (v) => ({ ...v, isEdge: v.name.includes('Neural') || v.name.includes('Microsoft') || v.name.toLowerCase().includes('edge') });
return {
arabic: arabic.map(markEdge),
english: english.map(markEdge),
other: other.map(markEdge)
};
};
// ✅ V25 FIX: Smart TTS chunking at sentence boundaries (no mid-word cuts)
// Splits at ., !, ?, ،, ؟ — never in the middle of a word
const splitAtSentences = (text, maxLen) => {
const chunks = [];
// Sentence-ending punctuation (Arabic + Latin)
const sentenceEnd = /([.!?،؟؛\n])\s*/g;
let last = 0;
let current = '';
let match;
while ((match = sentenceEnd.exec(text)) !== null) {
const segment = text.slice(last, match.index + match[0].length);
if (current.length + segment.length > maxLen && current.length > 0) {
chunks.push(current.trim());
current = segment;
} else {
current += segment;
}
last = match.index + match[0].length;
}
// Append any remaining text
if (last < text.length) {
const tail = text.slice(last);
if (current.length + tail.length > maxLen && current.length > 0) {
chunks.push(current.trim());
current = tail;
} else {
current += tail;
}
}
if (current.trim()) chunks.push(current.trim());
// Safety: if any chunk is still too long (no punctuation), split at word boundary
const result = [];
for (const ch of chunks) {
if (ch.length <= maxLen) { result.push(ch); continue; }
let rem = ch;
while (rem.length > maxLen) {
const sp = rem.lastIndexOf(' ', maxLen);
const cut = sp > 0 ? sp : maxLen;
result.push(rem.slice(0, cut).trim());
rem = rem.slice(cut).trim();
}
if (rem) result.push(rem);
}
return result.filter(Boolean);
};
// Main TTS speak function — V25: smart chunking, no Keep-Alive timer
const speakWithEdgeTTS = (text, edgeVoiceName, lang, rate = 1, pitch = 1, onStart, onEnd) => {
if (!('speechSynthesis' in window)) { if(onEnd) onEnd(); return; }
unlockAudioContext();
window.speechSynthesis.cancel();
const clean = text
.replace(/[#*`_\[\]>|~^]/g, '')
.replace(/!?\[([^\]]*?)\]\([^)]*?\)/g, '$1')
.replace(/\n{2,}/g, '。') // preserve paragraph breaks as pause cue
.replace(/\n/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim()
.substring(0, 2400); // ✅ V25: increased from 1200 to 2400
if (!clean) { if(onEnd) onEnd(); return; }
// ✅ V25: split at sentence boundaries, not fixed character count
const chunks = splitAtSentences(clean, 280);
if (chunks.length === 0) { if(onEnd) onEnd(); return; }
let ci = 0;
let started = false;
const speakNext = () => {
if (ci >= chunks.length) { if (onEnd) onEnd(); return; }
const chunk = chunks[ci++];
const voices = window.speechSynthesis.getVoices();
const voice = voices.length > 0 ? findSystemVoiceForEdge(edgeVoiceName, lang || 'ar-SA') : null;
const utt = new SpeechSynthesisUtterance(chunk);
if (voice) utt.voice = voice;
utt.lang = voice?.lang || lang || 'ar-SA';
utt.rate = Math.max(0.5, Math.min(2.0, rate));
utt.pitch = Math.max(0.5, Math.min(2.0, pitch));
utt.volume = 1;
utt.onstart = () => { if (!started) { started = true; if (onStart) onStart(); } };
utt.onend = () => { setTimeout(() => speakNext(), 80); }; // small natural pause between sentences
utt.onerror = (ev) => {
if (ev.error === 'interrupted' || ev.error === 'canceled') { if (onEnd) onEnd(); return; }
// Retry once on transient error, then move on
setTimeout(() => speakNext(), 250);
};
window.speechSynthesis.speak(utt);
// ✅ V25: No _ttsKATimer — the Keep-Alive hack is removed entirely.
// Chrome's 15-second bug only triggers on very long single utterances,
// which our sentence chunking prevents by design.
};
const vs = window.speechSynthesis.getVoices();
if (vs.length === 0) {
const onReady = () => { window.speechSynthesis.removeEventListener('voiceschanged', onReady); speakNext(); };
window.speechSynthesis.addEventListener('voiceschanged', onReady);
setTimeout(() => speakNext(), 800);
} else {
speakNext();
}
};
// Legacy speakText — kept for compatibility, delegates to Edge TTS
const speakText = (text, voiceName, rate = 1, pitch = 1) => {
// Find matching voice entry
const edgeVoice = ALL_EDGE_VOICES.find(v => v.name === voiceName) || ALL_EDGE_VOICES[0];
speakWithEdgeTTS(text, edgeVoice.name, edgeVoice.lang, rate, pitch, null, null);
};
// Multi-speaker audio overview with Edge TTS
const playAudioOverview = (scriptText, edgeVoiceAName, edgeVoiceBName, voiceRate, voicePitch) => {
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
const voiceA = ALL_EDGE_VOICES.find(v => v.name === edgeVoiceAName) || EDGE_TTS_VOICES.arabic[0];
const voiceB = ALL_EDGE_VOICES.find(v => v.name === edgeVoiceBName) || EDGE_TTS_VOICES.arabic[1] || EDGE_TTS_VOICES.arabic[0];
const lines = scriptText.split('\n').filter(l => l.trim().length > 0);
const utterances = [];
for (const line of lines) {
const textClean = line.replace(/^\*\*[^:]+:\*\*\s*/, '').replace(/[#*`_\[\]>|~]/g, '').trim();
if (!textClean) continue;
const isHostB = /نور:|نور :|Speaker B:|🎙️ب:|طارق:/i.test(line);
const chosenEdgeVoice = isHostB ? voiceB : voiceA;
const sysVoice = findSystemVoiceForEdge(chosenEdgeVoice.name, chosenEdgeVoice.lang);
const utt = new SpeechSynthesisUtterance(textClean.substring(0, 300));
if (sysVoice) utt.voice = sysVoice;
utt.lang = sysVoice?.lang || chosenEdgeVoice.lang;
utt.rate = isHostB ? voiceRate * 0.94 : voiceRate;
utt.pitch = isHostB ? voicePitch * 1.06 : voicePitch;
utterances.push(utt);
}
const speak = (index) => {
if (index >= utterances.length) return;
utterances[index].onend = () => setTimeout(() => speak(index + 1), 150);
window.speechSynthesis.speak(utterances[index]);
};
speak(0);
};
// ─────────────────────────────────────────────
// FILE PARSERS
// ─────────────────────────────────────────────
const parsePDF = async (file) => {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
let text = '';
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
// Layout-aware parsing: preserve line structure
const items = content.items;
let lastY = null;
let line = '';
for (const item of items) {
if (lastY !== null && Math.abs(item.transform[5] - lastY) > 3) {
text += line.trim() + '\n';
line = item.str;
} else {
line += (line ? ' ' : '') + item.str;
}
lastY = item.transform[5];
}
if (line.trim()) text += line.trim() + '\n';
text += '\n';
}
return text.trim();
};
const parseDOCX = async (file) => {
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.extractRawText({ arrayBuffer });
return result.value.trim();
};
const fileToBase64 = (file) => new Promise((res, rej) => {
const reader = new FileReader();
reader.onload = e => res(e.target.result);
reader.onerror = rej;
reader.readAsDataURL(file);
});
const fileToText = (file) => new Promise((res, rej) => {
const reader = new FileReader();
reader.onload = e => res(e.target.result);
reader.onerror = rej;
reader.readAsText(file);
});
// Parse CSV into readable text
const parseCSV = async (file) => {
const text = await fileToText(file);
const lines = text.split('\n').filter(l => l.trim());
if (lines.length === 0) return text;
const header = lines[0];
const summary = `[جدول CSV: ${file.name}]\nعدد الأعمدة: ${header.split(',').length}\nعدد الصفوف: ${lines.length - 1}\n\n${text.substring(0, 5000)}`;
return summary;
};
const isImage = (f) => f.type.startsWith('image/');
const isAudio = (f) => f.type.startsWith('audio/');
const isVideo = (f) => f.type.startsWith('video/');
const isPDF = (f) => f.type === 'application/pdf';
const isDOCX = (f) => f.type.includes('wordprocessingml') || f.name.endsWith('.docx');
const isCSV = (f) => f.type === 'text/csv' || f.name.endsWith('.csv');
const isText = (f) => f.type.startsWith('text/') || ['.txt','.md','.csv','.json','.xml','.html','.htm'].some(e => f.name.toLowerCase().endsWith(e));
const formatSize = (bytes) => bytes < 1024*1024 ? `${(bytes/1024).toFixed(1)} KB` : `${(bytes/1024/1024).toFixed(1)} MB`;
// ─────────────────────────────────────────────
// ══ PBKDF2 PASSWORD HASHING ══
// ─────────────────────────────────────────────
const hashPassword = async (password, salt) => {
const enc = new TextEncoder();
const saltBytes = salt
? Uint8Array.from(atob(salt), c => c.charCodeAt(0))
: crypto.getRandomValues(new Uint8Array(16));
const km = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits({ name:'PBKDF2', salt:saltBytes, iterations:100000, hash:'SHA-256' }, km, 256);
const hex = Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2,'0')).join('');
const s64 = btoa(String.fromCharCode(...saltBytes));
return { hash: hex, salt: s64 };
};
const verifyPassword = async (password, storedHash, storedSalt) => {
const { hash } = await hashPassword(password, storedSalt);
return hash === storedHash;
};
// ─────────────────────────────────────────────
// ══ DICOM PARSER ══
// ─────────────────────────────────────────────
const isDICOM = (f) => f.name.toLowerCase().endsWith('.dcm') || f.type === 'application/dicom';
const parseDICOM = async (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
if (typeof dicomParser === 'undefined') { resolve({ error: 'dicomParser not loaded', metadata:{} }); return; }
const byteArray = new Uint8Array(e.target.result);
const ds = dicomParser.parseDicom(byteArray);
const str = (tag) => { try { return ds.string(tag) || ''; } catch(_) { return ''; } };
const ui = (tag) => { try { return ds.uint16(tag); } catch(_) { return 0; } };
const meta = {
patientName: str('x00100010'), patientId: str('x00100020'),
studyDate: str('x00080020'), modality: str('x00080060'),
studyDesc: str('x00081030'), seriesDesc:str('x0008103e'),
institutionName: str('x00080080'),
rows: ui('x00280010'), columns: ui('x00280011'),
bitsAllocated: ui('x00280100'),
windowCenter: str('x00281050'), windowWidth: str('x00281051'),
};
resolve({ metadata: meta, error: null });
} catch(err) { resolve({ error: err.message, metadata:{} }); }
};
reader.onerror = () => resolve({ error: 'read error', metadata:{} });
reader.readAsArrayBuffer(file);
});
};
// ─────────────────────────────────────────────
// ══ OCR ENGINE (Tesseract.js) ══
// ─────────────────────────────────────────────
const runOCR = async (imageDataUrl, lang, onProgress) => {
lang = lang || 'ara+eng';
try {
if (typeof Tesseract === 'undefined') return { text: null, error: 'Tesseract not loaded' };
const result = await Tesseract.recognize(imageDataUrl, lang, {
logger: onProgress ? (m) => { if (m.status === 'recognizing text') onProgress(Math.round(m.progress * 100)); } : undefined
});
return { text: result.data.text.trim(), confidence: result.data.confidence };
} catch(e) { return { text: null, error: e.message }; }
};
// ─────────────────────────────────────────────
// ══ PUBMED SEARCH ══
// ─────────────────────────────────────────────
const searchPubMed = async (query, maxResults) => {
maxResults = maxResults || 6;
try {
const sUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed'
+ '&term=' + encodeURIComponent(query) + '&retmax=' + maxResults + '&format=json';
const sRes = await fetch(sUrl);
if (!sRes.ok) return [];
const sData = await sRes.json();
const ids = sData.esearchresult && sData.esearchresult.idlist ? sData.esearchresult.idlist : [];
if (ids.length === 0) return [];
const sumUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed'
+ '&id=' + ids.join(',') + '&format=json';
const sumRes = await fetch(sumUrl);
if (!sumRes.ok) return [];
const sumData = await sumRes.json();
const results = [];
ids.forEach(id => {
const doc = sumData.result && sumData.result[id];
if (doc) {
results.push({
pmid: id, title: doc.title || '',
authors: (doc.authors || []).slice(0,3).map(a => a.name).join(', '),
journal: doc.source || '', year: (doc.pubdate || '').substring(0,4),
url: 'https://pubmed.ncbi.nlm.nih.gov/' + id + '/',
});
}
});
return results;
} catch(_) { return []; }
};
// ─────────────────────────────────────────────
// ══ ICD-10 LOOKUP ══
// ─────────────────────────────────────────────
const lookupICD10 = async (query) => {
try {
const url = 'https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search?sf=code,name'
+ '&terms=' + encodeURIComponent(query) + '&maxList=8';
const res = await fetch(url);
if (!res.ok) return [];
const data = await res.json();
const codes = data[1] || [];
const names = data[3] || [];
return codes.map((code, i) => {
const n = names[i];
return { code, name: Array.isArray(n) ? (n[1] || n[0] || '') : '' };
});
} catch(_) { return []; }
};
// ─────────────────────────────────────────────
// ══ DRUG INTERACTION CHECKER (RxNorm) ══
// ─────────────────────────────────────────────
const checkDrugInteractions = async (drugs) => {
const results = { interactions: [], checked: drugs, error: null };
if (!drugs || drugs.length < 2) return results;
try {
const rxcuis = [];
for (const drug of drugs.slice(0, 6)) {
const r = await fetch('https://rxnav.nlm.nih.gov/REST/rxcui.json?name=' + encodeURIComponent(drug));
if (r.ok) {
const d = await r.json();
const cui = d.idGroup && d.idGroup.rxnormId && d.idGroup.rxnormId[0];
if (cui) rxcuis.push({ drug, cui });
}
}
if (rxcuis.length < 2) { results.error = 'لم يُتعرَّف على بعض الأدوية في RxNorm'; return results; }
for (let i = 0; i < rxcuis.length; i++) {
const ir = await fetch('https://rxnav.nlm.nih.gov/REST/interaction/interaction.json?rxcui=' + rxcuis[i].cui);
if (!ir.ok) continue;
const id = await ir.json();
const tg0 = id.interactionTypeGroup && id.interactionTypeGroup[0];
const it0 = tg0 && tg0.interactionType && tg0.interactionType[0];
const pairs = (it0 && it0.interactionPair) || [];
for (let j = 0; j < rxcuis.length; j++) {
if (j === i) continue;
pairs.forEach(pair => {
const inv = pair.interactionConcept && pair.interactionConcept.some(c =>
(c.minConceptItem && c.minConceptItem.rxcui === rxcuis[j].cui) ||
(c.sourceConceptItem && c.sourceConceptItem.rxcui === rxcuis[j].cui)
);
if (inv) {
results.interactions.push({
drug1: rxcuis[i].drug, drug2: rxcuis[j].drug,
severity: pair.severity || 'غير محدد',
desc: pair.description || '',
});
}
});
}
}
} catch(err) { results.error = err.message; }
return results;
};
// ─────────────────────────────────────────────
// ══ MEDICAL CALCULATORS ══
// ─────────────────────────────────────────────
const MedCalc = {
bmi: (w, h) => {
const hm = h / 100;
const v = w / (hm * hm);
let cat = '', color = '';
if (v < 18.5) { cat = 'نقص وزن'; color = 'warn'; }
else if (v < 25) { cat = 'طبيعي'; color = 'normal'; }
else if (v < 30) { cat = 'زيادة وزن'; color = 'warn'; }
else { cat = 'سمنة'; color = 'danger'; }
return { value: v.toFixed(1), category: cat, color };
},
ckdEpi: (cr, age, female, black) => {
const k = female ? 0.7 : 0.9;
const a = female ? -0.241 : -0.302;
const s = female ? 1.012 : 1.0;
const r = black ? 1.159 : 1.0;
const x = cr / k;
const g = 142 * Math.pow(Math.min(x,1),a) * Math.pow(Math.max(x,1),-1.2) * Math.pow(0.9938,age) * s * r;
let stage = '', color = '';
if (g >= 90) { stage = 'G1 — طبيعي'; color = 'normal'; }
else if (g >= 60) { stage = 'G2 — خفيف'; color = 'normal'; }
else if (g >= 45) { stage = 'G3a — خفيف-متوسط'; color = 'warn'; }
else if (g >= 30) { stage = 'G3b — متوسط-شديد'; color = 'warn'; }
else if (g >= 15) { stage = 'G4 — شديد'; color = 'danger'; }
else { stage = 'G5 — فشل كلوي'; color = 'danger'; }
return { value: Math.round(g), stage, color };
},
cha2ds2vasc: (hf, htn, age, dm, stroke, vasc, female) => {
let s = 0;
if (hf) s += 1; if (htn) s += 1;
if (age >= 75) s += 2; else if (age >= 65) s += 1;
if (dm) s += 1; if (stroke) s += 2;
if (vasc) s += 1; if (female) s += 1;
let risk = '', rec = '', color = '';
if (s === 0) { risk = 'منخفض جداً (0.3%)'; color = 'normal'; rec = 'لا حاجة لمضادات التخثر'; }
else if (s === 1) { risk = 'منخفض (0.9%)'; color = 'warn'; rec = 'فكّر في مضادات التخثر (للذكور)'; }
else if (s === 2) { risk = 'متوسط (2.9%)'; color = 'warn'; rec = 'مضادات التخثر الفموية موصى بها'; }
else { risk = 'مرتفع (' + (s * 1.8).toFixed(1) + '%)'; color = 'danger'; rec = 'مضادات التخثر الفموية ضرورية'; }
return { score: s, risk, recommendation: rec, color };
},
wellsDVT: (cancer, paralysis, bedridden, tenderness, swollen, pitting, collateral, prevdvt, altdx) => {
let s = 0;
if (cancer) s++; if (paralysis) s++; if (bedridden) s++;
if (tenderness) s++; if (swollen) s++; if (pitting) s++;
if (collateral) s++; if (prevdvt) s++; if (altdx) s -= 2;
let prob = '', color = '';
if (s <= 0) { prob = 'منخفض (3%)'; color = 'normal'; }
else if (s <= 2) { prob = 'متوسط (17%)'; color = 'warn'; }
else { prob = 'مرتفع (75%)'; color = 'danger'; }
return { score: s, probability: prob, color };
},
sofa: (resp, coag, liver, cardio, cns, renal) => {
const t = resp + coag + liver + cardio + cns + renal;
let mortality = '', color = '';
if (t <= 1) { mortality = '< 1%'; color = 'normal'; }
else if (t <= 3) { mortality = '< 5%'; color = 'normal'; }
else if (t <= 6) { mortality = '< 10%'; color = 'warn'; }
else if (t <= 9) { mortality = '15-20%'; color = 'warn'; }
else if (t <= 11) { mortality = '40-50%'; color = 'danger'; }
else { mortality = '> 50%'; color = 'danger'; }
return { total: t, mortality, color };
},
news2: (respRate, spo2, suppO2, systolic, pulse, consciousness, temp) => {
// NEWS2: National Early Warning Score 2
let s = 0;
// معدل التنفس
if (respRate <= 8) s += 3;
else if (respRate <= 11) s += 1;
else if (respRate <= 20) s += 0;
else if (respRate <= 24) s += 2;
else s += 3;
// SpO2 (Scale 1)
if (spo2 <= 91) s += 3;
else if (spo2 <= 93) s += 2;
else if (spo2 <= 95) s += 1;
// أكسجين تكميلي
if (suppO2) s += 2;
// الضغط الانقباضي
if (systolic <= 90) s += 3;
else if (systolic <= 100) s += 2;
else if (systolic <= 110) s += 1;
else if (systolic <= 219) s += 0;
else s += 3;
// النبض
if (pulse <= 40) s += 3;
else if (pulse <= 50) s += 1;
else if (pulse <= 90) s += 0;
else if (pulse <= 110) s += 1;
else if (pulse <= 130) s += 2;
else s += 3;
// الوعي
if (consciousness === 'A') s += 0;
else s += 3;
// الحرارة
if (temp <= 35.0) s += 3;
else if (temp <= 36.0) s += 1;
else if (temp <= 38.0) s += 0;
else if (temp <= 39.0) s += 1;
else s += 2;
let risk = '', color = '', action = '';
if (s === 0) { risk = 'خطر منخفض جداً'; color = 'normal'; action = 'مراقبة كل 12 ساعة'; }
else if (s <= 4) { risk = 'خطر منخفض'; color = 'normal'; action = 'مراقبة كل 4-6 ساعات'; }
else if (s <= 6) { risk = 'خطر متوسط'; color = 'warn'; action = 'تقييم فوري — مراقبة مستمرة'; }
else { risk = 'خطر مرتفع 🚨'; color = 'danger'; action = 'استجابة طارئة فورية'; }
return { score: s, risk, action, color };
},
cockcroftGault: (age, weight, creatinine, female) => {
// معادلة Cockcroft-Gault لتصفية الكرياتينين
const sexFactor = female ? 0.85 : 1.0;
const crcl = ((140 - age) * weight * sexFactor) / (72 * creatinine);
let stage = '', color = '';
if (crcl >= 90) { stage = 'طبيعي ≥ 90'; color = 'normal'; }
else if (crcl >= 60) { stage = 'خفيف 60-89'; color = 'normal'; }
else if (crcl >= 30) { stage = 'متوسط 30-59'; color = 'warn'; }
else if (crcl >= 15) { stage = 'شديد 15-29'; color = 'danger'; }
else { stage = 'فشل كلوي < 15'; color = 'danger'; }
return { value: Math.round(crcl), stage, color, unit: 'mL/min' };
},
apacheII: (age, temp, map, hr, rr, fio2, pao2, ph, na, k, cr, hct, wbc, gcs, chronicScore) => {
// APACHE II — نسخة مبسطة للعيادة
let aps = 0;
// العمر
if (age < 44) aps += 0;
else if (age < 54) aps += 2;
else if (age < 64) aps += 3;
else if (age < 74) aps += 5;
else aps += 6;
// الحرارة
const tempAbs = Math.abs(temp - 38.5);
if (tempAbs >= 4) aps += 4;
else if (tempAbs >= 3) aps += 3;
else if (tempAbs >= 2) aps += 2;
else if (tempAbs >= 1) aps += 1;
// الضغط الشرياني الوسطى
if (map >= 160 || map < 50) aps += 4;
else if (map >= 130 || map < 70) aps += 2;
else if (map >= 110) aps += 1;
// معدل القلب
if (hr >= 180 || hr < 40) aps += 4;
else if (hr >= 140 || hr < 55) aps += 3;
else if (hr >= 110 || hr < 70) aps += 2;
// معدل التنفس
if (rr >= 50 || rr < 6) aps += 4;
else if (rr >= 35) aps += 3;
else if (rr >= 25 || rr < 10) aps += 2;
else if (rr < 12) aps += 1;
// GCS
aps += Math.max(0, 15 - (gcs || 15));
// أمراض مزمنة
aps += (chronicScore || 0);
let mortality = '', color = '';
const predDeath = Math.round(100 / (1 + Math.exp(-(- 3.517 + 0.146 * aps))));
if (aps < 5) { mortality = `${predDeath}% (~${predDeath})`; color = 'normal'; }
else if (aps < 15) { mortality = `${predDeath}%`; color = 'normal'; }
else if (aps < 25) { mortality = `${predDeath}%`; color = 'warn'; }
else { mortality = `${predDeath}%`; color = 'danger'; }
return { score: aps, mortality, color };
},
pediatricDose: (weight, drug) => {
const doses = {
'Amoxicillin': { dose: 25, unit: 'mg/kg/dose', freq: 'كل 8 ساعات', max: 500 },
'Paracetamol': { dose: 15, unit: 'mg/kg/dose', freq: 'كل 4-6 ساعات', max: 1000 },
'Ibuprofen': { dose: 10, unit: 'mg/kg/dose', freq: 'كل 6-8 ساعات', max: 400 },
'Azithromycin': { dose: 10, unit: 'mg/kg/dose', freq: 'مرة يومياً', max: 500 },
'Cefuroxime': { dose: 15, unit: 'mg/kg/dose', freq: 'كل 8 ساعات', max: 250 },
'Metronidazole': { dose: 7.5, unit: 'mg/kg/dose', freq: 'كل 8 ساعات', max: 500 },
};
const d = doses[drug];
if (!d || !weight) return null;
const calc = Math.min(parseFloat(weight) * d.dose, d.max);
return { drug, calc: calc.toFixed(1), unit: d.unit, freq: d.freq, max: d.max };
},
};
// ─────────────────────────────────────────────
// ══ RED FLAG DETECTOR ══
// ─────────────────────────────────────────────
const RED_FLAGS = [
{ p: /ألم صدر.{0,30}(تعرق|ضيق تنفس|غثيان|ذراع)/i, s:'critical', msg:'🚨 احتمال احتشاء عضلة القلب — استبعد ACS فوراً' },
{ p: /انخفاض ضغط.{0,20}(سريع|مفاجئ|شديد)/i, s:'critical', msg:'🚨 صدمة محتملة — تقييم ABCDE فوري' },
{ p: /صداع.{0,20}(رعدي|أشد ما عانى)/i, s:'critical', msg:'🚨 نزيف تحت العنكبوتية — CT رأس فوري' },
{ p: /ضعف مفاجئ.{0,30}(وجه|ذراع|ساق)|تعذر كلام/i, s:'critical', msg:'🚨 سكتة دماغية — FAST Protocol، الوقت حرج' },
{ p: /حمى.{0,20}(صلابة رقبة|طفح)/i, s:'critical', msg:'🚨 تهاب سحايا — مضادات حيوية فورية' },
{ p: /فقدان وعي|إغماء.{0,20}مفاجئ/i, s:'high', msg:'⚠️ فقدان وعي — استبعد قصور قلبي وصرع' },
{ p: /ألم بطن.{0,30}(صلب|حجري|حراسة)/i, s:'high', msg:'⚠️ بطن حاد — استشارة جراحية فورية' },
{ p: /حمل.{0,30}(ألم شديد|نزيف|دوخة)/i, s:'critical', msg:'🚨 حمل خارج الرحم محتمل — β-HCG وإيكو فوري' },
{ p: /ضيق تنفس.{0,20}(مفاجئ|شديد)/i, s:'moderate', msg:'⚠️ ضيق تنفس — استبعد PE وAHF' },
];
const detectRedFlags = (text) => {
if (!text || text.trim().length < 5) return [];
return RED_FLAGS.filter(rf => rf.p.test(text));
};
// ─────────────────────────────────────────────
// ══ SOAP NOTE GENERATOR ══
// ─────────────────────────────────────────────
const generateSOAPNote = async (messages) => {
const conv = messages.slice(-20).map(m =>
'[' + (m.role === 'user' ? 'الطبيب' : 'BrainMap') + ']: ' + m.text.substring(0, 400)
).join('\n');
const prompt = 'أنت كاتب تقارير طبية. بناءً على المحادثة، أنشئ تقرير SOAP باللغة العربية.\n'
+ 'صيغة الإخراج JSON فقط بدون أي نص إضافي:\n'
+ '{"subjective":"","objective":"","assessment":"","plan":"","icd10_codes":[],"follow_up":""}\n\n'
+ 'المحادثة:\n' + conv;
const result = await callGroqText([{ role: 'user', text: prompt }], '', null, []);
try {
const bt3 = String.fromCharCode(96,96,96);
const cleaned = result.text.split(bt3 + 'json').join('').split(bt3).join('').trim();
const parsed = JSON.parse(cleaned);
return { ...parsed, raw: result.text };
} catch(_) {
return { subjective:'', objective:'', assessment:'', plan: result.text, raw: result.text };
}
};
// ─────────────────────────────────────────────
// ══ AGENTIC PIPELINE (3 Agents) ══
// Diagnostician → Pharmacist → Critic → Final
// ─────────────────────────────────────────────
const runAgenticPipeline = async (query, ragCtx, citations, msgs) => {
const diagPrompt = 'أنت وكيل تشخيص طبي. حلّل الأعراض وقدّم قائمة التشخيص التفاضلي.\n'
+ 'أجب بـ JSON: {"differentials":[{"dx":"","probability":"عالي/متوسط/منخفض","reasoning":""}],"urgency":"حرج/عاجل/روتيني"}\n'
+ (ragCtx ? '\nالمصادر:\n' + ragCtx.substring(0, 1500) : '');
const diagRes = await callGroqText(
[...msgs.slice(-5), { role:'user', text: query + '\n\n' + diagPrompt }], ragCtx, null, citations
);
const pharmaPrompt = 'أنت وكيل صيدلاني. راجع الأدوية المناسبة وتفاعلاتها.\n'
+ 'أجب بـ JSON: {"medications":[{"drug":"","dose":"","warnings":""}],"contraindications":[]}\n'
+ '\nالتشخيص:\n' + diagRes.text.substring(0, 1000);
const pharmaRes = await callGroqText(
[{ role:'user', text: query + '\n\n' + pharmaPrompt }], ragCtx, null, citations
);
const criticPrompt = 'أنت وكيل ناقد طبي. راجع التشخيص والعلاج وأشر للأخطاء.\n'
+ 'أجب بـ JSON: {"is_safe":true,"concerns":[],"confidence":0}\n'
+ '\nالتشخيص:\n' + diagRes.text.substring(0, 700)
+ '\nالأدوية:\n' + pharmaRes.text.substring(0, 700);
const criticRes = await callGroqText(
[{ role:'user', text: query + '\n\n' + criticPrompt }], ragCtx, null, citations
);
const finalPrompt = 'أنت BrainMap OS. بناءً على تحليل ثلاثة وكلاء، قدّم إجابة طبية شاملة بالعربية مع Markdown.\n'
+ '\nسؤال الطبيب: ' + query
+ '\nالتشخيص: ' + diagRes.text.substring(0, 800)
+ '\nالأدوية: ' + pharmaRes.text.substring(0, 600)
+ '\nالمراجعة: ' + criticRes.text.substring(0, 500)
+ (ragCtx ? '\nالمصادر: ' + ragCtx.substring(0, 1200) : '');
const finalRes = await callGroqText(
[...msgs.slice(-3), { role:'user', text: finalPrompt }], ragCtx, null, citations
);
return {
steps: [
{ agent:'diagnostician', label:'وكيل التشخيص', icon:'🔬', result: diagRes.text },
{ agent:'pharmacist', label:'وكيل الصيدلة', icon:'💊', result: pharmaRes.text },
{ agent:'critic', label:'وكيل المراجعة', icon:'✅', result: criticRes.text },
],
finalText: finalRes.text,
model: finalRes.model,
};
};
// ─────────────────────────────────────────────
// ══ AMBIENT SCRIBE ══
// ─────────────────────────────────────────────
let _ambientRecorder = null;
let _ambientChunks = [];
let _ambientTimer = null;
let _ambientCB = null;
const startAmbientScribe = async (onTranscript) => {
if (_ambientRecorder) return false;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
_ambientCB = onTranscript;
_ambientChunks = [];
_ambientRecorder = new MediaRecorder(stream, { mimeType:'audio/webm' });
_ambientRecorder.ondataavailable = (e) => { if (e.data.size > 0) _ambientChunks.push(e.data); };
_ambientRecorder.start(3000);
_ambientTimer = setInterval(async () => {
if (_ambientChunks.length === 0) return;
const blob = new Blob(_ambientChunks, { type:'audio/webm' });
_ambientChunks = [];
const txt = await callGroqWhisper(blob, '');
if (txt && txt.trim().length > 3 && _ambientCB) _ambientCB(txt.trim());
}, 30000);
return true;
} catch(_) { return false; }
};
const stopAmbientScribe = () => {
if (_ambientTimer) { clearInterval(_ambientTimer); _ambientTimer = null; }
if (_ambientRecorder) {
try { _ambientRecorder.stream && _ambientRecorder.stream.getTracks().forEach(t => t.stop()); } catch(_) {}
try { _ambientRecorder.stop(); } catch(_) {}
_ambientRecorder = null;
}
_ambientChunks = []; _ambientCB = null;
};
// ─────────────────────────────────────────────
// ══ UNIFIED AI ROUTING ENGINE V19 ══
// يدعم جميع المزوّدين: Ollama (محلي)، Groq Cloud، OpenAI،
// Anthropic (Claude)، Google (Gemini)، Mistral
// يحدّد المزوّد تلقائياً من اسم النموذج النشط (ACTIVE_MODEL_KEY)
// ─────────────────────────────────────────────
const callGroqText = async (messages, ragContext, fileContext, citations, onStream, signal) => {
// ── النموذج النشط حالياً ──
const model = ACTIVE_MODEL_KEY;
// ── بناء الـ System Prompt ──
const hasSources = ragContext && ragContext.trim().length > 0;
const citationList = citations && citations.length > 0
? citations.map(c => `[مرجع ${c.id}]: ${c.source}${c.page ? ` — ${c.page}` : ''}`).join('\n')
: '';
const systemPrompt = [
"أنت مساعد طبي استنتاجي متقدم (BrainMap OS V25) يعمل بنظام RAG مغلق (Source-Grounded AI).",
"القواعد الأساسية:",
hasSources
? "- إجاباتك مستندة أولاً وأساساً للسياق المسترجع من المصادر المرفوعة. لا تتجاوزها إلا إذا لم تكن كافية."
: "- لا توجد مصادر مرفوعة. استخدم معرفتك الطبية المبنية على الأدلة.",
"- استخدم Markdown الغني: جداول منظمة، عناوين، قوائم، كود طبي عند الحاجة.",
"- 90% طبي وعلمي عالي الجودة، 10% تفاعل عام.",
"- اربط السياق بالمحادثات السابقة (30 رسالة).",
"- عند الاستشهاد بمعلومة من المصادر المسترجعة، أشر إليها بـ [مرجع X] حيث X رقم المرجع.",
"- عند وجود جداول استخدم الأنواع المناسبة: تفاضلي، دوائي، مخبري، علامات حيوية، خلوي، مقارنة، خطة علاج.",
(ACTIVE_PATIENT_CONTEXT && ACTIVE_PATIENT_CONTEXT.trim()) ? `\n═══ سياق المريض الثابت ═══\n${ACTIVE_PATIENT_CONTEXT.trim()}\n═══ نهاية سياق المريض ═══` : '',
hasSources ? `\n═══ قاعدة المعرفة المسترجعة (RAG) ═══\n${ragContext}\n═══ نهاية المصادر ═══` : '',
citationList ? `\n── فهرس المراجع ──\n${citationList}` : '',
fileContext ? `\n═══ محتوى الملف الحالي: [${fileContext.name}] ═══\n${fileContext.content ? fileContext.content.substring(0, 4000) : ''}\n═══` : ''
].filter(Boolean).join('\n');
// ── رسائل OpenAI-compatible (للمزودين غير Claude) ──
const apiMsgs = [
{ role: "system", content: systemPrompt },
...messages.slice(-30).map(m => ({ role: m.role === 'ai' ? 'assistant' : 'user', content: m.text }))
];
const doStream = typeof onStream === 'function';
// ✅ V25 FIX: Robust streaming parser with proper line-buffer handling.
// Network packets may split a JSON line in the middle. We keep a lineBuf
// that accumulates bytes until a complete '\n'-terminated line arrives.
const parseStreamResponse = async (res, isClaudeFormat = false) => {
const reader = res.body.getReader();
const dec = new TextDecoder();
let full = '';
let lineBuf = ''; // accumulates an incomplete SSE line across chunks
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// decode this chunk and append to incomplete-line buffer
lineBuf += dec.decode(value, { stream: true });
// split only on complete lines (\n); the last element may be partial
const lines = lineBuf.split('\n');
lineBuf = lines.pop(); // keep the incomplete tail
for (const rawLine of lines) {
const t = rawLine.trim();
if (!t || t === 'data: [DONE]') continue;
if (!t.startsWith('data: ')) continue;
try {
const d = JSON.parse(t.slice(6));
let tok = '';
if (isClaudeFormat) {
// Claude SSE: content_block_delta / text_delta
if (d.type === 'content_block_delta' && d.delta?.type === 'text_delta') {
tok = d.delta.text || '';
}
} else {
tok = d.choices?.[0]?.delta?.content || '';
}
if (tok) { full += tok; if (onStream) onStream(full); }
} catch(_) {
// incomplete JSON — will be completed in next network chunk via lineBuf
}
}
}
// flush anything left in the buffer (shouldn't normally be non-empty at stream end)
if (lineBuf.trim().startsWith('data: ')) {
try {
const d = JSON.parse(lineBuf.trim().slice(6));
const tok = isClaudeFormat
? (d.type === 'content_block_delta' ? (d.delta?.text || '') : '')
: (d.choices?.[0]?.delta?.content || '');
if (tok) { full += tok; if (onStream) onStream(full); }
} catch(_) {}
}
} finally {
reader.releaseLock();
}
return full || '…';
};
// ═══════════════════════════════════════════════════════
// ── PROVIDER RESOLVER — V25 ──
// Determines WHERE to send this model's request.
// Priority order:
// 1. Per-model custom key/URL (MODEL_KEY_STORE)
// 2. Provider detected from CLOSED_MODELS lists
// 3. Global OpenRouter key (can run any model)
// 4. Global Together AI key (can run many open models)
// 5. Ollama via ACTIVE_NGROK_URL
// ═══════════════════════════════════════════════════════
const resolveProvider = (modelName) => {
const perKey = getCurrentKeyForModel(modelName);
const perUrl = getCurrentUrlForModel(modelName);
// 1. Per-model custom entry takes highest priority
if (perKey || perUrl) {
return { type: 'custom', key: perKey || '', url: perUrl || '', model: modelName };
}
// 2. Known closed-source provider by model name
if (CLOSED_MODELS.groq.includes(modelName) && API_KEYS.groq) {
return { type: 'openai_compat', key: API_KEYS.groq, url: 'https://api.groq.com/openai/v1/chat/completions', model: modelName };
}
if (CLOSED_MODELS.openai.includes(modelName) && API_KEYS.openai) {
return { type: 'openai_compat', key: API_KEYS.openai, url: 'https://api.openai.com/v1/chat/completions', model: modelName };
}
if (CLOSED_MODELS.claude.includes(modelName) && API_KEYS.anthropic) {
return { type: 'anthropic', key: API_KEYS.anthropic, model: modelName };
}
if (CLOSED_MODELS.google.includes(modelName) && API_KEYS.google) {
return { type: 'openai_compat', key: API_KEYS.google, url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: modelName };
}
if (CLOSED_MODELS.mistral.includes(modelName) && API_KEYS.mistral) {
return { type: 'openai_compat', key: API_KEYS.mistral, url: 'https://api.mistral.ai/v1/chat/completions', model: modelName };
}
// 3. OpenRouter — can run hundreds of models including open-source ones
if (API_KEYS.openrouter && API_KEYS.openrouter.trim()) {
// For known Ollama/open-source model names, map to OpenRouter model IDs
const orModel = mapToOpenRouterModel(modelName);
return { type: 'openai_compat', key: API_KEYS.openrouter, url: 'https://openrouter.ai/api/v1/chat/completions', model: orModel };
}
// 4. Together AI
if (API_KEYS.together && API_KEYS.together.trim()) {
const tgModel = mapToTogetherModel(modelName);
return { type: 'openai_compat', key: API_KEYS.together, url: 'https://api.together.xyz/v1/chat/completions', model: tgModel };
}
// 5. Ollama via ngrok/localhost — last resort
const ollamaUrl = (getCurrentUrlForModel(modelName) || ACTIVE_NGROK_URL || '').trim().replace(/\/$/, '');
if (ollamaUrl) {
return { type: 'ollama', key: '', url: ollamaUrl + '/v1/chat/completions', model: modelName };
}
return null; // Nothing configured
};
// Maps common Ollama model names to OpenRouter equivalents
const mapToOpenRouterModel = (m) => {
const map = {
'gemma4:e4b': 'google/gemma-3-4b-it:free', 'gemma4:9b': 'google/gemma-3-9b-it:free',
'gemma4:27b': 'google/gemma-3-27b-it', 'gemma4:31b': 'google/gemma-4-31b-it',
'gemma3:4b': 'google/gemma-3-4b-it:free', 'gemma3:27b': 'google/gemma-3-27b-it',
'gemma2:9b': 'google/gemma-2-9b-it:free', 'gemma2:27b': 'google/gemma-2-27b-it',
'llama4:scout': 'meta-llama/llama-4-scout-17b-16e-instruct:free',
'llama4:maverick': 'meta-llama/llama-4-maverick-17b-128e-instruct:free',
'llama3.3:70b': 'meta-llama/llama-3.3-70b-instruct:free',
'llama3.2:3b': 'meta-llama/llama-3.2-3b-instruct:free',
'llama3.2:11b': 'meta-llama/llama-3.2-11b-vision-instruct:free',
'llama3.1:8b': 'meta-llama/llama-3.1-8b-instruct:free',
'llama3.1:70b': 'meta-llama/llama-3.1-70b-instruct',
'mistral:7b': 'mistralai/mistral-7b-instruct:free',
'mistral-nemo:12b': 'mistralai/mistral-nemo:free',
'mistral-small:22b': 'mistralai/mistral-small-3.1-24b-instruct:free',
'qwen3:8b': 'qwen/qwen3-8b:free', 'qwen3:14b': 'qwen/qwen3-14b:free',
'qwen3:30b': 'qwen/qwen3-30b-a3b:free', 'qwen3:32b': 'qwen/qwen3-32b:free',
'qwen2.5:7b': 'qwen/qwen-2.5-7b-instruct:free',
'qwen2.5:72b': 'qwen/qwen-2.5-72b-instruct',
'deepseek-r1:8b': 'deepseek/deepseek-r1:free',
'deepseek-r1:14b': 'deepseek/deepseek-r1:free',
'deepseek-r1:70b': 'deepseek/deepseek-r1',
'deepseek-v3': 'deepseek/deepseek-chat-v3-0324:free',
'phi4:14b': 'microsoft/phi-4', 'phi4-mini:3.8b': 'microsoft/phi-4-mini',
'meditron:7b': 'mistralai/mistral-7b-instruct:free',
'biomistral:7b': 'mistralai/mistral-7b-instruct:free',
'med42:8b': 'meta-llama/llama-3.1-8b-instruct:free',
};
// If mapped, return mapped; if already looks like an OR model (has /), return as-is
if (map[m]) return map[m];
if (m.includes('/')) return m;
// Generic fallback: use free Llama
return 'meta-llama/llama-3.1-8b-instruct:free';
};
// Maps Ollama model names to Together AI equivalents
const mapToTogetherModel = (m) => {
const map = {
'llama4:scout': 'meta-llama/Llama-4-Scout-17B-16E-Instruct',
'llama3.3:70b': 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
'llama3.1:8b': 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo',
'llama3.1:70b': 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo',
'qwen2.5:7b': 'Qwen/Qwen2.5-7B-Instruct-Turbo',
'qwen2.5:72b': 'Qwen/Qwen2.5-72B-Instruct-Turbo',
'deepseek-r1:70b': 'deepseek-ai/DeepSeek-R1',
'deepseek-v3': 'deepseek-ai/DeepSeek-V3',
'mistral:7b': 'mistralai/Mistral-7B-Instruct-v0.3',
'gemma2:9b': 'google/gemma-2-9b-it',
};
if (map[m]) return map[m];
if (m.includes('/')) return m;
return 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo';
};
// ════════════════════════════════════════════════════
// ── SINGLE UNIFIED DISPATCHER — V25 ──
// Calls resolveProvider() then dispatches to the right handler.
// All old isGroqCloud/isOpenAI/isClaude/isCustom/isOllama branches replaced.
// ════════════════════════════════════════════════════
const provider = resolveProvider(model);
if (!provider) {
return {
text: `⚠️ لا يوجد مزوّد مُعيَّن للنموذج **${model}**.\n\nالحلول:\n- أضف مفتاح **Groq** أو **Google** أو **OpenAI** في الإعدادات ← 🔑 مفاتيح API\n- أو أضف مفتاح **OpenRouter** (مجاني، يدعم أكثر من 300 نموذج)\n- أو أضف رابط **Ollama/ngrok** في الإعدادات\n\nبعد إضافة المفتاح سيتغيّر النموذج النشط تلقائياً.`,
model: null
};
}
// ── Claude API (Anthropic) — different request format ──
if (provider.type === 'anthropic') {
const rawMsgs = messages.slice(-30).map(m => ({
role: m.role === 'ai' ? 'assistant' : 'user',
content: (m.text || '').trim() || '…'
}));
const claudeMsgs = rawMsgs.reduce((acc, cur) => {
if (acc.length > 0 && acc[acc.length - 1].role === cur.role) {
acc[acc.length - 1] = { ...acc[acc.length - 1], content: acc[acc.length - 1].content + '\n\n' + cur.content };
} else { acc.push({ ...cur }); }
return acc;
}, []);
if (claudeMsgs.length > 0 && claudeMsgs[0].role === 'assistant') claudeMsgs.unshift({ role: 'user', content: 'مرحباً' });
if (claudeMsgs.length > 0 && claudeMsgs[claudeMsgs.length - 1].role === 'assistant') claudeMsgs.push({ role: 'user', content: 'تابع الإجابة.' });
if (claudeMsgs.length === 0) claudeMsgs.push({ role: 'user', content: 'مرحباً' });
try {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': provider.key, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' },
body: JSON.stringify({ model: provider.model, max_tokens: 4096, system: systemPrompt, messages: claudeMsgs, stream: doStream }),
signal: signal || undefined
});
if (!res.ok) {
let e = ''; try { e = await res.text(); } catch(_) {}
return { text: `⚠️ Claude خطأ ${res.status}: ${e.substring(0, 200)}`, model: null };
}
if (doStream && res.body) { const text = await parseStreamResponse(res, true); return { text, model: provider.model }; }
const data = await res.json();
const t = data.content?.[0]?.text;
return t ? { text: t, model: provider.model } : { text: `⚠️ رد غير متوقع من Claude: ${JSON.stringify(data).substring(0,150)}`, model: null };
} catch(err) {
if (err.name === 'AbortError') return { text: '⏹ تم إيقاف الرد', model: null };
return { text: `⚠️ خطأ الاتصال بـ Claude: ${err.message}`, model: null };
}
}
// ── llama.cpp / Ollama — uses OpenAI-compatible endpoint, no auth header ──
if (provider.type === 'ollama') {
try {
// نُضمّن model في الطلب للتوافق — llama.cpp يتجاهله ويستخدم النموذج المحمَّل
const requestBody = { model: provider.model, messages: apiMsgs, temperature: 0.35, max_tokens: 4096, stream: doStream };
const res = await fetch(provider.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': 'true' },
body: JSON.stringify(requestBody),
signal: signal || undefined
});
if (!res.ok) return { text: `⚠️ Ollama خطأ ${res.status} للنموذج ${provider.model}`, model: null };
if (doStream && res.body) { const text = await parseStreamResponse(res, false); return { text, model: provider.model }; }
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
return content ? { text: content, model: provider.model } : { text: `⚠️ رد غير متوقع من Ollama: ${JSON.stringify(data).substring(0,150)}`, model: null };
} catch(err) {
if (err.name === 'AbortError') return { text: '⏹ تم إيقاف الرد', model: null };
return { text: `⚠️ تعذّر الاتصال بـ Ollama (${provider.url})\nتأكد من تشغيل الخادم وصحة الرابط.\n${err.message}`, model: null };
}
}
// ── OpenAI-compatible (Groq, OpenAI, Google, Mistral, OpenRouter, Together, Custom) ──
if (provider.type === 'openai_compat' || provider.type === 'custom') {
const headers = { 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': 'true' };
if (provider.key) headers['Authorization'] = `Bearer ${provider.key}`;
// OpenRouter requires HTTP-Referer for free models
if (provider.url && provider.url.includes('openrouter')) {
headers['HTTP-Referer'] = 'https://brainmap-os.app';
headers['X-Title'] = 'BrainMap OS';
}
try {
const res = await fetch(provider.url, {
method: 'POST',
headers,
body: JSON.stringify({ model: provider.model, messages: apiMsgs, temperature: 0.35, max_tokens: 4096, stream: doStream }),
signal: signal || undefined
});
if (res.status === 401) return { text: `⚠️ مفتاح API خاطئ أو منتهي الصلاحية (401) — تحقق من مفتاح ${provider.url.split('/')[2]}`, model: null };
if (res.status === 429) return { text: `⚠️ تجاوزت الحد المسموح (429) — انتظر قليلاً أو غيّر المفتاح`, model: null };
if (!res.ok) {
let e = ''; try { e = await res.text(); } catch(_) {}
return { text: `⚠️ خطأ HTTP ${res.status}: ${e.substring(0, 250)}`, model: null };
}
if (doStream && res.body) { const text = await parseStreamResponse(res, false); return { text, model: provider.model }; }
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
return content ? { text: content, model: provider.model } : { text: `⚠️ رد غير متوقع: ${JSON.stringify(data).substring(0,150)}`, model: null };
} catch(err) {
if (err.name === 'AbortError') return { text: '⏹ تم إيقاف الرد', model: null };
return { text: `⚠️ خطأ الاتصال (${err.message})`, model: null };
}
}
return { text: `⚠️ نوع مزوّد غير معروف: ${provider?.type}`, model: null };
};
const callGroqVision = async (base64DataUrl, userPrompt, groqKey) => {
const mimeMatch = base64DataUrl.match(/^data:([^;]+);base64,/);
const mimeType = mimeMatch ? mimeMatch[1] : 'image/jpeg';
const base64Data = base64DataUrl.split(',')[1];
const perModelUrl = getCurrentUrlForModel(VISION_MODEL);
const isGroqModel = CLOSED_MODELS.groq.includes(VISION_MODEL);
// ✅ HF Space Fix: إذا لم يوجد رابط مخصص، استخدم نفس الخادم (ACTIVE_NGROK_URL)
const baseUrl = perModelUrl ? perModelUrl.trim().replace(/\/$/, '') :
(isGroqModel ? 'https://api.groq.com/openai' : (ACTIVE_NGROK_URL || window.location.origin).trim().replace(/\/$/, ''));
const resolvedKey = getCurrentKeyForModel(VISION_MODEL) || groqKey || API_KEYS.groq || '';
const headers = {
"Content-Type": "application/json",
"ngrok-skip-browser-warning": "true"
};
if (resolvedKey && resolvedKey !== 'ollama') {
headers["Authorization"] = `Bearer ${resolvedKey}`;
}
try {
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
// llama.cpp يتجاهل model ويستخدم النموذج المحمَّل
messages: [{ role: "user", content: [
{ type: "text", text: userPrompt || "حلّل هذه الصورة الطبية بالتفصيل وقدّم تحليلاً سريرياً وعلمياً شاملاً." },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Data}` } }
]}],
max_tokens: 2048,
stream: false
})
});
if (res.ok) {
const data = await res.json();
return data.choices?.[0]?.message?.content || null;
}
console.warn('callGroqVision HTTP error:', res.status);
} catch(err) {
console.warn('callGroqVision fetch error:', err.message);
}
return null;
};
const callGroqWhisper = async (audioBlob, key) => {
const formData = new FormData();
const ext = audioBlob.type.includes('mp4') ? 'mp4'
: audioBlob.type.includes('ogg') ? 'ogg'
: 'webm';
formData.append('file', audioBlob, `audio.${ext}`);
formData.append('model', WHISPER_MODEL);
formData.append('language', 'ar');
const perModelUrl = getCurrentUrlForModel(WHISPER_MODEL);
const isGroqModel = WHISPER_MODEL.includes('whisper'); // افتراض أن نماذج whisper غالباً سحابية عبر Groq أو OpenAI
const baseUrl = perModelUrl ? perModelUrl.trim().replace(/\/$/, '') :
(isGroqModel ? 'https://api.groq.com/openai' : (ACTIVE_NGROK_URL || '').trim().replace(/\/$/, ''));
const resolvedKey = getCurrentKeyForModel(WHISPER_MODEL) || key || API_KEYS.groq || '';
const headers = { "ngrok-skip-browser-warning": "true" };
if (resolvedKey && resolvedKey !== 'ollama') {
headers["Authorization"] = `Bearer ${resolvedKey}`;
}
try {
const res = await fetch(`${baseUrl}/v1/audio/transcriptions`, {
method: "POST",
headers,
body: formData
});
if (res.ok) { const data = await res.json(); return data.text; }
let errMsg = '';
try { const errData = await res.json(); errMsg = errData?.error?.message || `HTTP ${res.status}`; } catch(_) { errMsg = `HTTP ${res.status}`; }
console.warn('Whisper API error:', errMsg);
} catch(err) {
console.warn('Whisper fetch error:', err.message);
}
return null;
};
// ─────────────────────────────────────────────
// WEB SEARCH (DuckDuckGo via CORS proxy)
// ─────────────────────────────────────────────
const searchWeb = async (query) => {
try {
// ✅ إصلاح: DuckDuckGo لا يدعم CORS مباشرةً من المتصفح — نستخدم بروكسي
const ddgEndpoint = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`;
const proxyUrl = `https://corsproxy.io/?url=${encodeURIComponent(ddgEndpoint)}`;
const res = await fetch(proxyUrl);
const data = await res.json();
const results = [];
if (data.Abstract) results.push({ title: data.Heading, snippet: data.Abstract, url: data.AbstractURL });
if (data.RelatedTopics) {
data.RelatedTopics.slice(0, 5).forEach(t => {
if (t.Text) results.push({ title: t.Text.substring(0, 60), snippet: t.Text, url: t.FirstURL });
});
}
return results;
} catch(_) { return []; }
};
// ─────────────────────────────────────────────
// EXPORT CHAT
// ─────────────────────────────────────────────
const exportChat = (msgs, title) => {
const text = msgs.map(m => `[${m.role === 'user' ? 'أنا' : 'BrainMap'}]\n${m.text}\n`).join('\n─────\n');
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${title || 'chat'}.txt`; a.click();
URL.revokeObjectURL(url);
};
// ─────────────────────────────────────────────
// ══ TOAST SYSTEM ══
// ─────────────────────────────────────────────
const ToastCtx = React.createContext(null);
const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([]);
const addToast = useCallback((msg, type = 'info', dur = 3000) => {
const id = Date.now();
setToasts(p => [...p, { id, msg, type, exiting: false }]);
setTimeout(() => setToasts(p => p.map(t => t.id === id ? { ...t, exiting: true } : t)), dur - 350);
setTimeout(() => setToasts(p => p.filter(t => t.id !== id)), dur);
}, []);
const typeColors = { success: '#10b981', error: '#ef4444', info: '#3b82f6', warning: '#f59e0b' };
const typeIcons = { success: '✅', error: '❌', info: 'ℹ️', warning: '⚠️' };
return (
<ToastCtx.Provider value={addToast}>
{children}
<div className="toast-wrap">
{toasts.map(t => (
<div key={t.id}
className={`w-full px-4 py-3 rounded-xl text-white font-bold text-xs flex items-center gap-2 shadow-2xl ${t.exiting ? 'toast-exit' : 'toast-enter'}`}
style={{ background: typeColors[t.type], pointerEvents: 'auto' }}>
<span>{typeIcons[t.type]}</span>
<span className="flex-1">{t.msg}</span>
</div>
))}
</div>
</ToastCtx.Provider>
);
};
const useToast = () => React.useContext(ToastCtx);
// ═══════════════════════════════════════════════
// ══ MAIN APP ══
// ═══════════════════════════════════════════════
const App = () => {
const toast = useToast();
// ── AUTH ──
const [view, setView] = useState('login');
const [accounts, setAccounts] = useState([]);
const [currentUser, setCurrentUser] = useState(null);
const [regForm, setRegForm] = useState({ name: '', email: '', password: '' });
// ── CHAT ──
const [sessions, setSessions] = useState([]);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
// ── FILES ──
const [pendingFile, setPendingFile] = useState(null);
const [globalContext, setGlobalContext] = useState('');
const [uploadedFiles, setUploadedFiles] = useState([]);
// ── RAG STATE ──
const [allChunks, setAllChunks] = useState([]);
const [ragEnabled, setRagEnabled] = useState(true);
const [sourceToggles, setSourceToggles] = useState({});
const [citationsMap, setCitationsMap] = useState({});
const [lastQueryIntent, setLastQueryIntent] = useState(null);
const [isChunking, setIsChunking] = useState(false);
const [ragStats, setRagStats] = useState({ chunks: 0, sources: 0 });
// ── NOTES ──
const [notes, setNotes] = useState([]);
const [noteInput, setNoteInput] = useState({ title: '', content: '' });
const [activeNote, setActiveNote] = useState(null);
const [isCreatingNote, setIsCreatingNote] = useState(false);
const [isAiNoting, setIsAiNoting] = useState(false);
const [noteMode, setNoteMode] = useState('list');
// ── FAVORITES ──
const [favorites, setFavorites] = useState([]);
// ── AUDIO OVERVIEW ──
const [audioOverviewPlaying, setAudioOverviewPlaying] = useState(false);
const [audioOverviewScript, setAudioOverviewScript] = useState('');
// ── INLINE IMAGE SEARCH ──
const [inlineImages, setInlineImages] = useState({});
const [bgSearchStatus, setBgSearchStatus] = useState({});
const [lightboxImg, setLightboxImg] = useState(null);
// ── PANELS ──
const [panels, setPanels] = useState({
plusMenu: false, settings: false, sidebar: false, accounts: false,
notebooklm: false, search: false, notes: false, sources: false, favorites: false,
medtools: false, drugcheck: false, pubmed: false, calculator: false, soap: false, icd10: false
});
// ── MEDICAL TOOLS STATE ──
const [redFlags, setRedFlags] = useState([]);
const [agentSteps, setAgentSteps] = useState([]);
const [isAgentRunning, setIsAgentRunning] = useState(false);
const [agentEnabled, setAgentEnabled] = useState(false);
const [soapNote, setSoapNote] = useState(null);
const [isGeneratingSOAP, setIsGeneratingSOAP] = useState(false);
const [drugInput, setDrugInput] = useState('');
const [drugList, setDrugList] = useState([]);
const [drugResults, setDrugResults] = useState(null);
const [isCheckingDrugs, setIsCheckingDrugs] = useState(false);
const [pubmedQuery, setPubmedQuery] = useState('');
const [pubmedResults, setPubmedResults] = useState([]);
const [isSearchingPubmed,setIsSearchingPubmed]= useState(false);
const [icdQuery, setIcdQuery] = useState('');
const [icdResults, setIcdResults] = useState([]);
const [isSearchingICD, setIsSearchingICD] = useState(false);
const [calcMode, setCalcMode] = useState('bmi');
const [calcInputs, setCalcInputs] = useState({});
const [calcResult, setCalcResult] = useState(null);
const [ambientActive, setAmbientActive] = useState(false);
const [ambientTranscript,setAmbientTranscript]= useState('');
// ── VOICE / TTS ──
const [voiceMode, setVoiceMode] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const [activeModel, setActiveModel] = useState(() => {
try { return localStorage.getItem('brainmap_active_model') || "gemma-4-E2B-it-Q8_0"; }
catch(_) { return "gemma-4-E2B-it-Q8_0"; }
});
const [systemVoices, setSystemVoices] = useState([]);
const [selectedVoice, setSelectedVoice] = useState(EDGE_TTS_VOICES.arabic[0]);
const [voiceRate, setVoiceRate] = useState(1);
const [voicePitch, setVoicePitch] = useState(1);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsPlaying, setTtsPlaying] = useState(false);
const [voiceTabActive, setVoiceTabActive] = useState('arabic');
// ── THEME ──
const [theme, setTheme] = useState(THEMES[0]);
// ── DEV ──
const [devData, setDevData] = useState({ htmlModules: [], socialLinks: [] });
const [devInput, setDevInput] = useState({ html: '', link: '' });
// ── NEW V18 STATE ──
const [showModelBar, setShowModelBar] = useState(false);
const [streamingText, setStreamingText] = useState('');
const [streamingId, setStreamingId] = useState(null);
// ── V23 NEW STATE ──
const [patientContext, setPatientContext] = useState(() => { try { return localStorage.getItem('bm_patient_ctx') || ''; } catch(_) { return ''; } });
const [showPatientCtx, setShowPatientCtx] = useState(false);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [charCount, setCharCount] = useState(0);
const [exportFormat, setExportFormat] = useState('txt');
const [showClearConfirm, setShowClearConfirm] = useState(false);
// ── حالة UI لإدارة مفاتيح/روابط النماذج ──
const [mkStoreTick, setMkStoreTick] = useState(0); // لإجبار React على إعادة الرسم عند تغيير MODEL_KEY_STORE
const [mkNewKey, setMkNewKey] = useState(''); // حقل إدخال مفتاح جديد
const [mkNewUrl, setMkNewUrl] = useState(''); // حقل إدخال رابط جديد
const [mkSelectedModel, setMkSelectedModel] = useState(''); // النموذج المختار في لوحة المفاتيح
const [mkSearchQuery, setMkSearchQuery] = useState(''); // بحث في قائمة النماذج
const [isAutoModel, setIsAutoModel] = useState(false); // وضع الاختيار التلقائي للنموذج
const [autoCheckStatus, setAutoCheckStatus] = useState(''); // حالة فحص النماذج التلقائي
const [savedUrls, setSavedUrls] = useState(() => {
try { const s = localStorage.getItem('bm_saved_urls'); return s ? JSON.parse(s) : []; }
catch(_) { return []; }
});
// ── NGROK / OLLAMA URL STATE ──
const [ngrokUrlInput, setNgrokUrlInput] = useState(() => {
try { return localStorage.getItem('brainmap_ngrok_url') || window.location.origin; }
catch(_) { return window.location.origin; }
});
const [ngrokTestStatus, setNgrokTestStatus] = useState('idle'); // 'idle' | 'testing' | 'ok' | 'fail'
// ── API KEYS STATE (V19) ──
// قيم محفوظة في localStorage — تُزامَن مع المتغيّر العالمي API_KEYS
const [apiKeys, setApiKeys] = useState({
groq: (() => { try { return localStorage.getItem('brainmap_key_groq') || ''; } catch(_) { return ''; } })(),
openai: (() => { try { return localStorage.getItem('brainmap_key_openai') || ''; } catch(_) { return ''; } })(),
anthropic: (() => { try { return localStorage.getItem('brainmap_key_anthropic') || ''; } catch(_) { return ''; } })(),
google: (() => { try { return localStorage.getItem('brainmap_key_google') || ''; } catch(_) { return ''; } })(),
mistral: (() => { try { return localStorage.getItem('brainmap_key_mistral') || ''; } catch(_) { return ''; } })(),
openrouter: (() => { try { return localStorage.getItem('brainmap_key_openrouter') || ''; } catch(_) { return ''; } })(),
together: (() => { try { return localStorage.getItem('brainmap_key_together') || ''; } catch(_) { return ''; } })(),
});
// حقول الإدخال المؤقتة لمفاتيح API (قبل الحفظ)
const [apiKeysInput, setApiKeysInput] = useState({
groq: (() => { try { return localStorage.getItem('brainmap_key_groq') || ''; } catch(_) { return ''; } })(),
openai: (() => { try { return localStorage.getItem('brainmap_key_openai') || ''; } catch(_) { return ''; } })(),
anthropic: (() => { try { return localStorage.getItem('brainmap_key_anthropic') || ''; } catch(_) { return ''; } })(),
google: (() => { try { return localStorage.getItem('brainmap_key_google') || ''; } catch(_) { return ''; } })(),
mistral: (() => { try { return localStorage.getItem('brainmap_key_mistral') || ''; } catch(_) { return ''; } })(),
openrouter: (() => { try { return localStorage.getItem('brainmap_key_openrouter') || ''; } catch(_) { return ''; } })(),
together: (() => { try { return localStorage.getItem('brainmap_key_together') || ''; } catch(_) { return ''; } })(),
});
// حالة إظهار/إخفاء مفاتيح API
const [showApiKey, setShowApiKey] = useState({
groq: false, openai: false, anthropic: false, google: false, mistral: false, openrouter: false, together: false
});
const saveNgrokUrl = (url) => {
const clean = url.trim().replace(/\/$/, '');
if (!clean) return;
ACTIVE_NGROK_URL = clean;
try { localStorage.setItem('brainmap_ngrok_url', clean); } catch(_) {}
setNgrokUrlInput(clean);
// احفظ في قائمة الروابط
setSavedUrls(prev => {
if (prev.find(u => u.url === clean)) return prev;
const next = [{ url: clean, ts: Date.now() }, ...prev].slice(0, 10);
try { localStorage.setItem('bm_saved_urls', JSON.stringify(next)); } catch(_) {}
return next;
});
toast('تم حفظ الرابط ✅', 'success');
};
// ── حفظ مفتاح API لمزوّد محدد ──
const saveApiKey = (vendor, key) => {
const clean = key.trim();
try { localStorage.setItem(`brainmap_key_${vendor}`, clean); } catch(_) {}
setApiKeys(prev => ({ ...prev, [vendor]: clean }));
setApiKeysInput(prev => ({ ...prev, [vendor]: clean }));
API_KEYS[vendor] = clean;
if (clean) {
// ✅ FIX: Auto-switch to appropriate model for this vendor
// so the key actually works immediately without manual model selection
const vendorModelMap = {
groq: CLOSED_MODELS.groq[0], // llama-3.3-70b-versatile
openai: CLOSED_MODELS.openai[0], // gpt-4o-mini
anthropic: CLOSED_MODELS.claude[0], // claude-sonnet-4-6
google: CLOSED_MODELS.google[0], // gemini-2.5-flash
mistral: CLOSED_MODELS.mistral[0], // mistral-large-latest
openrouter: 'meta-llama/llama-4-scout-17b-16e-instruct',
together: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
};
const suggestedModel = vendorModelMap[vendor];
if (suggestedModel) {
setActiveModel(suggestedModel);
ACTIVE_MODEL_KEY = suggestedModel;
try { localStorage.setItem('brainmap_active_model', suggestedModel); } catch(_) {}
toast(`✅ مفتاح ${vendor} محفوظ — النموذج النشط: ${suggestedModel.split('/').pop()}`, 'success', 3500);
} else {
toast(`تم حفظ مفتاح ${vendor} ✅`, 'success');
}
} else {
toast(`تم مسح مفتاح ${vendor}`, 'info', 1500);
}
};
// ── مزامنة activeModel مع ACTIVE_MODEL_KEY العالمي ──
useEffect(() => {
ACTIVE_MODEL_KEY = activeModel;
try { localStorage.setItem('brainmap_active_model', activeModel); } catch(_) {}
}, [activeModel]);
// ── مزامنة apiKeys مع API_KEYS العالمي عند التغيير ──
useEffect(() => {
API_KEYS.groq = apiKeys.groq;
API_KEYS.openai = apiKeys.openai;
API_KEYS.anthropic = apiKeys.anthropic;
API_KEYS.google = apiKeys.google;
API_KEYS.mistral = apiKeys.mistral;
API_KEYS.openrouter = apiKeys.openrouter || '';
API_KEYS.together = apiKeys.together || '';
}, [apiKeys]);
const activateSavedUrl = (url) => {
ACTIVE_NGROK_URL = url;
try { localStorage.setItem('brainmap_ngrok_url', url); } catch(_) {}
setNgrokUrlInput(url);
toast('تم تفعيل الرابط', 'success', 1500);
};
const deleteSavedUrl = (url) => {
setSavedUrls(prev => {
const next = prev.filter(u => u.url !== url);
try { localStorage.setItem('bm_saved_urls', JSON.stringify(next)); } catch(_) {}
return next;
});
};
const testNgrokConnection = async () => {
const url = ngrokUrlInput.trim().replace(/\/$/, '');
if (!url) { toast('أدخل الرابط أولاً', 'warning'); return; }
setNgrokTestStatus('testing');
try {
// ✅ llama.cpp server يستخدم /v1/models لا /api/tags (خاص بـ Ollama)
const res = await fetch(url + '/v1/models', {
headers: { 'ngrok-skip-browser-warning': 'true' },
signal: AbortSignal.timeout(6000)
});
if (res.ok) {
const data = await res.json();
const detectedModel = data.data?.[0]?.id || '';
if (detectedModel) {
setActiveModel(detectedModel);
ACTIVE_MODEL_KEY = detectedModel;
try { localStorage.setItem('brainmap_active_model', detectedModel); } catch(_) {}
setNgrokTestStatus('ok');
toast(`✅ اتصال ناجح — النموذج: ${detectedModel}`, 'success', 3000);
} else {
setNgrokTestStatus('ok');
toast('الاتصال ناجح ✅', 'success');
}
} else { setNgrokTestStatus('fail'); toast('الخادم أجاب لكن بخطأ: ' + res.status, 'error'); }
} catch(e) {
setNgrokTestStatus('fail');
toast('فشل الاتصال ❌ تأكد من تشغيل الخادم وصحة الرابط', 'error');
}
};
// ── SEARCH PANEL ──
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
// ── NOTEBOOK LM ──
const [nbMode, setNbMode] = useState('');
const [nbResult, setNbResult] = useState('');
const [isNbProcessing, setIsNbProcessing] = useState(false);
// ── REFS ──
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const scrollRef = useRef(null);
const chatRef = useRef(null);
const recognitionRef = useRef(null);
const fileInputRef = useRef(null);
const inputRef = useRef(null);
// ✅ إصلاح الإرسال المزدوج: ref يُتابع حالة الإرسال أثناء البث
const isSendingRef = useRef(false);
// ✅ V25: AbortController لإيقاف البث في أي وقت
const abortControllerRef = useRef(null);
const T = theme;
// ── INIT ──
useEffect(() => {
allocateArchive();
loadAccounts();
loadInjectedHTML();
loadFavorites();
// ✅ HF Space: اكتشاف نموذج llama.cpp تلقائياً عند بدء التشغيل
const autoDetectServer = async () => {
try {
const origin = window.location.origin;
const res = await fetch(origin + '/v1/models', {
signal: AbortSignal.timeout(5000)
});
if (res.ok) {
const data = await res.json();
const modelId = data.data?.[0]?.id;
// حدّث URL الخادم دائماً إلى origin الحالي
ACTIVE_NGROK_URL = origin;
setNgrokUrlInput(origin);
try { localStorage.setItem('brainmap_ngrok_url', origin); } catch(_) {}
// حدّث اسم النموذج إن اكتُشف ولم يختر المستخدم نموذجاً يدوياً
if (modelId && !localStorage.getItem('brainmap_active_model')) {
setActiveModel(modelId);
ACTIVE_MODEL_KEY = modelId;
try { localStorage.setItem('brainmap_active_model', modelId); } catch(_) {}
}
}
} catch(_) {
// الخادم المحلي غير متاح (ربما يعمل خارج HF Space)
}
};
autoDetectServer();
// تحميل الأصوات — مع دعم Chrome الذي يحتاج event
const tryLoadVoices = () => {
const v = window.speechSynthesis?.getVoices() || [];
if (v.length > 0) { setSystemVoices(v); return true; }
return false;
};
let iv = null;
if (!tryLoadVoices()) {
if (window.speechSynthesis) {
window.speechSynthesis.addEventListener('voiceschanged', tryLoadVoices);
}
iv = setInterval(() => { if (tryLoadVoices()) clearInterval(iv); }, 300);
setTimeout(() => clearInterval(iv), 6000);
}
// ✅ إصلاح: تنظيف عند إلغاء تحميل المكوّن (unmount)
return () => {
if (iv) clearInterval(iv);
if (window.speechSynthesis) {
window.speechSynthesis.removeEventListener('voiceschanged', tryLoadVoices);
window.speechSynthesis.cancel();
}
};
}, []);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollIntoView({ behavior: 'smooth' });
}, [messages, isTyping]);
// ── V23: Scroll-to-bottom detector ──
useEffect(() => {
const chatEl = chatRef.current;
if (!chatEl) return;
const onScroll = () => {
const distFromBottom = chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight;
setShowScrollBottom(distFromBottom > 220);
};
chatEl.addEventListener('scroll', onScroll, { passive: true });
return () => chatEl.removeEventListener('scroll', onScroll);
}, []);
// ── V23: Save patient context to localStorage on change and sync to global ──
useEffect(() => {
try { localStorage.setItem('bm_patient_ctx', patientContext); } catch(_) {}
ACTIVE_PATIENT_CONTEXT = patientContext;
}, [patientContext]);
useEffect(() => {
if (currentUser) {
loadSessions(currentUser.id);
loadGlobalContext(currentUser.id);
loadAllChunks(currentUser.id);
loadNotes(currentUser.id);
}
}, [currentUser]);
useEffect(() => {
if (currentSessionId !== null) loadMessages(currentSessionId);
}, [currentSessionId]);
useEffect(() => {
const uniqueSources = new Set(allChunks.map(c => c.fileId));
setRagStats({ chunks: allChunks.length, sources: uniqueSources.size });
}, [allChunks]);
// ── LOADERS ──
const loadAccounts = async () => setAccounts(await db.users.toArray());
const loadFavorites = async () => setFavorites(await db.favorites.toArray());
const loadSessions = async (uid) => {
const list = await db.sessions.where({ userId: String(uid) }).reverse().sortBy('timestamp');
setSessions(list);
if (list.length > 0) {
// ✅ FIX: Load the most recent session, but clear messages first to avoid bleed-over
setMessages([]);
setCurrentSessionId(list[0].id);
} else {
// New user — create a fresh session with empty messages
setMessages([]);
await createNewSession(uid);
}
};
const loadMessages = async (sid) => {
if (!sid) { setMessages([]); return; }
// ✅ FIX: Always clear messages before loading to prevent bleed between conversations
setMessages([]);
const msgs = await db.messages.where({ sessionId: sid }).sortBy('timestamp');
setMessages(msgs);
};
const loadGlobalContext = async (uid) => {
const files = await db.files.where({ userId: String(uid) }).toArray();
setUploadedFiles(files);
const ctx = files.filter(f => f.content).map(f => `[ملف: ${f.name}]\n${f.content}`).join('\n\n---\n\n');
setGlobalContext(ctx);
};
const loadAllChunks = async (uid) => {
const chunks = await db.chunks.where({ userId: String(uid) }).toArray();
setAllChunks(chunks);
};
const loadInjectedHTML = async () => {
const modules = await db.injected_html.toArray();
setDevData(p => ({ ...p, htmlModules: modules }));
};
const loadNotes = async (uid) => {
const notesList = await db.notes.where({ userId: String(uid) }).reverse().sortBy('timestamp');
setNotes(notesList);
};
// ── RAG CHUNK STORE ──
const storeChunksForFile = async (fileId, userId, text, sourceName) => {
setIsChunking(true);
try {
await db.chunks.where({ fileId: String(fileId) }).delete();
const textChunks = chunkText(text, sourceName);
if (textChunks.length === 0) return;
const records = textChunks.map(c => ({
fileId: String(fileId), userId: String(userId),
text: c.text, chunkIndex: c.chunkIndex,
metadata: c.metadata, timestamp: Date.now()
}));
await db.chunks.bulkAdd(records);
await loadAllChunks(userId);
} finally { setIsChunking(false); }
};
const deleteChunksForFile = async (fileId, userId) => {
await db.chunks.where({ fileId: String(fileId) }).delete();
await loadAllChunks(userId || currentUser?.id || 'dev');
};
// ── NOTES ──
const saveNote = async (title, content, sourceRefs = [], isAiGenerated = false) => {
const uid = String(currentUser?.id || 'dev');
const id = await db.notes.add({
userId: uid, sessionId: currentSessionId,
title: title || `ملاحظة ${new Date().toLocaleString('ar-EG')}`,
content, sourceRefs, isAiGenerated, timestamp: Date.now()
});
await loadNotes(uid);
return id;
};
const deleteNote = async (id) => {
await db.notes.delete(id);
await loadNotes(currentUser?.id || 'dev');
};
const aiSynthesizeNote = async () => {
setIsAiNoting(true);
try {
const sourceText = uploadedFiles.filter(f => f.content).map(f => f.content).join('\n\n').substring(0, 5000)
|| messages.slice(-10).map(m => m.text).join('\n').substring(0, 4000);
if (!sourceText.trim()) {
toast('يرجى رفع مصادر أو وجود محادثة لتوليد ملاحظة.', 'warning');
return;
}
const prompt = `استناداً للمحتوى التالي فقط، أنشئ ملاحظة طبية موجزة ومنظمة. ابدأ بعنوان مقتضب ثم اكتب المحتوى:\n\n**العنوان المقترح:**\n\n**المحتوى:**\n\n${sourceText}`;
const result = await callGroqText([{ role: 'user', text: prompt }], '', null, []);
const lines = result.text.split('\n');
const titleLine = lines.find(l => l.includes('العنوان') || l.startsWith('#'));
const derivedTitle = titleLine ? titleLine.replace(/[#*\[\]]/g, '').replace('العنوان المقترح:', '').trim() : 'ملاحظة ذكاء اصطناعي';
setNoteInput({ title: derivedTitle, content: result.text });
setIsCreatingNote(true); setNoteMode('create');
toast('تم توليد الملاحظة 🤖', 'success');
} catch(err) {
toast('خطأ في توليد الملاحظة: ' + err.message, 'error');
} finally {
setIsAiNoting(false);
}
};
// ── FAVORITES ──
const toggleFavorite = async (msg) => {
const existing = favorites.find(f => f.msgId === msg.id);
if (existing) {
await db.favorites.delete(existing.id);
toast('تم إزالة من المفضلة', 'info', 1500);
} else {
await db.favorites.add({ msgId: msg.id, text: msg.text, timestamp: Date.now() });
toast('تم الحفظ في المفضلة ⭐', 'success', 1500);
}
await loadFavorites();
};
const isFavorite = (msgId) => favorites.some(f => f.msgId === msgId);
const copyMessage = async (text) => {
try { await navigator.clipboard.writeText(text); toast('تم النسخ ✅', 'success', 1500); }
catch(_) { toast('تعذّر النسخ', 'error', 1500); }
};
// ── TTS HELPERS ──
const handleSpeak = useCallback((text) => {
// ✅ إصلاح: لا نضبط ttsPlaying=true هنا — ندعه لـ onStart callback
// لأن الضبط المبكر يُبقيه true إذا فشل TTS بصمت
speakWithEdgeTTS(
text, selectedVoice.name, selectedVoice.lang, voiceRate, voicePitch,
() => setTtsPlaying(true),
() => setTtsPlaying(false)
);
}, [selectedVoice, voiceRate, voicePitch]);
const stopSpeaking = () => {
window.speechSynthesis?.cancel();
setTtsPlaying(false);
};
// ✅ V25: Stop ongoing AI stream immediately
const stopStreaming = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
isSendingRef.current = false;
setIsTyping(false);
setStreamingId(null);
toast('تم إيقاف الرد ⏹', 'info', 1500);
};
// ── AUTH ──
const handleRegister = async () => {
if (!regForm.name.trim()) return toast('الاسم مطلوب', 'warning');
if (!regForm.email.endsWith(EDU_DOMAIN)) return toast(`البريد يجب أن ينتهي بـ ${EDU_DOMAIN}`, 'warning');
if (regForm.password.length !== 9) return toast('كلمة المرور 9 رموز بالضبط', 'warning');
// ✅ إصلاح: منع تسجيل بريد إلكتروني مكرر
const existingEmail = await db.users.where({ email: regForm.email }).first();
if (existingEmail) return toast('هذا البريد مسجّل مسبقاً', 'warning');
if (await db.users.count() >= 3) return toast('الحد الأقصى 3 حسابات', 'error');
const u = { name: regForm.name, email: regForm.email, password: regForm.password, type: 'user' };
u.id = await db.users.add(u);
setAccounts(p => [...p, u]);
loginUser(u);
};
const loginUser = (u) => {
setCurrentUser(u);
setPanels(p => ({ ...p, accounts: false }));
setView('app');
toast(`أهلاً ${u.name} 👋`, 'success', 2000);
};
const handleDevLogin = () => {
if (regForm.password === DEV_CODE) {
loginUser({ id: 'dev', name: 'Dr: Ibrahim Taha', email: 'root@brainmap.sys', type: 'dev' });
} else toast('رمز المطور غير صحيح', 'error');
};
const handleLogout = () => {
stopSpeaking();
// ✅ إصلاح: إعادة ضبط حارس الإرسال عند تسجيل الخروج
isSendingRef.current = false;
// V23: reset new state
setShowClearConfirm(false);
setShowPatientCtx(false);
setShowScrollBottom(false);
setCharCount(0);
setCurrentUser(null); setCurrentSessionId(null); setMessages([]);
setPendingFile(null); setGlobalContext(''); setUploadedFiles([]);
setAllChunks([]); setNotes([]); setCitationsMap({}); setLastQueryIntent(null);
setInlineImages({}); setBgSearchStatus({});
setIsTyping(false); setStreamingId(null);
setView('login');
setPanels({ plusMenu:false, settings:false, sidebar:false, accounts:false, notebooklm:false, search:false, notes:false, sources:false, favorites:false });
};
// ── SESSIONS ──
const createNewSession = async (uid) => {
// ✅ FIX: Clear messages immediately to prevent old conversation showing during transition
setMessages([]);
setCitationsMap({});
setInlineImages({});
setRedFlags([]);
const id = await db.sessions.add({
userId: String(uid || currentUser?.id || 'dev'),
title: `محادثة ${new Date().toLocaleString('ar-EG')}`,
timestamp: Date.now()
});
setCurrentSessionId(id);
const list = await db.sessions.where({ userId: String(uid || currentUser?.id || 'dev') }).reverse().sortBy('timestamp');
setSessions(list);
setPanels(p => ({ ...p, sidebar: false }));
toast('محادثة جديدة ✨', 'info', 1500);
};
const deleteSession = async (sid) => {
await db.messages.where({ sessionId: sid }).delete();
await db.sessions.delete(sid);
const list = await db.sessions.where({ userId: String(currentUser?.id || 'dev') }).reverse().sortBy('timestamp');
setSessions(list);
if (currentSessionId === sid) {
if (list.length > 0) setCurrentSessionId(list[0].id);
else await createNewSession(currentUser?.id || 'dev');
}
toast('تم حذف المحادثة', 'info', 1500);
};
// ── V23: Clear messages of current session (keep session) ──
const clearCurrentSession = async () => {
if (!currentSessionId) return;
await db.messages.where({ sessionId: currentSessionId }).delete();
setMessages([]);
setShowClearConfirm(false);
toast('تم مسح رسائل المحادثة 🗑️', 'info', 2000);
};
// ── V23: Export chat as Markdown ──
const exportChatMarkdown = (msgs, title) => {
const lines = [`# ${title || 'محادثة BrainMap OS V25'}`, `> تصدير: ${new Date().toLocaleString('ar-EG')}`, ''];
msgs.forEach(m => {
lines.push(`## ${m.role === 'user' ? `👤 ${currentUser?.name || 'أنت'}` : '🧠 BrainMap OS'}`);
lines.push(`*${new Date(m.timestamp).toLocaleTimeString('ar-EG')}*`);
lines.push('');
lines.push(m.text);
lines.push('');
lines.push('---');
lines.push('');
});
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${(title || 'brainmap_chat').replace(/\s+/g, '_')}.md`; a.click();
URL.revokeObjectURL(url);
toast('تم تصدير Markdown ✅', 'success', 2000);
};
// ── V23: Export chat as HTML ──
const exportChatHTML = (msgs, title) => {
const rows = msgs.map(m => {
const isUser = m.role === 'user';
const bg = isUser ? '#eff6ff' : '#f0fdf4';
const name = isUser ? (currentUser?.name || 'أنت') : 'BrainMap OS';
return `<div style="margin:12px 0;padding:14px 16px;background:${bg};border-radius:12px;"><strong style="color:${isUser?'#2563eb':'#059669'}">${name}</strong><br/><div style="margin-top:8px;font-size:14px;line-height:1.7;">${marked.parse(m.text)}</div></div>`;
}).join('');
const html = `<!DOCTYPE html><html dir="rtl" lang="ar"><head><meta charset="UTF-8"/><title>${title||'BrainMap Chat'}</title><style>body{font-family:Tajawal,Arial,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;background:#f8fafc;color:#1e293b;}h1{color:#2563eb;}</style></head><body><h1>🧠 ${title||'BrainMap OS V25'}</h1><p style="opacity:0.5;font-size:12px;">تصدير: ${new Date().toLocaleString('ar-EG')}</p>${rows}</body></html>`;
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${(title || 'brainmap_chat').replace(/\s+/g, '_')}.html`; a.click();
URL.revokeObjectURL(url);
toast('تم تصدير HTML ✅', 'success', 2000);
};
// ── FILE HANDLING ──
const processFile = async (file) => {
const uid = String(currentUser?.id || 'dev');
let content = null, base64 = null, fileType = 'unknown';
if (isImage(file)) {
fileType = 'image';
base64 = await fileToBase64(file);
// Also describe image with vision model for RAG context
try {
const desc = await callGroqVision(base64, 'صف هذه الصورة بالتفصيل للفهرسة الطبية', GROQ_KEYS[0]);
if (desc) content = `[وصف الصورة الطبية: ${file.name}]\n${desc}`;
} catch(_) {}
} else if (isAudio(file)) {
fileType = 'audio';
base64 = await fileToBase64(file);
const key = GROQ_KEYS[Math.floor(Math.random() * GROQ_KEYS.length)];
const transcription = await callGroqWhisper(file, key);
if (transcription) content = `[تفريغ صوتي]\n${transcription}`;
} else if (isVideo(file)) {
fileType = 'video';
base64 = await fileToBase64(file);
content = `[ملف فيديو: ${file.name}] — الحجم: ${formatSize(file.size)}`;
} else if (isPDF(file)) {
fileType = 'pdf';
content = await parsePDF(file);
} else if (isDOCX(file)) {
fileType = 'docx';
content = await parseDOCX(file);
} else if (isCSV(file)) {
fileType = 'csv';
content = await parseCSV(file);
} else if (isText(file)) {
fileType = 'text';
content = await fileToText(file);
} else {
fileType = 'binary';
content = `[ملف: ${file.name}] — النوع: ${file.type || 'غير معروف'}. الحجم: ${formatSize(file.size)}`;
}
const record = { userId: uid, sessionId: currentSessionId, name: file.name, fileType, content, base64, timestamp: Date.now(), size: file.size };
const recordId = await db.files.add(record);
record.id = recordId;
// 2.1 Ingestion Layer: chunk and index for RAG
if (content && content.trim().length > 100 && !['video','binary'].includes(fileType)) {
await storeChunksForFile(recordId, uid, content, file.name);
}
setSourceToggles(prev => ({ ...prev, [String(recordId)]: true }));
await loadGlobalContext(uid);
return { ...record, file };
};
const handleFileChange = async (e) => {
const f = e.target.files[0];
if (!f) return;
e.target.value = '';
setPanels(p => ({ ...p, plusMenu: false }));
setIsTyping(true);
try {
const processed = await processFile(f);
setPendingFile(processed);
toast(`تم تحميل: ${f.name} ✅`, 'success');
} catch(err) {
toast(`خطأ في قراءة الملف: ${err.message}`, 'error');
}
setIsTyping(false);
};
const deleteFile = async (id) => {
await db.files.delete(id);
await deleteChunksForFile(id, currentUser?.id || 'dev');
setSourceToggles(prev => { const u = { ...prev }; delete u[String(id)]; return u; });
await loadGlobalContext(currentUser?.id || 'dev');
toast('تم حذف الملف', 'info', 1500);
};
const toggleSource = (fileId) => {
// ✅ إصلاح: تبسيط المنطق — القيمة الافتراضية true، التبديل يعكسها
setSourceToggles(prev => ({ ...prev, [String(fileId)]: !(prev[String(fileId)] !== false) }));
};
// ── BACKGROUND IMAGE SEARCH ──
const runBackgroundImageSearch = useCallback(async (query, msgId) => {
setBgSearchStatus(p => ({ ...p, [msgId]: 'loading' }));
const imgs = await searchImages(query);
setInlineImages(p => ({ ...p, [msgId]: imgs }));
setBgSearchStatus(p => ({ ...p, [msgId]: imgs.length > 0 ? 'done' : 'none' }));
}, []);
// ── SEND ──
const handleSend = async () => {
if (!input.trim() && !pendingFile) return;
if (isTyping || isSendingRef.current) return;
isSendingRef.current = true;
// ✅ V25: Create a fresh AbortController for this request so stop button can cancel it
const ac = new AbortController();
abortControllerRef.current = ac;
const signal = ac.signal;
let userText = input.trim();
let imageBase64 = null;
if (pendingFile) {
const { name, fileType, content, base64 } = pendingFile;
if (fileType === 'image') {
imageBase64 = base64;
userText = userText || 'حلّل هذه الصورة طبياً بالتفصيل';
} else if (fileType === 'audio') {
userText = userText ? `[صوت: ${name}] ${userText}` : `تفريغ صوتي من: ${name}\n${content}`;
} else if (fileType === 'video') {
userText = userText ? `[فيديو: ${name}] ${userText}` : `تحليل فيديو: ${name}`;
} else {
userText = userText ? `[${name}] ${userText}` : `حلّل هذا الملف: ${name}`;
}
}
// 3.9 Query Understanding + Red Flag Detection
const queryIntent = classifyQueryIntent(userText);
setLastQueryIntent(queryIntent);
checkAndShowRedFlags(userText);
const userMsg = { sessionId: currentSessionId, role: 'user', text: userText, timestamp: Date.now(), mediaType: pendingFile?.fileType || null };
const userMsgId = await db.messages.add(userMsg);
userMsg.id = userMsgId;
const hadFile = pendingFile;
setMessages(p => [...p, userMsg]);
setInput(''); setCharCount(0); setPendingFile(null); setIsTyping(true);
// 2.4 Retrieval Engine
let ragContext = '', retrievedCitations = [];
if (ragEnabled && allChunks.length > 0) {
const enabledIds = new Set(
Object.entries(sourceToggles).filter(([_, en]) => en !== false).map(([fid]) => fid)
);
const effectiveIds = enabledIds.size > 0 ? enabledIds : null;
const topChunks = retrieveTopChunks(userText, allChunks, TOP_K_CHUNKS, effectiveIds);
const ragResult = buildRAGContext(topChunks);
ragContext = ragResult.context;
retrievedCitations = ragResult.citations || [];
}
let aiText = '', usedModel = null;
// أضف رسالة فارغة للبث المباشر
const placeholder = { sessionId: currentSessionId, role: 'ai', text: '…', timestamp: Date.now() };
const phId = await db.messages.add(placeholder);
placeholder.id = phId;
setStreamingId(phId);
setMessages(p => [...p, placeholder]);
// ✅ إصلاح: لا نوقف isTyping هنا — نبقيه نشطاً حتى finally لمنع الإرسال المزدوج
// ── تغليف كامل في try/catch/finally لمنع تجمّد الواجهة ──
try {
if (imageBase64 && hadFile?.fileType === 'image') {
const visionResult = await callGroqVision(imageBase64, userText, GROQ_KEYS[0]);
if (visionResult) { aiText = visionResult; usedModel = VISION_MODEL; }
}
if (!aiText) {
const fileCtx = hadFile?.content ? { name: hadFile.name, content: hadFile.content } : null;
if (agentEnabled && queryIntent.depth === 'deep' && !hadFile) {
setAgentSteps([]); setIsAgentRunning(true);
setMessages(p => p.map(m => m.id === phId ? { ...m, text: '🤖 جاري تحليل الاستعلام عبر وكلاء متخصصين...' } : m));
try {
const ar = await runAgenticPipeline(userText, ragContext, retrievedCitations, messages);
setAgentSteps(ar.steps);
aiText = ar.finalText; usedModel = ar.model;
setMessages(p => p.map(m => m.id === phId ? { ...m, text: aiText } : m));
} catch(_) {
const streamCb = (partial) => { setMessages(p => p.map(m => m.id === phId ? { ...m, text: partial } : m)); };
const result = await callGroqText([...messages, userMsg], ragContext, fileCtx, retrievedCitations, streamCb, signal);
aiText = result.text; usedModel = result.model;
} finally { setIsAgentRunning(false); }
} else {
const streamCb = (partial) => { setMessages(p => p.map(m => m.id === phId ? { ...m, text: partial } : m)); };
const result = await callGroqText([...messages, userMsg], ragContext, fileCtx, retrievedCitations, streamCb, signal);
aiText = result.text; usedModel = result.model;
}
}
if (usedModel) {
setActiveModel(usedModel);
ACTIVE_MODEL_KEY = usedModel;
try { localStorage.setItem('brainmap_active_model', usedModel); } catch(_) {}
}
// تحديث الرسالة النهائية في قاعدة البيانات والواجهة
await db.messages.update(phId, { text: aiText });
setMessages(p => p.map(m => m.id === phId ? { ...m, text: aiText } : m));
// ✅ إصلاح: استخدام phId بدلاً من aiId (كان غير معرَّف)
if (retrievedCitations.length > 0) {
setCitationsMap(prev => ({ ...prev, [phId]: retrievedCitations }));
}
// TTS with Edge voice
if (ttsEnabled) { handleSpeak(aiText); }
// ✅ إصلاح: استخدام phId بدلاً من aiId (كان غير معرَّف)
if (shouldFetchImages(userText)) {
runBackgroundImageSearch(userText, phId);
}
// Auto-update session title
const msgsCount = await db.messages.where({ sessionId: currentSessionId }).count();
if (msgsCount <= 2) {
await db.sessions.update(currentSessionId, { title: userText.substring(0, 42) });
const list = await db.sessions.where({ userId: String(currentUser?.id || 'dev') }).reverse().sortBy('timestamp');
setSessions(list);
}
} catch(err) {
// إذا حدث خطأ غير متوقع: تحديث الرسالة بالخطأ بدلاً من التجمّد
const errText = `⚠️ حدث خطأ غير متوقع: ${err.message}`;
try { await db.messages.update(phId, { text: errText }); } catch(_) {}
setMessages(p => p.map(m => m.id === phId ? { ...m, text: errText } : m));
toast('حدث خطأ: ' + err.message, 'error');
} finally {
// ضمان إعادة الواجهة لحالتها الطبيعية دائماً
abortControllerRef.current = null;
isSendingRef.current = false;
setIsTyping(false);
setStreamingId(null);
}
};
const regenerateLast = async () => {
const lastAi = [...messages].reverse().find(m => m.role === 'ai');
if (!lastAi) return;
if (isSendingRef.current) return;
isSendingRef.current = true;
const ac2 = new AbortController();
abortControllerRef.current = ac2;
await db.messages.delete(lastAi.id);
const newMsgs = messages.filter(m => m.id !== lastAi.id);
setMessages(newMsgs);
setIsTyping(true);
try {
const placeholder = { sessionId: currentSessionId, role: 'ai', text: '…', timestamp: Date.now() };
const phId = await db.messages.add(placeholder);
placeholder.id = phId;
setStreamingId(phId);
setMessages(p => [...p, placeholder]);
const streamCb = (partial) => {
setMessages(p => p.map(m => m.id === phId ? { ...m, text: partial } : m));
};
const result = await callGroqText(newMsgs, '', null, [], streamCb, ac2.signal);
if (result.model) {
setActiveModel(result.model);
ACTIVE_MODEL_KEY = result.model;
try { localStorage.setItem('brainmap_active_model', result.model); } catch(_) {}
}
await db.messages.update(phId, { text: result.text });
setMessages(p => p.map(m => m.id === phId ? { ...m, text: result.text } : m));
toast('تم إعادة التوليد 🔄', 'info', 1500);
} catch(err) {
toast('خطأ في إعادة التوليد: ' + err.message, 'error');
} finally {
abortControllerRef.current = null;
isSendingRef.current = false;
setIsTyping(false);
setStreamingId(null);
}
};
// ── VOICE INPUT ──
const startVoiceRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// ✅ إصلاح: تحديد mimeType المدعوم ديناميكياً لدعم Safari وFirefox وChrome
// Safari يدعم audio/mp4 فقط — Chrome يدعم audio/webm — Firefox يدعم كليهما
const supportedMime = (() => {
if (typeof MediaRecorder === 'undefined') return '';
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) return 'audio/webm;codecs=opus';
if (MediaRecorder.isTypeSupported('audio/webm')) return 'audio/webm';
if (MediaRecorder.isTypeSupported('audio/mp4;codecs=mp4a')) return 'audio/mp4;codecs=mp4a';
if (MediaRecorder.isTypeSupported('audio/mp4')) return 'audio/mp4';
if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) return 'audio/ogg;codecs=opus';
return '';
})();
const mr = supportedMime ? new MediaRecorder(stream, { mimeType: supportedMime }) : new MediaRecorder(stream);
const blobMime = supportedMime || mr.mimeType || 'audio/webm';
mediaRecorderRef.current = mr;
audioChunksRef.current = [];
mr.ondataavailable = e => { if (e.data.size > 0) audioChunksRef.current.push(e.data); };
mr.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
// ✅ إصلاح: استخدام النوع المكتشف ديناميكياً بدلاً من audio/webm الصلب
const blob = new Blob(audioChunksRef.current, { type: blobMime });
const key = GROQ_KEYS[Math.floor(Math.random() * GROQ_KEYS.length)];
toast('جاري تحليل الصوت...', 'info', 2000);
const transcript = await callGroqWhisper(blob, key);
if (transcript) { setInput(transcript); toast('تم تحويل الصوت إلى نص ✅', 'success'); }
else toast('تعذّر تحليل الصوت', 'error');
setVoiceMode(false);
};
mr.start();
setIsRecording(true); setVoiceMode(true);
setPanels(p => ({ ...p, plusMenu: false }));
} catch(err) {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { toast('المتصفح لا يدعم التعرف على الصوت', 'error'); return; }
if (recognitionRef.current) recognitionRef.current.stop();
const rec = new SR();
recognitionRef.current = rec;
rec.lang = selectedVoice.lang.startsWith('ar') ? 'ar-SA' : selectedVoice.lang;
rec.continuous = false; rec.interimResults = false;
rec.onresult = e => { setInput(e.results[0][0].transcript); setVoiceMode(false); toast('تم التعرف على الكلام ✅', 'success'); };
rec.onerror = () => setVoiceMode(false);
rec.onend = () => setVoiceMode(false);
rec.start(); setVoiceMode(true);
setPanels(p => ({ ...p, plusMenu: false }));
}
};
const stopVoiceRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop(); setIsRecording(false);
} else { recognitionRef.current?.stop(); setVoiceMode(false); }
};
// ── WEB SEARCH ──
const handleSearch = async () => {
if (!searchQuery.trim()) return;
setIsSearching(true); setSearchResults([]);
const results = await searchWeb(searchQuery);
setSearchResults(results); setIsSearching(false);
if (results.length === 0) toast('لم تُوجد نتائج', 'warning');
else toast(`${results.length} نتيجة بحث ✅`, 'success', 1500);
};
// ── NOTEBOOKLM ──
const runNotebookLM = async (mode) => {
if (uploadedFiles.length === 0 && messages.length === 0) {
toast('يرجى رفع ملفات أو وجود محادثة أولاً', 'warning'); return;
}
setIsNbProcessing(true); setNbMode(mode); setNbResult('');
try {
let sourceContext = '';
if (ragEnabled && allChunks.length > 0) {
const modeQuery = {
summary: 'ملخص شامل للمحتوى الطبي', studyguide: 'دليل مراجعة طبي شامل',
flashcards: 'أهم المفاهيم الطبية للبطاقات التعليمية', podcast: 'موضوعات طبية للحوار التعليمي',
mcq: 'أسئلة طبية اختبارية', mindmap: 'المفاهيم الطبية الرئيسية والفرعية'
}[mode] || 'محتوى طبي';
const topChunks = retrieveTopChunks(modeQuery, allChunks, TOP_K_CHUNKS * 2, null);
const { context } = buildRAGContext(topChunks, MAX_RAG_CONTEXT_TOKENS * 1.5);
sourceContext = context;
}
if (!sourceContext) {
sourceContext = uploadedFiles.filter(f => f.content).map(f => f.content).join('\n\n').substring(0, 10000)
|| messages.slice(-20).map(m => m.text).join('\n').substring(0, 6000);
}
const prompts = {
summary: `استناداً للمحتوى التالي فقط، قدّم ملخصاً طبياً شاملاً ومنظماً يشمل: النقاط الرئيسية، المفاهيم المحورية، والخلاصة العلمية.\n\n${sourceContext}`,
studyguide: `استناداً للمحتوى التالي، أنشئ دليل مراجعة طبي متكاملاً يشمل: الأهداف التعليمية، النقاط الجوهرية، الجداول المقارنة، الأمثلة السريرية.\n\n${sourceContext}`,
flashcards: `استناداً للمحتوى التالي، أنشئ 15 بطاقة تعليمية (Flashcard) بصيغة:\n**السؤال:** ...\n**الإجابة:** ...\n\n${sourceContext}`,
podcast: `استناداً للمحتوى التالي فقط، أنشئ نصاً لحلقة بودكاست طبي تعليمي بأسلوب حوار بين مقدّمَين (طارق ونور) يناقشان المفاهيم الطبية. صيغ حوار المقدّمَين:\n**طارق:** ...\n**نور:** ...\n\n${sourceContext}`,
mcq: `استناداً للمحتوى التالي، أنشئ 10 أسئلة اختيار متعدد (MCQ) طبية مع 4 خيارات لكل سؤال وأجوبة موضحة.\n\n${sourceContext}`,
mindmap: `استناداً للمحتوى التالي، أنشئ خريطة ذهنية نصية منظمة هرمياً باستخدام تنسيق Markdown للمفاهيم الطبية الرئيسية والفرعية.\n\n${sourceContext}`
};
const result = await callGroqText([{ role: 'user', text: prompts[mode] }], '', null, []);
setNbResult(result.text);
if (mode === 'podcast') setAudioOverviewScript(result.text);
toast('اكتمل التحليل ✅', 'success');
} catch(err) {
toast('خطأ في المعالجة: ' + err.message, 'error');
} finally {
setIsNbProcessing(false);
}
};
// ── DEV ──
const injectHTML = async () => {
if (!devInput.html.trim()) return;
await db.injected_html.add({ code: devInput.html, timestamp: Date.now() });
setDevInput(d => ({ ...d, html: '' }));
await loadInjectedHTML();
toast('تم حفظ الوحدة البرمجية ✅', 'success');
};
const deleteModule = async (id) => { await db.injected_html.delete(id); await loadInjectedHTML(); };
const togglePanel = (name) => setPanels(p => ({ ...p, [name]: !p[name] }));
// ── RED FLAG CHECK ──
const checkAndShowRedFlags = (text) => {
const flags = detectRedFlags(text);
setRedFlags(flags);
return flags;
};
// ── DRUG CHECKER ──
const handleDrugCheck = async () => {
if (drugList.length < 2) { toast('أضف دواءين على الأقل', 'warning'); return; }
setIsCheckingDrugs(true); setDrugResults(null);
try {
const r = await checkDrugInteractions(drugList);
setDrugResults(r);
if (r.interactions.length === 0 && !r.error) toast('✅ لا تعارضات دوائية', 'success', 3000);
else if (r.interactions.length > 0) toast('⚠️ ' + r.interactions.length + ' تعارض دوائي', 'warning', 4000);
} catch(err) { toast('خطأ: ' + err.message, 'error'); }
finally { setIsCheckingDrugs(false); }
};
// ── PUBMED ──
const handlePubmedSearch = async () => {
if (!pubmedQuery.trim()) return;
setIsSearchingPubmed(true); setPubmedResults([]);
try {
const r = await searchPubMed(pubmedQuery.trim(), 8);
setPubmedResults(r);
if (r.length === 0) toast('لا نتائج', 'warning');
else toast(r.length + ' بحث من PubMed', 'success', 2000);
} catch(err) { toast('خطأ: ' + err.message, 'error'); }
finally { setIsSearchingPubmed(false); }
};
// ── ICD-10 ──
const handleICD10Search = async (q) => {
if (!q || q.trim().length < 2) { setIcdResults([]); return; }
setIsSearchingICD(true);
try { const r = await lookupICD10(q.trim()); setIcdResults(r); }
catch(_) { setIcdResults([]); }
finally { setIsSearchingICD(false); }
};
// ── SOAP ──
const handleGenerateSOAP = async () => {
if (messages.length < 2) { toast('يلزم وجود محادثة', 'warning'); return; }
setIsGeneratingSOAP(true); setSoapNote(null);
try {
const note = await generateSOAPNote(messages);
setSoapNote(note);
toast('✅ تم توليد SOAP', 'success');
setPanels(p => ({ ...p, soap: true }));
} catch(err) { toast('خطأ: ' + err.message, 'error'); }
finally { setIsGeneratingSOAP(false); }
};
// ── CALCULATOR ──
const handleCalculate = () => {
const i = calcInputs;
try {
let r = null;
if (calcMode === 'bmi') r = MedCalc.bmi(parseFloat(i.weight), parseFloat(i.height));
else if (calcMode === 'gfr') r = MedCalc.ckdEpi(parseFloat(i.creatinine), parseFloat(i.age), i.sex==='female', i.race==='black');
else if (calcMode === 'cha2ds2') r = MedCalc.cha2ds2vasc(!!i.hf, !!i.htn, parseInt(i.age||0), !!i.dm, !!i.stroke, !!i.vasc, i.sex==='female');
else if (calcMode === 'wellsdvt') r = MedCalc.wellsDVT(!!i.cancer,!!i.paralysis,!!i.bedridden,!!i.tenderness,!!i.swollen,!!i.pitting,!!i.collateral,!!i.prevdvt,!!i.altdx);
else if (calcMode === 'sofa') r = MedCalc.sofa(parseInt(i.resp||0),parseInt(i.coag||0),parseInt(i.liver||0),parseInt(i.cardio||0),parseInt(i.cns||0),parseInt(i.renal||0));
else if (calcMode === 'news2') r = MedCalc.news2(parseFloat(i.respRate||16), parseFloat(i.spo2||98), !!i.suppO2, parseFloat(i.systolic||120), parseFloat(i.pulse||80), i.consciousness||'A', parseFloat(i.temp||37));
else if (calcMode === 'cg') r = MedCalc.cockcroftGault(parseFloat(i.age||0), parseFloat(i.weight||0), parseFloat(i.creatinine||0), i.sex==='female');
else if (calcMode === 'apache') r = MedCalc.apacheII(parseFloat(i.age||0), parseFloat(i.temp||37), parseFloat(i.map||90), parseFloat(i.hr||80), parseFloat(i.rr||16), parseFloat(i.fio2||21), parseFloat(i.pao2||90), parseFloat(i.ph||7.4), parseFloat(i.na||140), parseFloat(i.k||4), parseFloat(i.cr||1), parseFloat(i.hct||40), parseFloat(i.wbc||8), parseFloat(i.gcs||15), parseInt(i.chronic||0));
else if (calcMode === 'peddose') r = MedCalc.pediatricDose(parseFloat(i.weight||0), i.drug||'Paracetamol');
setCalcResult(r);
} catch(err) { toast('خطأ في الحساب: ' + err.message, 'error'); }
};
// ── AMBIENT SCRIBE ──
const toggleAmbientScribe = async () => {
if (ambientActive) {
stopAmbientScribe(); setAmbientActive(false);
toast('🔇 توقّف الكاتب الصوتي', 'info', 2000);
} else {
const ok = await startAmbientScribe((txt) => {
setAmbientTranscript(prev => prev + (prev ? '\n' : '') + txt);
setInput(prev => prev ? prev + ' ' + txt : txt);
});
if (ok) { setAmbientActive(true); toast('🎙️ الكاتب الصوتي يعمل في الخلفية', 'success', 3000); }
else toast('❌ تعذّر الوصول للميكروفون', 'error');
}
};
// ── AUTO MODEL SELECTOR ──
// يفحص المفاتيح المتاحة ويختار أفضل نموذج تلقائياً
const handleAutoSelectModel = async () => {
setAutoCheckStatus('checking');
setIsAutoModel(true);
toast('🔍 جاري فحص المفاتيح...', 'info', 2000);
// قائمة النماذج للفحص مرتبة حسب الأولوية
const candidates = [];
// 1. نماذج بمفاتيح مخصّصة أولاً
Object.entries(MODEL_KEY_STORE).forEach(([m, e]) => {
if (e.keys && e.keys.length > 0) {
candidates.push({ model: m, key: e.keys[(e.ki||0) % e.keys.length], url: e.urls && e.urls.length > 0 ? e.urls[(e.ui||0) % e.urls.length] : null, source: 'custom' });
} else if (e.urls && e.urls.length > 0) {
candidates.push({ model: m, key: '', url: e.urls[(e.ui||0) % e.urls.length], source: 'custom-url' });
}
});
// 2. نماذج المزوّدين العامة
if (API_KEYS.groq && API_KEYS.groq.trim()) {
candidates.push({ model: 'llama-3.3-70b-versatile', key: API_KEYS.groq, url: 'https://api.groq.com/openai/v1', source: 'groq' });
candidates.push({ model: 'gemma2-9b-it', key: API_KEYS.groq, url: 'https://api.groq.com/openai/v1', source: 'groq' });
}
if (API_KEYS.google && API_KEYS.google.trim()) {
candidates.push({ model: 'gemini-2.0-flash', key: API_KEYS.google, url: 'https://generativelanguage.googleapis.com/v1beta/openai', source: 'google' });
}
if (API_KEYS.openai && API_KEYS.openai.trim()) {
candidates.push({ model: 'gpt-4o-mini', key: API_KEYS.openai, url: 'https://api.openai.com/v1', source: 'openai' });
}
if (API_KEYS.anthropic && API_KEYS.anthropic.trim()) {
candidates.push({ model: 'claude-haiku-4-5-20251001', key: API_KEYS.anthropic, url: null, source: 'anthropic' });
}
if (API_KEYS.mistral && API_KEYS.mistral.trim()) {
candidates.push({ model: 'mistral-small-latest', key: API_KEYS.mistral, url: 'https://api.mistral.ai/v1', source: 'mistral' });
}
// Ollama آخراً
if (ACTIVE_NGROK_URL.trim()) {
candidates.push({ model: OLLAMA_MODELS[0], key: '', url: ACTIVE_NGROK_URL.trim(), source: 'ollama' });
}
if (candidates.length === 0) {
setAutoCheckStatus('none');
setIsAutoModel(false);
toast('⚠️ لا يوجد أي مفتاح أو رابط مضاف. أضف مفتاحاً أولاً.', 'warning', 4000);
return;
}
// فحص كل مرشّح بطلب خفيف
for (const c of candidates) {
try {
setAutoCheckStatus(`فحص ${c.model}...`);
let testRes = null;
if (c.source === 'anthropic') {
// Claude يستخدم endpoint مختلف
testRes = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': c.key,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true'
},
body: JSON.stringify({
model: c.model,
max_tokens: 5,
messages: [{ role: 'user', content: 'ping' }]
}),
signal: AbortSignal.timeout(8000)
});
} else if (c.url) {
const endpoint = c.url.replace(/\/$/, '') + '/chat/completions';
const headers = { 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': 'true' };
if (c.key) headers['Authorization'] = `Bearer ${c.key}`;
testRes = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: c.model,
messages: [{ role: 'user', content: 'ping' }],
max_tokens: 5,
stream: false
}),
signal: AbortSignal.timeout(8000)
});
}
// 200 أو 400 يعني المفتاح والرابط يعملان (400 قد يكون خطأ في الطلب لكن الاتصال نجح)
if (testRes && (testRes.ok || testRes.status === 400)) {
setActiveModel(c.model);
ACTIVE_MODEL_KEY = c.model;
try { localStorage.setItem('brainmap_active_model', c.model); } catch(_) {}
setAutoCheckStatus('ok');
setIsAutoModel(false);
toast(`✅ تم اختيار: ${c.model} (${c.source})`, 'success', 3000);
return;
}
} catch(_) {
// انتقل للمرشّح التالي
}
}
// لم ينجح أي مرشّح
setAutoCheckStatus('failed');
setIsAutoModel(false);
toast('❌ لم يستجب أي نموذج. تحقق من المفاتيح والروابط.', 'error', 4000);
};
// ── COMPUTED ──
const enabledChunksCount = useMemo(() => {
if (Object.keys(sourceToggles).length === 0) return allChunks.length;
return allChunks.filter(c => sourceToggles[String(c.fileId)] !== false).length;
}, [allChunks, sourceToggles]);
// ── Get voices for current category tab ──
const voicesForTab = useMemo(() => {
const tab = voiceTabActive;
if (tab === 'system') {
const cats = getSystemVoicesByCategory();
return [
...cats.arabic.map(v => ({ name: v.name, label: `${v.name} (نظام)`, lang: v.lang, gender: v.name.includes('female') || v.name.toLowerCase().includes('salma') || v.name.toLowerCase().includes('zariyah') ? '♀' : '♂', isSystem: true })),
...cats.english.map(v => ({ name: v.name, label: `${v.name} (نظام)`, lang: v.lang, gender: '?', isSystem: true })),
...cats.other.map(v => ({ name: v.name, label: `${v.name} (نظام)`, lang: v.lang, gender: '?', isSystem: true })),
];
}
return EDGE_TTS_VOICES[tab] || EDGE_TTS_VOICES.arabic;
}, [voiceTabActive, systemVoices]);
// ═══════════════════════════════════════
// LOGIN VIEW
// ═══════════════════════════════════════
if (view === 'login') return (
<div className="h-screen flex flex-col justify-center items-center p-6 overflow-hidden" style={{ background: 'radial-gradient(ellipse at 50% 0%, #1e3a8a22 0%, #020617 60%)', color: '#f8fafc' }}>
<div className="absolute inset-0 opacity-[0.04]" style={{ backgroundImage: 'linear-gradient(rgba(59,130,246,0.4) 1px, transparent 1px),linear-gradient(90deg, rgba(59,130,246,0.4) 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
<div className="relative z-10 w-full max-w-sm">
<div className="text-center mb-10 animate-slide-up">
<div className="w-28 h-28 rounded-3xl mx-auto flex items-center justify-center mb-6 relative" style={{ background: 'linear-gradient(135deg, #1d4ed8, #7c3aed)', boxShadow: '0 0 60px rgba(59,130,246,0.5)' }}>
<svg width="52" height="52" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/>
</svg>
</div>
<h1 className="text-4xl font-black italic tracking-tight" style={{ background: 'linear-gradient(135deg, #60a5fa, #a78bfa)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>BRAIN MAP OS</h1>
<p className="font-bold tracking-widest uppercase text-xs mt-2" style={{ color: '#60a5fa' }}>Sovereign Medical Engine V25 · Smart Routing + Stop Stream</p>
<div className="flex flex-wrap justify-center gap-2 mt-4">
{['Ollama · Groq · GPT · Claude','Vision AI','Whisper STT','RAG BM25','Edge TTS','NEWS2 · APACHE II','Wikimedia CC','Cockcroft-Gault','جرعات الأطفال'].map(b => (
<span key={b} className="text-[8px] px-2 py-1 rounded-full font-bold border" style={{ borderColor: 'rgba(59,130,246,0.3)', background: 'rgba(59,130,246,0.08)', color: 'rgba(148,163,184,0.7)' }}>{b}</span>
))}
</div>
</div>
<button onClick={() => togglePanel('accounts')} className="w-full p-5 bg-white text-black font-black rounded-2xl shadow-xl flex justify-between items-center active:scale-95 transition-transform" style={{ boxShadow: '0 8px 32px rgba(37,99,235,0.3)' }}>
<span>فتح مركز الحسابات</span>
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
</button>
</div>
{panels.accounts && (
<div className="fixed inset-0 z-50 bg-black/90 backdrop-blur-md flex justify-center items-end sm:items-center p-4">
<div className="bg-white w-full max-w-sm rounded-[2rem] p-6 text-slate-900 animate-scale-in shadow-2xl max-h-[92vh] overflow-y-auto">
<div className="flex justify-between items-center mb-5 border-b pb-4">
<h2 className="text-xl font-black">مركز الحسابات</h2>
<button onClick={() => togglePanel('accounts')} className="p-2 bg-slate-100 rounded-xl active:scale-90">
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{accounts.length > 0 && (
<div className="mb-5 space-y-2">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">الحسابات المحفوظة</p>
{accounts.map(acc => (
<button key={acc.id} onClick={() => loginUser(acc)} className="w-full text-right p-4 bg-blue-50 border border-blue-100 rounded-xl flex items-center justify-between active:scale-95 transition-transform hover:bg-blue-100">
<div><p className="font-black text-blue-900">{acc.name}</p><p className="text-[10px] text-blue-500 font-mono">{acc.email}</p></div>
<div className="w-10 h-10 bg-blue-600 text-white rounded-xl flex items-center justify-center font-black text-lg">{acc.name[0]}</div>
</button>
))}
</div>
)}
<div className="space-y-3">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">حساب جديد</p>
<div><label className="text-xs font-bold text-slate-500 block mb-1">الاسم</label><input type="text" value={regForm.name} onChange={e => setRegForm({ ...regForm, name: e.target.value })} className="w-full p-4 bg-slate-100 rounded-xl outline-none font-bold" /></div>
<div><label className="text-xs font-bold text-slate-500 block mb-1">البريد (ينتهي بـ .m-mail.edu.eg)</label><input type="email" value={regForm.email} onChange={e => setRegForm({ ...regForm, email: e.target.value })} className="w-full p-4 bg-slate-100 rounded-xl outline-none font-bold text-left" dir="ltr" /></div>
<div><label className="text-xs font-bold text-slate-500 block mb-1">كلمة المرور (9 رموز)</label><input type="password" maxLength="9" value={regForm.password} onChange={e => setRegForm({ ...regForm, password: e.target.value })} className="w-full p-4 bg-slate-100 rounded-xl outline-none font-bold text-center tracking-widest" /></div>
<button onClick={handleRegister} className="w-full py-4 bg-blue-600 text-white font-black rounded-xl shadow-lg active:scale-95 hover:bg-blue-700 transition">تسجيل وحفظ في IndexedDB</button>
<button onClick={handleDevLogin} className="w-full py-4 bg-slate-900 text-white font-black rounded-xl shadow-lg active:scale-95">⚙️ دخول المطور (رمز خاص)</button>
</div>
</div>
</div>
)}
</div>
);
// ═══════════════════════════════════════
// MAIN APP VIEW
// ═══════════════════════════════════════
return (
<div className="h-screen flex flex-col transition-colors duration-500 overflow-hidden" style={{ background: T.bg, color: T.text }}>
{/* ── LIGHTBOX ── */}
{lightboxImg && (
<div className="lightbox-overlay animate-fade-in" onClick={() => setLightboxImg(null)}>
<img src={lightboxImg.url} alt={lightboxImg.title} className="img-load" onError={() => setLightboxImg(null)} />
{lightboxImg.title && <div className="text-white text-xs font-bold opacity-70 mt-3 px-8 text-center max-w-lg">{lightboxImg.title}</div>}
{lightboxImg.license && <div className="text-white text-[9px] opacity-40 mt-1">{lightboxImg.source} · {lightboxImg.license}</div>}
{lightboxImg.sourceUrl && (
<a href={lightboxImg.sourceUrl} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="text-[10px] font-bold mt-2 px-3 py-1 rounded-full" style={{ background: 'rgba(255,255,255,0.15)', color: 'white' }}>
🔗 عرض المصدر
</a>
)}
<button className="absolute top-6 right-6 p-3 bg-white/10 rounded-full text-white active:scale-90" onClick={() => setLightboxImg(null)}>
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
)}
{/* ── HEADER ── */}
<header className="h-14 flex items-center justify-between px-3 border-b z-30 shrink-0" style={{ background: T.card, borderColor: `${T.text}18` }}>
<button onClick={() => togglePanel('sidebar')} className="p-2 rounded-xl active:scale-90 transition-transform">
<svg width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
</button>
<div className="flex flex-col items-center flex-1 mx-2">
<h1 className="font-black text-sm italic" style={{ color: T.primary }}>BRAIN MAP OS V25</h1>
<div className="flex items-center gap-1.5 flex-wrap justify-center">
<span className="text-[8px] font-bold opacity-40 max-w-[80px] truncate">{activeModel.split('/').pop()}</span>
{/* ✅ V25: Show active provider badge */}
{(() => {
const hasGroq = apiKeys.groq;
const hasGoogle = apiKeys.google;
const hasOpenAI = apiKeys.openai;
const hasClaude = apiKeys.anthropic;
const hasOR = apiKeys.openrouter;
const hasTG = apiKeys.together;
const hasOllama = ngrokUrlInput.trim();
const providerBadge = CLOSED_MODELS.groq.includes(activeModel) && hasGroq ? { label: 'Groq', color: '#f97316' }
: CLOSED_MODELS.google.includes(activeModel) && hasGoogle ? { label: 'Google', color: '#3b82f6' }
: CLOSED_MODELS.openai.includes(activeModel) && hasOpenAI ? { label: 'OpenAI', color: '#10b981' }
: CLOSED_MODELS.claude.includes(activeModel) && hasClaude ? { label: 'Claude', color: '#a855f7' }
: CLOSED_MODELS.mistral.includes(activeModel) && apiKeys.mistral ? { label: 'Mistral', color: '#06b6d4' }
: hasOR ? { label: 'OR', color: '#ec4899' }
: hasTG ? { label: 'Together', color: '#8b5cf6' }
: hasOllama ? { label: 'Ollama', color: '#f97316' }
: { label: '⚠️ لا مفتاح', color: '#ef4444' };
return (
<span className="text-[7px] font-black px-1.5 py-0.5 rounded-full" style={{ background: providerBadge.color + '20', color: providerBadge.color }}>
{providerBadge.label}
</span>
);
})()}
{autoCheckStatus === 'checking' && (
<span className="text-[7px] font-black px-1.5 py-0.5 rounded-full animate-pulse" style={{ background: `${T.primary}20`, color: T.primary }}>⚡ فحص...</span>
)}
{ragEnabled && allChunks.length > 0 && (
<span className="rag-indicator rag-pulse" style={{ background: `${T.primary}20`, color: T.primary }}>
<svg width="7" height="7" fill="currentColor" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4"/></svg>
RAG {enabledChunksCount}ق
</span>
)}
{isChunking && (
<span className="rag-indicator" style={{ background: '#f59e0b20', color: '#f59e0b' }}>
<div className="w-2 h-2 border border-current border-t-transparent rounded-full spin" />فهرسة
</span>
)}
{ttsPlaying && (
<div className="tts-playing-indicator" style={{ background: `${T.primary}20` }}>
{[1,2,3,4].map((_, i) => (
<div key={i} className="bar tts-bar" style={{ height: `${6 + i*2}px`, background: T.primary, animationDelay: `${i*0.12}s` }} />
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1.5">
<button onClick={() => togglePanel('favorites')} className="p-2 rounded-xl active:scale-90 transition-transform relative" style={{ color: T.primary }}>
<svg width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
{favorites.length > 0 && <span className="absolute top-1 right-1 w-3 h-3 rounded-full text-[7px] font-black flex items-center justify-center text-white" style={{ background: T.primary }}>{favorites.length}</span>}
</button>
<button onClick={() => togglePanel('search')} className="p-2 rounded-xl active:scale-90 transition-transform" style={{ color: T.primary }}>
<svg width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<button onClick={() => { togglePanel('notes'); setNoteMode('list'); }} className="p-2 rounded-xl active:scale-90 transition-transform relative" style={{ color: T.primary }}>
<svg width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
{notes.length > 0 && <span className="absolute top-1 right-1 w-3 h-3 rounded-full text-[7px] font-black flex items-center justify-center text-white" style={{ background: T.primary }}>{notes.length}</span>}
</button>
<button onClick={() => togglePanel('notebooklm')} className="p-2 rounded-xl active:scale-90 transition-transform" style={{ color: T.primary }}>
<svg width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
</button>
<button onClick={() => togglePanel('medtools')} className="p-1.5 rounded-xl active:scale-90 transition-transform relative" style={{ color: redFlags.length > 0 ? '#ef4444' : T.primary }}>
<span className="text-base leading-none">🏥</span>
{redFlags.length > 0 && <span className="absolute top-0 right-0 w-3 h-3 rounded-full bg-red-500 text-white text-[7px] font-black flex items-center justify-center red-flag-alert">{redFlags.length}</span>}
</button>
<button onClick={() => togglePanel('settings')} className="w-9 h-9 rounded-xl flex items-center justify-center text-white font-black text-sm shadow-lg active:scale-90 transition-transform" style={{ background: T.primary }}>
{currentUser?.name?.[0] || '?'}
</button>
</div>
</header>
{/* ── CHAT AREA ── */}
<main ref={chatRef} className="flex-1 overflow-y-auto" style={{ paddingBottom: showModelBar ? '420px' : '130px', transition: 'padding-bottom 0.3s' }}>
{/* Empty State with Quick Prompts */}
{messages.length === 0 && (
<div className="flex flex-col items-center justify-start pt-8 px-4 pb-4">
<div className="text-center mb-8 opacity-80">
<div className="w-20 h-20 rounded-3xl mx-auto mb-4 flex items-center justify-center" style={{ background: `${T.primary}15`, border: `2px solid ${T.primary}25` }}>
<svg width="36" height="36" fill="none" stroke={T.primary} strokeWidth="1.5" strokeLinecap="round">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/>
</svg>
</div>
<h2 className="text-lg font-black mb-1">مرحباً، {currentUser?.name?.split(' ')[0]} 👋</h2>
<p className="text-xs font-bold opacity-40 max-w-[270px] leading-relaxed mx-auto">
BrainMap OS V25 · Vision AI · Whisper · RAG BM25<br/>
TTS ذكي · زر إيقاف البث ■ · NEWS2 · APACHE II<br/>
Cockcroft-Gault · جرعات أطفال · OpenRouter · Together<br/>
{ragStats.chunks > 0 && <>{ragStats.chunks} قطعة مفهرسة من {ragStats.sources} مصدر</>}
{patientContext.trim() && <><br/>🧑‍⚕️ سياق المريض محدّد</>}
</p>
</div>
<div className="w-full max-w-lg grid grid-cols-3 gap-2">
{/* ✅ V25: Show setup hint if no keys configured and no Ollama URL */}
{!apiKeys.groq && !apiKeys.google && !apiKeys.openai && !apiKeys.anthropic && !apiKeys.mistral && !apiKeys.openrouter && !ngrokUrlInput.trim() && (
<div className="col-span-3 p-3 rounded-2xl border text-center animate-slide-up mb-2"
style={{ borderColor: `${T.primary}35`, background: `${T.primary}08` }}>
<p className="text-[11px] font-black mb-1" style={{ color: T.primary }}>⚙️ أضف مفتاح API للبدء</p>
<p className="text-[9px] opacity-60 mb-2">الإعدادات ← 🔑 مفاتيح API — أضف مفتاح Groq مجاني أو OpenRouter أو رابط Ollama</p>
<button onClick={() => togglePanel('settings')} className="text-[10px] font-black px-4 py-1.5 rounded-xl text-white active:scale-95" style={{ background: T.primary }}>
افتح الإعدادات
</button>
</div>
)}
{QUICK_PROMPTS.map((q, i) => (
<button key={i} onClick={() => { setInput(q.text); inputRef.current?.focus(); }}
className="quick-prompt-btn text-right p-3 rounded-xl border animate-slide-up"
style={{ background: `${T.primary}08`, borderColor: `${T.text}14`, animationDelay: `${i * 0.05}s` }}>
<span className="text-base block mb-1">{q.icon}</span>
<span className="text-[10px] font-black block leading-tight" style={{ color: T.text }}>{q.label}</span>
</button>
))}
</div>
</div>
)}
{/* Messages */}
<div className="w-full">
{messages.map((msg, idx) => (
<div key={msg.id || idx} className="w-full animate-slide-up border-b" style={{ borderColor: `${T.text}06`, padding: '14px 16px' }}>
<div className="flex flex-col w-full">
{/* Row: avatar + name + time */}
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full flex items-center justify-center text-[9px] font-black shrink-0"
style={{ background: msg.role === 'user' ? `${T.primary}30` : '#10b98120', color: msg.role === 'user' ? T.primary : '#10b981' }}>
{msg.role === 'user' ? (currentUser?.name?.[0] || 'أ') : '🧠'}
</div>
<span className="text-[10px] font-black" style={{ color: msg.role === 'user' ? T.primary : '#10b981' }}>
{msg.role === 'user' ? (currentUser?.name?.split(' ')[0] || 'أنت') : 'BrainMap'}
</span>
<span className="text-[8px] opacity-30 mr-auto">
{new Date(msg.timestamp).toLocaleTimeString('ar-EG', { hour: '2-digit', minute: '2-digit' })}
</span>
{streamingId === msg.id && (
<span className="text-[8px] font-black px-1.5 py-0.5 rounded-full" style={{ background: `${T.primary}20`, color: T.primary }}>⟳ جارٍ</span>
)}
</div>
<div className="w-full text-sm leading-relaxed pr-8">
{/* Image file indicator */}
{msg.mediaType === 'image' && msg.role === 'user' && (
<div className="mb-2 text-xs font-bold opacity-70 flex items-center gap-1">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
صورة طبية مرفوعة — Vision AI
</div>
)}
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: marked.parse(msg.text) }} />
{/* ── Citations (Grounding & Attribution) ── */}
{msg.role === 'ai' && citationsMap[msg.id] && citationsMap[msg.id].length > 0 && (
<div className="mt-3 pt-2.5 border-t" style={{ borderColor: `${T.text}18` }}>
<p className="text-[9px] font-black uppercase tracking-widest mb-1.5 opacity-50 flex items-center gap-1">
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
المصادر المستخدمة
</p>
<div className="flex flex-wrap gap-1">
{citationsMap[msg.id].map(c => (
<span key={c.id} className="citation-badge" style={{ background: `${T.primary}15`, color: T.primary, borderColor: `${T.primary}30` }} title={`القسم: ${c.section}\nالصفحة: ${c.page}\nدرجة الصلة: ${c.score}`}>
[{c.id}] {c.source.length > 22 ? c.source.substring(0, 22) + '…' : c.source}
{c.page && <span className="opacity-60 font-normal"> · {c.page}</span>}
</span>
))}
</div>
</div>
)}
{/* ── Inline Background Image Search Results ── */}
{msg.role === 'ai' && bgSearchStatus[msg.id] === 'loading' && (
<div className="mt-3 pt-2.5 border-t" style={{ borderColor: `${T.text}12` }}>
<span className="bg-search-badge" style={{ background: `${T.primary}15`, color: T.primary }}>
<div className="w-3 h-3 border border-current border-t-transparent rounded-full spin" />
جاري جلب الصور من Wikimedia...
</span>
</div>
)}
{msg.role === 'ai' && bgSearchStatus[msg.id] === 'done' && inlineImages[msg.id] && inlineImages[msg.id].length > 0 && (
<div className="mt-3 pt-2.5 border-t" style={{ borderColor: `${T.text}12` }}>
<p className="text-[9px] font-black uppercase tracking-widest mb-2 opacity-50 flex items-center gap-1.5">
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
صور طبية ذات صلة
<span className="opacity-60 font-normal normal-case">من Wikimedia Commons</span>
</p>
<div className="search-images-grid">
{inlineImages[msg.id].map((img, i) => (
<div key={i} className="search-img-item" onClick={() => setLightboxImg(img)} title={img.title}>
<img
src={img.url}
alt={img.title}
className="img-load"
onError={e => { e.target.closest('.search-img-item').style.display = 'none'; }}
/>
{img.source && <div className="img-source">{img.source}</div>}
{img.title && <div className="img-caption">{img.title.substring(0, 40)}</div>}
</div>
))}
</div>
<p className="text-[8px] opacity-30 mt-1">اضغط على الصورة للتكبير · CC License</p>
</div>
)}
{/* ── AI Message Actions ── */}
{msg.role === 'ai' && (
<div className="mt-2 flex items-center gap-3 flex-wrap">
<button onClick={() => handleSpeak(msg.text)} className="text-[10px] font-bold opacity-50 hover:opacity-100 flex items-center gap-1 transition-opacity" style={{ color: T.primary }}>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
{selectedVoice.label.split(' ')[0]}
</button>
<button onClick={() => copyMessage(msg.text)} className="text-[10px] font-bold opacity-50 hover:opacity-100 flex items-center gap-1 transition-opacity" style={{ color: T.primary }}>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
نسخ
</button>
<button onClick={() => toggleFavorite(msg)} className="text-[10px] font-bold opacity-50 hover:opacity-100 flex items-center gap-1 transition-opacity" style={{ color: isFavorite(msg.id) ? '#f59e0b' : T.primary }}>
<svg width="12" height="12" fill={isFavorite(msg.id) ? '#f59e0b' : 'none'} stroke="currentColor" strokeWidth="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
{isFavorite(msg.id) ? 'محفوظ' : 'حفظ'}
</button>
<button onClick={() => { setNoteInput({ title: 'ملاحظة من المحادثة', content: msg.text }); setNoteMode('create'); setIsCreatingNote(true); togglePanel('notes'); }} className="text-[10px] font-bold opacity-50 hover:opacity-100 flex items-center gap-1 transition-opacity" style={{ color: T.primary }}>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
ملاحظة
</button>
{idx === messages.length - 1 && (
<button onClick={regenerateLast} className="text-[10px] font-bold opacity-50 hover:opacity-100 flex items-center gap-1 transition-opacity" style={{ color: T.primary }}>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
إعادة توليد
</button>
)}
</div>
)}
</div>
</div>
</div>
))}
{/* Typing indicator */}
{isTyping && (
<div className="flex justify-end">
<div className="p-3 rounded-2xl rounded-tl-sm border flex flex-col gap-1.5" style={{ background: T.card, borderColor: `${T.text}15` }}>
<div className="flex items-center gap-2">
{[0,1,2].map(i => <div key={i} className="w-2 h-2 rounded-full typing-dot" style={{ background: T.primary, animationDelay: `${i*0.2}s` }} />)}
<span className="text-[10px] font-bold" style={{ color: T.primary }}>يستنبط...</span>
</div>
{lastQueryIntent && lastQueryIntent.intent !== 'general' && (
<div className="flex items-center gap-1">
<span className="intent-badge" style={{ background: lastQueryIntent.bg, color: lastQueryIntent.color }}>
{lastQueryIntent.icon} {lastQueryIntent.ar}
</span>
{ragEnabled && allChunks.length > 0 && (
<span className="intent-badge" style={{ background: `${T.primary}15`, color: T.primary }}>📎 RAG نشط</span>
)}
</div>
)}
</div>
</div>
)}
<div ref={scrollRef} />
</div>
</main>
{/* ── MODEL QUICK BAR ── */}
{showModelBar && (
<div className="animate-slide-up" style={{ position: 'fixed', bottom: '90px', left: 0, right: 0, zIndex: 39, borderTop: `1px solid ${T.text}15`, borderBottom: `1px solid ${T.text}10`, background: T.card, maxHeight: '55vh', display: 'flex', flexDirection: 'column', boxShadow: '0 -8px 24px rgba(0,0,0,0.25)' }}>
{/* شريط البحث */}
<div className="flex items-center gap-2 px-3 pt-2 pb-1.5 border-b" style={{ borderColor: `${T.text}10` }}>
<svg width="13" height="13" fill="none" stroke={T.primary} strokeWidth="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
autoFocus
type="text"
value={mkSearchQuery}
onChange={e => setMkSearchQuery(e.target.value)}
placeholder="ابحث عن نموذج..."
className="flex-1 bg-transparent outline-none text-[11px] font-bold"
style={{ color: T.text }}
dir="rtl"
/>
<span className="text-[9px] opacity-30 font-bold">{activeModel.split(':')[0]}</span>
<button onClick={() => { setShowModelBar(false); setMkSearchQuery(''); }} className="opacity-40 hover:opacity-100">
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/* قائمة النماذج */}
<div className="overflow-y-auto" style={{ flex: 1 }}>
{/* ── نماذج Ollama ── */}
{(() => {
const q = mkSearchQuery.trim().toLowerCase();
const filtered = OLLAMA_MODELS.filter(m => !q || m.toLowerCase().includes(q));
if (filtered.length === 0) return null;
return (
<div>
<p className="px-3 py-1 text-[8px] font-black uppercase tracking-widest opacity-30 sticky top-0" style={{ background: T.card }}>🦙 Ollama / محلي</p>
<div className="flex flex-wrap gap-1 px-2 pb-1.5">
{filtered.map(m => (
<button key={m} onClick={() => { setActiveModel(m); ACTIVE_MODEL_KEY = m; try { localStorage.setItem('brainmap_active_model', m); } catch(_) {} setShowModelBar(false); setMkSearchQuery(''); toast(m, 'info', 1000); }}
className="shrink-0 px-2.5 py-1 rounded-xl text-[9px] font-black border transition-all active:scale-95 whitespace-nowrap"
style={{ background: activeModel === m ? T.primary : `${T.text}08`, color: activeModel === m ? 'white' : T.text, borderColor: activeModel === m ? T.primary : `${T.text}15` }}>
{m}
{MODEL_KEY_STORE[m]?.keys?.length > 0 && <span className="ml-1 opacity-60">🔑{MODEL_KEY_STORE[m].keys.length}</span>}
{MODEL_KEY_STORE[m]?.urls?.length > 0 && <span className="ml-1 opacity-60">🔗{MODEL_KEY_STORE[m].urls.length}</span>}
{(MODEL_KEY_STORE[m]?.keys?.length > 0 || MODEL_KEY_STORE[m]?.urls?.length > 0) && <span className="ml-1 text-green-400 opacity-80"></span>}
</button>
))}
</div>
</div>
);
})()}
{/* ── نماذج API مغلقة المصدر ── */}
{Object.entries(CLOSED_MODELS).map(([vendor, models]) => {
const q = mkSearchQuery.trim().toLowerCase();
const filtered = models.filter(m => !q || m.toLowerCase().includes(q) || vendor.toLowerCase().includes(q));
if (filtered.length === 0) return null;
const vendorLabel = vendor === 'groq' ? '⚡ Groq' : vendor === 'openai' ? '🤖 OpenAI' : vendor === 'claude' ? '🧠 Anthropic' : vendor === 'google' ? '🔵 Google' : '🌊 Mistral';
const vendorKey = vendor === 'groq' ? apiKeys.groq : vendor === 'openai' ? apiKeys.openai : vendor === 'claude' ? apiKeys.anthropic : vendor === 'google' ? apiKeys.google : apiKeys.mistral;
return (
<div key={vendor}>
<p className="px-3 py-1 text-[8px] font-black uppercase tracking-widest opacity-30 sticky top-0 flex items-center gap-1.5" style={{ background: T.card }}>
{vendorLabel}
{vendorKey ? <span className="text-green-400 opacity-70 font-normal normal-case">● مفعّل</span> : <span className="text-red-400 opacity-70 font-normal normal-case">○ بدون مفتاح</span>}
</p>
<div className="flex flex-wrap gap-1 px-2 pb-1.5">
{filtered.map(m => (
<button key={m} onClick={() => { setActiveModel(m); ACTIVE_MODEL_KEY = m; try { localStorage.setItem('brainmap_active_model', m); } catch(_) {} setShowModelBar(false); setMkSearchQuery(''); toast(m, 'info', 1000); }}
className="shrink-0 px-2.5 py-1 rounded-xl text-[9px] font-black border transition-all active:scale-95 whitespace-nowrap"
style={{ background: activeModel === m ? T.primary : `${T.text}08`, color: activeModel === m ? 'white' : T.text, borderColor: activeModel === m ? T.primary : `${T.text}15`, opacity: (!vendorKey && !(MODEL_KEY_STORE[m]?.keys?.length > 0) && !(MODEL_KEY_STORE[m]?.urls?.length > 0)) ? 0.45 : 1 }}>
{m}
{MODEL_KEY_STORE[m]?.keys?.length > 0 && <span className="ml-1 opacity-60">🔑{MODEL_KEY_STORE[m].keys.length}</span>}
{MODEL_KEY_STORE[m]?.keys?.length > 0 && <span className="ml-1 text-green-400 opacity-80"></span>}
</button>
))}
</div>
</div>
);
})}
{mkSearchQuery && OLLAMA_MODELS.filter(m => m.toLowerCase().includes(mkSearchQuery.toLowerCase())).length === 0 && Object.values(CLOSED_MODELS).flat().filter(m => m.toLowerCase().includes(mkSearchQuery.toLowerCase())).length === 0 && (
<p className="text-center py-4 text-xs opacity-30 font-bold">لا نتائج لـ "{mkSearchQuery}"</p>
)}
</div>
</div>
)}
{/* ── INPUT BAR ── */}
<div className="fixed bottom-0 inset-x-0 pt-1.5 pb-2 px-2 z-40" style={{ background: `${T.bg}EE`, backdropFilter: 'blur(20px)', boxShadow: '0 -8px 32px rgba(0,0,0,0.3)' }}>
{pendingFile && (
<div className="max-w-xl mx-auto mb-2 p-2.5 rounded-xl border flex items-center justify-between text-xs font-bold animate-slide-up" style={{ background: `${T.primary}18`, borderColor: T.primary }}>
<div className="flex items-center gap-2 truncate flex-1">
{pendingFile.fileType === 'image' && pendingFile.base64 && (
<img src={pendingFile.base64} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0 border cursor-zoom-in" style={{ borderColor: `${T.primary}50` }} onClick={() => setLightboxImg({ url: pendingFile.base64, title: pendingFile.name, source: 'ملف محلي' })} />
)}
{pendingFile.fileType !== 'image' && (
<div className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 text-base" style={{ background: `${T.primary}20` }}>
{pendingFile.fileType==='pdf'?'📄':pendingFile.fileType==='audio'?'🎵':pendingFile.fileType==='video'?'🎬':pendingFile.fileType==='docx'?'📝':pendingFile.fileType==='csv'?'📊':'📁'}
</div>
)}
<div className="flex-1 min-w-0">
<p className="truncate" style={{ color: T.primary }}>{pendingFile.name}</p>
<p className="text-[9px] opacity-50 capitalize">{pendingFile.fileType}{pendingFile.size ? ` · ${formatSize(pendingFile.size)}` : ''}
{pendingFile.content && <span className="text-green-400"> · تم قراءة المحتوى ✓</span>}
</p>
</div>
</div>
<button onClick={() => setPendingFile(null)} className="shrink-0 p-1.5 rounded-lg text-red-400 active:scale-90" style={{ background: 'rgba(239,68,68,0.1)' }}>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
)}
{/* ── شريط النموذج ── */}
<div className="max-w-xl mx-auto flex items-center gap-1.5 px-2 pb-1">
<button onClick={() => setShowModelBar(v => !v)}
title="تبديل النموذج"
className="flex-1 max-w-[200px] h-7 px-2 rounded-lg border active:scale-95 flex items-center gap-1 text-[9px] font-black"
style={{ background: showModelBar ? `${T.primary}20` : `${T.text}06`, borderColor: showModelBar ? T.primary : `${T.text}15`, color: showModelBar ? T.primary : T.text }}>
<span className="shrink-0">{(() => { const isOA = !Object.values(CLOSED_MODELS).flat().includes(activeModel); const hc = !!(MODEL_KEY_STORE[activeModel]?.keys?.length || MODEL_KEY_STORE[activeModel]?.urls?.length); return isOA && hc ? '🔑' : isOA ? '🦙' : '☁️'; })()}</span>
<span className="truncate flex-1">{activeModel.split('/').pop().split(':')[0]}</span>
</button>
<button onClick={handleAutoSelectModel} disabled={isAutoModel || isTyping}
title="اختيار تلقائي لأفضل نموذج"
className="h-7 px-2.5 rounded-lg border active:scale-95 flex items-center gap-1 text-[9px] font-black disabled:opacity-40"
style={{ background: autoCheckStatus === 'ok' ? '#10b98120' : `${T.text}06`, borderColor: autoCheckStatus === 'ok' ? '#10b981' : `${T.text}15`, color: autoCheckStatus === 'ok' ? '#10b981' : T.text }}>
{isAutoModel ? <div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full spin" /> : <span>{autoCheckStatus === 'ok' ? '✓' : '⚡'}</span>}
<span>تلقائي</span>
</button>
{/* ── V23: Patient Context Toggle ── */}
<button onClick={() => setShowPatientCtx(v => !v)}
title="سياق المريض الدائم"
className="h-7 px-2 rounded-lg border active:scale-95 flex items-center gap-1 text-[9px] font-black"
style={{ background: patientContext.trim() ? '#10b98118' : `${T.text}06`, borderColor: patientContext.trim() ? '#10b98140' : `${T.text}15`, color: patientContext.trim() ? '#10b981' : T.text }}>
<span>🧑‍⚕️</span>
<span>{patientContext.trim() ? 'مريض' : 'سياق'}</span>
</button>
{charCount > 0 && (
<span className="text-[8px] font-black opacity-30 ml-auto tabular-nums">{charCount}</span>
)}
{ttsPlaying && (
<button onClick={stopSpeaking} className="h-7 px-2.5 rounded-lg border active:scale-95 flex items-center gap-1 text-[9px] font-black" style={{ background:'#ef444415', borderColor:'#ef444430', color:'#ef4444' }}>
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="14" height="14" rx="2"/></svg>
<span>إيقاف</span>
</button>
)}
{isChunking && <span className="text-[8px] font-black px-2 py-0.5 rounded-lg animate-pulse" style={{ background:'#f59e0b20', color:'#f59e0b' }}>⟳ فهرسة...</span>}
</div>
<div className="max-w-xl mx-auto flex items-end gap-1.5 p-1 rounded-[2rem] border shadow-2xl relative" style={{ background: T.card, borderColor: `${T.text}20` }}>
{/* PLUS MENU */}
{panels.plusMenu && (
<div className="absolute bottom-[110%] right-0 w-[290px] p-3 rounded-2xl grid grid-cols-2 gap-2 shadow-[0_12px_40px_rgba(0,0,0,0.5)] animate-scale-in border z-50" style={{ background: T.card, borderColor: `${T.text}18` }}>
<label className="flex flex-col items-center justify-center p-3 rounded-xl gap-1.5 cursor-pointer active:scale-95 transition-transform" style={{ background: '#3b82f618' }}>
<input ref={fileInputRef} type="file" accept="*/*" onChange={handleFileChange} />
<svg width="22" height="22" fill="none" stroke="#3b82f6" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/></svg>
<span className="text-[10px] font-black text-center" style={{ color: T.text }}>رفع أي ملف</span>
<span className="text-[8px] opacity-50" style={{ color: T.text }}>PDF·Word·صورة·صوت·CSV</span>
</label>
<button onClick={startVoiceRecording} className="flex flex-col items-center justify-center p-3 rounded-xl gap-1.5 active:scale-95 transition-transform" style={{ background: '#ef444418' }}>
<svg width="22" height="22" fill="none" stroke="#ef4444" strokeWidth="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
<span className="text-[10px] font-black" style={{ color: T.text }}>إدخال صوتي</span>
<span className="text-[8px] opacity-50" style={{ color: T.text }}>Whisper + Web Speech</span>
</button>
<button onClick={() => { togglePanel('search'); togglePanel('plusMenu'); }} className="flex flex-col items-center justify-center p-3 rounded-xl gap-1.5 active:scale-95 transition-transform" style={{ background: '#10b98118' }}>
<svg width="22" height="22" fill="none" stroke="#10b981" strokeWidth="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span className="text-[10px] font-black" style={{ color: T.text }}>بحث الويب</span>
<span className="text-[8px] opacity-50" style={{ color: T.text }}>DuckDuckGo</span>
</button>
<button onClick={() => { togglePanel('notebooklm'); togglePanel('plusMenu'); }} className="flex flex-col items-center justify-center p-3 rounded-xl gap-1.5 active:scale-95 transition-transform" style={{ background: '#a855f718' }}>
<svg width="22" height="22" fill="none" stroke="#a855f7" strokeWidth="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
<span className="text-[10px] font-black" style={{ color: T.text }}>NotebookLM</span>
<span className="text-[8px] opacity-50" style={{ color: T.text }}>ملخص·بطاقات·MCQ</span>
</button>
<button onClick={() => { togglePanel('notes'); setNoteMode('list'); togglePanel('plusMenu'); }} className="flex flex-col items-center justify-center p-3 rounded-xl gap-1.5 active:scale-95 transition-transform" style={{ background: '#f59e0b18' }}>
<svg width="22" height="22" fill="none" stroke="#f59e0b" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
<span className="text-[10px] font-black" style={{ color: T.text }}>الملاحظات</span>
<span className="text-[8px] opacity-50" style={{ color: T.text }}>{notes.length} ملاحظة</span>
</button>
<button onClick={() => { togglePanel('sources'); togglePanel('plusMenu'); }} className="flex flex-col items-center justify-center p-3 rounded-xl gap-1.5 active:scale-95 transition-transform" style={{ background: '#3b82f618' }}>
<svg width="22" height="22" fill="none" stroke="#3b82f6" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
<span className="text-[10px] font-black" style={{ color: T.text }}>المصادر</span>
<span className="text-[8px] opacity-50" style={{ color: T.text }}>{ragStats.chunks}ق · {ragStats.sources} مصدر</span>
</button>
<button onClick={() => { togglePanel('medtools'); togglePanel('plusMenu'); }} className="flex flex-col items-center justify-center p-3 rounded-xl gap-1.5 active:scale-95 transition-transform" style={{ background: '#ef444418' }}>
<span className="text-xl">🏥</span>
<span className="text-[10px] font-black" style={{ color: T.text }}>أدوات طبية</span>
<span className="text-[8px] opacity-50" style={{ color: T.text }}>SOAP · حاسبات · أدوية</span>
</button>
<button onClick={() => { togglePanel('pubmed'); togglePanel('plusMenu'); }} className="flex flex-col items-center justify-center p-3 rounded-xl gap-1.5 active:scale-95 transition-transform" style={{ background: '#10b98118' }}>
<span className="text-xl">📚</span>
<span className="text-[10px] font-black" style={{ color: T.text }}>PubMed</span>
<span className="text-[8px] opacity-50" style={{ color: T.text }}>أبحاث محكّمة</span>
</button>
</div>
)}
<button onClick={() => togglePanel('plusMenu')} className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-all active:scale-90 mb-0.5" style={panels.plusMenu ? { background: T.primary, color: 'white', transform: 'rotate(45deg)' } : { background: `${T.primary}18`, color: T.primary }}>
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<textarea
ref={inputRef}
rows={1}
value={input}
onChange={e => { setInput(e.target.value); setCharCount(e.target.value.length); e.target.style.height = 'auto'; e.target.style.height = Math.min(e.target.scrollHeight, 120) + 'px'; }}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }}
placeholder="اكتب استفسارك الطبي... (Enter للإرسال، Shift+Enter لسطر جديد)"
dir="rtl"
className="flex-1 bg-transparent border-none outline-none px-2 text-sm font-bold placeholder:opacity-30 py-2.5 leading-relaxed"
style={{ color: T.text, minHeight: '40px', maxHeight: '120px', overflowY: 'auto', marginBottom: '2px' }}
/>
{/* ✅ V25: Stop button when streaming, Send button otherwise */}
{(isTyping || streamingId !== null) ? (
<button
onClick={stopStreaming}
className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center text-white shadow-lg active:scale-90 transition-transform mb-0.5"
style={{ background: '#ef4444' }}
title="إيقاف الرد"
>
<svg width="14" height="14" fill="white" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>
</button>
) : (
<button onClick={handleSend} disabled={!input.trim() && !pendingFile} className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center text-white shadow-lg active:scale-90 transition-transform disabled:opacity-30 mb-0.5" style={{ background: T.primary }}>
<svg width="16" height="16" fill="none" stroke="white" strokeWidth="2.5"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
)}
</div>
</div>
{/* ── VOICE OVERLAY ── */}
{voiceMode && (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center animate-fade-in" style={{ background: `${T.primary}F5`, backdropFilter: 'blur(30px)', color: 'white' }}>
<button onClick={stopVoiceRecording} className="absolute top-8 right-8 p-4 bg-white/20 rounded-full active:scale-90">
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div className="w-36 h-36 bg-white/10 border border-white/30 rounded-full flex items-center justify-center voice-pulse mb-8 shadow-2xl">
<svg width="52" height="52" fill="none" stroke="white" strokeWidth="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
</div>
<h2 className="text-2xl font-black mb-1">{isRecording ? "يُسجّل الآن..." : "جاري الاستماع..."}</h2>
<p className="text-xs font-bold opacity-70 mb-3 uppercase tracking-widest">{isRecording ? "Groq Whisper V3" : "Web Speech API"}</p>
<p className="text-xs opacity-60 mb-8">اللغة: {selectedVoice.lang}</p>
<div className="flex gap-2 h-10 items-center">
{[...Array(12)].map((_, i) => <div key={i} className="w-1 bg-white rounded-full wave-bar" style={{ animationDelay: `${i * 0.08}s` }} />)}
</div>
<button onClick={stopVoiceRecording} className="mt-10 px-8 py-3 bg-white/20 rounded-full font-black text-sm active:scale-95">
{isRecording ? "⏹ إيقاف التسجيل" : "✕ إلغاء"}
</button>
</div>
)}
{/* ── SEARCH PANEL ── */}
{panels.search && (
<div className="fixed inset-0 z-[65] bg-black/60 backdrop-blur-sm flex justify-center items-start pt-4 px-3" onClick={e => e.target === e.currentTarget && togglePanel('search')}>
<div className="w-full max-w-lg rounded-2xl shadow-2xl animate-slide-up overflow-hidden border" style={{ background: T.card, borderColor: `${T.text}20` }}>
<div className="p-4 border-b flex items-center gap-3" style={{ borderColor: `${T.text}15` }}>
<svg width="20" height="20" fill="none" stroke={T.primary} strokeWidth="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input autoFocus type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} placeholder="ابحث طبياً على الويب..." className="flex-1 bg-transparent outline-none font-bold text-sm" style={{ color: T.text }} />
<button onClick={handleSearch} disabled={isSearching} className="px-4 py-2 rounded-xl font-black text-white text-xs active:scale-95 disabled:opacity-50" style={{ background: T.primary }}>
{isSearching ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full spin" /> : "بحث"}
</button>
<button onClick={() => togglePanel('search')} className="opacity-60"><svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{isSearching && (
<div className="p-6 space-y-3">
{[90,70,80].map((w,i) => <div key={i} className="skeleton h-4 rounded" style={{ width: `${w}%` }} />)}
</div>
)}
{searchResults.length === 0 && !isSearching && <div className="p-8 text-center opacity-30 text-sm font-bold">أدخل استعلامك ثم اضغط بحث</div>}
{searchResults.map((r, i) => (
<div key={i} className="p-4 search-result" style={{ borderColor: `${T.text}10` }}>
<a href={r.url} target="_blank" rel="noreferrer" className="font-black text-sm hover:underline block mb-1" style={{ color: T.primary }}>{r.title}</a>
<p className="text-xs opacity-60 mt-1 leading-relaxed">{r.snippet}</p>
<button onClick={() => { setInput(`استناداً لنتيجة بحث:\n**${r.title}**\n${r.snippet}`); togglePanel('search'); }} className="mt-2 text-[10px] font-black px-3 py-1 rounded-full active:scale-95" style={{ background: `${T.primary}20`, color: T.primary }}>
+ أضف للدردشة
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* ── FAVORITES PANEL ── */}
{panels.favorites && (
<div className="fixed inset-0 z-[65] bg-black/60 backdrop-blur-sm flex justify-center items-start pt-4 px-3" onClick={e => e.target === e.currentTarget && togglePanel('favorites')}>
<div className="w-full max-w-lg rounded-2xl shadow-2xl animate-slide-up overflow-hidden border" style={{ background: T.card, borderColor: `${T.text}20` }}>
<div className="p-4 border-b flex justify-between items-center" style={{ borderColor: `${T.text}12` }}>
<div className="flex items-center gap-2">
<svg width="18" height="18" fill="#f59e0b" stroke="#f59e0b" strokeWidth="1.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
<h2 className="font-black text-sm">المفضلة ({favorites.length})</h2>
</div>
<button onClick={() => togglePanel('favorites')} className="opacity-60"><svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div className="max-h-[70vh] overflow-y-auto p-3 space-y-2">
{favorites.length === 0 && <div className="py-12 text-center opacity-30 text-sm font-bold">لا توجد رسائل محفوظة</div>}
{favorites.map(f => (
<div key={f.id} className="p-3 rounded-xl border text-xs font-bold leading-relaxed" style={{ background: `${T.text}06`, borderColor: `${T.text}12` }}>
<p className="opacity-70 mb-2">{f.text.substring(0, 220)}{f.text.length > 220 ? '...' : ''}</p>
<div className="flex gap-2 items-center flex-wrap">
<span className="text-[9px] opacity-30">{new Date(f.timestamp).toLocaleDateString('ar-EG')}</span>
<button onClick={() => { setInput(f.text); togglePanel('favorites'); }} className="text-[9px] font-black px-2 py-0.5 rounded-full" style={{ background: `${T.primary}18`, color: T.primary }}>استخدم</button>
<button onClick={() => copyMessage(f.text)} className="text-[9px] font-black px-2 py-0.5 rounded-full" style={{ background: `${T.text}10`, color: T.text }}>نسخ</button>
<button onClick={async () => { await db.favorites.delete(f.id); loadFavorites(); }} className="text-[9px] font-black px-2 py-0.5 rounded-full text-red-400" style={{ background: 'rgba(239,68,68,0.1)' }}>حذف</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* ── NOTEBOOKLM PANEL ── */}
{panels.notebooklm && (
<div className="fixed inset-0 z-[65] bg-black/60 backdrop-blur-sm flex justify-center items-start pt-4 px-3 overflow-y-auto" onClick={e => e.target === e.currentTarget && togglePanel('notebooklm')}>
<div className="w-full max-w-lg rounded-2xl shadow-2xl animate-slide-up overflow-hidden mb-4 border" style={{ background: T.card, borderColor: `${T.text}20` }}>
<div className="p-4 border-b flex justify-between items-center" style={{ borderColor: `${T.text}15` }}>
<div className="flex items-center gap-2">
<svg width="20" height="20" fill="none" stroke={T.primary} strokeWidth="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
<h2 className="font-black text-base">NotebookLM الطبي</h2>
{ragEnabled && allChunks.length > 0 && (
<span className="text-[8px] font-black px-2 py-0.5 rounded-full" style={{ background: `${T.primary}20`, color: T.primary }}>RAG نشط</span>
)}
</div>
<button onClick={() => togglePanel('notebooklm')}><svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div className="p-4 grid grid-cols-3 gap-2">
{[
{ id: 'summary', label: 'ملخص شامل', icon: '📋', desc: 'تلخيص المصادر' },
{ id: 'studyguide', label: 'دليل المراجعة', icon: '📚', desc: 'خطة منظمة' },
{ id: 'flashcards', label: 'بطاقات تعليمية', icon: '🃏', desc: '15 بطاقة' },
{ id: 'podcast', label: 'بودكاست', icon: '🎙️', desc: 'حوار تعليمي' },
{ id: 'mcq', label: 'أسئلة MCQ', icon: '✅', desc: '10 أسئلة' },
{ id: 'mindmap', label: 'خريطة ذهنية', icon: '🗺️', desc: 'هيكل هرمي' }
].map(btn => (
<button key={btn.id} onClick={() => runNotebookLM(btn.id)} disabled={isNbProcessing} className="nb-card flex flex-col items-center justify-center p-3 rounded-xl gap-1 active:scale-95 transition-transform disabled:opacity-40 border" style={{ background: `${T.primary}15`, borderColor: `${T.primary}30` }}>
<span className="text-xl">{btn.icon}</span>
<span className="text-[10px] font-black text-center" style={{ color: T.text }}>{btn.label}</span>
<span className="text-[8px] opacity-50" style={{ color: T.text }}>{btn.desc}</span>
</button>
))}
</div>
{isNbProcessing && (
<div className="p-6 flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-t-transparent rounded-full spin" style={{ borderColor: `${T.primary}40`, borderTopColor: T.primary }} />
<p className="text-xs font-bold opacity-60">يُعالج المحتوى عبر RAG...</p>
</div>
)}
{nbResult && !isNbProcessing && (
<div className="p-4 border-t" style={{ borderColor: `${T.text}12` }}>
<div className="flex justify-between items-center mb-3 flex-wrap gap-2">
<span className="text-xs font-black uppercase opacity-50">النتيجة — {nbMode}</span>
<div className="flex gap-2 flex-wrap">
{nbMode === 'podcast' && audioOverviewScript && (
<button
onClick={() => {
setAudioOverviewPlaying(true);
// Find two Arabic voices for A/B speakers
const voiceA = EDGE_TTS_VOICES.arabic.find(v => v.gender === '♂') || EDGE_TTS_VOICES.arabic[0];
const voiceB = EDGE_TTS_VOICES.arabic.find(v => v.gender === '♀') || EDGE_TTS_VOICES.arabic[1];
playAudioOverview(audioOverviewScript, voiceA.name, voiceB.name, voiceRate, voicePitch);
setTimeout(() => setAudioOverviewPlaying(false), 30000);
}}
disabled={audioOverviewPlaying}
className="text-[10px] font-black px-3 py-1 rounded-full flex items-center gap-1 active:scale-95 disabled:opacity-50"
style={{ background: '#a855f720', color: '#a855f7' }}>
{audioOverviewPlaying ? <div className="w-3 h-3 border border-current border-t-transparent rounded-full spin" /> : '🎙️'}
{audioOverviewPlaying ? 'يشغّل...' : 'نظرة صوتية (Edge)'}
</button>
)}
<button onClick={() => handleSpeak(nbResult)} className="text-[10px] font-black px-3 py-1 rounded-full" style={{ background: `${T.primary}20`, color: T.primary }}>🔊 استمع</button>
<button onClick={() => copyMessage(nbResult)} className="text-[10px] font-black px-3 py-1 rounded-full" style={{ background: `${T.text}10`, color: T.text }}>📋 نسخ</button>
<button onClick={() => { saveNote(`${nbMode} — NotebookLM`, nbResult, [], true); togglePanel('notebooklm'); toast('تم حفظ الملاحظة ✅', 'success'); }} className="text-[10px] font-black px-3 py-1 rounded-full" style={{ background: '#10b98120', color: '#10b981' }}>💾 حفظ</button>
<button onClick={() => { setInput(`\n\n${nbResult}`); togglePanel('notebooklm'); }} className="text-[10px] font-black px-3 py-1 rounded-full" style={{ background: `${T.primary}20`, color: T.primary }}>+ دردشة</button>
</div>
</div>
<div className="max-h-64 overflow-y-auto rounded-xl p-3 text-xs leading-relaxed prose prose-sm max-w-none" style={{ background: `${T.text}08` }} dangerouslySetInnerHTML={{ __html: marked.parse(nbResult) }} />
</div>
)}
<div className="p-3 border-t text-[10px] font-bold opacity-50 flex items-center justify-between" style={{ borderColor: `${T.text}12` }}>
<div className="flex items-center gap-2">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
{uploadedFiles.length} ملف · {ragStats.chunks} قطعة مفهرسة
</div>
<span style={{ color: T.primary }}>BM25 RAG</span>
</div>
</div>
</div>
)}
{/* ── NOTES PANEL ── */}
{panels.notes && (
<div className="fixed inset-0 z-[65] bg-black/60 backdrop-blur-sm flex justify-center items-start pt-4 px-3 overflow-y-auto" onClick={e => e.target === e.currentTarget && togglePanel('notes')}>
<div className="w-full max-w-lg rounded-2xl shadow-2xl animate-slide-up overflow-hidden mb-4 border" style={{ background: T.card, borderColor: `${T.text}20` }}>
<div className="p-4 border-b flex justify-between items-center" style={{ borderColor: `${T.text}15` }}>
<div className="flex items-center gap-2">
<svg width="20" height="20" fill="none" stroke={T.primary} strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
<h2 className="font-black text-base">الملاحظات الطبية</h2>
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full" style={{ background: `${T.primary}20`, color: T.primary }}>{notes.length}</span>
</div>
<div className="flex gap-2 items-center">
<button onClick={() => { setNoteMode('create'); setIsCreatingNote(true); setNoteInput({ title: '', content: '' }); }} className="text-[10px] font-black px-3 py-1.5 rounded-xl flex items-center gap-1 active:scale-95 text-white" style={{ background: T.primary }}>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>جديد
</button>
<button onClick={() => togglePanel('notes')}><svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
</div>
{noteMode === 'create' && (
<div className="p-4 space-y-3">
<input type="text" value={noteInput.title} onChange={e => setNoteInput(p => ({ ...p, title: e.target.value }))} placeholder="عنوان الملاحظة..." className="w-full p-3 rounded-xl border outline-none font-black text-sm" style={{ background: `${T.text}08`, borderColor: `${T.text}18`, color: T.text }} />
<textarea value={noteInput.content} onChange={e => setNoteInput(p => ({ ...p, content: e.target.value }))} placeholder="محتوى الملاحظة... (يدعم Markdown)" rows={8} className="w-full p-3 rounded-xl border outline-none font-bold text-sm leading-relaxed" style={{ background: `${T.text}08`, borderColor: `${T.text}18`, color: T.text }} />
<div className="flex gap-2">
<button onClick={async () => { if (!noteInput.content.trim()) return; await saveNote(noteInput.title, noteInput.content, [], false); setNoteInput({ title: '', content: '' }); setNoteMode('list'); setIsCreatingNote(false); toast('تم حفظ الملاحظة ✅', 'success'); }} className="flex-1 py-3 rounded-xl font-black text-white text-sm active:scale-95" style={{ background: T.primary }}>💾 حفظ الملاحظة</button>
<button onClick={aiSynthesizeNote} disabled={isAiNoting} className="px-4 py-3 rounded-xl font-black text-sm active:scale-95 flex items-center gap-1.5 disabled:opacity-50" style={{ background: '#a855f720', color: '#a855f7' }}>
{isAiNoting ? <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full spin" /> : '🤖'}
{isAiNoting ? 'يُولّد...' : 'توليد ذكي'}
</button>
<button onClick={() => { setNoteMode('list'); setIsCreatingNote(false); }} className="px-4 py-3 rounded-xl font-black text-sm active:scale-95" style={{ background: `${T.text}10`, color: T.text }}>إلغاء</button>
</div>
</div>
)}
{noteMode === 'detail' && activeNote && (
<div className="p-4">
<div className="flex justify-between items-start mb-3 gap-2">
<h3 className="font-black text-sm leading-tight flex-1">{activeNote.title}</h3>
<div className="flex gap-2 shrink-0">
{activeNote.isAiGenerated && <span className="text-[9px] font-black px-2 py-0.5 rounded-full" style={{ background: '#a855f720', color: '#a855f7' }}>🤖 AI</span>}
<button onClick={() => handleSpeak(activeNote.content)} className="p-1.5 rounded-lg active:scale-90" style={{ background: `${T.primary}15`, color: T.primary }}>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/></svg>
</button>
<button onClick={() => { setNoteInput({ title: activeNote.title, content: activeNote.content }); setNoteMode('create'); }} className="p-1.5 rounded-lg active:scale-90" style={{ background: `${T.primary}15`, color: T.primary }}>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button onClick={async () => { await deleteNote(activeNote.id); setNoteMode('list'); setActiveNote(null); toast('تم حذف الملاحظة', 'info', 1500); }} className="p-1.5 rounded-lg active:scale-90 text-red-400" style={{ background: '#ef444415' }}>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div>
<div className="max-h-[50vh] overflow-y-auto rounded-xl p-3 text-xs leading-relaxed prose prose-sm max-w-none" style={{ background: `${T.text}08` }} dangerouslySetInnerHTML={{ __html: marked.parse(activeNote.content) }} />
<button onClick={() => { setNoteMode('list'); setActiveNote(null); }} className="mt-3 text-[11px] font-black flex items-center gap-1 opacity-60 hover:opacity-100" style={{ color: T.text }}>← العودة للقائمة</button>
</div>
)}
{noteMode === 'list' && (
<div className="p-3 max-h-[65vh] overflow-y-auto">
{notes.length === 0 && (
<div className="text-center p-10 opacity-30">
<p className="text-3xl mb-2">📝</p>
<p className="text-xs font-bold">لا توجد ملاحظات بعد</p>
<p className="text-[10px] opacity-70 mt-1">أنشئ ملاحظة يدوياً أو استخدم التوليد الذكي</p>
</div>
)}
{notes.map(note => (
<button key={note.id} onClick={() => { setActiveNote(note); setNoteMode('detail'); }} className="note-card w-full text-right p-3 mb-2 border flex items-start justify-between gap-2" style={{ background: `${T.text}05`, borderColor: `${T.text}12` }}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<p className="font-black text-xs truncate">{note.title || 'ملاحظة بدون عنوان'}</p>
{note.isAiGenerated && <span className="shrink-0 text-[8px] font-black px-1.5 py-0.5 rounded-full" style={{ background: '#a855f720', color: '#a855f7' }}>AI</span>}
</div>
<p className="text-[10px] opacity-50 truncate leading-relaxed">{note.content.replace(/[#*`\[\]]/g, '').substring(0, 80)}</p>
<p className="text-[9px] opacity-30 mt-1">{new Date(note.timestamp).toLocaleString('ar-EG')}</p>
</div>
<button onClick={e => { e.stopPropagation(); deleteNote(note.id); }} className="shrink-0 p-1 text-red-400 opacity-40 hover:opacity-100 mt-1">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
</button>
</button>
))}
</div>
)}
{noteMode === 'list' && uploadedFiles.length > 0 && (
<div className="p-3 border-t" style={{ borderColor: `${T.text}12` }}>
<button onClick={aiSynthesizeNote} disabled={isAiNoting} className="w-full py-2.5 rounded-xl font-black text-sm active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50" style={{ background: '#a855f720', color: '#a855f7' }}>
{isAiNoting ? <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full spin" /> : '🤖'}
{isAiNoting ? 'يُولّد ملاحظة من المصادر...' : 'توليد ملاحظة ذكية من المصادر'}
</button>
</div>
)}
</div>
</div>
)}
{/* ── SOURCES MANAGEMENT PANEL ── */}
{panels.sources && (
<div className="fixed inset-0 z-[65] bg-black/60 backdrop-blur-sm flex justify-center items-start pt-4 px-3 overflow-y-auto" onClick={e => e.target === e.currentTarget && togglePanel('sources')}>
<div className="w-full max-w-lg rounded-2xl shadow-2xl animate-slide-up overflow-hidden mb-4 border" style={{ background: T.card, borderColor: `${T.text}20` }}>
<div className="p-4 border-b flex justify-between items-center" style={{ borderColor: `${T.text}15` }}>
<div className="flex items-center gap-2">
<svg width="20" height="20" fill="none" stroke={T.primary} strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
<h2 className="font-black text-base">إدارة المصادر</h2>
</div>
<button onClick={() => togglePanel('sources')}><svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div className="p-4 border-b flex items-center justify-between" style={{ borderColor: `${T.text}12` }}>
<div>
<p className="font-black text-sm">تفعيل نظام RAG</p>
<p className="text-[10px] opacity-50 mt-0.5">{ragStats.chunks} قطعة مفهرسة · {ragStats.sources} مصدر · BM25</p>
</div>
<div onClick={() => setRagEnabled(v => !v)} className="toggle-switch cursor-pointer" style={{ background: ragEnabled ? T.primary : `${T.text}30` }}>
<div className="toggle-thumb" style={{ right: ragEnabled ? '2px' : '18px' }} />
</div>
</div>
<div className="p-3 max-h-[55vh] overflow-y-auto">
{uploadedFiles.length === 0 && (
<div className="text-center p-10 opacity-30">
<p className="text-3xl mb-2">📁</p>
<p className="text-xs font-bold">لا توجد مصادر مرفوعة</p>
<p className="text-[10px] opacity-70 mt-1">ارفع ملفات PDF أو Word أو نص لبناء قاعدة المعرفة</p>
</div>
)}
{uploadedFiles.map(f => {
const fileChunks = allChunks.filter(c => String(c.fileId) === String(f.id));
const isEnabled = sourceToggles[String(f.id)] !== false;
const fileIcon = f.fileType==='image'?'🖼️':f.fileType==='pdf'?'📄':f.fileType==='audio'?'🎵':f.fileType==='video'?'🎬':f.fileType==='docx'?'📝':f.fileType==='csv'?'📊':'📁';
return (
<div key={f.id} className="source-row p-3 rounded-xl mb-2 border flex items-center gap-3" style={{ borderColor: isEnabled ? `${T.primary}30` : `${T.text}10`, background: isEnabled ? `${T.primary}08` : 'transparent', opacity: isEnabled ? 1 : 0.5 }}>
<span className="text-lg shrink-0">{fileIcon}</span>
<div className="flex-1 min-w-0">
<p className="font-black text-xs truncate">{f.name}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[9px] opacity-50 font-bold capitalize">{f.fileType}</span>
{fileChunks.length > 0 && <span className="text-[9px] font-black" style={{ color: T.primary }}>{fileChunks.length} قطعة</span>}
{f.content && <span className="text-[8px] text-green-400">✓ محتوى مقروء</span>}
</div>
{fileChunks.length > 0 && (
<p className="text-[9px] opacity-40 mt-0.5">{fileChunks[0]?.metadata?.section?.substring(0, 40)}...</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<div onClick={() => toggleSource(f.id)} className="toggle-switch" style={{ background: isEnabled ? T.primary : `${T.text}30` }}>
<div className="toggle-thumb" style={{ right: isEnabled ? '2px' : '18px' }} />
</div>
<button onClick={() => deleteFile(f.id)} className="p-1.5 rounded-lg text-red-400 opacity-60 hover:opacity-100 active:scale-90" style={{ background: '#ef444410' }}>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
</button>
</div>
</div>
);
})}
</div>
{uploadedFiles.length > 0 && (
<div className="p-3 border-t flex gap-2" style={{ borderColor: `${T.text}12` }}>
<button onClick={() => { const all = {}; uploadedFiles.forEach(f => { all[String(f.id)] = true; }); setSourceToggles(all); }} className="flex-1 py-2 rounded-xl font-black text-xs active:scale-95" style={{ background: `${T.primary}15`, color: T.primary }}>✅ تفعيل الكل</button>
<button onClick={() => { const all = {}; uploadedFiles.forEach(f => { all[String(f.id)] = false; }); setSourceToggles(all); }} className="flex-1 py-2 rounded-xl font-black text-xs active:scale-95" style={{ background: `${T.text}10`, color: T.text }}>⬜ تعطيل الكل</button>
</div>
)}
</div>
</div>
)}
{/* ── SIDEBAR ── */}
{panels.sidebar && (
<div className="fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm" onClick={e => e.target === e.currentTarget && togglePanel('sidebar')}>
<div className="w-[78%] max-w-xs h-full flex flex-col shadow-2xl border-r" style={{ background: T.card, borderColor: `${T.text}18` }}>
<div className="p-4 border-b shrink-0" style={{ borderColor: `${T.text}10` }}>
<button onClick={() => createNewSession(currentUser?.id || 'dev')} className="w-full py-3 rounded-xl font-black text-white flex justify-center items-center gap-2 active:scale-95 shadow-lg text-sm" style={{ background: T.primary }}>
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>محادثة جديدة
</button>
</div>
<div className="flex-1 overflow-y-auto p-3">
<div className="flex items-center justify-between mb-2">
<p className="text-[9px] font-black uppercase tracking-widest opacity-40">المحادثات ({sessions.length})</p>
{messages.length > 0 && (
<div className="flex items-center gap-1">
<button onClick={() => exportChatMarkdown(messages, sessions.find(s => s.id === currentSessionId)?.title)} title="تصدير Markdown" className="text-[8px] font-black opacity-50 hover:opacity-100 px-1.5 py-0.5 rounded" style={{ color: T.primary, background: `${T.primary}12` }}>
MD
</button>
<button onClick={() => exportChatHTML(messages, sessions.find(s => s.id === currentSessionId)?.title)} title="تصدير HTML" className="text-[8px] font-black opacity-50 hover:opacity-100 px-1.5 py-0.5 rounded" style={{ color: '#10b981', background: '#10b98112' }}>
HTML
</button>
<button onClick={() => exportChat(messages, sessions.find(s => s.id === currentSessionId)?.title)} className="text-[8px] font-black opacity-50 hover:opacity-100 flex items-center gap-1" style={{ color: T.primary }}>
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>TXT
</button>
</div>
)}
</div>
{messages.length > 0 && (
<div className="mb-2">
{showClearConfirm ? (
<div className="flex items-center gap-1.5 p-2 rounded-xl border text-[9px] font-black" style={{ borderColor: '#ef444430', background: '#ef444408' }}>
<span className="flex-1 opacity-70">مسح رسائل المحادثة الحالية؟</span>
<button onClick={clearCurrentSession} className="px-2 py-0.5 rounded-lg text-white bg-red-500">نعم</button>
<button onClick={() => setShowClearConfirm(false)} className="px-2 py-0.5 rounded-lg opacity-50" style={{ background: `${T.text}15` }}>لا</button>
</div>
) : (
<button onClick={() => setShowClearConfirm(true)} className="w-full text-[9px] font-black px-2 py-1.5 rounded-xl border flex items-center gap-1.5 opacity-60 hover:opacity-100 transition-opacity" style={{ borderColor: '#ef444425', color: '#ef4444', background: '#ef444408' }}>
<svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
مسح رسائل المحادثة الحالية
</button>
)}
</div>
)}
{sessions.map(s => (
<div key={s.id} className={`flex items-center gap-1 mb-1.5 rounded-xl transition-all ${currentSessionId === s.id ? 'border' : ''}`} style={currentSessionId === s.id ? { background: `${T.primary}18`, borderColor: T.primary } : {}}>
<button onClick={() => { setCurrentSessionId(s.id); togglePanel('sidebar'); }} className="flex-1 text-right p-3 font-bold text-[11px] truncate leading-tight">
<span className="block truncate">{s.title}</span>
<span className="text-[9px] opacity-30">{new Date(s.timestamp).toLocaleDateString('ar-EG')}</span>
</button>
<button onClick={() => deleteSession(s.id)} className="shrink-0 p-2 text-red-400 opacity-60 hover:opacity-100">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
))}
{allChunks.length > 0 && (
<div className="mt-4 p-3 rounded-xl border" style={{ borderColor: `${T.primary}25`, background: `${T.primary}08` }}>
<div className="flex items-center justify-between mb-2">
<p className="text-[9px] font-black uppercase tracking-widest" style={{ color: T.primary }}>قاعدة المعرفة RAG</p>
<button onClick={() => { togglePanel('sources'); togglePanel('sidebar'); }} className="text-[8px] font-black px-2 py-0.5 rounded-full" style={{ background: `${T.primary}25`, color: T.primary }}>إدارة</button>
</div>
<div className="flex items-center gap-3 text-[10px] font-bold">
<div className="flex flex-col items-center"><span className="text-lg font-black" style={{ color: T.primary }}>{ragStats.chunks}</span><span className="opacity-50">قطعة</span></div>
<div className="w-px h-8 opacity-20" style={{ background: T.text }} />
<div className="flex flex-col items-center"><span className="text-lg font-black" style={{ color: T.primary }}>{ragStats.sources}</span><span className="opacity-50">مصدر</span></div>
<div className="w-px h-8 opacity-20" style={{ background: T.text }} />
<div className="flex flex-col items-center"><span className="text-lg font-black" style={{ color: T.primary }}>{enabledChunksCount}</span><span className="opacity-50">نشط</span></div>
</div>
</div>
)}
{uploadedFiles.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<p className="text-[9px] font-black uppercase tracking-widest opacity-40">المصادر ({uploadedFiles.length})</p>
<button onClick={() => { togglePanel('sources'); togglePanel('sidebar'); }} className="text-[9px] font-black opacity-50">إدارة</button>
</div>
{uploadedFiles.map(f => {
const fileChunks = allChunks.filter(c => String(c.fileId) === String(f.id));
const isEnabled = sourceToggles[String(f.id)] !== false;
return (
<div key={f.id} className="flex items-center gap-2 p-2 rounded-lg mb-1 text-[10px] font-bold border" style={{ borderColor: `${T.text}12`, opacity: isEnabled ? 1 : 0.4 }}>
<span className="opacity-60">{f.fileType==='image'?'🖼️':f.fileType==='pdf'?'📄':f.fileType==='audio'?'🎵':f.fileType==='video'?'🎬':f.fileType==='csv'?'📊':'📁'}</span>
<div className="flex-1 min-w-0">
<p className="truncate">{f.name}</p>
{fileChunks.length > 0 && <p className="text-[8px] opacity-40">{fileChunks.length}ق</p>}
</div>
<div onClick={() => toggleSource(f.id)} className="toggle-switch shrink-0" style={{ width: '28px', height: '16px', background: isEnabled ? T.primary : `${T.text}30` }}>
<div className="toggle-thumb" style={{ width: '12px', height: '12px', right: isEnabled ? '2px' : '14px' }} />
</div>
<button onClick={() => deleteFile(f.id)} className="text-red-400 shrink-0">×</button>
</div>
);
})}
</div>
)}
{notes.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<p className="text-[9px] font-black uppercase tracking-widest opacity-40">الملاحظات ({notes.length})</p>
<button onClick={() => { togglePanel('notes'); togglePanel('sidebar'); }} className="text-[9px] font-black opacity-50">عرض الكل</button>
</div>
{notes.slice(0, 3).map(n => (
<button key={n.id} onClick={() => { setActiveNote(n); setNoteMode('detail'); togglePanel('notes'); togglePanel('sidebar'); }} className="w-full text-right p-2 rounded-lg mb-1 text-[10px] font-bold border flex items-center gap-2" style={{ borderColor: `${T.text}12` }}>
<span>{n.isAiGenerated ? '🤖' : '📝'}</span>
<span className="truncate flex-1">{n.title || 'ملاحظة'}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
{/* ── SCROLL TO BOTTOM BUTTON ── */}
{showScrollBottom && (
<button
onClick={() => scrollRef.current?.scrollIntoView({ behavior: 'smooth' })}
className="fixed z-[35] animate-fade-in active:scale-90 shadow-xl flex items-center justify-center rounded-full text-white"
style={{ bottom: showModelBar ? '430px' : '145px', left: '50%', transform: 'translateX(-50%)', background: T.primary, width: '38px', height: '38px', boxShadow: `0 4px 20px ${T.primary}60` }}>
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="6 9 12 15 18 9"/></svg>
</button>
)}
{/* ── PATIENT CONTEXT QUICK BAR ── */}
{showPatientCtx && (
<div className="fixed top-14 inset-x-0 z-[55] px-3 pt-2 animate-slide-up">
<div className="max-w-xl mx-auto p-3 rounded-2xl border shadow-2xl" style={{ background: T.card, borderColor: `${T.primary}30` }}>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm">🧑‍⚕️</span>
<p className="text-[10px] font-black flex-1" style={{ color: T.primary }}>سياق المريض الدائم (يُرسَل مع كل رسالة)</p>
<button onClick={() => setShowPatientCtx(false)} className="opacity-50 hover:opacity-100">
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<textarea
value={patientContext}
onChange={e => setPatientContext(e.target.value)}
placeholder="مثال: مريض ذكر عمر 65، يعاني من سكري نوع 2، ارتفاع ضغط، يأخذ Metformin 500mg و Amlodipine 5mg..."
rows={3}
dir="rtl"
className="w-full p-2.5 rounded-xl border outline-none text-xs font-bold leading-relaxed"
style={{ background: `${T.text}08`, borderColor: `${T.primary}25`, color: T.text, resize: 'none' }}
/>
<div className="flex justify-between items-center mt-2">
<span className="text-[9px] opacity-40">{patientContext.length} حرف</span>
<div className="flex gap-2">
<button onClick={() => setPatientContext('')} className="text-[9px] font-black px-2 py-1 rounded-lg opacity-60 hover:opacity-100" style={{ background: '#ef444415', color: '#ef4444' }}>مسح</button>
<button onClick={() => { setShowPatientCtx(false); toast('تم حفظ سياق المريض ✅', 'success', 1500); }} className="text-[9px] font-black px-3 py-1 rounded-lg text-white active:scale-95" style={{ background: T.primary }}>حفظ وإغلاق</button>
</div>
</div>
</div>
</div>
)}
{redFlags.length > 0 && (
<div className="fixed top-14 inset-x-0 z-[62] px-3 pt-1">
<div className="max-w-xl mx-auto space-y-1">
{redFlags.slice(0,2).map((rf, i) => (
<div key={i} className={`red-flag-alert flex items-center gap-2 px-3 py-2 rounded-xl text-xs font-black text-white ${rf.s === 'critical' ? 'bg-red-600' : 'bg-amber-500'}`}>
<span className="text-sm shrink-0">{rf.s === 'critical' ? '🚨' : '⚠️'}</span>
<span className="flex-1">{rf.msg}</span>
<button onClick={() => setRedFlags(f => f.filter((_,j)=>j!==i))} className="shrink-0 opacity-70 text-lg leading-none">×</button>
</div>
))}
</div>
</div>
)}
{/* ── AGENT STEPS ── */}
{isAgentRunning && agentSteps.length > 0 && (
<div className="fixed top-14 inset-x-0 z-[58] px-3 pt-1">
<div className="max-w-xl mx-auto p-2 rounded-xl border" style={{ background: T.card, borderColor: `${T.primary}30` }}>
<p className="text-[8px] font-black opacity-40 mb-1">🤖 وكلاء يعملون...</p>
<div className="flex gap-1.5 flex-wrap">
{agentSteps.map((s,i) => (
<span key={i} className="text-[9px] font-black px-2 py-0.5 rounded-full" style={{ background:`${T.primary}20`, color:T.primary }}>{s.icon} {s.label}</span>
))}
<div className="w-3 h-3 border-2 border-t-transparent rounded-full spin agent-thinking" style={{ borderColor:`${T.primary}40`, borderTopColor:T.primary }} />
</div>
</div>
</div>
)}
{/* ── MEDICAL TOOLS PANEL ── */}
{panels.medtools && (
<div className="fixed inset-0 z-[67] bg-black/60 backdrop-blur-sm overflow-y-auto" onClick={e => e.target===e.currentTarget && togglePanel('medtools')}>
<div className="w-full max-w-lg mx-auto my-4 px-3">
<div className="rounded-2xl shadow-2xl animate-slide-up border overflow-hidden" style={{ background:T.card, borderColor:`${T.text}20` }}>
<div className="p-4 border-b flex justify-between items-center" style={{ borderColor:`${T.text}15` }}>
<div className="flex items-center gap-2"><span className="text-xl">🏥</span><h2 className="font-black text-base">الأدوات السريرية</h2></div>
<button onClick={() => togglePanel('medtools')}><svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div className="p-4 grid grid-cols-3 gap-2">
{[
{id:'calculator',icon:'🧮',label:'الحاسبات',desc:'BMI·NEWS2·APACHE·Cockcroft'},
{id:'drugcheck', icon:'💊',label:'تعارض أدوية',desc:'RxNorm API'},
{id:'pubmed', icon:'📚',label:'PubMed',desc:'أبحاث محكّمة'},
{id:'soap', icon:'📋',label:'تقرير SOAP',desc:'توليد فوري'},
{id:'icd10', icon:'🏷️',label:'ICD-10',desc:'ترميز تشخيصي'},
{id:'ambient', icon:'🎙️',label:'كاتب صوتي',desc:ambientActive?'● يعمل':'تسجيل مستمر'},
].map(t => (
<button key={t.id}
onClick={() => {
if (t.id==='soap') { handleGenerateSOAP(); }
else if (t.id==='ambient') { toggleAmbientScribe(); }
else { togglePanel('medtools'); togglePanel(t.id); }
}}
className="nb-card flex flex-col items-center justify-center p-3 rounded-xl gap-1 active:scale-95 border"
style={{ background:(t.id==='ambient'&&ambientActive)?'#ef444418':`${T.primary}12`, borderColor:(t.id==='ambient'&&ambientActive)?'#ef4444':`${T.primary}25` }}>
<span className={`text-xl ${t.id==='ambient'&&ambientActive?'ambient-active':''}`}>{t.icon}</span>
<span className="text-[9px] font-black text-center" style={{ color:T.text }}>{t.label}</span>
<span className="text-[7px] opacity-50" style={{ color:T.text }}>{t.desc}</span>
</button>
))}
</div>
<div className="px-4 pb-4">
<div className="p-3 rounded-xl border flex items-center justify-between" style={{ borderColor:`${T.text}12`, background:agentEnabled?`${T.primary}10`:`${T.text}06` }}>
<div>
<p className="font-black text-xs">🤖 وضع الوكلاء المتخصصين</p>
<p className="text-[9px] opacity-50 mt-0.5">تشخيص → صيدلة → مراجعة (3× أدق)</p>
</div>
<div onClick={() => setAgentEnabled(v=>!v)} className="toggle-switch cursor-pointer" style={{ background:agentEnabled?T.primary:`${T.text}30` }}>
<div className="toggle-thumb" style={{ right:agentEnabled?'2px':'18px' }} />
</div>
</div>
</div>
{ambientTranscript && (
<div className="px-4 pb-4">
<div className="p-3 rounded-xl border text-xs" style={{ borderColor:'#ef444430', background:'#ef444408' }}>
<div className="flex justify-between items-center mb-1">
<p className="font-black text-[10px] text-red-400">🎙️ نص مسجّل</p>
<button onClick={() => setAmbientTranscript('')} className="text-[9px] opacity-50 text-red-400">مسح</button>
</div>
<p className="leading-relaxed opacity-70 text-[10px]" style={{ color:T.text }}>{ambientTranscript}</p>
<button onClick={() => { setInput(p => p + '\n' + ambientTranscript); setAmbientTranscript(''); togglePanel('medtools'); }}
className="mt-2 w-full py-1.5 rounded-lg font-black text-[10px] text-white active:scale-95" style={{ background:T.primary }}>
أضف للدردشة
</button>
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* ── DRUG CHECKER PANEL ── */}
{panels.drugcheck && (
<div className="fixed inset-0 z-[68] bg-black/60 backdrop-blur-sm overflow-y-auto" onClick={e => e.target===e.currentTarget && togglePanel('drugcheck')}>
<div className="w-full max-w-lg mx-auto my-4 px-3">
<div className="rounded-2xl shadow-2xl animate-slide-up border overflow-hidden" style={{ background:T.card, borderColor:`${T.text}20` }}>
<div className="p-4 border-b flex justify-between items-center" style={{ borderColor:`${T.text}15` }}>
<div className="flex items-center gap-2"><span className="text-xl">💊</span><h2 className="font-black text-base">فاحص تعارضات الأدوية</h2></div>
<button onClick={() => togglePanel('drugcheck')}><svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div className="p-4 space-y-3">
<p className="text-[9px] font-bold opacity-50">أدوية بالاسم الإنجليزي — دواءان على الأقل</p>
<div className="flex gap-2">
<input type="text" value={drugInput} onChange={e=>setDrugInput(e.target.value)}
onKeyDown={e => { if (e.key==='Enter'&&drugInput.trim()) { setDrugList(p=>[...p,drugInput.trim()]); setDrugInput(''); } }}
placeholder="Warfarin, Aspirin, Metformin..."
className="flex-1 p-2.5 rounded-xl border outline-none font-mono text-sm"
style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} dir="ltr" />
<button onClick={() => { if(drugInput.trim()){setDrugList(p=>[...p,drugInput.trim()]);setDrugInput('');} }}
className="px-4 rounded-xl font-black text-sm text-white active:scale-95" style={{ background:T.primary }}>+</button>
</div>
{drugList.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{drugList.map((d,i) => (
<span key={i} className="flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-black border" style={{ background:`${T.primary}15`, borderColor:`${T.primary}30`, color:T.primary }}>
{d}<button onClick={()=>setDrugList(p=>p.filter((_,j)=>j!==i))} className="text-red-400 ml-0.5">×</button>
</span>
))}
</div>
)}
{drugList.length >= 2 && (
<button onClick={handleDrugCheck} disabled={isCheckingDrugs}
className="w-full py-2.5 rounded-xl font-black text-sm text-white active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50"
style={{ background:T.primary }}>
{isCheckingDrugs ? <><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full spin" /><span>فحص عبر RxNorm...</span></> : '🔍 فحص التعارضات'}
</button>
)}
{drugResults && (
<div className="space-y-2">
{drugResults.error && <p className="text-[11px] text-red-400 font-bold">{drugResults.error}</p>}
{!drugResults.error && drugResults.interactions.length === 0 && (
<div className="p-3 rounded-xl" style={{ background:'#10b98115', border:'1px solid #10b98130' }}>
<p className="text-green-400 font-black text-sm">✅ لا تعارضات دوائية في قاعدة RxNorm</p>
</div>
)}
{drugResults.interactions.map((x,i) => (
<div key={i} className={'drug-' + (x.severity==='High'||x.severity==='Major'?'major':x.severity==='Moderate'?'moderate':'minor')}>
<p className="font-black text-xs">{x.drug1} × {x.drug2}</p>
<p className="text-[10px] opacity-70 mt-0.5">{x.severity} — {x.desc.substring(0,120)}</p>
</div>
))}
{drugResults.interactions.length > 0 && (
<button onClick={() => { setInput('تعارض دوائي: ' + drugResults.interactions.map(x=>x.drug1+' x '+x.drug2+': '+x.severity).join(' / ') + ' - الرجاء التفسير السريري.'); togglePanel('drugcheck'); }}
className="w-full py-2 rounded-xl font-black text-[11px] text-white active:scale-95" style={{ background:T.primary }}>
تفسير بالذكاء الاصطناعي
</button>
)}
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* ── PUBMED PANEL ── */}
{panels.pubmed && (
<div className="fixed inset-0 z-[68] bg-black/60 backdrop-blur-sm overflow-y-auto" onClick={e => e.target===e.currentTarget && togglePanel('pubmed')}>
<div className="w-full max-w-lg mx-auto my-4 px-3">
<div className="rounded-2xl shadow-2xl animate-slide-up border overflow-hidden" style={{ background:T.card, borderColor:`${T.text}20` }}>
<div className="p-4 border-b flex justify-between items-center" style={{ borderColor:`${T.text}15` }}>
<div className="flex items-center gap-2"><span className="text-xl">📚</span><h2 className="font-black text-base">بحث PubMed</h2></div>
<button onClick={() => togglePanel('pubmed')}><svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div className="p-4 space-y-3">
<div className="flex gap-2">
<input autoFocus type="text" value={pubmedQuery} onChange={e=>setPubmedQuery(e.target.value)}
onKeyDown={e => e.key==='Enter' && handlePubmedSearch()}
placeholder="myocardial infarction treatment 2024..."
className="flex-1 p-2.5 rounded-xl border outline-none font-mono text-sm"
style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} dir="ltr" />
<button onClick={handlePubmedSearch} disabled={isSearchingPubmed}
className="px-4 rounded-xl font-black text-sm text-white active:scale-95 disabled:opacity-50"
style={{ background:T.primary }}>
{isSearchingPubmed ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full spin" /> : '🔍'}
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto space-y-2">
{pubmedResults.length===0 && !isSearchingPubmed && <p className="text-center py-6 text-xs opacity-30 font-bold">35+ مليون بحث طبي محكّم</p>}
{pubmedResults.map((r,i) => (
<div key={i} className="p-3 rounded-xl border" style={{ background:`${T.text}06`, borderColor:`${T.text}12` }}>
<a href={r.url} target="_blank" rel="noreferrer" className="font-black text-xs hover:underline block mb-1" style={{ color:T.primary }}>{r.title}</a>
<p className="text-[9px] opacity-50">{r.authors}{r.journal?' · '+r.journal:''}{r.year?' '+r.year:''}</p>
<div className="flex gap-1.5 mt-1.5 flex-wrap">
<a href={r.url} target="_blank" rel="noreferrer" className="text-[9px] font-black px-2 py-0.5 rounded-full" style={{ background:`${T.primary}20`, color:T.primary }}>PMID {r.pmid}</a>
<button onClick={() => { setInput(r.title + ' (' + r.authors + ', ' + r.year + ') — ما أبرز النتائج والتطبيق السريري؟'); togglePanel('pubmed'); }}
className="text-[9px] font-black px-2 py-0.5 rounded-full" style={{ background:`${T.text}10`, color:T.text }}>تحليل بالذكاء الاصطناعي</button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* ── ICD-10 PANEL ── */}
{panels.icd10 && (
<div className="fixed inset-0 z-[68] bg-black/60 backdrop-blur-sm overflow-y-auto" onClick={e => e.target===e.currentTarget && togglePanel('icd10')}>
<div className="w-full max-w-lg mx-auto my-4 px-3">
<div className="rounded-2xl shadow-2xl animate-slide-up border overflow-hidden" style={{ background:T.card, borderColor:`${T.text}20` }}>
<div className="p-4 border-b flex justify-between items-center" style={{ borderColor:`${T.text}15` }}>
<div className="flex items-center gap-2"><span className="text-xl">🏷️</span><h2 className="font-black text-base">ترميز ICD-10</h2></div>
<button onClick={() => togglePanel('icd10')}><svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div className="p-4 space-y-3">
<input autoFocus type="text" value={icdQuery} onChange={e => { setIcdQuery(e.target.value); handleICD10Search(e.target.value); }}
placeholder="diabetes, pneumonia, heart failure..."
className="w-full p-2.5 rounded-xl border outline-none font-mono text-sm"
style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} dir="ltr" />
<div className="max-h-[60vh] overflow-y-auto space-y-1.5">
{isSearchingICD && <div className="flex justify-center py-4"><div className="w-6 h-6 border-2 border-t-transparent rounded-full spin" style={{ borderColor:`${T.primary}40`, borderTopColor:T.primary }} /></div>}
{icdResults.map((r,i) => (
<div key={i} className="p-3 rounded-xl border flex items-center gap-3" style={{ background:`${T.text}06`, borderColor:`${T.text}12` }}>
<span className="font-mono font-black text-sm shrink-0" style={{ color:T.primary }}>{r.code}</span>
<span className="text-xs font-bold flex-1">{r.name}</span>
<button onClick={() => { setInput('ICD-10: ' + r.code + ' — ' + r.name + ' الرجاء الشرح السريري.'); togglePanel('icd10'); }}
className="shrink-0 text-[9px] font-black px-2 py-0.5 rounded-full active:scale-95" style={{ background:`${T.primary}20`, color:T.primary }}>
تفسير
</button>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* ── CALCULATOR PANEL ── */}
{panels.calculator && (
<div className="fixed inset-0 z-[68] bg-black/60 backdrop-blur-sm overflow-y-auto" onClick={e => e.target===e.currentTarget && togglePanel('calculator')}>
<div className="w-full max-w-lg mx-auto my-4 px-3">
<div className="rounded-2xl shadow-2xl animate-slide-up border overflow-hidden" style={{ background:T.card, borderColor:`${T.text}20` }}>
<div className="p-4 border-b flex justify-between items-center" style={{ borderColor:`${T.text}15` }}>
<div className="flex items-center gap-2"><span className="text-xl">🧮</span><h2 className="font-black text-base">الحاسبات الطبية</h2></div>
<button onClick={() => togglePanel('calculator')}><svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div className="flex gap-1 p-3 flex-wrap border-b" style={{ borderColor:`${T.text}10` }}>
{[
{id:'bmi', l:'BMI'},
{id:'gfr', l:'GFR CKD'},
{id:'cha2ds2', l:'CHA₂DS₂'},
{id:'wellsdvt', l:'Wells DVT'},
{id:'sofa', l:'SOFA'},
{id:'news2', l:'NEWS2 🆕'},
{id:'cg', l:'Cockcroft 🆕'},
{id:'apache', l:'APACHE II 🆕'},
{id:'peddose', l:'جرعة أطفال 🆕'},
].map(c => (
<button key={c.id} onClick={() => { setCalcMode(c.id); setCalcInputs({}); setCalcResult(null); }}
className="px-2.5 py-1.5 rounded-xl font-black text-[9px] border transition-all active:scale-95"
style={{ background:calcMode===c.id?T.primary:`${T.text}08`, color:calcMode===c.id?'white':T.text, borderColor:calcMode===c.id?T.primary:`${T.text}15` }}>
{c.l}
</button>
))}
</div>
<div className="p-4 space-y-3">
{calcMode === 'bmi' && (
<div className="grid grid-cols-2 gap-3">
<div><label className="text-[9px] font-black opacity-50 block mb-1">الوزن (كجم)</label><input type="number" placeholder="70" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,weight:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">الطول (سم)</label><input type="number" placeholder="170" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,height:e.target.value}))} /></div>
</div>
)}
{calcMode === 'gfr' && (
<>
<div className="grid grid-cols-2 gap-3">
<div><label className="text-[9px] font-black opacity-50 block mb-1">الكرياتينين (mg/dL)</label><input type="number" step="0.1" placeholder="1.0" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,creatinine:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">العمر</label><input type="number" placeholder="45" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,age:e.target.value}))} /></div>
</div>
<div className="flex gap-4 text-xs font-bold">
<label className="flex items-center gap-1.5 cursor-pointer"><input type="radio" name="sex_gfr" onChange={()=>setCalcInputs(p=>({...p,sex:'male'}))} defaultChecked /><span>ذكر</span></label>
<label className="flex items-center gap-1.5 cursor-pointer"><input type="radio" name="sex_gfr" onChange={()=>setCalcInputs(p=>({...p,sex:'female'}))} /><span>أنثى</span></label>
</div>
</>
)}
{calcMode === 'cha2ds2' && (
<div className="grid grid-cols-2 gap-2 text-xs font-bold">
{[{k:'hf',l:'قصور القلب'},{k:'htn',l:'ارتفاع الضغط'},{k:'dm',l:'السكري'},{k:'stroke',l:'سكتة سابقة×2'},{k:'vasc',l:'مرض وعائي'}].map(f => (
<label key={f.k} className="flex items-center gap-2 p-2 rounded-xl border cursor-pointer" style={{ background:calcInputs[f.k]?`${T.primary}15`:`${T.text}06`, borderColor:calcInputs[f.k]?T.primary:`${T.text}12` }}>
<input type="checkbox" checked={!!calcInputs[f.k]} onChange={e=>setCalcInputs(p=>({...p,[f.k]:e.target.checked}))} />
<span>{f.l}</span>
</label>
))}
<div className="grid grid-cols-2 gap-2 col-span-2">
<div><label className="text-[9px] font-black opacity-50 block mb-1">العمر</label><input type="number" placeholder="65" className="w-full p-2 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,age:e.target.value}))} /></div>
<label className="flex items-center gap-2 p-2 rounded-xl border cursor-pointer mt-4" style={{ background:calcInputs.sex==='female'?`${T.primary}15`:`${T.text}06`, borderColor:calcInputs.sex==='female'?T.primary:`${T.text}12` }}>
<input type="checkbox" checked={calcInputs.sex==='female'} onChange={e=>setCalcInputs(p=>({...p,sex:e.target.checked?'female':'male'}))} />
<span className="text-xs font-bold">أنثى (+1)</span>
</label>
</div>
</div>
)}
{calcMode === 'wellsdvt' && (
<div className="grid grid-cols-1 gap-1.5 text-xs font-bold">
{[{k:'cancer',l:'سرطان نشط'},{k:'paralysis',l:'شلل أو تجبيس'},{k:'bedridden',l:'طريح الفراش 3+ أيام'},{k:'tenderness',l:'وجع على امتداد الوريد'},{k:'swollen',l:'تورم الساق كلها'},{k:'pitting',l:'وذمة انطباعية'},{k:'collateral',l:'أوردة سطحية ظاهرة'},{k:'prevdvt',l:'DVT سابق'},{k:'altdx',l:'تشخيص بديل محتمل (-2)'}].map(f => (
<label key={f.k} className="flex items-center gap-2 p-2 rounded-xl border cursor-pointer" style={{ background:calcInputs[f.k]?`${T.primary}15`:`${T.text}06`, borderColor:calcInputs[f.k]?T.primary:`${T.text}12` }}>
<input type="checkbox" checked={!!calcInputs[f.k]} onChange={e=>setCalcInputs(p=>({...p,[f.k]:e.target.checked}))} />
<span>{f.l}</span>
</label>
))}
</div>
)}
{calcMode === 'sofa' && (
<div className="grid grid-cols-2 gap-3">
{[{k:'resp',l:'التنفس'},{k:'coag',l:'التخثر'},{k:'liver',l:'الكبد'},{k:'cardio',l:'القلب/الأوعية'},{k:'cns',l:'الجهاز العصبي'},{k:'renal',l:'الكلى'}].map(f => (
<div key={f.k}>
<label className="text-[9px] font-black opacity-50 block mb-1">{f.l}</label>
<select className="w-full p-2 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,[f.k]:e.target.value}))}>
{['0 — طبيعي','1 — خفيف','2 — متوسط','3 — شديد','4 — حرج'].map((o,i)=><option key={i} value={i}>{o}</option>)}
</select>
</div>
))}
</div>
)}
{calcMode === 'news2' && (
<div className="grid grid-cols-2 gap-3">
<div><label className="text-[9px] font-black opacity-50 block mb-1">معدل التنفس (مرة/دقيقة)</label><input type="number" placeholder="16" defaultValue="16" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,respRate:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">SpO₂ (%)</label><input type="number" placeholder="98" defaultValue="98" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,spo2:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">الضغط الانقباضي (mmHg)</label><input type="number" placeholder="120" defaultValue="120" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,systolic:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">النبض (مرة/دقيقة)</label><input type="number" placeholder="80" defaultValue="80" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,pulse:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">درجة الحرارة (°C)</label><input type="number" step="0.1" placeholder="37.0" defaultValue="37" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,temp:e.target.value}))} /></div>
<div>
<label className="text-[9px] font-black opacity-50 block mb-1">الوعي</label>
<select className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,consciousness:e.target.value}))}>
<option value="A">A — متنبّه</option>
<option value="CVPU">CVPU — استجابة مخففة</option>
</select>
</div>
<label className="flex items-center gap-2 p-2 rounded-xl border cursor-pointer col-span-2 text-xs font-bold" style={{ background:calcInputs.suppO2?`${T.primary}15`:`${T.text}06`, borderColor:calcInputs.suppO2?T.primary:`${T.text}12` }}>
<input type="checkbox" checked={!!calcInputs.suppO2} onChange={e=>setCalcInputs(p=>({...p,suppO2:e.target.checked}))} />
يتلقى أكسجين تكميلي (+2)
</label>
</div>
)}
{calcMode === 'cg' && (
<div className="grid grid-cols-2 gap-3">
<div><label className="text-[9px] font-black opacity-50 block mb-1">العمر (سنة)</label><input type="number" placeholder="50" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,age:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">الوزن (كجم)</label><input type="number" placeholder="70" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,weight:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">الكرياتينين (mg/dL)</label><input type="number" step="0.1" placeholder="1.0" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,creatinine:e.target.value}))} /></div>
<div className="flex gap-4 items-end pb-1 text-xs font-bold">
<label className="flex items-center gap-1.5 cursor-pointer"><input type="radio" name="sex_cg" onChange={()=>setCalcInputs(p=>({...p,sex:'male'}))} defaultChecked /><span>ذكر</span></label>
<label className="flex items-center gap-1.5 cursor-pointer"><input type="radio" name="sex_cg" onChange={()=>setCalcInputs(p=>({...p,sex:'female'}))} /><span>أنثى (×0.85)</span></label>
</div>
</div>
)}
{calcMode === 'apache' && (
<div className="grid grid-cols-2 gap-3">
<div><label className="text-[9px] font-black opacity-50 block mb-1">العمر</label><input type="number" placeholder="55" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,age:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">الحرارة (°C)</label><input type="number" step="0.1" placeholder="37" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,temp:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">الضغط الوسطى MAP</label><input type="number" placeholder="90" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,map:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">النبض</label><input type="number" placeholder="80" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,hr:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">التنفس/دقيقة</label><input type="number" placeholder="16" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,rr:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">GCS</label><input type="number" min="3" max="15" placeholder="15" defaultValue="15" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,gcs:e.target.value}))} /></div>
<div><label className="text-[9px] font-black opacity-50 block mb-1">الكرياتينين (mg/dL)</label><input type="number" step="0.1" placeholder="1.0" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,cr:e.target.value}))} /></div>
<div>
<label className="text-[9px] font-black opacity-50 block mb-1">نقاط الأمراض المزمنة</label>
<select className="w-full p-2 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,chronic:e.target.value}))}>
<option value="0">0 — لا توجد</option>
<option value="2">2 — مرض مزمن غير جراحي</option>
<option value="5">5 — جراحة طارئة أو مناعة</option>
</select>
</div>
</div>
)}
{calcMode === 'peddose' && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><label className="text-[9px] font-black opacity-50 block mb-1">الوزن (كجم)</label><input type="number" step="0.5" placeholder="15" className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,weight:e.target.value}))} /></div>
<div>
<label className="text-[9px] font-black opacity-50 block mb-1">الدواء</label>
<select className="w-full p-2.5 rounded-xl border outline-none font-bold text-sm" style={{ background:`${T.text}08`, borderColor:`${T.text}18`, color:T.text }} onChange={e=>setCalcInputs(p=>({...p,drug:e.target.value}))}>
{['Paracetamol','Ibuprofen','Amoxicillin','Azithromycin','Cefuroxime','Metronidazole'].map(d=><option key={d} value={d}>{d}</option>)}
</select>
</div>
</div>
<p className="text-[9px] opacity-40 font-bold">* الجرعة المحسوبة على أساس الوزن مع الحد الأقصى للبالغين</p>
</div>
)}
<button onClick={handleCalculate} className="w-full py-2.5 rounded-xl font-black text-sm text-white active:scale-95" style={{ background:T.primary }}>
احسب
</button>
{calcResult && (
<div className={'p-4 rounded-xl border calc-result-' + calcResult.color} style={{ borderColor:'currentColor', background:'rgba(0,0,0,0.04)' }}>
{calcMode === 'peddose' ? (
<div>
<p className="font-black text-sm mb-1">💊 جرعة {calcResult.drug}</p>
<p className="text-2xl font-black" style={{ color: '#10b981' }}>{calcResult.calc} mg</p>
<p className="text-[10px] opacity-70 mt-1">{calcResult.unit} · {calcResult.freq}</p>
<p className="text-[9px] opacity-50">الحد الأقصى: {calcResult.max} mg/dose</p>
</div>
) : (
<div className="flex items-center gap-3">
<span className="text-3xl font-black">{calcResult.value ?? calcResult.score ?? calcResult.total}</span>
<div>
<p className="font-black text-sm">{calcResult.category || calcResult.stage || calcResult.risk || calcResult.probability || calcResult.mortality}</p>
{calcResult.recommendation && <p className="text-[10px] opacity-70 mt-0.5">{calcResult.recommendation}</p>}
{calcResult.action && <p className="text-[10px] opacity-70 mt-0.5">⚡ {calcResult.action}</p>}
{calcResult.unit && <p className="text-[10px] opacity-50">{calcResult.unit}</p>}
</div>
</div>
)}
<button onClick={() => {
let summary = '';
if (calcMode === 'peddose') summary = `جرعة الأطفال: ${calcResult.drug} ${calcResult.calc} mg، ${calcResult.freq}`;
else if (calcMode === 'news2') summary = `NEWS2: ${calcResult.score} نقطة — ${calcResult.risk} — ${calcResult.action}`;
else if (calcMode === 'cg') summary = `Cockcroft-Gault: ${calcResult.value} mL/min — ${calcResult.stage}`;
else if (calcMode === 'apache') summary = `APACHE II: ${calcResult.score} — وفيات متوقعة: ${calcResult.mortality}`;
else summary = 'نتيجة الحاسبة: ' + JSON.stringify(calcResult);
setInput(summary + ' — الرجاء التفسير السريري التفصيلي.'); togglePanel('calculator');
}}
className="w-full py-2 rounded-xl font-black text-[11px] text-white active:scale-95 mt-3" style={{ background:T.primary }}>
تفسير بالذكاء الاصطناعي 🤖
</button>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* ── SOAP PANEL ── */}
{panels.soap && soapNote && (
<div className="fixed inset-0 z-[68] bg-black/60 backdrop-blur-sm overflow-y-auto" onClick={e => e.target===e.currentTarget && togglePanel('soap')}>
<div className="w-full max-w-lg mx-auto my-4 px-3">
<div className="rounded-2xl shadow-2xl animate-slide-up border overflow-hidden" style={{ background:T.card, borderColor:`${T.text}20` }}>
<div className="p-4 border-b flex justify-between items-center" style={{ borderColor:`${T.text}15` }}>
<div className="flex items-center gap-2"><span className="text-xl">📋</span><h2 className="font-black text-base">تقرير SOAP</h2></div>
<div className="flex gap-2 items-center">
<button onClick={() => { navigator.clipboard.writeText('S: '+soapNote.subjective+'\nO: '+soapNote.objective+'\nA: '+soapNote.assessment+'\nP: '+soapNote.plan).then(()=>toast('تم نسخ SOAP ✅','success',2000)); }} className="text-[10px] font-black px-3 py-1.5 rounded-xl active:scale-95" style={{ background:`${T.primary}20`, color:T.primary }}>📋 نسخ</button>
<button onClick={() => togglePanel('soap')}><svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
</div>
<div className="p-4 space-y-3">
{[{k:'subjective',l:'S — Subjective',c:'soap-s',icon:'🗣️'},{k:'objective',l:'O — Objective',c:'soap-o',icon:'🔬'},{k:'assessment',l:'A — Assessment',c:'soap-a',icon:'📊'},{k:'plan',l:'P — Plan',c:'soap-p',icon:'📝'}].map(s => (
<div key={s.k} className={'soap-section ' + s.c}>
<p className="font-black text-[10px] opacity-60 mb-1">{s.icon} {s.l}</p>
<p className="text-xs leading-relaxed font-bold" style={{ color:T.text }}>{soapNote[s.k] || '—'}</p>
</div>
))}
{soapNote.icd10_codes && soapNote.icd10_codes.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{soapNote.icd10_codes.map((c,i) => <span key={i} className="text-[10px] font-black px-2 py-0.5 rounded-full" style={{ background:`${T.primary}20`, color:T.primary }}>{c}</span>)}
</div>
)}
{soapNote.follow_up && <p className="text-[10px] font-bold opacity-60">⏰ المتابعة: {soapNote.follow_up}</p>}
</div>
</div>
</div>
</div>
)}
{/* ── SETTINGS ── */}
{panels.settings && (
<div className="fixed inset-0 z-[70] bg-black/60 backdrop-blur-md flex justify-end" onClick={e => e.target === e.currentTarget && togglePanel('settings')}>
<div className="w-[88%] max-w-sm h-full flex flex-col shadow-2xl border-l" style={{ background: T.card, borderColor: `${T.text}18` }}>
<div className="p-5 border-b flex justify-between items-center shrink-0" style={{ borderColor: `${T.text}10` }}>
<h2 className="text-base font-black">الإعدادات</h2>
<button onClick={() => togglePanel('settings')} className="p-2 rounded-xl" style={{ background: `${T.text}08` }}>
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-5">
{/* User Card */}
<div className="p-3.5 rounded-2xl border flex items-center gap-3" style={{ borderColor: `${T.text}12`, background: `${T.primary}10` }}>
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-white font-black text-lg shrink-0 shadow-lg" style={{ background: T.primary }}>{currentUser?.name?.[0]}</div>
<div className="flex-1 min-w-0">
<p className="font-black text-sm truncate">{currentUser?.name}</p>
<p className="text-[9px] opacity-45 font-mono truncate">{currentUser?.email}</p>
<span className="text-[8px] font-black mt-0.5 inline-block" style={{ color: T.primary }}>{currentUser?.type === 'dev' ? '⚙️ Developer Root' : '🩺 Medical User'}</span>
</div>
</div>
{/* ── OLLAMA / NGROK URL — مطور فقط ── */}
{currentUser?.type === 'dev' && (
<div>
<p className="text-[9px] font-black uppercase tracking-widest opacity-40 mb-2">⚙️ إدارة الروابط</p>
<div className="p-3 rounded-xl border space-y-3" style={{ borderColor: `${T.text}12`, background: 'rgba(0,0,0,0.15)' }}>
{/* حقل إضافة رابط جديد */}
<input
type="url"
value={ngrokUrlInput}
onChange={e => setNgrokUrlInput(e.target.value)}
placeholder="https://xxxx.ngrok-free.app"
className="w-full p-2.5 rounded-xl outline-none font-mono text-[10px] border"
style={{ background: `${T.text}06`, borderColor: `${T.text}15`, color: T.text }}
dir="ltr"
/>
<div className="flex gap-1.5">
<button onClick={testNgrokConnection} disabled={ngrokTestStatus === 'testing'}
className="flex-1 py-2 rounded-xl font-black text-[10px] active:scale-95 flex items-center justify-center gap-1"
style={{ background: ngrokTestStatus === 'ok' ? '#10b98120' : ngrokTestStatus === 'fail' ? '#ef444420' : `${T.primary}15`, color: ngrokTestStatus === 'ok' ? '#10b981' : ngrokTestStatus === 'fail' ? '#ef4444' : T.primary }}>
{ngrokTestStatus === 'testing' ? <span className="spin"></span> : ngrokTestStatus === 'ok' ? '✅' : ngrokTestStatus === 'fail' ? '❌' : '🔌'} اختبار
</button>
<button onClick={() => saveNgrokUrl(ngrokUrlInput)}
className="flex-1 py-2 rounded-xl font-black text-[10px] active:scale-95 text-white"
style={{ background: T.primary }}>
💾 حفظ وأضف
</button>
</div>
{/* قائمة الروابط المحفوظة */}
{savedUrls.length > 0 && (
<div>
<p className="text-[9px] font-bold opacity-40 mb-1.5">محفوظة ({savedUrls.length})</p>
<div className="space-y-1 max-h-40 overflow-y-auto">
{savedUrls.map((u, i) => (
<div key={i} className="flex items-center gap-1 p-2 rounded-xl border"
style={{ borderColor: ACTIVE_NGROK_URL === u.url ? `${T.primary}40` : `${T.text}10`, background: ACTIVE_NGROK_URL === u.url ? `${T.primary}10` : `${T.text}04` }}>
<p className="font-mono text-[9px] truncate flex-1" style={{ color: ACTIVE_NGROK_URL === u.url ? T.primary : T.text }}>{u.url}</p>
{ACTIVE_NGROK_URL !== u.url && (
<button onClick={() => activateSavedUrl(u.url)} className="shrink-0 text-[8px] font-black px-2 py-0.5 rounded-lg active:scale-95"
style={{ background: `${T.primary}15`, color: T.primary }}>تفعيل</button>
)}
{ACTIVE_NGROK_URL === u.url && (
<span className="shrink-0 text-[8px] font-black" style={{ color: T.primary }}>● نشط</span>
)}
<button onClick={() => deleteSavedUrl(u.url)} className="shrink-0 text-[8px] px-1 py-0.5 rounded active:scale-95" style={{ color: '#ef4444' }}>✕</button>
</div>
))}
</div>
</div>
)}
<p className="text-[7px] opacity-25 font-mono truncate">نشط: {ACTIVE_NGROK_URL || 'غير محدد'}</p>
</div>
</div>
)}
{/* RAG Settings */}
<div>
<p className="text-[9px] font-black uppercase tracking-widest opacity-40 mb-2">نظام الاسترجاع المعزز (RAG)</p>
<div className="p-3 rounded-xl border space-y-3" style={{ borderColor: `${T.text}12` }}>
<div className="flex justify-between items-center">
<div><p className="font-black text-xs">تفعيل RAG</p><p className="text-[9px] opacity-50">استرجاع المعرفة من المصادر</p></div>
<div onClick={() => setRagEnabled(v => !v)} className="toggle-switch cursor-pointer" style={{ background: ragEnabled ? T.primary : `${T.text}30` }}>
<div className="toggle-thumb" style={{ right: ragEnabled ? '2px' : '18px' }} />
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-[10px] font-bold">
<div className="p-2 rounded-lg" style={{ background: `${T.primary}12` }}><p className="text-lg font-black" style={{ color: T.primary }}>{ragStats.chunks}</p><p className="opacity-50">قطعة</p></div>
<div className="p-2 rounded-lg" style={{ background: `${T.primary}12` }}><p className="text-lg font-black" style={{ color: T.primary }}>{ragStats.sources}</p><p className="opacity-50">مصدر</p></div>
<div className="p-2 rounded-lg" style={{ background: `${T.primary}12` }}><p className="text-lg font-black" style={{ color: T.primary }}>{TOP_K_CHUNKS}</p><p className="opacity-50">أعلى K</p></div>
</div>
<button onClick={() => { togglePanel('sources'); togglePanel('settings'); }} className="w-full py-2 rounded-xl font-black text-xs active:scale-95" style={{ background: `${T.primary}15`, color: T.primary }}>إدارة المصادر</button>
</div>
</div>
{/* Themes */}
<div>
<div className="flex items-center justify-between mb-3">
<p className="text-[9px] font-black uppercase tracking-widest opacity-40">المظهر</p>
<span className="text-[9px] font-black px-2 py-0.5 rounded-full" style={{ background: `${T.primary}18`, color: T.primary }}>{T.name}</span>
</div>
<div className="grid grid-cols-5 gap-2">
{THEMES.map(t => (
<button key={t.id} onClick={() => setTheme(t)} title={t.name}
className="aspect-square rounded-xl border-2 transition-all"
style={{ background: t.bg, borderColor: theme.id === t.id ? t.primary : `${t.text}20`,
transform: theme.id === t.id ? 'scale(1.12)' : 'scale(1)',
boxShadow: theme.id === t.id ? `0 4px 16px ${t.primary}60` : 'none' }} />
))}
</div>
</div>
{/* ── EDGE TTS VOICE SETTINGS ── */}
<div>
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<p className="text-[9px] font-black uppercase tracking-widest opacity-40">محرك الصوت</p>
<span className="edge-voice-badge">Microsoft Edge TTS</span>
</div>
<div onClick={() => setTtsEnabled(v => !v)} className="toggle-switch cursor-pointer" style={{ background: ttsEnabled ? T.primary : `${T.text}30` }}>
<div className="toggle-thumb" style={{ right: ttsEnabled ? '2px' : '18px' }} />
</div>
</div>
{/* Voice Category Tabs */}
<div className="flex gap-1.5 mb-3 flex-wrap">
{[
{ id: 'arabic', label: '🇸🇦 عربي' },
{ id: 'english', label: '🇺🇸 إنجليزي' },
{ id: 'multilingual', label: '🌍 متعدد' },
{ id: 'system', label: '💻 نظام' },
].map(tab => (
<button key={tab.id} onClick={() => setVoiceTabActive(tab.id)}
className="voice-tab"
style={voiceTabActive === tab.id
? { background: T.primary, borderColor: T.primary, color: 'white' }
: { background: `${T.text}08`, borderColor: `${T.text}15`, color: T.text }}>
{tab.label}
</button>
))}
</div>
{/* Voice Grid */}
<div className="grid grid-cols-2 gap-1.5 max-h-48 overflow-y-auto mb-3">
{voicesForTab.map((v, i) => (
<button key={v.name + i} onClick={() => {
const edgeVoice = ALL_EDGE_VOICES.find(ev => ev.name === v.name);
if (edgeVoice) {
setSelectedVoice(edgeVoice);
} else {
setSelectedVoice({ name: v.name, label: v.label, lang: v.lang, gender: v.gender || '?' });
}
if (ttsEnabled) {
const vc = ALL_EDGE_VOICES.find(ev => ev.name === v.name) || { name: v.name, lang: v.lang };
speakWithEdgeTTS('مرحباً، هذا الصوت المختار.', vc.name, vc.lang, voiceRate, voicePitch, null, null);
}
}}
className="p-2 rounded-xl border text-[9px] font-bold text-right transition-all active:scale-95 flex items-center gap-1.5"
style={{
background: selectedVoice.name === v.name ? `${T.primary}20` : `${T.text}06`,
borderColor: selectedVoice.name === v.name ? T.primary : `${T.text}12`,
color: selectedVoice.name === v.name ? T.primary : T.text
}}>
<span className="opacity-60">{v.gender || '?'}</span>
<span className="truncate flex-1">{v.label || v.name}</span>
{selectedVoice.name === v.name && <span style={{ color: T.primary }}></span>}
</button>
))}
</div>
{/* Rate & Pitch */}
<div className="grid grid-cols-2 gap-3 text-[10px]">
<div>
<p className="font-bold opacity-50 mb-1.5">السرعة: {voiceRate.toFixed(1)}×</p>
<input type="range" min="0.5" max="2" step="0.1" value={voiceRate} onChange={e => setVoiceRate(parseFloat(e.target.value))} className="w-full" />
</div>
<div>
<p className="font-bold opacity-50 mb-1.5">الحدة: {voicePitch.toFixed(1)}</p>
<input type="range" min="0.5" max="2" step="0.1" value={voicePitch} onChange={e => setVoicePitch(parseFloat(e.target.value))} className="w-full" />
</div>
</div>
<div className="mt-2 p-2.5 rounded-xl border text-[10px] font-bold" style={{ borderColor: `${T.primary}25`, background: `${T.primary}08` }}>
<p style={{ color: T.primary }}>الصوت الحالي: <span className="font-black">{selectedVoice.label}</span></p>
<p className="opacity-50 mt-0.5">اللغة: {selectedVoice.lang} · {selectedVoice.gender}</p>
</div>
<button onClick={() => { if(ttsEnabled) speakWithEdgeTTS('مرحباً، هذا اختبار الصوت المختار في BrainMap OS.', selectedVoice.name, selectedVoice.lang, voiceRate, voicePitch, () => setTtsPlaying(true), () => setTtsPlaying(false)); }} className="mt-2 w-full py-2.5 rounded-xl font-black text-xs flex items-center justify-center gap-2 active:scale-95" style={{ background: `${T.primary}15`, color: T.primary }}>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
اختبر الصوت الآن
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-2 text-center">
{[
{ label: 'الرسائل', val: messages.length },
{ label: 'الملفات', val: uploadedFiles.length },
{ label: 'المحادثات', val: sessions.length }
].map(s => (
<div key={s.label} className="p-3 rounded-xl border" style={{ borderColor: `${T.text}10`, background: `${T.text}05` }}>
<p className="text-lg font-black" style={{ color: T.primary }}>{s.val}</p>
<p className="text-[9px] opacity-40 font-bold">{s.label}</p>
</div>
))}
</div>
{/* ════════════════════════════════════════
🔑 API KEYS SECTION — V20
مفاتيح API لجميع مزوّدي النماذج المغلقة
════════════════════════════════════════ */}
<div>
<p className="text-[9px] font-black uppercase tracking-widest opacity-40 mb-3">🔑 مفاتيح API</p>
{/* ── شرح مختصر ── */}
<div className="p-2.5 rounded-xl border mb-3 text-[9px] font-bold leading-relaxed" style={{ borderColor: `${T.primary}20`, background: `${T.primary}08` }}>
<p style={{ color: T.primary }}>🚀 أدخل أي مفتاح وانقر حفظ — سيتغيّر النموذج تلقائياً!</p>
<p className="opacity-50 mt-0.5">بعد الحفظ يصبح النموذج النشط هو أول نموذج من هذا المزوّد. مفاتيح OpenRouter مجانية وتدعم Gemma وLlama وQwen وDeepSeek وغيرها.</p>
</div>
{/* ── Groq Cloud ── */}
<div className="mb-3 p-3 rounded-xl border" style={{ borderColor: `${T.text}12`, background: `${T.text}04` }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-sm"></span>
<p className="text-[10px] font-black">Groq Cloud</p>
<span className="text-[8px] px-1.5 py-0.5 rounded-full font-black" style={{ background: '#f97316' + '20', color: '#f97316' }}>سريع جداً · مجاني</span>
</div>
{apiKeys.groq && <span className="text-[8px] font-black text-green-400">✓ نشط → {CLOSED_MODELS.groq[0]}</span>}
</div>
<p className="text-[8px] opacity-40 mb-1.5">console.groq.com → Llama 3.3 70B · Gemma 2 · Qwen</p>
<div className="flex gap-1.5">
<div className="flex-1 relative">
<input
type={showApiKey.groq ? 'text' : 'password'}
value={apiKeysInput.groq}
onChange={e => setApiKeysInput(prev => ({ ...prev, groq: e.target.value }))}
placeholder="gsk_..."
className="w-full p-2 rounded-lg text-[10px] font-mono outline-none border"
style={{ background: 'rgba(0,0,0,0.3)', borderColor: `${T.text}20`, color: T.text }}
dir="ltr"
/>
<button onClick={() => setShowApiKey(prev => ({ ...prev, groq: !prev.groq }))} className="absolute left-2 top-1/2 -translate-y-1/2 opacity-40 hover:opacity-100 text-[10px]">
{showApiKey.groq ? '🙈' : '👁'}
</button>
</div>
<button onClick={() => saveApiKey('groq', apiKeysInput.groq)} className="px-3 rounded-lg font-black text-[10px] active:scale-95 text-white shrink-0" style={{ background: '#f97316' }}>حفظ</button>
{apiKeys.groq && <button onClick={() => saveApiKey('groq', '')} className="px-2 rounded-lg font-black text-[10px] active:scale-95 text-red-400 shrink-0" style={{ background: '#ef444415' }}>✕</button>}
</div>
</div>
{/* ── OpenAI ── */}
<div className="mb-3 p-3 rounded-xl border" style={{ borderColor: `${T.text}12`, background: `${T.text}04` }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">🤖</span>
<p className="text-[10px] font-black">OpenAI</p>
<span className="text-[8px] px-1.5 py-0.5 rounded-full font-black" style={{ background: '#10b981' + '20', color: '#10b981' }}>GPT-4o · o3</span>
</div>
{apiKeys.openai && <span className="text-[8px] font-black text-green-400">✓ محفوظ</span>}
</div>
<p className="text-[8px] opacity-40 mb-1.5">platform.openai.com — مدفوع</p>
<div className="flex gap-1.5">
<div className="flex-1 relative">
<input
type={showApiKey.openai ? 'text' : 'password'}
value={apiKeysInput.openai}
onChange={e => setApiKeysInput(prev => ({ ...prev, openai: e.target.value }))}
placeholder="sk-..."
className="w-full p-2 rounded-lg text-[10px] font-mono outline-none border"
style={{ background: 'rgba(0,0,0,0.3)', borderColor: `${T.text}20`, color: T.text }}
/>
<button onClick={() => setShowApiKey(prev => ({ ...prev, openai: !prev.openai }))} className="absolute left-2 top-1/2 -translate-y-1/2 opacity-40 hover:opacity-100 text-[10px]">
{showApiKey.openai ? '🙈' : '👁'}
</button>
</div>
<button onClick={() => saveApiKey('openai', apiKeysInput.openai)} className="px-3 rounded-lg font-black text-[10px] active:scale-95 text-white shrink-0" style={{ background: '#10b981' }}>حفظ</button>
{apiKeys.openai && <button onClick={() => saveApiKey('openai', '')} className="px-2 rounded-lg font-black text-[10px] active:scale-95 text-red-400 shrink-0" style={{ background: '#ef444415' }}>✕</button>}
</div>
</div>
{/* ── Anthropic (Claude) ── */}
<div className="mb-3 p-3 rounded-xl border" style={{ borderColor: `${T.text}12`, background: `${T.text}04` }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">🧠</span>
<p className="text-[10px] font-black">Anthropic</p>
<span className="text-[8px] px-1.5 py-0.5 rounded-full font-black" style={{ background: '#8b5cf6' + '20', color: '#8b5cf6' }}>Claude Opus · Sonnet</span>
</div>
{apiKeys.anthropic && <span className="text-[8px] font-black text-green-400">✓ محفوظ</span>}
</div>
<p className="text-[8px] opacity-40 mb-1.5">console.anthropic.com — مدفوع</p>
<div className="flex gap-1.5">
<div className="flex-1 relative">
<input
type={showApiKey.anthropic ? 'text' : 'password'}
value={apiKeysInput.anthropic}
onChange={e => setApiKeysInput(prev => ({ ...prev, anthropic: e.target.value }))}
placeholder="sk-ant-..."
className="w-full p-2 rounded-lg text-[10px] font-mono outline-none border"
style={{ background: 'rgba(0,0,0,0.3)', borderColor: `${T.text}20`, color: T.text }}
/>
<button onClick={() => setShowApiKey(prev => ({ ...prev, anthropic: !prev.anthropic }))} className="absolute left-2 top-1/2 -translate-y-1/2 opacity-40 hover:opacity-100 text-[10px]">
{showApiKey.anthropic ? '🙈' : '👁'}
</button>
</div>
<button onClick={() => saveApiKey('anthropic', apiKeysInput.anthropic)} className="px-3 rounded-lg font-black text-[10px] active:scale-95 text-white shrink-0" style={{ background: '#8b5cf6' }}>حفظ</button>
{apiKeys.anthropic && <button onClick={() => saveApiKey('anthropic', '')} className="px-2 rounded-lg font-black text-[10px] active:scale-95 text-red-400 shrink-0" style={{ background: '#ef444415' }}>✕</button>}
</div>
</div>
{/* ── Google (Gemini) ── */}
<div className="mb-3 p-3 rounded-xl border" style={{ borderColor: `${T.text}12`, background: `${T.text}04` }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">🔵</span>
<p className="text-[10px] font-black">Google AI</p>
<span className="text-[8px] px-1.5 py-0.5 rounded-full font-black" style={{ background: '#3b82f6' + '20', color: '#3b82f6' }}>Gemini 2.5 Pro · Flash</span>
</div>
{apiKeys.google && <span className="text-[8px] font-black text-green-400">✓ محفوظ</span>}
</div>
<p className="text-[8px] opacity-40 mb-1.5">aistudio.google.com — مجاني مع حد</p>
<div className="flex gap-1.5">
<div className="flex-1 relative">
<input
type={showApiKey.google ? 'text' : 'password'}
value={apiKeysInput.google}
onChange={e => setApiKeysInput(prev => ({ ...prev, google: e.target.value }))}
placeholder="AIza..."
className="w-full p-2 rounded-lg text-[10px] font-mono outline-none border"
style={{ background: 'rgba(0,0,0,0.3)', borderColor: `${T.text}20`, color: T.text }}
/>
<button onClick={() => setShowApiKey(prev => ({ ...prev, google: !prev.google }))} className="absolute left-2 top-1/2 -translate-y-1/2 opacity-40 hover:opacity-100 text-[10px]">
{showApiKey.google ? '🙈' : '👁'}
</button>
</div>
<button onClick={() => saveApiKey('google', apiKeysInput.google)} className="px-3 rounded-lg font-black text-[10px] active:scale-95 text-white shrink-0" style={{ background: '#3b82f6' }}>حفظ</button>
{apiKeys.google && <button onClick={() => saveApiKey('google', '')} className="px-2 rounded-lg font-black text-[10px] active:scale-95 text-red-400 shrink-0" style={{ background: '#ef444415' }}>✕</button>}
</div>
</div>
{/* ── Mistral ── */}
<div className="mb-3 p-3 rounded-xl border" style={{ borderColor: `${T.text}12`, background: `${T.text}04` }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">🌊</span>
<p className="text-[10px] font-black">Mistral AI</p>
<span className="text-[8px] px-1.5 py-0.5 rounded-full font-black" style={{ background: '#06b6d4' + '20', color: '#06b6d4' }}>Large · Codestral</span>
</div>
{apiKeys.mistral && <span className="text-[8px] font-black text-green-400">✓ محفوظ</span>}
</div>
<p className="text-[8px] opacity-40 mb-1.5">console.mistral.ai — مدفوع</p>
<div className="flex gap-1.5">
<div className="flex-1 relative">
<input
type={showApiKey.mistral ? 'text' : 'password'}
value={apiKeysInput.mistral}
onChange={e => setApiKeysInput(prev => ({ ...prev, mistral: e.target.value }))}
placeholder="..."
className="w-full p-2 rounded-lg text-[10px] font-mono outline-none border"
style={{ background: 'rgba(0,0,0,0.3)', borderColor: `${T.text}20`, color: T.text }}
/>
<button onClick={() => setShowApiKey(prev => ({ ...prev, mistral: !prev.mistral }))} className="absolute left-2 top-1/2 -translate-y-1/2 opacity-40 hover:opacity-100 text-[10px]">
{showApiKey.mistral ? '🙈' : '👁'}
</button>
</div>
<button onClick={() => saveApiKey('mistral', apiKeysInput.mistral)} className="px-3 rounded-lg font-black text-[10px] active:scale-95 text-white shrink-0" style={{ background: '#06b6d4' }}>حفظ</button>
{apiKeys.mistral && <button onClick={() => saveApiKey('mistral', '')} className="px-2 rounded-lg font-black text-[10px] active:scale-95 text-red-400 shrink-0" style={{ background: '#ef444415' }}>✕</button>}
</div>
</div>
{/* ── OpenRouter 🆕 ── */}
<div className="mb-3 p-3 rounded-xl border-2" style={{ borderColor: apiKeys.openrouter ? '#ec489950' : `${T.primary}30`, background: apiKeys.openrouter ? '#ec489908' : `${T.primary}06` }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">🔀</span>
<p className="text-[10px] font-black">OpenRouter</p>
<span className="text-[8px] px-1.5 py-0.5 rounded-full font-black" style={{ background: '#ec4899' + '20', color: '#ec4899' }}>⭐ مُوصَى به · بعضه مجاني</span>
</div>
{apiKeys.openrouter && <span className="text-[8px] font-black text-green-400">✓ نشط · يدعم Llama·Gemma·Qwen</span>}
</div>
<p className="text-[8px] mb-1.5" style={{ color: '#ec4899', opacity: 0.7 }}>openrouter.ai/keys — يشغّل Ollama models عبر السحابة بدون خادم محلي!</p>
<div className="flex gap-1.5">
<div className="flex-1 relative">
<input
type={showApiKey.openrouter ? 'text' : 'password'}
value={apiKeysInput.openrouter || ''}
onChange={e => setApiKeysInput(prev => ({ ...prev, openrouter: e.target.value }))}
placeholder="sk-or-v1-..."
className="w-full p-2 rounded-lg text-[10px] font-mono outline-none border"
style={{ background: 'rgba(0,0,0,0.3)', borderColor: `${T.text}20`, color: T.text }}
dir="ltr"
/>
<button onClick={() => setShowApiKey(prev => ({ ...prev, openrouter: !prev.openrouter }))} className="absolute left-2 top-1/2 -translate-y-1/2 opacity-40 hover:opacity-100 text-[10px]">
{showApiKey.openrouter ? '🙈' : '👁'}
</button>
</div>
<button onClick={() => saveApiKey('openrouter', apiKeysInput.openrouter || '')} className="px-3 rounded-lg font-black text-[10px] active:scale-95 text-white shrink-0" style={{ background: '#ec4899' }}>حفظ</button>
{apiKeys.openrouter && <button onClick={() => saveApiKey('openrouter', '')} className="px-2 rounded-lg font-black text-[10px] active:scale-95 text-red-400 shrink-0" style={{ background: '#ef444415' }}>✕</button>}
</div>
</div>
{/* ── Together AI 🆕 ── */}
<div className="mb-3 p-3 rounded-xl border" style={{ borderColor: `${T.text}12`, background: `${T.text}04` }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">🤝</span>
<p className="text-[10px] font-black">Together AI</p>
<span className="text-[8px] px-1.5 py-0.5 rounded-full font-black" style={{ background: '#8b5cf6' + '20', color: '#8b5cf6' }}>Llama · Qwen · DeepSeek</span>
</div>
{apiKeys.together && <span className="text-[8px] font-black text-green-400">✓ محفوظ</span>}
</div>
<p className="text-[8px] opacity-40 mb-1.5">api.together.xyz — مدفوع رخيص</p>
<div className="flex gap-1.5">
<div className="flex-1 relative">
<input
type={showApiKey.together ? 'text' : 'password'}
value={apiKeysInput.together || ''}
onChange={e => setApiKeysInput(prev => ({ ...prev, together: e.target.value }))}
placeholder="tog_..."
className="w-full p-2 rounded-lg text-[10px] font-mono outline-none border"
style={{ background: 'rgba(0,0,0,0.3)', borderColor: `${T.text}20`, color: T.text }}
dir="ltr"
/>
<button onClick={() => setShowApiKey(prev => ({ ...prev, together: !prev.together }))} className="absolute left-2 top-1/2 -translate-y-1/2 opacity-40 hover:opacity-100 text-[10px]">
{showApiKey.together ? '🙈' : '👁'}
</button>
</div>
<button onClick={() => saveApiKey('together', apiKeysInput.together || '')} className="px-3 rounded-lg font-black text-[10px] active:scale-95 text-white shrink-0" style={{ background: '#8b5cf6' }}>حفظ</button>
{apiKeys.together && <button onClick={() => saveApiKey('together', '')} className="px-2 rounded-lg font-black text-[10px] active:scale-95 text-red-400 shrink-0" style={{ background: '#ef444415' }}>✕</button>}
</div>
</div>
{/* ملاحظة Ollama */}
<div className="p-2.5 rounded-xl border text-[9px] font-bold" style={{ borderColor: '#f97316' + '20', background: '#f97316' + '08' }}>
<p style={{ color: '#f97316' }}>🦙 Ollama (المحلي) / أي رابط مخصّص</p>
<p className="opacity-60 mt-0.5">Ollama لا يحتاج مفتاح — فقط رابط ngrok أو localhost. OpenRouter أو Together يمكن استخدامهما عبر قسم <strong>🔑 مفاتيح وروابط النماذج</strong> أيضاً لكل نموذج على حدة.</p>
</div>
</div>
{/* ════════════════════════════════════════
🔑 PER-MODEL KEYS & URLS MANAGER
مفتاح / رابط لكل نموذج على حدة
مع تبديل تلقائي عند فشل المفتاح
════════════════════════════════════════ */}
<div>
<p className="text-[9px] font-black uppercase tracking-widest opacity-40 mb-2">🔑 مفاتيح وروابط النماذج</p>
<div className="p-2.5 rounded-xl border mb-3 text-[9px] font-bold leading-relaxed" style={{ borderColor: `${T.primary}20`, background: `${T.primary}08` }}>
<p style={{ color: T.primary }}>أضف مفاتيح أو روابط متعددة لأي نموذج — Ollama أو Groq أو OpenAI أو غيرها.</p>
<p className="opacity-50 mt-0.5">أي نموذج يملك مفتاحاً أو رابطاً يعمل تلقائياً بصرف النظر عن نوعه. عند فشل مفتاح يتحول للتالي تلقائياً.</p>
</div>
{/* بحث في النماذج */}
<div className="flex items-center gap-2 p-2 rounded-xl border mb-2" style={{ borderColor: `${T.text}15`, background: `${T.text}06` }}>
<svg width="12" height="12" fill="none" stroke={T.primary} strokeWidth="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
type="text"
value={mkSearchQuery}
onChange={e => setMkSearchQuery(e.target.value)}
placeholder="ابحث عن نموذج لإضافة مفتاح..."
className="flex-1 bg-transparent outline-none text-[10px] font-bold"
style={{ color: T.text }}
dir="rtl"
/>
</div>
{/* قائمة النماذج مع أدوات المفاتيح */}
<div className="max-h-96 overflow-y-auto space-y-1.5">
{(() => {
const q = mkSearchQuery.trim().toLowerCase();
const allModels = [
...OLLAMA_MODELS.map(m => ({ m, vendor: 'ollama' })),
...Object.entries(CLOSED_MODELS).flatMap(([v, ms]) => ms.map(m => ({ m, vendor: v })))
].filter(({ m }) => !q || m.toLowerCase().includes(q));
if (allModels.length === 0) return <p className="text-center py-6 text-xs opacity-30 font-bold">لا نتائج</p>;
return allModels.map(({ m, vendor }) => {
const entry = MODEL_KEY_STORE[m] || { keys: [], urls: [], ki: 0, ui: 0 };
const hasKeys = entry.keys.length > 0;
const hasUrls = entry.urls.length > 0;
const isSelected = mkSelectedModel === m;
const vendorColor = vendor === 'ollama' ? '#f97316' : vendor === 'groq' ? '#f97316' : vendor === 'openai' ? '#10b981' : vendor === 'claude' ? '#a855f7' : vendor === 'google' ? '#3b82f6' : '#06b6d4';
// نموذج مخصّص: له مفتاح أو رابط لكن ليس في قوائم المزوّدين المعروفين
const hasCustomConfig = !!(MODEL_KEY_STORE[m]?.keys?.length || MODEL_KEY_STORE[m]?.urls?.length);
const isCustomModel = vendor === 'ollama' && hasCustomConfig;
return (
<div key={m} className="rounded-xl border overflow-hidden" style={{ borderColor: isSelected ? `${T.primary}40` : `${T.text}12`, background: isSelected ? `${T.primary}06` : `${T.text}04` }}>
{/* رأس النموذج */}
<button
onClick={() => { setMkSelectedModel(isSelected ? '' : m); setMkNewKey(''); setMkNewUrl(''); }}
className="w-full flex items-center justify-between px-3 py-2 active:scale-[0.99]"
>
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0 text-[8px] font-black px-1.5 py-0.5 rounded" style={{ background: (isCustomModel ? '#10b981' : vendorColor) + '20', color: isCustomModel ? '#10b981' : vendorColor }}>{isCustomModel ? 'custom' : vendor}</span>
<span className="font-mono text-[10px] font-black truncate" style={{ color: isSelected ? T.primary : T.text }}>{m}</span>
{activeModel === m && <span className="shrink-0 text-[8px] font-black" style={{ color: T.primary }}>● نشط</span>}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{hasKeys && <span className="text-[8px] font-black px-1.5 py-0.5 rounded-full" style={{ background: '#10b98120', color: '#10b981' }}>🔑{entry.keys.length}</span>}
{hasUrls && <span className="text-[8px] font-black px-1.5 py-0.5 rounded-full" style={{ background: '#3b82f620', color: '#3b82f6' }}>🔗{entry.urls.length}</span>}
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-30" style={{ transform: isSelected ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}><polyline points="6 9 12 15 18 9"/></svg>
</div>
</button>
{/* تفاصيل النموذج عند الفتح */}
{isSelected && (
<div className="px-3 pb-3 border-t" style={{ borderColor: `${T.text}10` }}>
{/* ── مفاتيح API ── */}
<p className="text-[8px] font-black opacity-40 uppercase tracking-widest mt-2 mb-1">🔑 مفاتيح API ({entry.keys.length})</p>
{entry.keys.length > 0 && (
<div className="space-y-1 mb-1.5">
{entry.keys.map((k, ki) => (
<div key={ki} className="flex items-center gap-1.5 p-1.5 rounded-lg" style={{ background: ki === (entry.ki || 0) ? `${T.primary}15` : `${T.text}08`, borderLeft: ki === (entry.ki || 0) ? `2px solid ${T.primary}` : '2px solid transparent' }}>
<span className="text-[8px] font-black opacity-40 shrink-0 w-4 text-center">{ki + 1}</span>
<span className="font-mono text-[8px] flex-1 truncate opacity-70">{k.substring(0, 32)}…</span>
{ki === (entry.ki || 0) && <span className="text-[7px] font-black shrink-0" style={{ color: T.primary }}>نشط</span>}
<button onClick={() => { removeKeyForModel(m, ki); setMkStoreTick(v => v + 1); toast('تم حذف المفتاح', 'info', 1500); }} className="shrink-0 text-red-400 opacity-60 hover:opacity-100 text-[11px] font-black">✕</button>
</div>
))}
</div>
)}
{/* إضافة مفتاح جديد */}
<div className="flex gap-1.5">
<input
type="text"
value={mkNewKey}
onChange={e => setMkNewKey(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && mkNewKey.trim()) {
const added = addKeyForModel(m, mkNewKey);
if (added) { setMkNewKey(''); setMkStoreTick(v => v + 1); toast('تم إضافة المفتاح ✅', 'success', 1500); }
else toast('المفتاح موجود مسبقاً', 'warning', 1500);
}
}}
placeholder="أدخل مفتاح API..."
className="flex-1 p-1.5 rounded-lg text-[9px] font-mono outline-none border"
style={{ background: 'rgba(0,0,0,0.3)', borderColor: `${T.text}20`, color: T.text }}
dir="ltr"
/>
<button
onClick={() => {
if (!mkNewKey.trim()) return;
const added = addKeyForModel(m, mkNewKey);
if (added) { setMkNewKey(''); setMkStoreTick(v => v + 1); toast('تم إضافة المفتاح ✅', 'success', 1500); }
else toast('المفتاح موجود مسبقاً', 'warning', 1500);
}}
className="px-2.5 rounded-lg font-black text-[9px] active:scale-95 text-white shrink-0"
style={{ background: T.primary }}
>+ مفتاح</button>
</div>
{/* ── روابط URL (لأي مزوّد) ── */}
<p className="text-[8px] font-black opacity-40 uppercase tracking-widest mt-2.5 mb-1">🔗 روابط Base URL ({entry.urls.length})</p>
{entry.urls.length > 0 && (
<div className="space-y-1 mb-1.5">
{entry.urls.map((u, ui) => (
<div key={ui} className="flex items-center gap-1.5 p-1.5 rounded-lg" style={{ background: ui === (entry.ui || 0) ? `#3b82f615` : `${T.text}08`, borderLeft: ui === (entry.ui || 0) ? `2px solid #3b82f6` : '2px solid transparent' }}>
<span className="text-[8px] font-black opacity-40 shrink-0 w-4 text-center">{ui + 1}</span>
<span className="font-mono text-[8px] flex-1 truncate opacity-70">{u}</span>
{ui === (entry.ui || 0) && <span className="text-[7px] font-black shrink-0 text-blue-400">نشط</span>}
<button onClick={() => { removeUrlForModel(m, ui); setMkStoreTick(v => v + 1); toast('تم حذف الرابط', 'info', 1500); }} className="shrink-0 text-red-400 opacity-60 hover:opacity-100 text-[11px] font-black">✕</button>
</div>
))}
</div>
)}
{/* إضافة رابط جديد */}
<div className="flex gap-1.5">
<input
type="url"
value={mkNewUrl}
onChange={e => setMkNewUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && mkNewUrl.trim()) {
const added = addUrlForModel(m, mkNewUrl);
if (added) { setMkNewUrl(''); setMkStoreTick(v => v + 1); toast('تم إضافة الرابط ✅', 'success', 1500); }
else toast('الرابط موجود مسبقاً', 'warning', 1500);
}
}}
placeholder="https://api.openrouter.ai / ngrok / localhost:11434"
className="flex-1 p-1.5 rounded-lg text-[9px] font-mono outline-none border"
style={{ background: 'rgba(0,0,0,0.3)', borderColor: `${T.text}20`, color: T.text }}
dir="ltr"
/>
<button
onClick={() => {
if (!mkNewUrl.trim()) return;
const added = addUrlForModel(m, mkNewUrl);
if (added) { setMkNewUrl(''); setMkStoreTick(v => v + 1); toast('تم إضافة الرابط ✅', 'success', 1500); }
else toast('الرابط موجود مسبقاً', 'warning', 1500);
}}
className="px-2.5 rounded-lg font-black text-[9px] active:scale-95 text-white shrink-0"
style={{ background: '#3b82f6' }}
>+ رابط</button>
</div>
{/* مصادر هذا النموذج */}
{(() => {
const famKey = Object.keys(MODEL_REGISTRY).find(f => m.toLowerCase().includes(f.toLowerCase()) || MODEL_REGISTRY[f].sources.some(s => s.models.includes(m)));
if (!famKey) return null;
const reg = MODEL_REGISTRY[famKey];
return (
<div className="mt-2 mb-1">
<p className="text-[8px] font-black opacity-40 uppercase tracking-widest mb-1">🌐 مصادر تدعم هذا النموذج</p>
<div className="flex flex-wrap gap-1">
{reg.sources.map((s, si) => {
const srcColors = { groq:'#f97316', openai:'#10b981', google:'#3b82f6', anthropic:'#a855f7', claude:'#a855f7', mistral:'#06b6d4', ollama:'#6b7280', openrouter:'#ec4899', together:'#8b5cf6', cloudflare:'#f59e0b' };
const c = srcColors[s.provider] || '#6b7280';
const providerKey = s.provider === 'anthropic' || s.provider === 'claude' ? API_KEYS.anthropic : API_KEYS[s.provider];
const hasKey = s.provider === 'ollama' ? !!ACTIVE_NGROK_URL.trim() : !!(providerKey && providerKey.trim());
return (
<span key={si} className="text-[7px] font-black px-1.5 py-0.5 rounded-full border" style={{ background: c + '18', color: c, borderColor: c + '40', opacity: hasKey ? 1 : 0.4 }}>
{hasKey ? '✓' : '○'} {s.provider}
</span>
);
})}
</div>
</div>
);
})()}
{/* تفعيل هذا النموذج */}
<button
onClick={() => { setActiveModel(m); ACTIVE_MODEL_KEY = m; try { localStorage.setItem('brainmap_active_model', m); } catch(_) {} toast(`النموذج النشط: ${m}`, 'success', 1500); }}
className="w-full mt-2 py-1.5 rounded-xl font-black text-[10px] active:scale-95 text-white"
style={{ background: activeModel === m ? '#10b981' : T.primary }}
>
{activeModel === m ? '✓ هذا النموذج نشط حالياً' : `▶ تفعيل ${m}`}
</button>
</div>
)}
</div>
);
});
})()}
</div>
</div>
{/* النموذج النشط */}
<div>
<p className="text-[9px] font-black uppercase tracking-widest opacity-40 mb-2">⚡ النموذج النشط</p>
<div className="p-2.5 rounded-xl border mb-2 flex items-center justify-between" style={{ borderColor: `${T.primary}30`, background: `${T.primary}10` }}>
<div><p className="text-[9px] font-black opacity-50">النموذج الحالي</p><p className="font-mono text-[10px] font-black" style={{ color: T.primary }}>{activeModel}</p></div>
<button onClick={() => { setShowModelBar(v => !v); if (panels.settings) togglePanel('settings'); }} className="text-[9px] font-black px-3 py-1.5 rounded-xl active:scale-95 text-white" style={{ background: T.primary }}>تغيير</button>
</div>
</div>
{/* Dev Console */}
{currentUser?.type === 'dev' && (
<div className="p-4 rounded-xl border bg-black/90 text-emerald-400 font-mono" style={{ borderColor: '#065f46' }}>
<h3 className="font-black mb-1 text-white text-sm">⚙️ DEVELOPER CONSOLE</h3>
<p className="text-[9px] text-emerald-600 mb-3">Root Access — Dr: Ibrahim Taha</p>
<div className="mb-3">
<label className="text-[9px] uppercase font-bold text-emerald-500 mb-1 block">Inject HTML Module</label>
<textarea value={devInput.html} onChange={e => setDevInput(d => ({ ...d, html: e.target.value }))} className="w-full h-20 p-2 bg-slate-950 border border-emerald-900 rounded-lg outline-none text-[10px] resize-none font-mono" placeholder="<!-- HTML / JS Code -->" />
<button onClick={injectHTML} className="w-full py-2 bg-emerald-800 text-white font-bold rounded-lg mt-1.5 text-xs active:scale-95">حفظ في IndexedDB</button>
</div>
{devData.htmlModules.length > 0 && (
<div className="mb-3">
<label className="text-[9px] uppercase font-bold text-emerald-500 block mb-1">Modules ({devData.htmlModules.length})</label>
{devData.htmlModules.map((m, i) => (
<div key={m.id} className="flex justify-between items-center p-1.5 bg-slate-950 border border-emerald-900 rounded-lg text-[9px] mb-1">
<span>Module_{i+1}_{m.id}</span>
<button onClick={() => deleteModule(m.id)} className="text-red-500 font-bold">× حذف</button>
</div>
))}
</div>
)}
<div>
<label className="text-[9px] uppercase font-bold text-emerald-500 mb-1 block">Training Links</label>
<div className="flex gap-1.5">
<input type="text" value={devInput.link} onChange={e => setDevInput(d => ({ ...d, link: e.target.value }))} className="flex-1 p-2 bg-slate-950 border border-emerald-900 rounded-lg outline-none text-[10px]" placeholder="https://..." />
<button onClick={() => { if(devInput.link.trim()) { setDevData(d => ({...d, socialLinks:[...d.socialLinks,devInput.link]})); setDevInput(d=>({...d,link:''})); } }} className="px-3 bg-emerald-800 text-white font-bold rounded-lg text-xs">Add</button>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{devData.socialLinks.map((l,i) => <span key={i} className="text-[8px] bg-emerald-950 px-2 py-0.5 rounded border border-emerald-800 text-emerald-300">{l.substring(0,24)}...</span>)}
</div>
</div>
</div>
)}
{/* Privacy */}
<div className="p-3 rounded-xl border text-[10px] font-bold leading-relaxed" style={{ borderColor: `${T.text}10`, background: `${T.text}04` }}>
<p className="font-black mb-1" style={{ color: T.primary }}>🔐 الخصوصية والمعمارية V25</p>
<p className="opacity-50">بيانات محلية 100% في IndexedDB. RAG محلي BM25. TTS ذكي بحدود الجمل. زر إيقاف البث. حاسبات: BMI·GFR·CHA₂DS₂·Wells·SOFA·NEWS2·APACHE II·Cockcroft-Gault·جرعة أطفال. مزوّدون: Ollama·Groq·OpenAI·Claude·Gemini·Mistral·OpenRouter·Together. لا رفع سحابي.</p>
</div>
<button onClick={handleLogout} className="w-full py-4 rounded-xl font-black border flex items-center justify-center gap-2 active:scale-95 text-sm" style={{ background: 'rgba(239,68,68,0.08)', color: '#ef4444', borderColor: 'rgba(239,68,68,0.2)' }}>
<svg width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
تسجيل الخروج
</button>
</div>
</div>
</div>
)}
</div>
);
};
// ── ROOT ──
const Root = () => (
<ToastProvider>
<App />
</ToastProvider>
);
ReactDOM.createRoot(document.getElementById('root')).render(<Root />);
</script>
</body>
</html>