| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> |
| <meta name="color-scheme" content="dark" /> |
| <title>BlackRiver AI · Gemma 4 Multimodal</title> |
| <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='7' y='7' width='50' height='50' rx='15' fill='none' stroke='%238DB4FF' stroke-width='3'/%3E%3Cpath d='M16 39.5C25 25 35.5 43 47 19.5' stroke='%23F7F9FC' stroke-width='4.6' stroke-linecap='round' fill='none'/%3E%3Cpath d='M16 28.5C25 14 36 32 47 8.5' stroke='%238DB4FF' stroke-width='3.1' stroke-linecap='round' fill='none' opacity='.8'/%3E%3C/svg%3E"> |
| <meta name="description" content="BlackRiver AI implementation of Gemma 4 E2B and E4B — vision, audio and text running locally with WebGPU." /> |
| <meta property="og:title" content="BlackRiver AI · Gemma 4 Multimodal" /> |
| <meta property="og:description" content="Run Gemma 4 E2B or E4B locally with vision, audio and text input through WebGPU." /> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet"> |
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js", |
| "three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/" |
| } |
| } |
| </script> |
| <style> |
| :root { |
| --bg: #020203; |
| --t1: rgba(255, 255, 255, 0.92); |
| --t2: rgba(255, 255, 255, 0.55); |
| --t3: rgba(255, 255, 255, 0.32); |
| --t4: rgba(255, 255, 255, 0.20); |
| --line: rgba(255, 255, 255, 0.08); |
| --line-soft: rgba(255, 255, 255, 0.06); |
| --panel: rgba(255, 255, 255, 0.03); |
| --panel-hover: rgba(255, 255, 255, 0.06); |
| --ok: #64ffa0; |
| --warn: #ffcd6b; |
| --danger: #ff7a6b; |
| --user-bubble: rgba(255, 255, 255, 0.065); |
| --display: "Instrument Serif", Georgia, "Times New Roman", serif; |
| --body: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| --mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; |
| --maxw: 820px; |
| } |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| html { height: 100%; } |
| body { |
| background: var(--bg); |
| color: var(--t1); |
| font-family: var(--body); |
| font-size: 16px; |
| font-weight: 400; |
| line-height: 1.6; |
| -webkit-font-smoothing: antialiased; |
| height: 100dvh; |
| overflow-x: hidden; |
| overflow-y: scroll; |
| scroll-behavior: smooth; |
| } |
| button, textarea, input { font: inherit; color: inherit; } |
| button { appearance: none; background: none; border: 0; cursor: pointer; } |
| a { color: inherit; text-decoration: none; } |
| ::selection { background: rgba(255, 255, 255, 0.18); } |
| body::-webkit-scrollbar { width: 0; } |
| .screen { min-height: 100dvh; position: relative; width: 100%; } |
| |
| |
| #landing { |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| #crt-frame { |
| position: absolute; |
| inset: 0; |
| z-index: 0; |
| overflow: hidden; |
| pointer-events: none; |
| opacity: 0; |
| transition: opacity 2.4s cubic-bezier(0.25, 0.1, 0.25, 1) 0.6s; |
| } |
| #crt-frame.visible { opacity: 1; } |
| #crt-frame canvas { display: block; width: 100% !important; height: 100% !important; pointer-events: none; } |
| .hero-fade { |
| position: absolute; |
| bottom: 0; left: 0; |
| width: 100%; height: 68%; |
| background: linear-gradient(to top, var(--bg) 0%, var(--bg) 14%, rgba(2, 2, 3, 0.55) 52%, transparent 100%); |
| z-index: 1; |
| pointer-events: none; |
| opacity: 0; |
| transition: opacity 2s ease 1s; |
| } |
| .hero-fade.in { opacity: 1; } |
| .hero-overlay { |
| position: relative; |
| z-index: 2; |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| pointer-events: none; |
| min-height: 100dvh; |
| } |
| .brand-name { |
| font-family: var(--mono); |
| font-size: 12px; |
| font-weight: 500; |
| letter-spacing: 0.22em; |
| text-transform: uppercase; |
| color: var(--t1); |
| } |
| .hero-content { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| justify-content: flex-end; |
| padding: 0 48px 18px; |
| pointer-events: none; |
| } |
| .eyebrow { |
| display: inline-flex; |
| align-items: center; |
| gap: 10px; |
| width: fit-content; |
| font-family: var(--mono); |
| font-size: 11px; |
| letter-spacing: 0.2em; |
| text-transform: uppercase; |
| color: var(--t3); |
| margin-bottom: 22px; |
| text-shadow: 0 2px 16px var(--bg); |
| } |
| .eyebrow::before { |
| content: ""; |
| flex-shrink: 0; |
| width: 7px; height: 7px; border-radius: 50%; |
| background: var(--ok); |
| box-shadow: 0 0 12px rgba(100, 255, 160, 0.5); |
| animation: statusPulse 2.4s ease-in-out infinite; |
| } |
| .hero-h1 { |
| font-family: var(--display); |
| font-size: clamp(44px, 7.6vw, 100px); |
| font-weight: 400; |
| line-height: 0.98; |
| letter-spacing: -0.015em; |
| color: #fff; |
| margin-bottom: 30px; |
| max-width: 26ch; |
| text-shadow: 0 3px 40px var(--bg), 0 1px 14px rgba(2, 2, 3, 0.85); |
| } |
| .hero-h1 .thin { color: var(--t3); } |
| .hero-row { |
| display: flex; |
| align-items: flex-end; |
| justify-content: space-between; |
| gap: 56px; |
| } |
| .hero-main { display: flex; flex-direction: column; min-width: 0; } |
| .hero-sub { |
| font-size: 15px; |
| font-weight: 300; |
| line-height: 1.7; |
| color: var(--t2); |
| max-width: 52ch; |
| text-shadow: 0 2px 16px var(--bg), 0 1px 30px var(--bg); |
| margin-bottom: 26px; |
| } |
| .hero-sub b { color: var(--t1); font-weight: 500; } |
| .hero-stats { display: flex; flex-wrap: wrap; gap: 36px; } |
| .stat { display: flex; flex-direction: column; gap: 5px; } |
| .stat-val { |
| font-family: var(--display); |
| font-size: 30px; |
| font-weight: 400; |
| letter-spacing: -0.01em; |
| color: var(--t1); |
| line-height: 1; |
| text-shadow: 0 2px 16px var(--bg); |
| } |
| .stat-label { |
| font-family: var(--mono); |
| font-size: 10.5px; |
| letter-spacing: 0.12em; |
| text-transform: uppercase; |
| color: var(--t3); |
| } |
| .hero-side { |
| display: flex; |
| flex-direction: column; |
| align-items: flex-end; |
| gap: 18px; |
| flex-shrink: 0; |
| text-align: right; |
| } |
| .hero-actions { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| pointer-events: auto; |
| } |
| .btn-primary { |
| position: relative; |
| display: inline-flex; |
| align-items: center; |
| gap: 10px; |
| padding: 14px 28px; |
| color: #000; |
| font-size: 13.5px; |
| font-weight: 500; |
| letter-spacing: 0.01em; |
| border-radius: 100px; |
| border: 1px solid #fff; |
| white-space: nowrap; |
| overflow: hidden; |
| isolation: isolate; |
| transition: opacity 0.2s ease; |
| } |
| .btn-primary::before { |
| content: ""; |
| position: absolute; |
| inset: 0; |
| background: #fff; |
| border-radius: inherit; |
| transform: scaleX(1); |
| transform-origin: left center; |
| transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
| z-index: -1; |
| } |
| .btn-primary:hover:not(:disabled)::before { transform: scaleX(0); transform-origin: right center; } |
| .btn-primary:hover:not(:disabled) { color: #fff; } |
| .btn-primary svg { width: 14px; height: 14px; } |
| .btn-primary:disabled { opacity: 0.4; cursor: not-allowed; } |
| .btn-secondary { |
| display: inline-flex; |
| align-items: center; |
| gap: 9px; |
| padding: 14px 26px; |
| color: var(--t2); |
| font-size: 13.5px; |
| font-weight: 400; |
| border-radius: 100px; |
| border: 1px solid var(--line); |
| white-space: nowrap; |
| transition: border-color 0.25s ease, color 0.25s ease; |
| } |
| .btn-secondary:hover { border-color: rgba(255, 255, 255, 0.4); color: var(--t1); } |
| .btn-secondary svg { width: 12px; height: 12px; } |
| .hero-foot { |
| display: flex; |
| flex-direction: column; |
| align-items: flex-end; |
| gap: 8px; |
| pointer-events: auto; |
| } |
| .kernel-note-cta { |
| width: fit-content; |
| font-size: 13px; |
| font-weight: 400; |
| color: var(--t2); |
| border-bottom: 1px solid var(--line); |
| padding-bottom: 2px; |
| transition: color 0.2s ease, border-color 0.2s ease; |
| } |
| .kernel-note-cta b { color: var(--t1); font-weight: 600; } |
| .kernel-note-cta:hover { color: var(--t1); border-color: rgba(255, 255, 255, 0.4); } |
| .hero-foot-note { |
| font-family: var(--mono); |
| font-size: 10px; |
| letter-spacing: 0.06em; |
| color: var(--t4); |
| } |
| .scroll-cue { |
| flex: 0 0 auto; |
| align-self: center; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 9px; |
| padding-bottom: 18px; |
| pointer-events: none; |
| opacity: 0; |
| transition: opacity 0.8s ease 1.8s; |
| } |
| .scroll-cue.in { opacity: 1; } |
| .scroll-cue span { |
| font-family: var(--mono); |
| font-size: 9.5px; |
| letter-spacing: 0.2em; |
| text-transform: uppercase; |
| color: var(--t4); |
| } |
| .scroll-cue-line { width: 1px; height: 30px; background: var(--line); position: relative; overflow: hidden; } |
| .scroll-cue-line::after { |
| content: ""; |
| position: absolute; |
| top: -100%; left: 0; |
| width: 100%; height: 100%; |
| background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.4), transparent); |
| animation: scrollDrop 2s ease-in-out infinite; |
| } |
| |
| |
| #chat { |
| display: flex; |
| flex-direction: column; |
| height: 100dvh; |
| background: radial-gradient(circle at 50% -20%, rgba(255, 255, 255, 0.035), transparent 55%), var(--bg); |
| } |
| .chat-head { |
| position: relative; |
| flex: 0 0 auto; |
| border-bottom: 1px solid var(--line-soft); |
| background: rgba(2, 2, 3, 0.72); |
| backdrop-filter: blur(12px); |
| -webkit-backdrop-filter: blur(12px); |
| } |
| .chat-head-inner { |
| margin: 0 auto; |
| max-width: var(--maxw); |
| padding: 16px 28px; |
| display: flex; |
| align-items: center; |
| gap: 16px; |
| } |
| .to-top { |
| display: inline-flex; |
| align-items: center; |
| gap: 9px; |
| flex-shrink: 0; |
| transition: opacity 0.2s ease; |
| } |
| .to-top:hover { opacity: 0.7; } |
| .to-top svg { width: 13px; height: 13px; color: var(--t3); } |
| .to-top .brand-name { font-size: 11px; } |
| .status { |
| flex: 1; |
| min-width: 0; |
| display: flex; |
| align-items: center; |
| gap: 9px; |
| color: var(--t2); |
| font-size: 13px; |
| } |
| .status-dot { |
| width: 7px; height: 7px; border-radius: 50%; |
| background: var(--t4); |
| flex-shrink: 0; |
| transition: background 0.3s ease, box-shadow 0.3s ease; |
| } |
| .status.loading .status-dot { background: var(--warn); box-shadow: 0 0 10px rgba(255, 205, 107, 0.5); } |
| .status.ready .status-dot { background: var(--ok); box-shadow: 0 0 10px rgba(100, 255, 160, 0.5); } |
| .status.busy .status-dot { background: var(--ok); box-shadow: 0 0 10px rgba(100, 255, 160, 0.5); animation: statusPulse 1.1s ease-in-out infinite; } |
| .status.error .status-dot { background: var(--danger); box-shadow: 0 0 10px rgba(255, 122, 107, 0.5); } |
| .status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } |
| .status-text strong { color: var(--t1); font-weight: 500; } |
| .chat-head-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } |
| .head-btn { |
| display: inline-flex; |
| align-items: center; |
| gap: 7px; |
| padding: 8px 15px; |
| font-family: var(--mono); |
| font-size: 11px; |
| letter-spacing: 0.08em; |
| text-transform: uppercase; |
| color: var(--t2); |
| border: 1px solid var(--line); |
| border-radius: 100px; |
| transition: border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease; |
| } |
| .head-btn:hover:not(:disabled) { border-color: rgba(255, 255, 255, 0.35); color: var(--t1); } |
| .head-btn:disabled { opacity: 0.35; cursor: not-allowed; } |
| .head-btn[hidden] { display: none; } |
| .head-btn.solid { background: #fff; color: #000; border-color: #fff; } |
| .head-btn.solid:hover:not(:disabled) { opacity: 0.85; color: #000; } |
| .bar { |
| position: absolute; |
| left: 0; right: 0; bottom: 0; |
| height: 2px; |
| overflow: hidden; |
| pointer-events: none; |
| } |
| .bar > div { |
| height: 100%; |
| width: 0%; |
| background: linear-gradient(90deg, var(--t2), #fff); |
| transition: opacity 0.4s ease; |
| } |
| .bar.done > div { opacity: 0; } |
| .thread-scroll { |
| flex: 1 1 auto; |
| min-height: 0; |
| overflow-y: auto; |
| scroll-behavior: smooth; |
| } |
| .thread { |
| margin: 0 auto; |
| max-width: var(--maxw); |
| min-height: 100%; |
| padding: 40px 28px 24px; |
| display: flex; |
| flex-direction: column; |
| gap: 32px; |
| } |
| .welcome { |
| margin: auto 0; |
| text-align: center; |
| padding: 4vh 0; |
| animation: rise 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; |
| } |
| .welcome h2 { |
| font-family: var(--display); |
| font-size: clamp(30px, 6vw, 46px); |
| font-weight: 400; |
| line-height: 1.05; |
| color: #fff; |
| margin-bottom: 14px; |
| } |
| .welcome h2 .thin { color: var(--t3); } |
| .welcome p { color: var(--t2); max-width: 46ch; margin: 0 auto 28px; font-weight: 300; } |
| .seeds { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; } |
| .seed { |
| padding: 9px 16px; |
| font-size: 13.5px; |
| color: var(--t2); |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: 100px; |
| transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease, transform 0.15s ease, opacity 0.2s ease; |
| } |
| .seed:hover:not(:disabled) { border-color: rgba(255, 255, 255, 0.3); color: var(--t1); background: var(--panel-hover); transform: translateY(-1px); } |
| .seed:disabled { opacity: 0.4; cursor: not-allowed; } |
| .msg { display: flex; flex-direction: column; animation: rise 0.4s cubic-bezier(0.2, 0.7, 0.2, 1) both; } |
| .role { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| font-family: var(--mono); |
| font-size: 10.5px; |
| letter-spacing: 0.16em; |
| text-transform: uppercase; |
| margin-bottom: 10px; |
| } |
| .role::before { content: ""; width: 5px; height: 5px; border-radius: 50%; } |
| .msg.user { align-items: flex-end; } |
| .msg.user .role { color: var(--t3); } |
| .msg.user .role::before { background: var(--t3); } |
| .msg.assistant .role { color: var(--ok); } |
| .msg.assistant .role::before { background: var(--ok); } |
| .bubble { overflow-wrap: anywhere; } |
| .bubble.user { |
| background: var(--user-bubble); |
| border: 1px solid var(--line); |
| border-radius: 16px 16px 4px 16px; |
| padding: 12px 17px; |
| line-height: 1.55; |
| max-width: min(82%, 600px); |
| white-space: pre-wrap; |
| color: var(--t1); |
| } |
| .bubble.assistant { font-size: 16px; line-height: 1.72; color: var(--t1); max-width: 100%; } |
| .bubble.assistant > :first-child { margin-top: 0; } |
| .bubble.assistant > :last-child { margin-bottom: 0; } |
| .bubble.assistant p { margin: 0 0 0.9em; } |
| .bubble.assistant strong { color: #fff; font-weight: 600; } |
| .bubble.assistant em { font-style: italic; } |
| .bubble.assistant a { color: #8ab4ff; text-decoration: underline; text-underline-offset: 2px; } |
| .bubble.assistant a:hover { color: #aecbff; } |
| .bubble.assistant h1, .bubble.assistant h2, .bubble.assistant h3, |
| .bubble.assistant h4, .bubble.assistant h5, .bubble.assistant h6 { |
| color: #fff; font-weight: 600; line-height: 1.25; margin: 1.3em 0 0.6em; |
| } |
| .bubble.assistant h1 { font-size: 1.5em; } |
| .bubble.assistant h2 { font-size: 1.3em; } |
| .bubble.assistant h3 { font-size: 1.13em; } |
| .bubble.assistant h4, .bubble.assistant h5, .bubble.assistant h6 { font-size: 1em; } |
| .bubble.assistant ul, .bubble.assistant ol { margin: 0 0 0.9em; padding-left: 1.5em; } |
| .bubble.assistant li { margin: 0.25em 0; } |
| .bubble.assistant li::marker { color: var(--t3); } |
| .bubble.assistant blockquote { |
| margin: 0 0 0.9em; padding: 2px 0 2px 16px; |
| border-left: 2px solid var(--line); color: var(--t2); |
| } |
| .bubble.assistant hr { border: 0; border-top: 1px solid var(--line); margin: 1.3em 0; } |
| .bubble.assistant code { |
| font-family: var(--mono); |
| font-size: 0.85em; |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: 5px; |
| padding: 1px 5px; |
| } |
| .bubble.assistant pre { |
| margin: 0 0 0.9em; |
| padding: 14px 16px; |
| background: #0a0a0c; |
| border: 1px solid var(--line); |
| border-radius: 12px; |
| overflow-x: auto; |
| } |
| .bubble.assistant pre code { background: none; border: 0; padding: 0; font-size: 0.82em; line-height: 1.6; } |
| .bubble.assistant table { border-collapse: collapse; margin: 0 0 0.9em; font-size: 0.92em; display: block; overflow-x: auto; } |
| .bubble.assistant th, .bubble.assistant td { border: 1px solid var(--line); padding: 6px 11px; text-align: left; } |
| .bubble.assistant th { background: var(--panel); color: var(--t1); font-weight: 600; } |
| .bubble.assistant pre::-webkit-scrollbar, .bubble.assistant table::-webkit-scrollbar { height: 8px; } |
| .bubble.assistant pre::-webkit-scrollbar-thumb, .bubble.assistant table::-webkit-scrollbar-thumb { background: var(--line); border-radius: 8px; } |
| .meta { |
| font-family: var(--mono); |
| font-size: 10.5px; |
| letter-spacing: 0.04em; |
| color: var(--t3); |
| margin-top: 12px; |
| } |
| .thinking { display: inline-flex; gap: 5px; padding: 4px 0; } |
| .thinking span { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); opacity: 0.5; animation: bob 1.3s ease-in-out infinite; } |
| .thinking span:nth-child(2) { animation-delay: 0.18s; } |
| .thinking span:nth-child(3) { animation-delay: 0.36s; } |
| .caret { display: inline-block; width: 2px; height: 1.05em; margin-left: 2px; vertical-align: -0.16em; background: var(--ok); animation: blink 1s steps(2) infinite; } |
| |
| |
| .attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; } |
| .attachment { |
| position: relative; |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| padding: 6px 10px; |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: 10px; |
| font-size: 12px; |
| color: var(--t2); |
| } |
| .attachment img { width: 32px; height: 32px; border-radius: 6px; object-fit: cover; } |
| .attachment .att-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .attachment .att-type { font-family: var(--mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--t4); } |
| .attachment .att-remove { |
| display: grid; place-items: center; |
| width: 18px; height: 18px; |
| border-radius: 50%; |
| background: rgba(255,255,255,0.08); |
| color: var(--t3); |
| font-size: 12px; |
| line-height: 1; |
| transition: background 0.15s ease, color 0.15s ease; |
| } |
| .attachment .att-remove:hover { background: rgba(255,122,107,0.2); color: var(--danger); } |
| .attachment.audio .att-icon { font-size: 18px; } |
| .attachment.audio canvas { width: 48px; height: 24px; border-radius: 4px; } |
| |
| .composer-wrap { |
| flex: 0 0 auto; |
| background: linear-gradient(rgba(2, 2, 3, 0), var(--bg) 30%); |
| padding-top: 8px; |
| } |
| .composer { margin: 0 auto; max-width: var(--maxw); padding: 0 28px 22px; } |
| .composer-tools { |
| display: flex; |
| align-items: center; |
| gap: 4px; |
| margin-bottom: 8px; |
| } |
| .tool-btn { |
| display: grid; |
| place-items: center; |
| width: 34px; |
| height: 34px; |
| border-radius: 9px; |
| color: var(--t3); |
| border: 1px solid transparent; |
| transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; |
| } |
| .tool-btn:hover:not(:disabled) { border-color: var(--line); color: var(--t1); background: var(--panel); } |
| .tool-btn:disabled { opacity: 0.3; cursor: not-allowed; } |
| .tool-btn.recording { color: var(--danger); border-color: rgba(255,122,107,0.3); background: rgba(255,122,107,0.08); animation: statusPulse 1s ease-in-out infinite; } |
| .tool-btn svg { width: 17px; height: 17px; } |
| .field { |
| display: grid; |
| grid-template-columns: 1fr 44px; |
| align-items: flex-end; |
| gap: 8px; |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: 18px; |
| padding: 8px 8px 8px 18px; |
| transition: border-color 0.2s ease, background 0.2s ease; |
| } |
| .field:focus-within { border-color: rgba(255, 255, 255, 0.28); background: rgba(255, 255, 255, 0.05); } |
| textarea { |
| background: transparent; |
| border: 0; |
| outline: none; |
| resize: none; |
| width: 100%; |
| min-height: 42px; |
| max-height: 180px; |
| padding: 8px 0; |
| color: var(--t1); |
| } |
| textarea::placeholder { color: var(--t3); } |
| .icon-button { |
| display: grid; |
| place-items: center; |
| width: 42px; |
| height: 42px; |
| border-radius: 13px; |
| transition: background 0.2s ease, opacity 0.2s ease, transform 0.1s ease; |
| } |
| .icon-button svg { width: 19px; height: 19px; } |
| .icon-button:active:not(:disabled) { transform: scale(0.94); } |
| .icon-button:disabled { opacity: 0.3; cursor: not-allowed; } |
| .send-button { background: #fff; color: #000; } |
| .send-button:hover:not(:disabled) { opacity: 0.86; } |
| .stop-button { background: rgba(255, 122, 107, 0.14); color: var(--danger); border: 1px solid rgba(255, 122, 107, 0.3); display: none; } |
| .stop-button:hover:not(:disabled) { background: rgba(255, 122, 107, 0.22); } |
| .composer-meta { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 12px; |
| margin: 10px 4px 0; |
| min-height: 22px; |
| font-family: var(--mono); |
| font-size: 10.5px; |
| letter-spacing: 0.04em; |
| text-transform: uppercase; |
| color: var(--t3); |
| } |
| .thread-scroll::-webkit-scrollbar { width: 10px; } |
| .thread-scroll::-webkit-scrollbar-thumb { background: var(--line); border: 3px solid var(--bg); border-radius: 10px; } |
| :focus-visible { outline: 1px solid rgba(255, 255, 255, 0.45); outline-offset: 3px; } |
| textarea:focus, textarea:focus-visible { outline: none; } |
| |
| |
| .kx { |
| position: fixed; |
| inset: 0; |
| z-index: 50; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 28px; |
| } |
| .kx[hidden] { display: none; } |
| .kx-backdrop { |
| position: absolute; |
| inset: 0; |
| background: rgba(0, 0, 0, 0.62); |
| backdrop-filter: blur(6px); |
| -webkit-backdrop-filter: blur(6px); |
| animation: kxFade 0.25s ease; |
| } |
| .kx-panel { |
| position: relative; |
| display: flex; |
| flex-direction: column; |
| width: min(1080px, 100%); |
| height: min(86vh, 920px); |
| background: #0a0a0c; |
| border: 1px solid var(--line); |
| border-radius: 18px; |
| overflow: hidden; |
| box-shadow: 0 40px 120px -30px rgba(0, 0, 0, 0.9); |
| animation: kxRise 0.3s cubic-bezier(0.2, 0.7, 0.2, 1); |
| } |
| .kx-head { |
| display: flex; |
| align-items: flex-start; |
| justify-content: space-between; |
| gap: 16px; |
| padding: 20px 22px; |
| border-bottom: 1px solid var(--line-soft); |
| } |
| .kx-title h3 { font-family: var(--display); font-weight: 400; font-size: 26px; color: #fff; line-height: 1; } |
| .kx-sub { display: block; margin-top: 7px; font-size: 12.5px; color: var(--t3); } |
| .kx-close { |
| display: grid; place-items: center; |
| width: 34px; height: 34px; |
| flex-shrink: 0; border-radius: 9px; |
| color: var(--t2); |
| border: 1px solid var(--line); |
| transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease; |
| } |
| .kx-close:hover { color: var(--t1); border-color: rgba(255, 255, 255, 0.35); background: var(--panel); } |
| .kx-close svg { width: 15px; height: 15px; } |
| .kx-body { flex: 1; display: grid; grid-template-columns: 236px 1fr; min-height: 0; } |
| .kx-side { position: relative; min-width: 0; min-height: 0; border-right: 1px solid var(--line-soft); } |
| .kx-list { height: 100%; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 2px; } |
| .kx-item { |
| flex-shrink: 0; |
| text-align: left; |
| padding: 9px 12px; |
| border-radius: 8px; |
| font-family: var(--mono); |
| font-size: 12px; |
| line-height: 1.5; |
| color: var(--t2); |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| transition: background 0.15s ease, color 0.15s ease; |
| } |
| .kx-item:hover { background: var(--panel); color: var(--t1); } |
| .kx-item.active { background: var(--panel-hover); color: #fff; } |
| .kx-view { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; } |
| .kx-view-head { |
| display: flex; align-items: center; justify-content: space-between; gap: 12px; |
| padding: 12px 18px; border-bottom: 1px solid var(--line-soft); |
| } |
| .kx-name { font-family: var(--mono); font-size: 13px; color: var(--t1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .kx-view-actions { display: flex; align-items: center; gap: 14px; flex-shrink: 0; } |
| .kx-lines { font-family: var(--mono); font-size: 11px; color: var(--t4); } |
| .kx-copy { |
| font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; |
| color: var(--t2); border: 1px solid var(--line); border-radius: 100px; padding: 5px 13px; |
| transition: color 0.2s ease, border-color 0.2s ease; |
| } |
| .kx-copy:hover { color: var(--t1); border-color: rgba(255, 255, 255, 0.35); } |
| .kx-code { flex: 1; min-height: 0; min-width: 0; overflow: auto; margin: 0; padding: 18px 20px; font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: var(--t2); tab-size: 2; } |
| .kx-code code { white-space: pre; } |
| .k-cm { color: #5d6b6f; font-style: italic; } |
| .k-kw { color: #c792ea; } |
| .k-ty { color: #6fb3ff; } |
| .k-at { color: #ffb074; } |
| .k-nu { color: #7ee787; } |
| .kx-source { flex: 1; min-height: 0; display: flex; flex-direction: column; } |
| .kx-source[hidden] { display: none; } |
| .kx-code::-webkit-scrollbar, .kx-list::-webkit-scrollbar { width: 10px; height: 10px; } |
| .kx-code::-webkit-scrollbar-thumb, .kx-list::-webkit-scrollbar-thumb { background: var(--line); border: 3px solid #0a0a0c; border-radius: 10px; } |
| body.kx-locked { overflow: hidden; } |
| |
| @keyframes kxFade { from { opacity: 0; } to { opacity: 1; } } |
| @keyframes kxRise { from { opacity: 0; transform: translateY(12px) scale(0.99); } to { opacity: 1; transform: none; } } |
| @keyframes statusPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } |
| @keyframes scrollDrop { 0% { top: -100%; } 55% { top: 100%; } 100% { top: 100%; } } |
| @keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } |
| @keyframes blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } } |
| @keyframes bob { 0%, 60%, 100% { opacity: 0.5; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-5px); } } |
| |
| .anim { opacity: 0; transition: opacity 0.9s cubic-bezier(0.25, 0.1, 0.25, 1); } |
| .anim.in { opacity: 1; } |
| .eyebrow { transition-delay: 0.35s; } |
| .hero-h1 { transition-delay: 0.5s; } |
| .hero-row { transition-delay: 0.78s; } |
| |
| @media (prefers-reduced-motion: reduce) { |
| *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } |
| .anim, #crt-frame, .hero-fade, .scroll-cue { opacity: 1 !important; } |
| .caret { opacity: 1; } |
| } |
| |
| @media (max-width: 760px) { |
| .hero-content { padding: 0 22px 22px; } |
| .hero-h1 { font-size: clamp(38px, 12vw, 60px); margin-bottom: 24px; max-width: none; } |
| .hero-row { flex-direction: column; align-items: stretch; gap: 28px; } |
| .hero-side { align-items: flex-start; text-align: left; } |
| .hero-foot { align-items: flex-start; } |
| .hero-actions { flex-wrap: wrap; } |
| .hero-stats { gap: 22px 30px; } |
| .stat-val { font-size: 24px; } |
| .scroll-cue { display: none; } |
| .chat-head-inner { padding: 13px 18px; gap: 12px; } |
| .to-top .brand-name { display: none; } |
| .thread { padding: 28px 18px 20px; } |
| .composer { padding: 0 18px 18px; } |
| .bubble.user { max-width: 88%; } |
| .head-btn { padding: 8px 12px; } |
| .kx { padding: 0; } |
| .kx-panel { width: 100%; height: 100%; border-radius: 0; border: 0; } |
| .kx-body { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } |
| .kx-side { border-right: 0; border-bottom: 1px solid var(--line-soft); } |
| .kx-list { flex-direction: row; height: auto; overflow-x: auto; overflow-y: hidden; } |
| .kx-item { flex-shrink: 0; } |
| } |
| </style> |
| <style id="blackriver-theme"> |
| :root { |
| --bg: #020306; |
| --panel: rgba(141, 180, 255, 0.055); |
| --panel2: rgba(255, 255, 255, 0.035); |
| --line: rgba(196, 211, 255, 0.20); |
| --line-soft: rgba(196, 211, 255, 0.10); |
| --t1: #f7f9fc; |
| --t2: #98a2b3; |
| --t3: #626b7d; |
| --accent: #8db4ff; |
| --accent2: #8df1ff; |
| --green: #58e6b1; |
| --sans: "Inter", system-ui, sans-serif; |
| --mono: "Geist Mono", "SFMono-Regular", Consolas, monospace; |
| } |
|
|
| html, body { background: var(--bg); } |
| body { font-family: var(--sans); color: var(--t1); } |
| ::selection { background: rgba(141, 180, 255, 0.28); color: #fff; } |
|
|
| body::before { |
| content: ""; |
| position: fixed; inset: 0; pointer-events: none; z-index: 9997; |
| background-image: |
| linear-gradient(rgba(141,180,255,.025) 1px, transparent 1px), |
| linear-gradient(90deg, rgba(141,180,255,.025) 1px, transparent 1px); |
| background-size: 52px 52px; |
| mask-image: linear-gradient(to bottom, #000, transparent 75%); |
| } |
| body::after { |
| content: ""; |
| position: fixed; inset: 0; pointer-events: none; z-index: 9998; opacity: .025; |
| background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); |
| } |
|
|
| #landing { background: #020306; isolation: isolate; } |
| #landing::after { |
| content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none; |
| background: radial-gradient(ellipse 60% 72% at 73% 44%, transparent 0%, rgba(2,3,6,.15) 45%, #020306 88%); |
| } |
| #crt-frame { opacity: .78; filter: saturate(.48) hue-rotate(169deg) contrast(1.12); } |
| .hero-fade { background: linear-gradient(90deg, rgba(2,3,6,.98) 0%, rgba(2,3,6,.78) 47%, rgba(2,3,6,.16) 100%); } |
| .hero-overlay { z-index: 2; } |
| .hero-content { width: min(1380px, calc(100% - 9vw)); padding-top: 5vh; } |
|
|
| .eyebrow { |
| display: flex; align-items: center; gap: 13px; margin-bottom: clamp(28px, 5vh, 58px); |
| color: var(--t2); font-family: var(--mono); font-size: 10px; font-weight: 500; |
| letter-spacing: .15em; text-transform: uppercase; |
| } |
| .eyebrow::before { display: none; } |
| .br-mark { width: 31px; height: 31px; flex: none; color: var(--t1); } |
| .br-mark.small { width: 22px; height: 22px; } |
| .br-lockup { display: grid; gap: 3px; } |
| .br-lockup strong { color: var(--t1); font-weight: 600; letter-spacing: .18em; } |
| .br-lockup span { color: var(--t3); font-size: 8px; letter-spacing: .17em; } |
|
|
| .hero-h1 { |
| max-width: 990px; margin-bottom: clamp(36px, 7vh, 78px); |
| font-family: var(--sans); font-size: clamp(54px, 7.8vw, 116px); line-height: .88; |
| letter-spacing: -.072em; font-weight: 580; |
| } |
| .hero-h1 .thin { |
| color: transparent; font-weight: 250; |
| background: linear-gradient(102deg, #f7f9fc 4%, #8db4ff 58%, #8df1ff 100%); |
| -webkit-background-clip: text; background-clip: text; |
| } |
| .hero-row { border-top: 1px solid var(--line); padding-top: 28px; grid-template-columns: minmax(0, 1.25fr) minmax(280px, .75fr); } |
| .hero-main { padding-right: clamp(30px, 6vw, 90px); } |
| .hero-side { border-left: 1px solid var(--line-soft); padding-left: clamp(24px, 4vw, 58px); } |
| .hero-sub { max-width: 710px; color: var(--t2); font-size: 14px; line-height: 1.75; } |
| .hero-sub b { color: var(--t1); } |
| .hero-stats { border-top-color: var(--line-soft); } |
| .stat { border-left: 1px solid var(--line-soft); padding-left: 15px; } |
| .stat:first-child { border-left: 0; padding-left: 0; } |
| .stat-val { font-family: var(--sans); font-size: 20px; font-weight: 450; letter-spacing: -.03em; } |
| .stat-label { font-size: 8px; letter-spacing: .14em; text-transform: uppercase; } |
|
|
| .hero-actions { align-items: stretch; } |
| .btn-primary { |
| min-height: 50px; border: 1px solid rgba(141,180,255,.62); border-radius: 0; |
| background: rgba(141,180,255,.12); color: #fff; box-shadow: inset 0 0 24px rgba(141,180,255,.04); |
| font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .15em; text-transform: uppercase; |
| } |
| .btn-primary:hover { background: var(--accent); border-color: var(--accent); color: #020306; transform: none; } |
| .btn-primary::before { display: none; } |
| #landingModelSelect { |
| min-height: 50px !important; border-radius: 0 !important; border-color: var(--line) !important; |
| background: rgba(2,3,6,.64) !important; text-transform: uppercase; letter-spacing: .08em; |
| } |
| .hero-foot { border-top: 1px solid var(--line-soft); padding-top: 18px; } |
| .kernel-note-cta { color: var(--t2); font-family: var(--mono); font-size: 9px; letter-spacing: .06em; text-transform: uppercase; } |
| .kernel-note-cta b { color: var(--accent2); } |
| .hero-foot-note { font-size: 8px; letter-spacing: .12em; text-transform: uppercase; } |
| .scroll-cue { left: auto; right: 4.5vw; bottom: 24px; align-items: flex-end; } |
| .scroll-cue span { font-size: 8px; letter-spacing: .18em; text-transform: uppercase; } |
| .scroll-cue-line { background: linear-gradient(90deg, var(--accent), transparent); width: 82px; height: 1px; } |
|
|
| #chat { |
| background: |
| radial-gradient(circle at 50% 22%, rgba(32,49,82,.24), transparent 34%), |
| linear-gradient(180deg, #05070c 0%, #020306 100%); |
| } |
| .chat-head { background: rgba(2,3,6,.82); border-bottom: 1px solid var(--line); backdrop-filter: blur(24px); } |
| .chat-head-inner { max-width: 1280px; height: 64px; } |
| .to-top { gap: 11px; opacity: 1; font-family: var(--mono); letter-spacing: .14em; text-transform: uppercase; } |
| .to-top svg:not(.br-mark) { display: none; } |
| .to-top .brand-name { color: var(--t1); font-size: 9px; font-weight: 600; } |
| .status { font-family: var(--mono); font-size: 8px; letter-spacing: .14em; text-transform: uppercase; } |
| .status-dot { background: var(--t3); box-shadow: none; } |
| .status.ready .status-dot { background: var(--green); box-shadow: 0 0 12px rgba(88,230,177,.5); } |
| .head-btn { |
| height: 34px; border-radius: 0; border-color: var(--line); background: transparent !important; |
| color: var(--t2); font-family: var(--mono); font-size: 8px; font-weight: 500; letter-spacing: .1em; text-transform: uppercase; |
| } |
| .head-btn:hover { color: var(--t1); border-color: rgba(141,180,255,.55); background: rgba(141,180,255,.07) !important; } |
| .head-btn.solid { background: rgba(141,180,255,.12) !important; color: var(--t1); border-color: rgba(141,180,255,.45); } |
| .bar { height: 1px; } |
| .bar > div { background: linear-gradient(90deg, var(--accent), var(--accent2), var(--green)); } |
|
|
| .thread-scroll { scrollbar-color: rgba(141,180,255,.25) transparent; } |
| .thread { max-width: 900px; padding: 52px 0 180px; } |
| .welcome { min-height: 54vh; display: flex; flex-direction: column; justify-content: center; text-align: left; } |
| .welcome-mark { width: 46px; height: 46px; margin-bottom: 30px; color: var(--accent); } |
| .welcome h2 { max-width: 670px; font-family: var(--sans); font-size: clamp(44px, 6vw, 78px); line-height: .92; letter-spacing: -.06em; font-weight: 550; } |
| .welcome h2 .thin { color: var(--accent); font-weight: 250; } |
| .welcome p { margin: 22px 0 0; max-width: none; color: var(--t2); font-family: var(--mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; } |
| .seeds { justify-content: flex-start; margin-top: 34px; } |
| .seed { border-radius: 0; border-color: var(--line); background: rgba(255,255,255,.02); font-family: var(--mono); font-size: 9px; letter-spacing: .05em; } |
| .seed:not(:disabled):hover { color: var(--accent2); border-color: var(--accent); background: rgba(141,180,255,.06); } |
|
|
| .msg { max-width: 100%; } |
| .msg .role { color: var(--t3); font-size: 8px; letter-spacing: .16em; text-transform: uppercase; } |
| .bubble { border-radius: 0; font-size: 14px; line-height: 1.72; } |
| .msg.user .bubble { background: rgba(141,180,255,.08); border: 1px solid rgba(141,180,255,.22); color: var(--t1); } |
| .msg.assistant .bubble { background: transparent; border-left: 1px solid var(--accent); padding-left: 20px; } |
| .msg-media img { border-radius: 0; border: 1px solid var(--line); } |
|
|
| .composer-wrap { background: linear-gradient(0deg, #020306 74%, transparent); border-top: 0; padding-top: 42px; } |
| .composer { max-width: 900px; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; } |
| .attachments { grid-column: 1 / -1; } |
| .composer-tools { grid-column: 1; align-self: stretch; gap: 6px; } |
| .field { grid-column: 2; min-height: 56px; border-radius: 0; border-color: rgba(141,180,255,.32); background: rgba(8,11,18,.94); box-shadow: 0 16px 60px rgba(0,0,0,.42); } |
| .field:focus-within { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(141,180,255,.14), 0 16px 60px rgba(0,0,0,.42); } |
| .field textarea { font-family: var(--sans); font-size: 14px; } |
| .field textarea::placeholder { color: #5d6678; } |
| .tool-btn { width: 48px; height: 56px; border-radius: 0; border: 1px solid var(--line); background: rgba(8,11,18,.94); color: var(--t2); } |
| .tool-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent2); background: rgba(141,180,255,.08); } |
| .tool-btn.recording { color: #ff938a; border-color: rgba(255,147,138,.5); background: rgba(255,147,138,.08); } |
| .icon-button { border-radius: 0; } |
| .send-button { color: #020306; background: var(--accent); } |
| .composer-meta { grid-column: 1 / -1; color: var(--t3); font-family: var(--mono); font-size: 8px; letter-spacing: .11em; text-transform: uppercase; } |
| .attachment { border-radius: 0; border-color: var(--line); background: rgba(8,11,18,.95); } |
|
|
| .kx-backdrop { background: rgba(2,3,6,.85); backdrop-filter: blur(15px); } |
| .kx-panel { border-radius: 0; border-color: var(--line); background: #060810; } |
| .kx-head, .kx-view-head { border-color: var(--line); } |
| .kx-title h3 { font-family: var(--mono); letter-spacing: .13em; text-transform: uppercase; } |
| .kx-side { border-color: var(--line); } |
| .kx-item.active { border-left-color: var(--accent); background: rgba(141,180,255,.08); } |
|
|
| @media (max-width: 800px) { |
| .hero-content { width: min(100% - 38px, 680px); padding-top: 28px; } |
| .eyebrow { margin-bottom: 34px; } |
| .hero-h1 { font-size: clamp(46px, 15vw, 72px); margin-bottom: 38px; } |
| .hero-row { grid-template-columns: 1fr; } |
| .hero-main { padding-right: 0; } |
| .hero-side { border-left: 0; border-top: 1px solid var(--line-soft); padding: 25px 0 0; } |
| .hero-stats { grid-template-columns: repeat(2, 1fr); row-gap: 20px; } |
| .stat:nth-child(3) { border-left: 0; padding-left: 0; } |
| .chat-head-inner { padding: 0 16px; } |
| #modelSelect { display: none; } |
| .thread, .composer { width: calc(100% - 28px); } |
| .welcome { min-height: 48vh; } |
| .welcome h2 { font-size: clamp(42px, 13vw, 64px); } |
| .composer-wrap { padding-bottom: max(10px, env(safe-area-inset-bottom)); } |
| } |
| .br-lab-dock { position: fixed; right: 16px; top: 50%; transform: translateY(-50%); z-index: 10000; display: flex; flex-direction: column; gap: 7px; padding: 6px; border: 1px solid rgba(196,211,255,.18); background: rgba(2,3,6,.78); backdrop-filter: blur(18px); box-shadow: 0 18px 60px rgba(0,0,0,.35); } |
| .br-lab-dock a { position: relative; width: 40px; height: 40px; display: grid; place-items: center; color: #98a2b3; border: 1px solid transparent; transition: .2s ease; } |
| .br-lab-dock a:hover { color: #f7f9fc; border-color: rgba(141,180,255,.35); background: rgba(141,180,255,.09); } |
| .br-lab-dock svg { width: 17px; height: 17px; } |
| .br-lab-dock span { position: absolute; right: 51px; white-space: nowrap; padding: 8px 10px; background: #080b12; border: 1px solid rgba(196,211,255,.18); font-family: var(--mono); font-size: 8px; letter-spacing: .14em; text-transform: uppercase; opacity: 0; pointer-events: none; transform: translateX(5px); transition: .2s ease; } |
| .br-lab-dock a:hover span { opacity: 1; transform: none; } |
| @media(max-width:650px) { .br-lab-dock { right: 7px; } .br-lab-dock a { width: 34px; height: 34px; } .br-lab-dock span { display: none; } } |
| </style> |
| </head> |
| <body> |
| <nav class="br-lab-dock" aria-label="BlackRiver navigation"> |
| <a href="./" aria-label="Gemma 4 Space home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 11.5 12 4l9 7.5"/><path d="M5.5 10v10h13V10"/></svg><span>Space home</span></a> |
| <a href="https://blackriver-ai.vercel.app/labs.html" target="_blank" rel="noopener" aria-label="BlackRiver Labs"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>BlackRiver Labs</span></a> |
| </nav> |
|
|
| <section id="landing" class="screen"> |
| <div id="crt-frame"></div> |
| <div class="hero-fade"></div> |
| <div class="hero-overlay"> |
| <div class="hero-content"> |
| <div class="eyebrow anim"> |
| <svg class="br-mark" viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M4 3h10v10H4zM18 3h10v10H18zM4 17h10v12H4z" fill="currentColor"/><path d="M18 17h10v4H18zM18 25h10v4H18z" fill="currentColor" opacity=".62"/></svg> |
| <span class="br-lockup"><strong>BlackRiver AI</strong><span>Private multimodal inference / WebGPU</span></span> |
| </div> |
| <h1 class="hero-h1 anim">Gemma 4.<br><span class="thin">Multimodal intelligence.</span></h1> |
| <div class="hero-row anim"> |
| <div class="hero-main"> |
| <p class="hero-sub"> |
| <b>Gemma 4 E2B/E4B</b> with native vision, audio and text intelligence. |
| Powered locally through WebGPU: the model is cached on this device and your data stays private. |
| </p> |
| <div class="hero-stats"> |
| <div class="stat"><span class="stat-val">2.3B</span><span class="stat-label">Effective params</span></div> |
| <div class="stat"><span class="stat-val">128K</span><span class="stat-label">Context window</span></div> |
| <div class="stat"><span class="stat-val">~41</span><span class="stat-label">tok/s · M4 Max</span></div> |
| <div class="stat"><span class="stat-val">100%</span><span class="stat-label">On-device</span></div> |
| </div> |
| </div> |
| <div class="hero-side"> |
| <div class="hero-actions"> |
| <button class="btn-primary" id="loadBtn" type="button"> |
| <span id="loadBtnLabel">Initialize model</span> |
| <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v9M4 7l4 4 4-4"/></svg> |
| </button> |
| <select id="landingModelSelect" style="appearance:auto;background:transparent;color:var(--t2);font-family:var(--mono);font-size:12px;padding:14px 16px;border:1px solid var(--line);border-radius:100px;cursor:pointer;"> |
| <option value="onnx-community/gemma-4-E2B-it-ONNX">E2B · 2.3B</option> |
| <option value="onnx-community/gemma-4-E4B-it-ONNX">E4B · 4B</option> |
| </select> |
| </div> |
| <div class="hero-foot"> |
| <a class="kernel-note-cta" href="https://x.com/xenovacom/status/2065656427117437213" target="_blank" rel="noopener">WebGPU kernels <b>100% written & optimized by Fable 5</b> →</a> |
| <span class="hero-foot-note">Tuned for Apple M4 Max · experimental multimodal</span> |
| </div> |
| </div> |
| </div> |
| </div> |
| <div class="scroll-cue" id="scrollCue"> |
| <span>Enter console</span> |
| <div class="scroll-cue-line"></div> |
| </div> |
| </div> |
| </section> |
|
|
| <section id="chat" class="screen"> |
| <header class="chat-head"> |
| <div class="chat-head-inner"> |
| <a class="to-top" href="#landing" id="toTop" title="Back to top"> |
| <svg class="br-mark small" viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M4 3h10v10H4zM18 3h10v10H18zM4 17h10v12H4z" fill="currentColor"/><path d="M18 17h10v4H18zM18 25h10v4H18z" fill="currentColor" opacity=".62"/></svg> |
| <span class="brand-name">BlackRiver · Gemma 4</span> |
| </a> |
| <div class="status" id="status"> |
| <span class="status-dot"></span> |
| <span class="status-text" id="statusText">Not loaded</span> |
| </div> |
| <div class="chat-head-actions"> |
| <select id="modelSelect" class="head-btn" style="appearance:auto;background:var(--panel);cursor:pointer;padding:8px 12px;"> |
| <option value="onnx-community/gemma-4-E2B-it-ONNX">Gemma 4 E2B (2.3B)</option> |
| <option value="onnx-community/gemma-4-E4B-it-ONNX">Gemma 4 E4B (4B)</option> |
| </select> |
| <button class="head-btn solid" id="headLoadBtn" type="button">Initialize</button> |
| <button class="head-btn" id="kernelsBtn" type="button" hidden>View Kernels</button> |
| <button class="head-btn" id="clearBtn" type="button" disabled>Clear</button> |
| </div> |
| </div> |
| <div class="bar" id="bar"><div></div></div> |
| </header> |
|
|
| <div class="thread-scroll" id="threadScroll"> |
| <div class="thread" id="thread"> |
| <div class="welcome" id="welcome"> |
| <svg class="welcome-mark" viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M4 3h10v10H4zM18 3h10v10H18zM4 17h10v12H4z" fill="currentColor"/><path d="M18 17h10v4H18zM18 25h10v4H18z" fill="currentColor" opacity=".62"/></svg> |
| <h2>What will you <span class="thin">explore?</span></h2> |
| <p>Private on-device intelligence · Image · Audio · Text</p> |
| <div class="seeds"> |
| <button class="seed" type="button" disabled>What does this image show?</button> |
| <button class="seed" type="button" disabled>Transcribe this audio</button> |
| <button class="seed" type="button" disabled>Write a haiku about on-device AI</button> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <footer class="composer-wrap"> |
| <div class="composer"> |
| <div id="attachments" class="attachments" style="display:none;"></div> |
| <div class="composer-tools"> |
| <button class="tool-btn" id="imageBtn" type="button" disabled title="Attach image" aria-label="Attach image"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg> |
| </button> |
| <button class="tool-btn" id="audioBtn" type="button" disabled title="Record audio" aria-label="Record audio"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg> |
| </button> |
| </div> |
| <div class="field"> |
| <textarea id="input" rows="1" placeholder="Initialize the model to begin..." disabled></textarea> |
| <button class="icon-button send-button" id="sendBtn" type="button" disabled aria-label="Send message"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg> |
| </button> |
| <button class="icon-button stop-button" id="stopBtn" type="button" aria-label="Stop generation"> |
| <svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1.5"/></svg> |
| </button> |
| </div> |
| <div class="composer-meta"> |
| <span id="hint">BlackRiver private runtime · nothing leaves this device</span> |
| <span id="liveStat"></span> |
| </div> |
| </div> |
| </footer> |
| </section> |
|
|
| <div class="kx" id="kernelsOverlay" hidden> |
| <div class="kx-backdrop" data-close></div> |
| <div class="kx-panel" role="dialog" aria-modal="true" aria-label="Rendered kernels"> |
| <div class="kx-head"> |
| <div class="kx-title"> |
| <h3>Compute kernels</h3> |
| <span class="kx-sub" id="kxSub"></span> |
| </div> |
| <button class="kx-close" id="kxClose" type="button" aria-label="Close" data-close> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg> |
| </button> |
| </div> |
| <div class="kx-body"> |
| <div class="kx-side"><div class="kx-list" id="kxList"></div></div> |
| <div class="kx-view"> |
| <div class="kx-view-head"> |
| <span class="kx-name" id="kxName"></span> |
| <div class="kx-view-actions"> |
| <span class="kx-lines" id="kxLines"></span> |
| <button class="kx-copy" id="kxCopy" type="button">Copy</button> |
| </div> |
| </div> |
| <pre class="kx-code"><code id="kxCode"></code></pre> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <input type="file" id="imageInput" accept="image/*" hidden> |
| <script> |
| |
| |
| |
| if (location.protocol === "file:") { |
| addEventListener("DOMContentLoaded", () => { |
| const message = "Ouvrez l’application avec start.bat — localhost est requis pour WebGPU et le microphone."; |
| const status = document.getElementById("status"); |
| const statusText = document.getElementById("statusText"); |
| const loadButtons = [document.getElementById("loadBtn"), document.getElementById("headLoadBtn")]; |
| status?.classList.add("error"); |
| if (statusText) statusText.textContent = message; |
| for (const button of loadButtons) { |
| if (!button) continue; |
| button.disabled = false; |
| button.title = message; |
| button.addEventListener("click", () => { |
| status?.classList.add("error"); |
| if (statusText) statusText.textContent = message; |
| alert(message); |
| }); |
| } |
| }); |
| } |
| </script> |
| <script type="module" src="./landing.js"></script> |
| <script> |
| (function () { |
| const fire = () => { |
| document.getElementById("crt-frame")?.classList.add("visible"); |
| document.querySelector(".hero-fade")?.classList.add("in"); |
| document.querySelectorAll(".anim").forEach((el) => el.classList.add("in")); |
| document.getElementById("scrollCue")?.classList.add("in"); |
| }; |
| requestAnimationFrame(() => requestAnimationFrame(fire)); |
| })(); |
| </script> |
|
|
| <script type="module"> |
| import { Gemma4Multimodal } from "./transformers-gemma4.js"; |
| |
| let marked = null; |
| import("https://esm.sh/marked@17") |
| .then((m) => { marked = m.marked; marked.use({ gfm: true, breaks: true }); }) |
| .catch(() => { marked = null; }); |
| |
| const $ = (id) => document.getElementById(id); |
| const landing = $("landing"); |
| const chat = $("chat"); |
| const threadScroll = $("threadScroll"); |
| const thread = $("thread"); |
| const loadBtn = $("loadBtn"); |
| const loadBtnLabel = $("loadBtnLabel"); |
| const headLoadBtn = $("headLoadBtn"); |
| const statusEl = $("status"); |
| const statusText = $("statusText"); |
| const bar = $("bar"); |
| const barFill = bar.firstElementChild; |
| const input = $("input"); |
| const sendBtn = $("sendBtn"); |
| const stopBtn = $("stopBtn"); |
| const clearBtn = $("clearBtn"); |
| const hint = $("hint"); |
| const liveStat = $("liveStat"); |
| const kernelsBtn = $("kernelsBtn"); |
| const kernelsOverlay = $("kernelsOverlay"); |
| const imageBtn = $("imageBtn"); |
| const audioBtn = $("audioBtn"); |
| const imageInput = $("imageInput"); |
| const attachmentsEl = $("attachments"); |
| |
| let model = null; |
| let kernels = []; |
| let messages = []; |
| let abortController = null; |
| let isGenerating = false; |
| let isLoading = false; |
| let attachments = []; |
| let targetProgress = 0; |
| let shownProgress = 0; |
| let progressRaf = 0; |
| |
| |
| |
| if (!navigator.gpu) { |
| const msg = "WebGPU isn't available here. Try a recent Chrome, Edge, or Safari Technology Preview."; |
| loadBtn.disabled = true; |
| headLoadBtn.disabled = true; |
| setStatus("error", msg); |
| } |
| |
| const modelSelect = $("modelSelect"); |
| const landingModelSelect = $("landingModelSelect"); |
| |
| modelSelect.addEventListener("change", () => { |
| landingModelSelect.value = modelSelect.value; |
| updateModelCard(); |
| }); |
| landingModelSelect.addEventListener("change", () => { |
| modelSelect.value = landingModelSelect.value; |
| updateModelCard(); |
| }); |
| |
| function getSelectedModelId() { |
| return modelSelect.value || "onnx-community/gemma-4-E2B-it-ONNX"; |
| } |
| |
| function updateModelCard() { |
| const id = getSelectedModelId(); |
| const isE4B = id.includes("E4B"); |
| const stats = document.querySelectorAll(".hero-stats .stat-val"); |
| if (stats.length >= 4) { |
| stats[0].textContent = isE4B ? "4B" : "2.3B"; |
| stats[2].textContent = isE4B ? "~20" : "~41"; |
| } |
| } |
| |
| loadBtn.addEventListener("click", loadModel); |
| headLoadBtn.addEventListener("click", loadModel); |
| sendBtn.addEventListener("click", send); |
| stopBtn.addEventListener("click", () => abortController?.abort()); |
| clearBtn.addEventListener("click", clearChat); |
| kernelsBtn.addEventListener("click", openKernels); |
| kernelsOverlay.addEventListener("click", (e) => { if (e.target.closest("[data-close]")) closeKernels(); }); |
| $("kxList").addEventListener("scroll", updateListFade, { passive: true }); |
| $("kxCopy").addEventListener("click", copyKernel); |
| document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !kernelsOverlay.hidden) closeKernels(); }); |
| $("toTop").addEventListener("click", (e) => { e.preventDefault(); landing.scrollIntoView({ behavior: "smooth" }); }); |
| |
| input.addEventListener("input", () => { autoGrow(); refreshSend(); }); |
| input.addEventListener("keydown", (e) => { |
| if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (!sendBtn.disabled) send(); } |
| }); |
| document.addEventListener("click", (e) => { |
| const seed = e.target.closest(".seed"); |
| if (!seed || seed.disabled || !model || isGenerating) return; |
| input.value = seed.textContent; |
| send(); |
| }); |
| |
| |
| imageBtn.addEventListener("click", () => imageInput.click()); |
| imageInput.addEventListener("change", (e) => { |
| for (const file of e.target.files) addImageAttachment(file); |
| imageInput.value = ""; |
| }); |
| |
| |
| document.addEventListener("paste", (e) => { |
| if (!model || isGenerating) return; |
| for (const file of [...(e.clipboardData?.files ?? [])]) { |
| if (file.type.startsWith("image/")) addImageAttachment(file); |
| else if (file.type.startsWith("audio/")) addAudioAttachment(file, file.name); |
| } |
| }); |
| const composer = document.querySelector(".composer"); |
| composer.addEventListener("dragover", (e) => { e.preventDefault(); }); |
| composer.addEventListener("drop", (e) => { |
| e.preventDefault(); |
| if (!model || isGenerating) return; |
| for (const file of [...(e.dataTransfer?.files ?? [])]) { |
| if (file.type.startsWith("image/")) addImageAttachment(file); |
| else if (file.type.startsWith("audio/")) addAudioAttachment(file, file.name); |
| } |
| }); |
| |
| let mediaRecorder = null; |
| let audioChunks = []; |
| let recordingStream = null; |
| let recordingTimer = 0; |
| audioBtn.addEventListener("click", async () => { |
| if (mediaRecorder && mediaRecorder.state === "recording") { |
| mediaRecorder.stop(); |
| audioBtn.classList.remove("recording"); |
| clearTimeout(recordingTimer); |
| return; |
| } |
| try { |
| recordingStream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| audioChunks = []; |
| const mimeType = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"] |
| .find((type) => MediaRecorder.isTypeSupported?.(type)); |
| mediaRecorder = new MediaRecorder(recordingStream, mimeType ? { mimeType } : undefined); |
| mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); }; |
| mediaRecorder.onstop = async () => { |
| clearTimeout(recordingTimer); |
| audioBtn.classList.remove("recording"); |
| const blob = new Blob(audioChunks, { type: mediaRecorder.mimeType || "audio/webm" }); |
| await addAudioAttachment(blob); |
| recordingStream?.getTracks().forEach(t => t.stop()); |
| recordingStream = null; |
| }; |
| mediaRecorder.start(); |
| audioBtn.classList.add("recording"); |
| recordingTimer = setTimeout(() => { |
| if (mediaRecorder?.state === "recording") mediaRecorder.stop(); |
| }, 30_000); |
| } catch (err) { |
| console.error("Audio recording failed:", err); |
| recordingStream?.getTracks().forEach(t => t.stop()); |
| recordingStream = null; |
| audioBtn.classList.remove("recording"); |
| setStatus("error", location.protocol === "file:" |
| ? "Microphone requires localhost or HTTPS" |
| : "Microphone access denied or unavailable"); |
| } |
| }); |
| |
| function addImageAttachment(file) { |
| if (!file?.type?.startsWith("image/")) return; |
| const url = URL.createObjectURL(file); |
| const id = crypto.randomUUID(); |
| attachments.push({ id, type: "image", url, file, name: file.name }); |
| renderAttachments(); |
| refreshSend(); |
| } |
| |
| async function addAudioAttachment(blob, suppliedName = "") { |
| let decoded; |
| try { |
| decoded = await decodeAudioToPcm(blob); |
| } catch (error) { |
| console.error("Audio decoding failed:", error); |
| setStatus("error", "Audio illisible — essayez un enregistrement plus long ou un fichier WAV."); |
| return; |
| } |
| if (decoded.duration < 0.2 || decoded.pcm.length < 3200) { |
| setStatus("error", "Enregistrement trop court — parlez pendant au moins une seconde."); |
| return; |
| } |
| const url = URL.createObjectURL(blob); |
| const id = crypto.randomUUID(); |
| const extension = blob.type.includes("mp4") ? "m4a" : blob.type.includes("wav") ? "wav" : "webm"; |
| attachments.push({ |
| id, type: "audio", url, blob, |
| pcm: decoded.pcm, |
| duration: decoded.duration, |
| name: suppliedName || `recording-${Date.now()}.${extension}`, |
| }); |
| renderAttachments(); |
| refreshSend(); |
| } |
| |
| async function decodeAudioToPcm(blob) { |
| const AudioContextClass = window.AudioContext || window.webkitAudioContext; |
| if (!AudioContextClass) throw new Error("Web Audio is unavailable"); |
| const context = new AudioContextClass({ sampleRate: 16000 }); |
| try { |
| const buffer = await context.decodeAudioData(await blob.arrayBuffer()); |
| const length = buffer.length; |
| const mono = new Float32Array(length); |
| for (let channel = 0; channel < buffer.numberOfChannels; channel++) { |
| const data = buffer.getChannelData(channel); |
| for (let i = 0; i < length; i++) mono[i] += data[i] / buffer.numberOfChannels; |
| } |
| const pcm = buffer.sampleRate === 16000 ? mono : resampleLinear(mono, buffer.sampleRate, 16000); |
| return { pcm, duration: buffer.duration }; |
| } finally { |
| await context.close(); |
| } |
| } |
| |
| function resampleLinear(inputSamples, fromRate, toRate) { |
| const output = new Float32Array(Math.max(1, Math.round(inputSamples.length * toRate / fromRate))); |
| const ratio = fromRate / toRate; |
| for (let i = 0; i < output.length; i++) { |
| const position = i * ratio; |
| const left = Math.floor(position); |
| const right = Math.min(left + 1, inputSamples.length - 1); |
| const mix = position - left; |
| output[i] = inputSamples[left] * (1 - mix) + inputSamples[right] * mix; |
| } |
| return output; |
| } |
| |
| function renderAttachments() { |
| attachmentsEl.innerHTML = ""; |
| if (attachments.length === 0) { attachmentsEl.style.display = "none"; return; } |
| attachmentsEl.style.display = "flex"; |
| for (const att of attachments) { |
| const el = document.createElement("div"); |
| el.className = `attachment ${att.type}`; |
| if (att.type === "image") { |
| el.innerHTML = `<img src="${att.url}"><span class="att-name">${escapeHtml(att.name)}</span><span class="att-type">IMG</span><button class="att-remove" data-id="${att.id}">×</button>`; |
| } else { |
| const duration = finiteNumber(att.duration) ? ` · ${att.duration.toFixed(1)}s` : ""; |
| el.innerHTML = `<span class="att-icon">🎙</span><span class="att-name">${escapeHtml(att.name)}</span><span class="att-type">AUDIO${duration}</span><button class="att-remove" data-id="${att.id}">×</button>`; |
| } |
| el.querySelector(".att-remove").addEventListener("click", () => { |
| attachments = attachments.filter(a => a.id !== att.id); |
| URL.revokeObjectURL(att.url); |
| renderAttachments(); |
| refreshSend(); |
| }); |
| attachmentsEl.appendChild(el); |
| } |
| } |
| |
| function setStatus(state, text) { |
| statusEl.className = "status" + (state ? " " + state : ""); |
| if (text !== undefined) statusText.innerHTML = text; |
| } |
| |
| async function loadModel() { |
| if (model || isLoading) return; |
| isLoading = true; |
| loadBtn.disabled = true; |
| headLoadBtn.disabled = true; |
| modelSelect.disabled = true; |
| landingModelSelect.disabled = true; |
| loadBtnLabel.textContent = "Loading…"; |
| bar.classList.remove("done"); |
| setProgressImmediate(0.02); |
| setStatus("loading", "Requesting WebGPU device…"); |
| chat.scrollIntoView({ behavior: "smooth" }); |
| |
| const started = performance.now(); |
| try { |
| const selectedModelId = getSelectedModelId(); |
| model = await Gemma4Multimodal.load(selectedModelId, { onProgress: updateLoadProgress }); |
| |
| const seconds = ((performance.now() - started) / 1000).toFixed(1); |
| setStatus("ready", `Ready in <strong>${seconds}s</strong> · multimodal · on-device`); |
| setProgressImmediate(1); |
| bar.classList.add("done"); |
| loadBtnLabel.textContent = "Model loaded"; |
| headLoadBtn.style.display = "none"; |
| enableChat(); |
| } catch (error) { |
| console.error(error); |
| setStatus("error", `Failed to load: ${escapeHtml(String(error?.message ?? error))}`); |
| bar.classList.add("done"); |
| loadBtn.disabled = false; |
| headLoadBtn.disabled = false; |
| modelSelect.disabled = false; |
| landingModelSelect.disabled = false; |
| loadBtnLabel.textContent = "Initialize model"; |
| isLoading = false; |
| } |
| } |
| |
| function labelFor(status) { |
| return { |
| init: "Requesting WebGPU device…", |
| tokenizer: "Loading tokenizer…", |
| weights: "Downloading weights…", |
| encoder: "Loading vision/audio encoder…", |
| ready: "Ready.", |
| }[status] ?? status; |
| } |
| |
| function updateLoadProgress(event) { |
| if (event.status !== "weights") { |
| setStatus("loading", labelFor(event.status)); |
| setPhaseProgress(event.status, event.fraction); |
| return; |
| } |
| const kind = event.kind ?? inferProgressKind(event); |
| const fraction = finiteNumber(event.fraction) ? clamp(event.fraction, 0, 1) : null; |
| if (kind !== "tensors") setPhaseProgress("weights", fraction); |
| setStatus("loading", formatWeightProgress(event, fraction)); |
| } |
| |
| function setPhaseProgress(status, frac) { |
| const [lo, hi] = status === "weights" |
| ? [0.04, 1.0] |
| : ({ init: [0, 0.02], tokenizer: [0.02, 0.04], encoder: [0.04, 0.06], ready: [1, 1] }[status] ?? [0, 1]); |
| const f = finiteNumber(frac) ? clamp(frac, 0, 1) : 0; |
| setProgressFraction(lo + (hi - lo) * f); |
| } |
| |
| function formatWeightProgress(event, fraction) { |
| const kind = event.kind ?? inferProgressKind(event); |
| const pct = fraction === null ? "" : ` (${Math.round(fraction * 100)}%)`; |
| const loaded = finiteNumber(event.loaded) ? event.loaded : null; |
| const total = finiteNumber(event.total) ? event.total : null; |
| if (kind === "bytes") { |
| const verb = event.fromCache ? "Loading cached weights" : "Downloading weights"; |
| if (loaded !== null && total !== null) return `${verb}: ${formatBytes(loaded)} / ${formatBytes(total)}${pct}`; |
| if (total !== null) return `${verb}: ${formatBytes(total)} total`; |
| return `${escapeHtml(event.message || verb)}…`; |
| } |
| if (loaded !== null && total !== null) { |
| const label = event.message ? ` (${escapeHtml(event.message)})` : ""; |
| return `Preparing GPU weights: ${formatInteger(loaded)} / ${formatInteger(total)} tensors${pct}${label}`; |
| } |
| return event.message ? `Preparing GPU weights: ${escapeHtml(event.message)}` : "Preparing GPU weights…"; |
| } |
| |
| function inferProgressKind(event) { |
| if (event.kind === "bytes" || event.kind === "tensors") return event.kind; |
| if (finiteNumber(event.total) && event.total > 1_000_000) return "bytes"; |
| return "tensors"; |
| } |
| |
| function setProgressFraction(value) { |
| if (!finiteNumber(value)) return; |
| targetProgress = Math.max(clamp(value, 0, 1), targetProgress); |
| if (!progressRaf) progressRaf = requestAnimationFrame(stepProgressBar); |
| } |
| |
| function stepProgressBar() { |
| const gap = targetProgress - shownProgress; |
| shownProgress += gap < 0.0015 ? gap : gap * 0.3; |
| barFill.style.width = `${(shownProgress * 100).toFixed(2)}%`; |
| progressRaf = shownProgress < targetProgress ? requestAnimationFrame(stepProgressBar) : 0; |
| } |
| |
| function setProgressImmediate(value) { |
| if (progressRaf) { cancelAnimationFrame(progressRaf); progressRaf = 0; } |
| targetProgress = shownProgress = clamp(value, 0, 1); |
| barFill.style.width = `${(shownProgress * 100).toFixed(2)}%`; |
| } |
| |
| function enableChat() { |
| isLoading = false; |
| input.disabled = false; |
| input.placeholder = "Message Gemma 4 · image, audio or text…"; |
| clearBtn.disabled = false; |
| kernelsBtn.hidden = true; |
| imageBtn.disabled = false; |
| audioBtn.disabled = false; |
| setSeedButtonsEnabled(true); |
| refreshSend(); |
| input.focus(); |
| } |
| |
| function setSeedButtonsEnabled(enabled) { |
| document.querySelectorAll(".seed").forEach((s) => { s.disabled = !enabled; }); |
| } |
| |
| function autoGrow() { |
| input.style.height = "auto"; |
| input.style.height = Math.min(input.scrollHeight, 180) + "px"; |
| } |
| |
| function refreshSend() { |
| sendBtn.disabled = input.value.trim().length === 0 && attachments.length === 0; |
| } |
| |
| input.addEventListener("input", refreshSend); |
| attachmentsEl.addEventListener("change", refreshSend); |
| |
| async function send() { |
| if (!model || isGenerating || (input.value.trim().length === 0 && attachments.length === 0)) return; |
| const text = input.value.trim(); |
| const currentAttachments = [...attachments]; |
| attachments = []; |
| renderAttachments(); |
| input.value = ""; |
| autoGrow(); |
| refreshSend(); |
| |
| const userMsg = { role: "user", content: text, attachments: currentAttachments }; |
| messages.push(userMsg); |
| |
| const userEl = renderMessage("user", text, currentAttachments); |
| thread.appendChild(userEl); |
| scrollToBottom(); |
| |
| const assistantEl = renderMessage("assistant", ""); |
| const bubble = assistantEl.querySelector(".bubble"); |
| const thinking = document.createElement("div"); |
| thinking.className = "thinking"; |
| thinking.innerHTML = "<span></span><span></span><span></span>"; |
| bubble.appendChild(thinking); |
| thread.appendChild(assistantEl); |
| scrollToBottom(); |
| |
| abortController = new AbortController(); |
| isGenerating = true; |
| sendBtn.style.display = "none"; |
| stopBtn.style.display = "grid"; |
| |
| try { |
| let fullText = ""; |
| const stream = model.generate(messages, { |
| maxNewTokens: 1024, |
| signal: abortController.signal, |
| }); |
| |
| thinking.remove(); |
| let lastLen = 0; |
| for await (const chunk of stream) { |
| fullText = chunk.text; |
| bubble.innerHTML = renderMarkdown(fullText); |
| if (bubble.innerHTML.length !== lastLen) { |
| scrollToBottom(); |
| lastLen = bubble.innerHTML.length; |
| } |
| if (liveStat) liveStat.textContent = `${fullText.length} chars`; |
| } |
| |
| messages.push({ role: "assistant", content: fullText }); |
| const meta = document.createElement("div"); |
| meta.className = "meta"; |
| meta.textContent = `${fullText.length} chars`; |
| assistantEl.appendChild(meta); |
| } catch (err) { |
| if (err?.name !== "AbortError") { |
| thinking.remove(); |
| bubble.textContent = `Error: ${err?.message ?? err}`; |
| console.error(err); |
| } |
| } finally { |
| isGenerating = false; |
| sendBtn.style.display = "grid"; |
| stopBtn.style.display = "none"; |
| } |
| } |
| |
| function renderMessage(role, text, atts = []) { |
| const el = document.createElement("div"); |
| el.className = `msg ${role}`; |
| const roleEl = document.createElement("div"); |
| roleEl.className = "role"; |
| roleEl.textContent = role; |
| el.appendChild(roleEl); |
| const bubble = document.createElement("div"); |
| bubble.className = `bubble ${role}`; |
| if (role === "user" && atts.length > 0) { |
| const attContainer = document.createElement("div"); |
| attContainer.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;"; |
| for (const att of atts) { |
| if (att.type === "image") { |
| const img = document.createElement("img"); |
| img.src = att.url; |
| img.style.cssText = "max-width:200px;max-height:200px;border-radius:8px;"; |
| attContainer.appendChild(img); |
| } else { |
| const badge = document.createElement("span"); |
| badge.textContent = "🎙 Audio"; |
| badge.style.cssText = "font-family:var(--mono);font-size:11px;padding:4px 8px;background:var(--panel);border:1px solid var(--line);border-radius:6px;"; |
| attContainer.appendChild(badge); |
| } |
| } |
| bubble.appendChild(attContainer); |
| } |
| if (text) bubble.appendChild(document.createTextNode(text)); |
| el.appendChild(bubble); |
| return el; |
| } |
| |
| function clearChat() { |
| for (const message of messages) { |
| for (const att of message.attachments ?? []) URL.revokeObjectURL(att.url); |
| } |
| messages = []; |
| thread.innerHTML = ""; |
| const welcome = document.createElement("div"); |
| welcome.className = "welcome"; |
| welcome.id = "welcome"; |
| welcome.innerHTML = ` |
| <svg class="welcome-mark" viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M4 3h10v10H4zM18 3h10v10H18zM4 17h10v12H4z" fill="currentColor"/><path d="M18 17h10v4H18zM18 25h10v4H18z" fill="currentColor" opacity=".62"/></svg> |
| <h2>What will you <span class="thin">explore?</span></h2> |
| <p>Private on-device intelligence · Image · Audio · Text</p> |
| <div class="seeds"> |
| <button class="seed" type="button">What does this image show?</button> |
| <button class="seed" type="button">Transcribe this audio</button> |
| <button class="seed" type="button">Write a haiku about on-device AI</button> |
| </div> |
| `; |
| thread.appendChild(welcome); |
| setSeedButtonsEnabled(!!model); |
| model?.reset?.(); |
| } |
| |
| function renderMarkdown(text) { |
| if (marked) return marked.parse(text); |
| return escapeHtml(text).replace(/\n/g, "<br>"); |
| } |
| |
| function scrollToBottom() { |
| threadScroll.scrollTo({ top: threadScroll.scrollHeight, behavior: "smooth" }); |
| } |
| |
| |
| function openKernels() { |
| if (!model) return; |
| kernels = model.runtime.getRenderedShaders?.() ?? []; |
| const list = $("kxList"); |
| list.replaceChildren(); |
| for (let i = 0; i < kernels.length; i++) { |
| const item = document.createElement("button"); |
| item.className = "kx-item"; |
| item.textContent = kernels[i].name; |
| item.addEventListener("click", () => selectKernel(i)); |
| list.appendChild(item); |
| } |
| $("kxSub").textContent = `${kernels.length} rendered WGSL shaders on ${model.deviceInfo?.vendor ?? "unknown"} ${model.deviceInfo?.architecture ?? ""}`.trim(); |
| kernelsOverlay.hidden = false; |
| document.body.classList.add("kx-locked"); |
| updateListFade(); |
| if (kernels.length > 0) selectKernel(0); |
| } |
| |
| function selectKernel(index) { |
| const k = kernels[index]; |
| if (!k) return; |
| document.querySelectorAll(".kx-item").forEach((el, i) => el.classList.toggle("active", i === index)); |
| $("kxName").textContent = k.name; |
| $("kxLines").textContent = `${k.source.split("\n").length} lines`; |
| $("kxCode").innerHTML = highlightWgsl(k.source); |
| kxCopySource = k.source; |
| updateListFade(); |
| } |
| |
| function updateListFade() { |
| const list = $("kxList"); |
| const side = list.parentElement; |
| side.classList.toggle("at-end", list.scrollTop + list.clientHeight >= list.scrollHeight - 2); |
| } |
| |
| function closeKernels() { |
| kernelsOverlay.hidden = true; |
| document.body.classList.remove("kx-locked"); |
| } |
| |
| function copyKernel() { |
| navigator.clipboard.writeText(kxCopySource); |
| const btn = $("kxCopy"); |
| const orig = btn.textContent; |
| btn.textContent = "Copied!"; |
| setTimeout(() => { btn.textContent = orig; }, 1500); |
| } |
| |
| let kxCopySource = ""; |
| |
| function highlightWgsl(source) { |
| let s = escapeHtml(source); |
| s = s.replace(/\/\/[^\n]*/g, '<span class="k-cm">$&</span>'); |
| s = s.replace(/\b(fn|var|let|const|for|if|else|while|loop|break|continue|return|struct|enable|@compute|@workgroup_size|@group|@binding|@builtin|@stage)\b/g, '<span class="k-kw">$&</span>'); |
| s = s.replace(/\b(f32|u32|i32|bool|vec2|vec3|vec4|mat4x4|mat3x3|array|ptr|void)\b/g, '<span class="k-ty">$&</span>'); |
| s = s.replace(/@[\w]+/g, '<span class="k-at">$&</span>'); |
| s = s.replace(/\b\d+\.?\d*[fu]?\b/g, '<span class="k-nu">$&</span>'); |
| return `<code>${s}</code>`; |
| } |
| |
| |
| function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } |
| function clamp(v, lo, hi) { return Math.min(Math.max(v, lo), hi); } |
| function finiteNumber(v) { return typeof v === "number" && Number.isFinite(v); } |
| function formatBytes(n) { if (n < 1024) return `${n} B`; if (n < 1048576) return `${(n/1024).toFixed(1)} KB`; if (n < 1073741824) return `${(n/1048576).toFixed(1)} MB`; return `${(n/1073741824).toFixed(2)} GB`; } |
| function formatInteger(n) { return Math.floor(n).toLocaleString(); } |
| </script> |
| </body> |
| </html> |
|
|