| <!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; } |
| |
| |
| .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-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; } |
| |
| |
| .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-prompt-btn { transition: all 0.2s; } |
| .quick-prompt-btn:hover { transform: translateY(-2px); } |
| .quick-prompt-btn:active { transform: scale(0.96); } |
| |
| |
| .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 { 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-actions { opacity: 0; transition: opacity 0.15s; } |
| .msg-wrapper:hover .msg-actions { opacity: 1; } |
| |
| |
| .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 { 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-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-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; } |
| |
| |
| @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-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-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; } |
| |
| |
| @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; } |
| |
| |
| @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-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'; |
| |
| |
| |
| |
| const DEV_CODE = "AaAa@3652"; |
| const EDU_DOMAIN = ".m-mail.edu.eg"; |
| |
| |
| |
| |
| |
| |
| |
| const NGROK_URL_DEFAULT = window.location.origin; |
| |
| |
| let ACTIVE_NGROK_URL = (() => { |
| try { return localStorage.getItem('brainmap_ngrok_url') || NGROK_URL_DEFAULT; } |
| catch(_) { return NGROK_URL_DEFAULT; } |
| })(); |
| |
| |
| |
| const getNgrokUrl = () => ACTIVE_NGROK_URL; |
| |
| Object.defineProperty(window, 'NGROK_URL', { get: () => ACTIVE_NGROK_URL, configurable: true }); |
| |
| |
| let ACTIVE_PATIENT_CONTEXT = (() => { |
| try { return localStorage.getItem('bm_patient_ctx') || ''; } catch(_) { return ''; } |
| })(); |
| Object.defineProperty(window, 'PATIENT_CTX', { get: () => ACTIVE_PATIENT_CONTEXT, configurable: true }); |
| |
| |
| const GROQ_KEYS = [ |
| "ollama" |
| ]; |
| |
| |
| |
| |
| |
| |
| 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"; } |
| })(); |
| |
| |
| |
| |
| |
| |
| 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 ''; } })(), |
| }; |
| |
| |
| |
| |
| |
| |
| |
| 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(); |
| } |
| }; |
| |
| |
| |
| 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; |
| }; |
| |
| |
| |
| 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; |
| }; |
| |
| |
| |
| |
| const OLLAMA_MODELS = [ |
| |
| "gemma-4-E2B-it-Q8_0", |
| |
| "gemma4:e2b","gemma4:e4b","gemma4:2b","gemma4:9b","gemma4:27b", |
| |
| "gemma3:1b","gemma3:4b","gemma4:31b","gemma3:27b", |
| "gemma3n:e2b","gemma3n:e4b", |
| |
| "gemma2:2b","gemma2:9b","gemma2:27b", |
| |
| "gemma:2b","gemma:7b", |
| |
| "llama4:scout","llama4:maverick","llama4:17b","llama4:109b", |
| |
| "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", |
| |
| "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", |
| |
| "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", |
| |
| "llama3:8b","llama3:70b", |
| "llama3:8b-instruct-q4_K_M","llama3:8b-instruct-fp16", |
| "llama3:70b-instruct-q4_K_M", |
| |
| "llama2:7b","llama2:13b","llama2:70b", |
| "llama2:7b-chat","llama2:13b-chat","llama2:70b-chat", |
| "llama2-uncensored:7b","llama2-uncensored:70b", |
| |
| "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:8x7b","mixtral:8x7b-instruct-v0.1", |
| "mixtral:8x22b","mixtral:8x22b-instruct-v0.1", |
| |
| "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", |
| |
| "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", |
| |
| "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", |
| |
| "qwen2:0.5b","qwen2:1.5b","qwen2:7b","qwen2:72b", |
| "qwen2:7b-instruct","qwen2:72b-instruct", |
| |
| "qwen2-vl:2b","qwen2-vl:7b","qwen2-vl:72b", |
| |
| "deepseek-r1:1.5b","deepseek-r1:7b","deepseek-r1:8b", |
| "deepseek-r1:14b","deepseek-r1:32b","deepseek-r1:70b","deepseek-r1:671b", |
| |
| "deepseek-r2:1.5b","deepseek-r2:7b","deepseek-r2:14b","deepseek-r2:32b","deepseek-r2:70b", |
| |
| "deepseek-v3","deepseek-v3:671b", |
| "deepseek-v2:16b","deepseek-v2:236b", |
| |
| "deepseek-coder-v2:16b","deepseek-coder-v2:236b", |
| "deepseek-coder:1.3b","deepseek-coder:6.7b","deepseek-coder:33b", |
| |
| "phi4:14b","phi4-mini:3.8b","phi4-reasoning:14b", |
| "phi4:14b-q4_K_M","phi4:14b-q8_0","phi4:14b-fp16", |
| |
| "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", |
| |
| "phi:2.7b","phi2:2.7b", |
| |
| "nemotron-mini:4b","nemotron:70b","nemotron:70b-instruct", |
| "llama-3.1-nemotron-70b-instruct", |
| |
| "command-r:35b","command-r-plus:104b", |
| "command-r7b:7b","command-r7b-arabic:7b", |
| |
| "aya:8b","aya:35b", |
| "aya-expanse:8b","aya-expanse:32b", |
| |
| "hermes3:3b","hermes3:8b","hermes3:70b","hermes3:405b", |
| |
| "nous-hermes:7b","nous-hermes:13b", |
| "nous-hermes2:10.7b","nous-hermes2:34b", |
| "nous-hermes2-mixtral:8x7b", |
| |
| "smollm2:135m","smollm2:360m","smollm2:1.7b", |
| "smollm2:135m-instruct","smollm2:360m-instruct","smollm2:1.7b-instruct", |
| |
| "smollm:135m","smollm:360m","smollm:1.7b", |
| |
| "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", |
| |
| "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", |
| |
| "meditron:7b","meditron:70b", |
| "medllama2:7b", |
| "med42:8b","med42:70b", |
| "biomistral:7b", |
| "clinical-camel:70b", |
| |
| "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:6b","yi:9b","yi:34b", |
| "yi-coder:1.5b","yi-coder:9b", |
| |
| "solar:10.7b","solar-pro:22b", |
| |
| "falcon:7b","falcon:40b","falcon:180b","falcon2:11b", |
| |
| "vicuna:7b","vicuna:13b","vicuna:33b", |
| |
| "openchat:7b","openchat:8b", |
| |
| "zephyr:7b","zephyr:141b", |
| |
| "internlm2:1.8b","internlm2:7b","internlm2:20b", |
| "internlm2.5:7b","internlm2.5:20b", |
| |
| "tinyllama:1.1b", |
| |
| "dolphin-phi:2.7b","dolphin-mistral:7b", |
| "dolphin-llama3:8b","dolphin-llama3:70b", |
| "dolphin3:8b", |
| |
| "dbrx:132b", |
| |
| "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:120b", |
| |
| "sqlcoder:7b","sqlcoder:15b", |
| |
| "starling-lm:7b", |
| |
| "neural-chat:7b", |
| |
| "llama-pro:8b", |
| |
| "everythinglm:13b", |
| |
| "samantha-mistral:7b", |
| |
| "stablelm2:1.6b","stablelm2:12b", |
| "stablelm-zephyr:3b", |
| |
| "marco-o1:7b", |
| |
| "reflection:70b", |
| |
| "glm4:9b", |
| |
| "exaone3.5:2.4b","exaone3.5:7.8b","exaone3.5:32b", |
| |
| "tulu3:8b","tulu3:70b", |
| |
| "olmo:7b","olmo2:7b","olmo2:13b", |
| |
| "bespoke-stratos:7b", |
| |
| "athene-v2:72b", |
| |
| "qwq:32b","qwq:32b-preview", |
| |
| "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", |
| ]; |
| |
| const CLOSED_MODELS = { |
| |
| groq: [ |
| |
| "meta-llama/llama-4-scout-17b-16e-instruct", |
| "meta-llama/llama-4-maverick-17b-128e-instruct", |
| |
| "llama-3.3-70b-versatile", |
| "llama-3.3-70b-specdec", |
| |
| "llama-3.2-90b-vision-preview", |
| "llama-3.2-11b-vision-preview", |
| |
| "llama-3.2-3b-preview", |
| "llama-3.2-1b-preview", |
| |
| "llama-3.1-70b-versatile", |
| "llama-3.1-8b-instant", |
| |
| "llama3-70b-8192", |
| "llama3-8b-8192", |
| "llama3-groq-70b-8192-tool-use-preview", |
| "llama3-groq-8b-8192-tool-use-preview", |
| |
| "mixtral-8x7b-32768", |
| |
| "gemma2-9b-it", |
| "gemma-7b-it", |
| |
| "deepseek-r1-distill-llama-70b", |
| "deepseek-r1-distill-qwen-32b", |
| |
| "qwen-qwq-32b", |
| "qwen-2.5-coder-32b", |
| "qwen-2.5-72b", |
| |
| "compound-beta", |
| "compound-beta-mini", |
| |
| "llama-guard-3-8b", |
| "llama-3.1-70b-versatile", |
| ], |
| |
| openai: [ |
| |
| "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-preview", |
| "gpt-4-turbo-2024-04-09", |
| |
| "gpt-4", |
| "gpt-4-0613", |
| "gpt-4-32k", |
| |
| "gpt-3.5-turbo", |
| "gpt-3.5-turbo-16k", |
| "gpt-3.5-turbo-0125", |
| |
| "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-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-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-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-pro", |
| ], |
| |
| mistral: [ |
| |
| "mistral-large-latest", |
| "mistral-large-2411", |
| "mistral-large-2407", |
| |
| "mistral-medium-latest", |
| "mistral-medium-2505", |
| |
| "mistral-small-latest", |
| "mistral-small-2501", |
| "mistral-small-2409", |
| |
| "mistral-saba-latest", |
| "mistral-saba-2502", |
| |
| "codestral-latest", |
| "codestral-2501", |
| "codestral-mamba-latest", |
| |
| "pixtral-large-latest", |
| "pixtral-large-2411", |
| "pixtral-12b-2409", |
| |
| "ministral-3b-latest", |
| "ministral-3b-2410", |
| "ministral-8b-latest", |
| "ministral-8b-2410", |
| |
| "open-mistral-nemo", |
| "open-mistral-7b", |
| "open-mixtral-8x7b", |
| "open-mixtral-8x22b", |
| |
| "devstral-small-latest", |
| "devstral-small-2505", |
| ], |
| }; |
| |
| 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", |
| ]; |
| |
| |
| |
| |
| |
| |
| const MODEL_REGISTRY = { |
| |
| "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"] }, |
| ] |
| }, |
| |
| "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": { |
| 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": { |
| 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": { |
| 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"] }, |
| ] |
| }, |
| |
| "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": { |
| 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": { |
| 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": { |
| 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"] }, |
| ] |
| }, |
| |
| "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": { |
| 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"] }, |
| ] |
| }, |
| }; |
| |
| |
| |
| |
| |
| 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', |
| }; |
| |
| |
| |
| |
| |
| |
| const getAutoModel = (preferFamily = null) => { |
| |
| 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]; |
| } |
| |
| |
| 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]; |
| } |
| } |
| |
| |
| 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 }); |
| } |
| }); |
| |
| |
| if (API_KEYS.groq && API_KEYS.groq.trim()) { |
| CLOSED_MODELS.groq.forEach(m => available.push({ model: m, source: 'groq', ready: true })); |
| } |
| |
| if (API_KEYS.openai && API_KEYS.openai.trim()) { |
| CLOSED_MODELS.openai.forEach(m => available.push({ model: m, source: 'openai', ready: true })); |
| } |
| |
| if (API_KEYS.anthropic && API_KEYS.anthropic.trim()) { |
| CLOSED_MODELS.claude.forEach(m => available.push({ model: m, source: 'anthropic', ready: true })); |
| } |
| |
| if (API_KEYS.google && API_KEYS.google.trim()) { |
| CLOSED_MODELS.google.forEach(m => available.push({ model: m, source: 'google', ready: true })); |
| } |
| |
| if (API_KEYS.mistral && API_KEYS.mistral.trim()) { |
| CLOSED_MODELS.mistral.forEach(m => available.push({ model: m, source: 'mistral', ready: true })); |
| } |
| |
| if (ACTIVE_NGROK_URL.trim()) { |
| OLLAMA_MODELS.forEach(m => available.push({ model: m, source: 'ollama', ready: true })); |
| } |
| |
| return available; |
| }; |
| |
| |
| |
| const VISION_MODEL = "gemma-4-E2B-it-Q8_0"; |
| |
| |
| |
| const WHISPER_MODEL = "whisper-large-v3"; |
| |
| |
| 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; |
| |
| |
| const IMAGE_TRIGGER_PATTERNS = [ |
| /صورة|صور|أرِني|اعرض|انظر|شكل|مظهر|كيف يبدو|تشريح|خريطة|مخطط|رسم|diagram|image|photo|show|picture|anatomy|chart|map|figure|وجه|جسم|عضو|قلب|دماغ|رئة|كلية|كبد|brain|heart|lung|liver|kidney/i |
| ]; |
| |
| |
| |
| |
| |
| 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' } |
| ]; |
| |
| |
| |
| |
| 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 => { |
| |
| e.target.result.close(); |
| }; |
| } catch(_) {} |
| }; |
| |
| |
| |
| |
| 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; |
| }; |
| |
| |
| |
| |
| 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] }; |
| }; |
| |
| |
| |
| |
| 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)); |
| }; |
| |
| |
| |
| |
| |
| |
| const searchImagesWikimedia = async (query) => { |
| try { |
| |
| 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 { |
| |
| 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 { |
| |
| 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) => { |
| |
| 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); |
| }; |
| |
| |
| |
| |
| |
| |
| |
| let systemVoicesCache = []; |
| |
| 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(); |
| } |
| |
| |
| const findSystemVoiceForEdge = (edgeVoiceName, lang) => { |
| const voices = window.speechSynthesis?.getVoices() || []; |
| |
| const exact = voices.find(v => v.name === edgeVoiceName); |
| if (exact) return exact; |
| |
| 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; |
| |
| const langMatch = voices.find(v => v.lang.startsWith(lang.substring(0, 5))); |
| if (langMatch) return langMatch; |
| |
| 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]; |
| }; |
| |
| |
| 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')); |
| |
| 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) |
| }; |
| }; |
| |
| |
| |
| const splitAtSentences = (text, maxLen) => { |
| const chunks = []; |
| |
| 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; |
| } |
| |
| 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()); |
| |
| 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); |
| }; |
| |
| |
| 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, '。') |
| .replace(/\n/g, ' ') |
| .replace(/\s{2,}/g, ' ') |
| .trim() |
| .substring(0, 2400); |
| if (!clean) { if(onEnd) onEnd(); return; } |
| |
| |
| 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); }; |
| utt.onerror = (ev) => { |
| if (ev.error === 'interrupted' || ev.error === 'canceled') { if (onEnd) onEnd(); return; } |
| |
| setTimeout(() => speakNext(), 250); |
| }; |
| window.speechSynthesis.speak(utt); |
| |
| |
| |
| }; |
| |
| 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(); |
| } |
| }; |
| |
| |
| const speakText = (text, voiceName, rate = 1, pitch = 1) => { |
| |
| const edgeVoice = ALL_EDGE_VOICES.find(v => v.name === voiceName) || ALL_EDGE_VOICES[0]; |
| speakWithEdgeTTS(text, edgeVoice.name, edgeVoice.lang, rate, pitch, null, null); |
| }; |
| |
| |
| 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); |
| }; |
| |
| |
| |
| |
| 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(); |
| |
| 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); |
| }); |
| |
| |
| 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`; |
| |
| |
| |
| |
| |
| 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; |
| }; |
| |
| |
| |
| |
| 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); |
| }); |
| }; |
| |
| |
| |
| |
| 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 }; } |
| }; |
| |
| |
| |
| |
| 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 []; } |
| }; |
| |
| |
| |
| |
| 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 []; } |
| }; |
| |
| |
| |
| |
| 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; |
| }; |
| |
| |
| |
| |
| 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) => { |
| |
| 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; |
| |
| 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) => { |
| |
| 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) => { |
| |
| 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; |
| |
| 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 }; |
| }, |
| }; |
| |
| |
| |
| |
| 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)); |
| }; |
| |
| |
| |
| |
| 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 }; |
| } |
| }; |
| |
| |
| |
| |
| |
| 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, |
| }; |
| }; |
| |
| |
| |
| |
| 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; |
| }; |
| |
| |
| |
| |
| |
| |
| |
| const callGroqText = async (messages, ragContext, fileContext, citations, onStream, signal) => { |
| |
| const model = ACTIVE_MODEL_KEY; |
| |
| |
| 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'); |
| |
| |
| 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'; |
| |
| |
| |
| |
| const parseStreamResponse = async (res, isClaudeFormat = false) => { |
| const reader = res.body.getReader(); |
| const dec = new TextDecoder(); |
| let full = ''; |
| let lineBuf = ''; |
| try { |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| |
| lineBuf += dec.decode(value, { stream: true }); |
| |
| const lines = lineBuf.split('\n'); |
| lineBuf = lines.pop(); |
| 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) { |
| |
| 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(_) { |
| |
| } |
| } |
| } |
| |
| 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 || '…'; |
| }; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const resolveProvider = (modelName) => { |
| const perKey = getCurrentKeyForModel(modelName); |
| const perUrl = getCurrentUrlForModel(modelName); |
| |
| |
| if (perKey || perUrl) { |
| return { type: 'custom', key: perKey || '', url: perUrl || '', model: modelName }; |
| } |
| |
| |
| 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 }; |
| } |
| |
| |
| if (API_KEYS.openrouter && API_KEYS.openrouter.trim()) { |
| |
| const orModel = mapToOpenRouterModel(modelName); |
| return { type: 'openai_compat', key: API_KEYS.openrouter, url: 'https://openrouter.ai/api/v1/chat/completions', model: orModel }; |
| } |
| |
| |
| 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 }; |
| } |
| |
| |
| const ollamaUrl = (getCurrentUrlForModel(modelName) || ACTIVE_NGROK_URL || '').trim().replace(/\/$/, ''); |
| if (ollamaUrl) { |
| return { type: 'ollama', key: '', url: ollamaUrl + '/v1/chat/completions', model: modelName }; |
| } |
| |
| return null; |
| }; |
| |
| |
| 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 (map[m]) return map[m]; |
| if (m.includes('/')) return m; |
| |
| return 'meta-llama/llama-3.1-8b-instruct:free'; |
| }; |
| |
| |
| 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'; |
| }; |
| |
| |
| |
| |
| |
| |
| 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 |
| }; |
| } |
| |
| |
| 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 }; |
| } |
| } |
| |
| |
| if (provider.type === 'ollama') { |
| try { |
| |
| 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 }; |
| } |
| } |
| |
| |
| 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}`; |
| |
| 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); |
| |
| |
| 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({ |
| |
| 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'); |
| |
| 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; |
| }; |
| |
| |
| |
| |
| const searchWeb = async (query) => { |
| try { |
| |
| 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 []; } |
| }; |
| |
| |
| |
| |
| 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); |
| }; |
| |
| |
| |
| |
| 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); |
| |
| |
| |
| |
| const App = () => { |
| const toast = useToast(); |
| |
| |
| const [view, setView] = useState('login'); |
| const [accounts, setAccounts] = useState([]); |
| const [currentUser, setCurrentUser] = useState(null); |
| const [regForm, setRegForm] = useState({ name: '', email: '', password: '' }); |
| |
| |
| const [sessions, setSessions] = useState([]); |
| const [currentSessionId, setCurrentSessionId] = useState(null); |
| const [messages, setMessages] = useState([]); |
| const [input, setInput] = useState(''); |
| |
| |
| const [pendingFile, setPendingFile] = useState(null); |
| const [globalContext, setGlobalContext] = useState(''); |
| const [uploadedFiles, setUploadedFiles] = useState([]); |
| |
| |
| 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 }); |
| |
| |
| 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'); |
| |
| |
| const [favorites, setFavorites] = useState([]); |
| |
| |
| const [audioOverviewPlaying, setAudioOverviewPlaying] = useState(false); |
| const [audioOverviewScript, setAudioOverviewScript] = useState(''); |
| |
| |
| const [inlineImages, setInlineImages] = useState({}); |
| const [bgSearchStatus, setBgSearchStatus] = useState({}); |
| const [lightboxImg, setLightboxImg] = useState(null); |
| |
| |
| 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 |
| }); |
| |
| 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(''); |
| |
| |
| 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'); |
| |
| |
| const [theme, setTheme] = useState(THEMES[0]); |
| |
| |
| const [devData, setDevData] = useState({ htmlModules: [], socialLinks: [] }); |
| const [devInput, setDevInput] = useState({ html: '', link: '' }); |
| |
| |
| const [showModelBar, setShowModelBar] = useState(false); |
| const [streamingText, setStreamingText] = useState(''); |
| const [streamingId, setStreamingId] = useState(null); |
| |
| 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); |
| |
| const [mkStoreTick, setMkStoreTick] = useState(0); |
| 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 []; } |
| }); |
| |
| |
| const [ngrokUrlInput, setNgrokUrlInput] = useState(() => { |
| try { return localStorage.getItem('brainmap_ngrok_url') || window.location.origin; } |
| catch(_) { return window.location.origin; } |
| }); |
| const [ngrokTestStatus, setNgrokTestStatus] = useState('idle'); |
| |
| |
| |
| 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 ''; } })(), |
| }); |
| |
| 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 ''; } })(), |
| }); |
| |
| 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'); |
| }; |
| |
| |
| 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) { |
| |
| |
| const vendorModelMap = { |
| groq: CLOSED_MODELS.groq[0], |
| openai: CLOSED_MODELS.openai[0], |
| anthropic: CLOSED_MODELS.claude[0], |
| google: CLOSED_MODELS.google[0], |
| mistral: CLOSED_MODELS.mistral[0], |
| 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); |
| } |
| }; |
| |
| |
| useEffect(() => { |
| ACTIVE_MODEL_KEY = activeModel; |
| try { localStorage.setItem('brainmap_active_model', activeModel); } catch(_) {} |
| }, [activeModel]); |
| |
| |
| 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 { |
| |
| 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'); |
| } |
| }; |
| |
| |
| const [searchQuery, setSearchQuery] = useState(''); |
| const [searchResults, setSearchResults] = useState([]); |
| const [isSearching, setIsSearching] = useState(false); |
| |
| |
| const [nbMode, setNbMode] = useState(''); |
| const [nbResult, setNbResult] = useState(''); |
| const [isNbProcessing, setIsNbProcessing] = useState(false); |
| |
| |
| 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); |
| |
| const isSendingRef = useRef(false); |
| |
| const abortControllerRef = useRef(null); |
| |
| const T = theme; |
| |
| |
| useEffect(() => { |
| allocateArchive(); |
| loadAccounts(); |
| loadInjectedHTML(); |
| loadFavorites(); |
| |
| |
| 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; |
| |
| 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(_) { |
| |
| } |
| }; |
| autoDetectServer(); |
| |
| |
| 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); |
| } |
| |
| 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]); |
| |
| |
| 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); |
| }, []); |
| |
| |
| 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]); |
| |
| |
| 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) { |
| |
| setMessages([]); |
| setCurrentSessionId(list[0].id); |
| } else { |
| |
| setMessages([]); |
| await createNewSession(uid); |
| } |
| }; |
| const loadMessages = async (sid) => { |
| if (!sid) { setMessages([]); return; } |
| |
| 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); |
| }; |
| |
| |
| 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'); |
| }; |
| |
| |
| 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); |
| } |
| }; |
| |
| |
| 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); } |
| }; |
| |
| |
| const handleSpeak = useCallback((text) => { |
| |
| |
| speakWithEdgeTTS( |
| text, selectedVoice.name, selectedVoice.lang, voiceRate, voicePitch, |
| () => setTtsPlaying(true), |
| () => setTtsPlaying(false) |
| ); |
| }, [selectedVoice, voiceRate, voicePitch]); |
| |
| const stopSpeaking = () => { |
| window.speechSynthesis?.cancel(); |
| setTtsPlaying(false); |
| }; |
| |
| |
| const stopStreaming = () => { |
| if (abortControllerRef.current) { |
| abortControllerRef.current.abort(); |
| abortControllerRef.current = null; |
| } |
| isSendingRef.current = false; |
| setIsTyping(false); |
| setStreamingId(null); |
| toast('تم إيقاف الرد ⏹', 'info', 1500); |
| }; |
| |
| |
| 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; |
| |
| 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 }); |
| }; |
| |
| |
| const createNewSession = async (uid) => { |
| |
| 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); |
| }; |
| |
| |
| const clearCurrentSession = async () => { |
| if (!currentSessionId) return; |
| await db.messages.where({ sessionId: currentSessionId }).delete(); |
| setMessages([]); |
| setShowClearConfirm(false); |
| toast('تم مسح رسائل المحادثة 🗑️', 'info', 2000); |
| }; |
| |
| |
| 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); |
| }; |
| |
| |
| 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); |
| }; |
| |
| |
| 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); |
| |
| 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; |
| |
| |
| 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) => { |
| |
| setSourceToggles(prev => ({ ...prev, [String(fileId)]: !(prev[String(fileId)] !== false) })); |
| }; |
| |
| |
| 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' })); |
| }, []); |
| |
| |
| const handleSend = async () => { |
| if (!input.trim() && !pendingFile) return; |
| if (isTyping || isSendingRef.current) return; |
| isSendingRef.current = true; |
| |
| 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}`; |
| } |
| } |
| |
| |
| 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); |
| |
| |
| 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]); |
| |
| |
| |
| 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)); |
| |
| |
| if (retrievedCitations.length > 0) { |
| setCitationsMap(prev => ({ ...prev, [phId]: retrievedCitations })); |
| } |
| |
| |
| if (ttsEnabled) { handleSpeak(aiText); } |
| |
| |
| if (shouldFetchImages(userText)) { |
| runBackgroundImageSearch(userText, phId); |
| } |
| |
| |
| 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); |
| } |
| }; |
| |
| |
| const startVoiceRecording = async () => { |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| |
| |
| 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()); |
| |
| 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); } |
| }; |
| |
| |
| 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); |
| }; |
| |
| |
| 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); |
| } |
| }; |
| |
| |
| 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] })); |
| |
| |
| |
| const checkAndShowRedFlags = (text) => { |
| const flags = detectRedFlags(text); |
| setRedFlags(flags); |
| return flags; |
| }; |
| |
| |
| 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); } |
| }; |
| |
| |
| 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); } |
| }; |
| |
| |
| 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); } |
| }; |
| |
| |
| 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); } |
| }; |
| |
| |
| 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'); } |
| }; |
| |
| |
| 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'); |
| } |
| }; |
| |
| |
| const handleAutoSelectModel = async () => { |
| setAutoCheckStatus('checking'); |
| setIsAutoModel(true); |
| toast('🔍 جاري فحص المفاتيح...', 'info', 2000); |
| |
| |
| const candidates = []; |
| |
| |
| 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' }); |
| } |
| }); |
| |
| |
| 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' }); |
| } |
| |
| 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') { |
| |
| 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) |
| }); |
| } |
| |
| |
| 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); |
| }; |
| |
| |
| const enabledChunksCount = useMemo(() => { |
| if (Object.keys(sourceToggles).length === 0) return allChunks.length; |
| return allChunks.filter(c => sourceToggles[String(c.fileId)] !== false).length; |
| }, [allChunks, sourceToggles]); |
| |
| |
| 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]); |
| |
| |
| |
| |
| 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> |
| ); |
| |
| |
| |
| |
| 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> |
| )} |
| |
| {} |
| <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> |
| ))} |
| |
| {} |
| {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> |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| <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> |
| {} |
| <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> |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| |
| {} |
| {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> |
| )} |
| {} |
| {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="" /> |
| <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> |
| ); |
| }; |
| |
| |
| const Root = () => ( |
| <ToastProvider> |
| <App /> |
| </ToastProvider> |
| ); |
| |
| ReactDOM.createRoot(document.getElementById('root')).render(<Root />); |
| </script> |
| </body> |
| </html> |