Spaces:
Running
Running
Download index.html from Yo-Purple/meditor-webgpu: direct link, hf CLI and curl.
- Browser
- Download file 38.9 kB
-
https://huggingface.co/spaces/Yo-Purple/meditor-webgpu/resolve/main/index.html
- Command line
-
hf download hf://spaces/Yo-Purple/meditor-webgpu/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Yo-Purple/meditor-webgpu/resolve/main/index.html
38.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meditor AI — In-Browser WebGPU Chat</title>
<script src="./marked.min.js"></script>
<style>
:root {
--bg: #090b11;
--sidebar-bg: #10141f;
--card-bg: #161b2a;
--card-elevated: #1d2438;
--border: #222b40;
--border-focus: #10b981;
--text: #f3f4f6;
--text-muted: #94a3b8;
--accent: #10b981;
--accent-glow: rgba(16, 185, 129, 0.25);
--accent-purple: #8b5cf6;
--user-bubble: #1f293d;
--bot-bubble: #141926;
--danger: #ef4444;
--warning: #f59e0b;
}
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
body { background: var(--bg); color: var(--text); display: flex; height: 100vh; overflow: hidden; }
/* SIDEBAR */
aside {
width: 340px;
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 18px;
gap: 16px;
overflow-y: auto;
flex-shrink: 0;
z-index: 10;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.brand-logo {
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #6366f1);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 800;
color: #fff;
box-shadow: 0 0 16px var(--accent-glow);
}
.brand-info h1 { font-size: 17px; font-weight: 700; color: #fff; letter-spacing: -0.3px; }
.brand-info p { font-size: 11px; color: var(--text-muted); }
/* HARDWARE STATUS CARD */
.hardware-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
}
.hardware-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.status-badge.webgpu-on {
background: rgba(16, 185, 129, 0.15);
border: 1px solid #10b981;
color: #34d399;
}
.status-badge.webgpu-off {
background: rgba(245, 158, 11, 0.15);
border: 1px solid #f59e0b;
color: #fbbf24;
}
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
/* CONTROLS & SELECTION */
.section-title {
font-size: 11px;
text-transform: uppercase;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.7px;
margin-top: 4px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 12px;
font-weight: 600;
color: #d1d5db;
display: flex;
justify-content: space-between;
}
.form-group label span { color: var(--text-muted); font-weight: normal; }
select, input[type="text"], input[type="number"], textarea {
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--text);
padding: 9px 12px;
border-radius: 8px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
width: 100%;
}
select:focus, input:focus, textarea:focus {
border-color: var(--border-focus);
}
textarea { resize: vertical; min-height: 56px; font-size: 12px; }
.model-info-box {
background: rgba(26, 32, 53, 0.6);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
font-size: 11.5px;
line-height: 1.45;
color: #cbd5e1;
}
.model-info-box b { color: #fff; }
.slider-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.slider-header {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-muted);
}
input[type="range"] {
accent-color: var(--accent);
cursor: pointer;
width: 100%;
}
/* BUTTONS */
.btn {
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
box-shadow: 0 2px 10px var(--accent-glow);
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--text);
}
.btn-secondary:hover {
background: var(--card-elevated);
border-color: #3b4563;
}
.btn-danger {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.25);
}
/* PROGRESS CONTAINER */
#progress-container {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
display: none;
flex-direction: column;
gap: 8px;
font-size: 12px;
}
.progress-bar-bg {
height: 8px;
background: #0f131d;
border-radius: 4px;
overflow: hidden;
position: relative;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #10b981, #6366f1);
width: 0%;
transition: width 0.15s ease;
}
.progress-text {
display: flex;
justify-content: space-between;
color: var(--text-muted);
font-size: 11px;
}
/* MAIN CHAT AREA */
main {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg);
position: relative;
overflow: hidden;
}
header {
background: var(--sidebar-bg);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.header-title {
display: flex;
align-items: center;
gap: 10px;
}
.active-model-pill {
background: var(--card-bg);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: 14px;
font-size: 12px;
font-weight: 600;
color: #34d399;
display: flex;
align-items: center;
gap: 6px;
}
.header-telemetry {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--text-muted);
}
.telemetry-tag {
background: var(--card-bg);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: 6px;
font-weight: 600;
}
.telemetry-speed { color: #38bdf8; }
/* CHAT STREAM */
#chat-scroll {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 18px;
max-width: 900px;
width: 100%;
margin: 0 auto;
}
.welcome-banner {
background: linear-gradient(180deg, rgba(16, 185, 129, 0.08) 0%, rgba(99, 102, 241, 0.04) 100%);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 14px;
padding: 24px;
text-align: center;
margin: auto 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.welcome-banner h2 { font-size: 20px; font-weight: 700; color: #fff; }
.welcome-banner p { font-size: 13.5px; color: var(--text-muted); max-width: 600px; line-height: 1.5; }
.preset-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 8px;
}
.chip {
background: var(--card-bg);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
color: #e2e8f0;
transition: all 0.2s;
}
.chip:hover {
background: var(--card-elevated);
border-color: var(--accent);
color: #fff;
transform: translateY(-1px);
}
/* MESSAGES */
.message {
display: flex;
flex-direction: column;
max-width: 85%;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.message.user {
align-self: flex-end;
}
.message.user .bubble {
background: var(--user-bubble);
border: 1px solid var(--border);
border-radius: 14px 14px 2px 14px;
padding: 12px 16px;
color: #fff;
font-size: 14px;
line-height: 1.5;
}
.message.bot {
align-self: flex-start;
max-width: 90%;
}
.message.bot .bubble {
background: var(--bot-bubble);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 14px 14px 14px 2px;
padding: 14px 18px;
color: var(--text);
font-size: 14px;
line-height: 1.6;
}
.bubble p { margin-bottom: 8px; }
.bubble p:last-child { margin-bottom: 0; }
.bubble pre {
background: #0b0d14;
border: 1px solid #1f2738;
border-radius: 8px;
padding: 12px;
overflow-x: auto;
margin: 8px 0;
position: relative;
}
.bubble code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12.5px;
color: #38bdf8;
}
.msg-meta {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
display: flex;
align-items: center;
gap: 8px;
}
/* INPUT SECTION */
.input-section {
padding: 14px 24px;
background: var(--sidebar-bg);
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.input-container {
max-width: 900px;
width: 100%;
margin: 0 auto;
display: flex;
gap: 10px;
align-items: flex-end;
}
#prompt-input {
flex: 1;
min-height: 48px;
max-height: 140px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
color: #fff;
font-size: 14px;
line-height: 1.4;
resize: none;
outline: none;
}
#prompt-input:focus { border-color: var(--accent); }
.send-btn {
width: 48px;
height: 48px;
border-radius: 10px;
background: var(--accent);
color: #000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: none;
font-size: 18px;
transition: all 0.2s;
flex-shrink: 0;
}
.send-btn:hover:not(:disabled) {
filter: brightness(1.15);
transform: scale(1.02);
}
.send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.stop-btn {
background: var(--danger);
color: #fff;
}
/* CACHE FOOTER */
.cache-info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: var(--text-muted);
padding-top: 8px;
border-top: 1px solid var(--border);
}
/* MOBILE TOGGLE */
.mobile-menu-btn {
display: none;
background: none;
border: none;
color: #fff;
font-size: 20px;
cursor: pointer;
}
@media (max-width: 768px) {
aside {
position: absolute;
top: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
transition: transform 0.3s ease;
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
}
aside.open {
transform: translateX(0);
}
.mobile-menu-btn { display: block; }
}
</style>
</head>
<body>
<!-- SIDEBAR CONTROLS -->
<aside id="sidebar">
<div class="brand">
<div class="brand-logo">M</div>
<div class="brand-info">
<h1>Meditor WebGPU</h1>
<p>100% Client-Side In-Browser AI</p>
</div>
</div>
<!-- HARDWARE ACCELERATION CARD -->
<div class="hardware-card">
<div class="hardware-header">
<span>Hardware Engine</span>
<div id="webgpu-status-pill" class="status-badge webgpu-off">
<span class="dot"></span>
<span id="webgpu-status-text">Detecting...</span>
</div>
</div>
<div id="hardware-desc" style="color: var(--text-muted); font-size: 11px;">
Checking WebGPU adapter...
</div>
</div>
<!-- MODEL SELECTION -->
<div class="section-title">Model Architecture</div>
<div class="form-group">
<label for="model-select">Pick a Meditor Model:</label>
<select id="model-select">
<!-- populated via js -->
</select>
</div>
<div id="model-info-box" class="model-info-box">
<!-- model details -->
</div>
<!-- CUSTOM MODEL INPUTS (Hidden unless 'Custom' is chosen) -->
<div id="custom-model-fields" style="display: none; flex-direction: column; gap: 8px;">
<div class="form-group">
<label>Hugging Face Repo ID:</label>
<input type="text" id="custom-repo" placeholder="e.g. Yo-Purple/Meditor-1-500M-Universal">
</div>
<div class="form-group">
<label>GGUF File Name / Path:</label>
<input type="text" id="custom-file" placeholder="e.g. meditor-1-universal-q4_k_m.gguf">
</div>
<div class="form-group">
<label>HF Token (optional for private models):</label>
<input type="password" id="custom-token" placeholder="hf_...">
</div>
</div>
<!-- LOAD MODEL BUTTON -->
<button id="load-model-btn" class="btn btn-primary" style="width: 100%;">
⚡ Load Model into WebGPU
</button>
<!-- DOWNLOAD / LOADING PROGRESS -->
<div id="progress-container">
<div class="progress-text">
<span id="progress-status-label">Downloading weights...</span>
<span id="progress-percent-label">0%</span>
</div>
<div class="progress-bar-bg">
<div id="progress-bar-fill" class="progress-bar-fill"></div>
</div>
<div class="progress-text">
<span id="progress-bytes-label">0 / 0 MB</span>
<span id="progress-speed-label">-- MB/s</span>
</div>
</div>
<!-- INFERENCE PARAMETERS -->
<div class="section-title">Inference Hyperparameters</div>
<div class="slider-row">
<div class="slider-header">
<span>Temperature:</span>
<span id="temp-val">0.7</span>
</div>
<input type="range" id="temp-slider" min="0" max="1.5" step="0.05" value="0.7">
</div>
<div class="slider-row">
<div class="slider-header">
<span>Context Window (Tokens):</span>
<span id="ctx-val">2048</span>
</div>
<input type="range" id="ctx-slider" min="512" max="8192" step="512" value="2048">
</div>
<div class="slider-row">
<div class="slider-header">
<span>Max Output Tokens:</span>
<span id="tokens-val">512</span>
</div>
<input type="range" id="tokens-slider" min="64" max="2048" step="64" value="512">
</div>
<div class="slider-row">
<div class="slider-header">
<span>GPU Offload Layers:</span>
<span id="gpu-layers-val">All</span>
</div>
<input type="range" id="gpu-layers-slider" min="0" max="40" step="1" value="40">
</div>
<div class="form-group">
<label>System Prompt:</label>
<textarea id="system-prompt">You are Meditor, an advanced reasoning and conversational AI assistant. Answer accurately, thoughtfully, and helpfully.</textarea>
</div>
<!-- CACHE & ACTIONS -->
<div style="margin-top: auto; display: flex; flex-direction: column; gap: 8px;">
<div class="cache-info-row">
<span id="cache-status-text">OPFS Cache: Checking...</span>
<button id="clear-cache-btn" class="btn btn-danger" style="padding: 4px 8px; font-size: 11px;">
Clear Cache
</button>
</div>
<button id="clear-chat-btn" class="btn btn-secondary" style="width: 100%;">
🗑️ Clear Conversation
</button>
</div>
</aside>
<!-- MAIN CHAT VIEWPORT -->
<main>
<header>
<div class="header-title">
<button class="mobile-menu-btn" id="toggle-sidebar-btn">☰</button>
<div id="active-model-display" class="active-model-pill">
<span class="dot"></span>
<span id="active-model-name">No Model Loaded</span>
</div>
</div>
<div class="header-telemetry">
<div class="telemetry-tag telemetry-speed" id="speed-meter">0.0 tok/s</div>
<div class="telemetry-tag" id="token-count-meter">0 tokens</div>
</div>
</header>
<!-- SCROLLABLE CHAT CONTAINER -->
<div id="chat-scroll">
<div id="welcome-box" class="welcome-banner">
<h2>⚡ Meditor WebGPU Engine</h2>
<p>
Experience true zero-latency client-side AI. The neural network runs entirely inside your browser using hardware-accelerated <b>WebGPU</b> compute shaders. Zero server cost, zero data leaves your machine.
</p>
<p style="font-size: 12px; color: #34d399;">
Select a Meditor model on the left sidebar and click <b>Load Model into WebGPU</b> to start!
</p>
<div class="preset-chips">
<div class="chip" data-prompt="What is 14 * 25 - 35?">🧮 14 * 25 - 35</div>
<div class="chip" data-prompt="Explain the difference between Mixture-of-Experts (MoE) and Dense LLMs.">🧠 MoE vs Dense</div>
<div class="chip" data-prompt="Write a Python function to check if a word is an anagram of another.">🐍 Python Anagrams</div>
<div class="chip" data-prompt="Explain quantum entanglement simply in 3 short bullet points.">🌌 Quantum Entanglement</div>
</div>
</div>
</div>
<!-- INPUT BAR -->
<div class="input-section">
<div class="input-container">
<textarea id="prompt-input" placeholder="Type your message to Meditor... (Press Enter to send, Shift+Enter for newline)" rows="1" disabled></textarea>
<button id="send-btn" class="send-btn" disabled title="Send Message">
➤
</button>
</div>
</div>
</main>
<!-- APP SCRIPT -->
<script type="module">
import { Wllama } from './wllama.js';
// Meditor Catalog (Published on Hugging Face Hub by @Yo-Purple)
const MEDITOR_MODELS = [
{
id: "meditor-1-500m-q4",
name: "Meditor-1 (500M Universal) — Q4_K_M",
tag: "Recommended ⚡",
description: "Ultra-fast ~468 MB. Instant load, 60-150 tok/s on WebGPU. Ideal for daily chat and mental math.",
repo: "Yo-Purple/Meditor-1-500M-Universal",
file: "meditor-1-universal-q4_k_m.gguf",
size: "468 MB",
params: "500M Dense",
context: 2048,
url: "https://huggingface.co/Yo-Purple/Meditor-1-500M-Universal/resolve/main/meditor-1-universal-q4_k_m.gguf"
},
{
id: "meditor-1-500m-q8",
name: "Meditor-1 (500M Universal) — Q8_0",
tag: "High Precision",
description: "644 MB. 8-bit quantization with near-zero quantization loss.",
repo: "Yo-Purple/Meditor-1-500M-Universal",
file: "meditor-1-universal-q8_0.gguf",
size: "644 MB",
params: "500M Dense",
context: 2048,
url: "https://huggingface.co/Yo-Purple/Meditor-1-500M-Universal/resolve/main/meditor-1-universal-q8_0.gguf"
},
{
id: "meditor-1-500m-bf16",
name: "Meditor-1 (500M Universal) — BF16",
tag: "Uncompressed",
description: "1.18 GB. Uncompressed 16-bit reference weights for studio-grade accuracy.",
repo: "Yo-Purple/Meditor-1-500M-Universal",
file: "meditor-1-universal-bf16.gguf",
size: "1.18 GB",
params: "500M Dense",
context: 2048,
url: "https://huggingface.co/Yo-Purple/Meditor-1-500M-Universal/resolve/main/meditor-1-universal-bf16.gguf"
},
{
id: "meditor-2-3.4b-q3",
name: "Meditor-2 (3.4B MoE Universal) — Q3_K_M",
tag: "MoE Fast",
description: "1.60 GB. 22-expert MoE architecture, 3-bit compact quantization for fast multi-expert reasoning.",
repo: "Yo-Purple/Meditor-2-3.4B-Universal",
file: "meditor-2-3.4b-universal-q3_k_m.gguf",
size: "1.60 GB",
params: "3.4B MoE (1.8B Active)",
context: 2048,
url: "https://huggingface.co/Yo-Purple/Meditor-2-3.4B-Universal/resolve/main/meditor-2-3.4b-universal-q3_k_m.gguf"
},
{
id: "meditor-2-3.4b-q4",
name: "Meditor-2 (3.4B MoE Universal) — Q4_K_M",
tag: "MoE Standard",
description: "2.00 GB. 4-bit balanced quantization with full multi-turn dialogue & code generation.",
repo: "Yo-Purple/Meditor-2-3.4B-Universal",
file: "meditor-2-3.4b-universal-q4_k_m.gguf",
size: "2.00 GB",
params: "3.4B MoE (1.8B Active)",
context: 2048,
url: "https://huggingface.co/Yo-Purple/Meditor-2-3.4B-Universal/resolve/main/meditor-2-3.4b-universal-q4_k_m.gguf"
},
{
id: "meditor-2-3.4b-q5",
name: "Meditor-2 (3.4B MoE Universal) — Q5_K_M",
tag: "MoE High Fidelity",
description: "2.33 GB. 5-bit high fidelity quantization.",
repo: "Yo-Purple/Meditor-2-3.4B-Universal",
file: "meditor-2-3.4b-universal-q5_k_m.gguf",
size: "2.33 GB",
params: "3.4B MoE (1.8B Active)",
context: 2048,
url: "https://huggingface.co/Yo-Purple/Meditor-2-3.4B-Universal/resolve/main/meditor-2-3.4b-universal-q5_k_m.gguf"
},
{
id: "custom",
name: "⚙️ Custom Hugging Face Model / GGUF URL",
tag: "Custom",
description: "Enter your own Hugging Face model repository or direct GGUF file link.",
repo: "",
file: "",
size: "Custom",
params: "Custom",
context: 2048,
url: ""
}
];
// State Variables
let wllamaInstance = null;
let isGenerating = false;
let shouldAbort = false;
let chatHistory = [];
let hasWebGPU = false;
let adapterInfo = null;
let downloadStartTime = 0;
let lastLoadedBytes = 0;
// Elements
const modelSelect = document.getElementById('model-select');
const modelInfoBox = document.getElementById('model-info-box');
const customFields = document.getElementById('custom-model-fields');
const loadBtn = document.getElementById('load-model-btn');
const progressContainer = document.getElementById('progress-container');
const progressBarFill = document.getElementById('progress-bar-fill');
const progressPercentLabel = document.getElementById('progress-percent-label');
const progressBytesLabel = document.getElementById('progress-bytes-label');
const progressSpeedLabel = document.getElementById('progress-speed-label');
const progressStatusLabel = document.getElementById('progress-status-label');
const webgpuStatusPill = document.getElementById('webgpu-status-pill');
const webgpuStatusText = document.getElementById('webgpu-status-text');
const hardwareDesc = document.getElementById('hardware-desc');
const activeModelName = document.getElementById('active-model-name');
const chatScroll = document.getElementById('chat-scroll');
const welcomeBox = document.getElementById('welcome-box');
const promptInput = document.getElementById('prompt-input');
const sendBtn = document.getElementById('send-btn');
const speedMeter = document.getElementById('speed-meter');
const tokenCountMeter = document.getElementById('token-count-meter');
const clearChatBtn = document.getElementById('clear-chat-btn');
const clearCacheBtn = document.getElementById('clear-cache-btn');
const cacheStatusText = document.getElementById('cache-status-text');
// Sliders
const tempSlider = document.getElementById('temp-slider');
const tempVal = document.getElementById('temp-val');
tempSlider.oninput = () => tempVal.textContent = tempSlider.value;
const ctxSlider = document.getElementById('ctx-slider');
const ctxVal = document.getElementById('ctx-val');
ctxSlider.oninput = () => ctxVal.textContent = ctxSlider.value;
const tokensSlider = document.getElementById('tokens-slider');
const tokensVal = document.getElementById('tokens-val');
tokensSlider.oninput = () => tokensVal.textContent = tokensSlider.value;
const gpuLayersSlider = document.getElementById('gpu-layers-slider');
const gpuLayersVal = document.getElementById('gpu-layers-val');
gpuLayersSlider.oninput = () => {
gpuLayersVal.textContent = gpuLayersSlider.value === "40" ? "All" : gpuLayersSlider.value;
};
// Populate Models
MEDITOR_MODELS.forEach((m) => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name;
modelSelect.appendChild(opt);
});
function updateModelInfo() {
const selectedId = modelSelect.value;
const model = MEDITOR_MODELS.find(m => m.id === selectedId);
if (!model) return;
if (selectedId === 'custom') {
customFields.style.display = 'flex';
modelInfoBox.innerHTML = `<b>Custom Model:</b> Provide the Hugging Face repo & GGUF filename.`;
} else {
customFields.style.display = 'none';
modelInfoBox.innerHTML = `
<div><b>Parameters:</b> ${model.params}</div>
<div><b>File Size:</b> ${model.size} | <b>Quant:</b> ${model.tag}</div>
<div style="margin-top:4px; color:#94a3b8;">${model.description}</div>
<div style="margin-top:6px; font-size:10.5px; color:#38bdf8;">HF: ${model.repo}/${model.file}</div>
`;
}
}
modelSelect.onchange = updateModelInfo;
updateModelInfo();
// Hardware Detection
async function initHardwareDetection() {
try {
hasWebGPU = !!navigator.gpu;
if (hasWebGPU) {
const adapter = await navigator.gpu.requestAdapter();
if (adapter) {
adapterInfo = (await adapter.requestAdapterInfo?.()) || adapter.info || {};
webgpuStatusPill.className = "status-badge webgpu-on";
webgpuStatusText.textContent = "WebGPU Ready ⚡";
const vendor = adapterInfo.vendor || "Hardware GPU";
const arch = adapterInfo.architecture || adapterInfo.device || "Dedicated";
hardwareDesc.textContent = `Active Device: ${vendor} (${arch})`;
} else {
webgpuStatusPill.className = "status-badge webgpu-off";
webgpuStatusText.textContent = "WASM Fallback";
hardwareDesc.textContent = "No suitable WebGPU adapter found. Using WebAssembly CPU.";
}
} else {
webgpuStatusPill.className = "status-badge webgpu-off";
webgpuStatusText.textContent = "WASM CPU";
hardwareDesc.textContent = "Browser WebGPU disabled or unavailable. Falling back to WASM.";
}
} catch (err) {
console.warn("Hardware check:", err);
webgpuStatusPill.className = "status-badge webgpu-off";
webgpuStatusText.textContent = "WASM CPU";
hardwareDesc.textContent = "WebGPU check failed; running CPU fallback.";
}
}
initHardwareDetection();
// Cache status inspection
async function updateCacheStatus() {
try {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const { usage, quota } = await navigator.storage.estimate();
const usedMB = (usage / (1024 * 1024)).toFixed(1);
cacheStatusText.textContent = `Browser Storage: ${usedMB} MB cached`;
} else {
cacheStatusText.textContent = "Browser Storage: Active";
}
} catch (e) {
cacheStatusText.textContent = "Storage Active";
}
}
updateCacheStatus();
clearCacheBtn.onclick = async () => {
if (!confirm("Clear all locally cached GGUF models from browser storage?")) return;
try {
if (wllamaInstance?.cacheManager) {
await wllamaInstance.cacheManager.clear();
}
if (window.caches) {
const keys = await caches.keys();
for (const k of keys) await caches.delete(k);
}
alert("Browser model cache cleared successfully!");
updateCacheStatus();
} catch (err) {
alert("Error clearing cache: " + err.message);
}
};
// Load Model Handler
loadBtn.onclick = async () => {
const selectedId = modelSelect.value;
const model = MEDITOR_MODELS.find(m => m.id === selectedId);
let modelUrl = "";
let displayName = "";
let hfToken = "";
if (selectedId === 'custom') {
const repo = document.getElementById('custom-repo').value.trim();
const file = document.getElementById('custom-file').value.trim();
hfToken = document.getElementById('custom-token').value.trim();
if (!repo || !file) {
alert("Please provide both Hugging Face Repo ID and GGUF file name.");
return;
}
modelUrl = `https://huggingface.co/${repo}/resolve/main/${file}`;
displayName = file;
} else {
modelUrl = model.url;
displayName = model.name;
}
loadBtn.disabled = true;
progressContainer.style.display = 'flex';
progressBarFill.style.width = '0%';
progressPercentLabel.textContent = '0%';
progressStatusLabel.textContent = 'Preparing WebGPU runtime...';
downloadStartTime = Date.now();
lastLoadedBytes = 0;
try {
// Unload existing model if present
if (wllamaInstance) {
try {
await wllamaInstance.exit();
} catch (e) {
console.warn("Clean exit error:", e);
}
wllamaInstance = null;
}
// Initialize Wllama instance with local WASM
const wasmUrl = new URL('./wllama.wasm', import.meta.url).href;
wllamaInstance = new Wllama({
default: wasmUrl,
});
const nCtx = parseInt(ctxSlider.value, 10);
const gpuLayersInput = gpuLayersSlider.value;
const nGpuLayers = gpuLayersInput === "40" ? (hasWebGPU ? 99 : 0) : parseInt(gpuLayersInput, 10);
progressStatusLabel.textContent = 'Downloading model weights...';
const downloadOptions = {
n_ctx: nCtx,
n_gpu_layers: nGpuLayers,
n_threads: Math.min(4, Math.max(1, (navigator.hardwareConcurrency || 4) - 1)),
progressCallback: ({ loaded, total }) => {
if (total > 0) {
const pct = Math.min(100, Math.round((loaded / total) * 100));
progressBarFill.style.width = `${pct}%`;
progressPercentLabel.textContent = `${pct}%`;
const loadedMB = (loaded / (1024 * 1024)).toFixed(1);
const totalMB = (total / (1024 * 1024)).toFixed(1);
progressBytesLabel.textContent = `${loadedMB} / ${totalMB} MB`;
const elapsedSec = (Date.now() - downloadStartTime) / 1000;
if (elapsedSec > 0.5) {
const speedMBps = ((loaded / (1024 * 1024)) / elapsedSec).toFixed(1);
progressSpeedLabel.textContent = `${speedMBps} MB/s`;
}
} else {
progressBytesLabel.textContent = `${(loaded / (1024 * 1024)).toFixed(1)} MB`;
}
}
};
if (hfToken) {
modelUrl += `?token=${encodeURIComponent(hfToken)}`;
}
// Load the model
await wllamaInstance.loadModelFromUrl(modelUrl, downloadOptions);
progressStatusLabel.textContent = 'Compiling WebGPU shaders...';
progressBarFill.style.width = '100%';
progressPercentLabel.textContent = '100%';
// Set Active Model Pill
activeModelName.textContent = displayName;
activeModelName.parentElement.classList.add('badge-accent');
promptInput.disabled = false;
sendBtn.disabled = false;
loadBtn.textContent = '✓ Model Loaded (Ready)';
loadBtn.disabled = false;
updateCacheStatus();
if (welcomeBox) welcomeBox.style.display = 'none';
appendMessage('bot', `**${displayName}** has been loaded successfully with **WebGPU** acceleration enabled.\n\nContext window: \`${nCtx} tokens\` | Offload: \`${nGpuLayers > 0 ? 'WebGPU' : 'CPU WASM'}\`.\n\nWhat would you like to explore?`);
promptInput.focus();
} catch (err) {
console.error("Load model error:", err);
progressStatusLabel.textContent = 'Failed: ' + err.message;
alert("Failed to load model: " + err.message);
loadBtn.disabled = false;
loadBtn.textContent = '⚡ Retry Loading Model';
}
};
// Chat Interface Helpers
function appendMessage(role, text) {
if (welcomeBox) welcomeBox.style.display = 'none';
const msgDiv = document.createElement('div');
msgDiv.className = `message ${role}`;
const bubble = document.createElement('div');
bubble.className = 'bubble';
if (role === 'bot') {
bubble.innerHTML = marked.parse(text);
} else {
bubble.textContent = text;
}
msgDiv.appendChild(bubble);
chatScroll.appendChild(msgDiv);
chatScroll.scrollTop = chatScroll.scrollHeight;
return bubble;
}
// Streaming Chat Completion
async function sendMessage() {
const text = promptInput.value.trim();
if (!text || isGenerating || !wllamaInstance) return;
appendMessage('user', text);
promptInput.value = '';
promptInput.style.height = '48px';
chatHistory.push({ role: 'user', content: text });
// Build payload
const systemPrompt = document.getElementById('system-prompt').value.trim();
const messages = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push(...chatHistory);
isGenerating = true;
shouldAbort = false;
sendBtn.innerHTML = '■';
sendBtn.classList.add('stop-btn');
sendBtn.title = "Stop Generation";
const botBubble = appendMessage('bot', '');
let accumulated = '';
let tokenCount = 0;
let startTime = Date.now();
let firstTokenTime = 0;
const temperature = parseFloat(tempSlider.value);
const maxTokens = parseInt(tokensSlider.value, 10);
try {
const stream = await wllamaInstance.createChatCompletion({
messages: messages,
temperature: temperature,
max_tokens: maxTokens,
stream: true,
});
for await (const chunk of stream) {
if (shouldAbort) {
break;
}
const delta = chunk.choices[0]?.delta?.content ?? '';
if (delta) {
if (tokenCount === 0) {
firstTokenTime = Date.now() - startTime;
}
accumulated += delta;
tokenCount++;
botBubble.innerHTML = marked.parse(accumulated);
chatScroll.scrollTop = chatScroll.scrollHeight;
// Live telemetry
const elapsed = (Date.now() - startTime) / 1000;
if (elapsed > 0.2) {
const tokSec = (tokenCount / elapsed).toFixed(1);
speedMeter.textContent = `${tokSec} tok/s`;
}
tokenCountMeter.textContent = `${tokenCount} tokens`;
}
}
chatHistory.push({ role: 'assistant', content: accumulated });
// Add telemetry meta beneath bot response
const totalElapsed = (Date.now() - startTime) / 1000;
const finalSpeed = (tokenCount / (totalElapsed || 0.001)).toFixed(1);
const metaDiv = document.createElement('div');
metaDiv.className = 'msg-meta';
metaDiv.innerHTML = `⚡ <span>${finalSpeed} tok/s</span> · <span>${tokenCount} tokens</span> · <span>TTFT: ${firstTokenTime}ms</span>`;
botBubble.parentElement.appendChild(metaDiv);
} catch (err) {
console.error("Inference error:", err);
botBubble.innerHTML += `<br><span style="color:var(--danger)">Error during generation: ${err.message}</span>`;
} finally {
isGenerating = false;
sendBtn.innerHTML = '➤';
sendBtn.classList.remove('stop-btn');
sendBtn.title = "Send Message";
promptInput.focus();
}
}
sendBtn.onclick = () => {
if (isGenerating) {
shouldAbort = true;
} else {
sendMessage();
}
};
promptInput.onkeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
// Auto-expand textarea
promptInput.oninput = () => {
promptInput.style.height = 'auto';
promptInput.style.height = Math.min(140, Math.max(48, promptInput.scrollHeight)) + 'px';
};
// Chip prompt triggers
document.querySelectorAll('.chip').forEach(chip => {
chip.onclick = () => {
promptInput.value = chip.getAttribute('data-prompt');
promptInput.focus();
promptInput.dispatchEvent(new Event('input'));
};
});
// Clear Chat
clearChatBtn.onclick = () => {
chatHistory = [];
chatScroll.innerHTML = '';
if (welcomeBox) {
chatScroll.appendChild(welcomeBox);
welcomeBox.style.display = 'flex';
}
speedMeter.textContent = '0.0 tok/s';
tokenCountMeter.textContent = '0 tokens';
};
// Sidebar Mobile Toggle
const sidebar = document.getElementById('sidebar');
const toggleSidebarBtn = document.getElementById('toggle-sidebar-btn');
if (toggleSidebarBtn) {
toggleSidebarBtn.onclick = () => sidebar.classList.toggle('open');
}
</script>
</body>
</html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meditor AI — In-Browser WebGPU Chat</title>
<script src="./marked.min.js"></script>
<style>
:root {
--bg: #090b11;
--sidebar-bg: #10141f;
--card-bg: #161b2a;
--card-elevated: #1d2438;
--border: #222b40;
--border-focus: #10b981;
--text: #f3f4f6;
--text-muted: #94a3b8;
--accent: #10b981;
--accent-glow: rgba(16, 185, 129, 0.25);
--accent-purple: #8b5cf6;
--user-bubble: #1f293d;
--bot-bubble: #141926;
--danger: #ef4444;
--warning: #f59e0b;
}
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
body { background: var(--bg); color: var(--text); display: flex; height: 100vh; overflow: hidden; }
/* SIDEBAR */
aside {
width: 340px;
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 18px;
gap: 16px;
overflow-y: auto;
flex-shrink: 0;
z-index: 10;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.brand-logo {
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #6366f1);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 800;
color: #fff;
box-shadow: 0 0 16px var(--accent-glow);
}
.brand-info h1 { font-size: 17px; font-weight: 700; color: #fff; letter-spacing: -0.3px; }
.brand-info p { font-size: 11px; color: var(--text-muted); }
/* HARDWARE STATUS CARD */
.hardware-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
}
.hardware-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.status-badge.webgpu-on {
background: rgba(16, 185, 129, 0.15);
border: 1px solid #10b981;
color: #34d399;
}
.status-badge.webgpu-off {
background: rgba(245, 158, 11, 0.15);
border: 1px solid #f59e0b;