| import { useState, useEffect, useRef, useCallback } from "react"; |
| import { Streamdown } from "streamdown"; |
| import { code } from "@streamdown/code"; |
| import { mermaid } from "@streamdown/mermaid"; |
| import { createMathPlugin } from "@streamdown/math"; |
| import { cjk } from "@streamdown/cjk"; |
| import "streamdown/styles.css"; |
| import "katex/dist/katex.min.css"; |
|
|
| const MODELS = [ |
| { |
| id: "1.7b", |
| name: "Bonsai 1.7B", |
| params: "1.7B", |
| size: "290 MB", |
| blurb: "Pocket-class. Built for wearables and always-on agents.", |
| }, |
| { |
| id: "4b", |
| name: "Bonsai 4B", |
| params: "4B", |
| size: "584 MB", |
| blurb: "The sweet spot. Strong reasoning at on-device latency.", |
| comingSoon: true, |
| }, |
| { |
| id: "8b", |
| name: "Bonsai 8B", |
| params: "8B", |
| size: "1.2 GB", |
| blurb: "Datacenter-grade reasoning, in your browser tab.", |
| comingSoon: true, |
| }, |
| ]; |
|
|
| const formatBytes = (bytes) => { |
| if (!bytes) return "0 MB"; |
| if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`; |
| return `${(bytes / 1e6).toFixed(0)} MB`; |
| }; |
|
|
| const math = createMathPlugin({ singleDollarTextMath: true }); |
|
|
| const STREAMDOWN_PLUGINS = { code, mermaid, math, cjk }; |
| const PRISM_GLYPH_CLASS = |
| "h-9 w-9 overflow-hidden opacity-90 [clip-path:polygon(50%_4%,100%_100%,0%_100%)] bg-[radial-gradient(circle_at_50%_18%,rgba(255,255,255,0.3),transparent_28%),linear-gradient(180deg,var(--prism-2)_0%,var(--prism-1)_42%,var(--prism-6)_100%)] drop-shadow-[0_0_18px_rgba(255,184,77,0.18)]"; |
|
|
| function SendIcon() { |
| return ( |
| <svg viewBox="0 0 16 16" fill="none" aria-hidden="true"> |
| <path |
| d="M4 12L12 4M6 4H12V10" |
| stroke="currentColor" |
| strokeWidth="1.7" |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| /> |
| </svg> |
| ); |
| } |
|
|
| function ResetIcon() { |
| return ( |
| <svg viewBox="0 0 16 16" fill="none" aria-hidden="true"> |
| <path |
| d="M4.5 6.5H1.75V3.75M2.2 6.2A5.8 5.8 0 1 1 3.6 11.7" |
| stroke="currentColor" |
| strokeWidth="1.7" |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| /> |
| </svg> |
| ); |
| } |
|
|
| function StopIcon() { |
| return ( |
| <svg viewBox="0 0 16 16" fill="none" aria-hidden="true"> |
| <rect |
| x="4.25" |
| y="4.25" |
| width="7.5" |
| height="7.5" |
| rx="1.8" |
| fill="currentColor" |
| /> |
| </svg> |
| ); |
| } |
|
|
| export default function PrismDemo() { |
| const canvasRef = useRef(null); |
| const mouseRef = useRef({ x: -9999, y: -9999, inside: false }); |
| const cellsRef = useRef([]); |
| const wavesRef = useRef([]); |
| const rafRef = useRef(null); |
| const playgroundRef = useRef(null); |
| const inputRef = useRef(null); |
| const messagesRef = useRef(null); |
| const autoScrollRef = useRef(true); |
| const optimizeMessageTimeoutRef = useRef(null); |
|
|
| |
| const [stage, setStage] = useState("select"); |
| const [selectedId, setSelectedId] = useState("1.7b"); |
| const [loadProgress, setLoadProgress] = useState(0); |
| const [loadMessage, setLoadMessage] = useState("Fetching weights"); |
| const [loadLoaded, setLoadLoaded] = useState(0); |
| const [loadTotal, setLoadTotal] = useState(0); |
| const [errorMessage, setErrorMessage] = useState(null); |
| const messagesHistoryRef = useRef([]); |
|
|
| |
| const [messages, setMessages] = useState([]); |
| const [input, setInput] = useState(""); |
| const [isThinking, setIsThinking] = useState(false); |
| const [isStreaming, setIsStreaming] = useState(false); |
| const [tps, setTps] = useState(null); |
|
|
| const workerRef = useRef(null); |
|
|
| const [fontsReady, setFontsReady] = useState(false); |
| useEffect(() => { |
| if (!document.fonts?.load) { |
| setFontsReady(true); |
| return; |
| } |
| Promise.all([ |
| document.fonts.load('400 132px "Instrument Serif"'), |
| document.fonts.load('italic 400 132px "Instrument Serif"'), |
| ]).finally(() => setFontsReady(true)); |
| }, []); |
|
|
| const selected = MODELS.find((m) => m.id === selectedId) || MODELS[0]; |
|
|
| const scrollToPlayground = useCallback(() => { |
| playgroundRef.current?.scrollIntoView({ |
| behavior: "smooth", |
| block: "start", |
| }); |
| }, []); |
|
|
| useEffect(() => { |
| if (!workerRef.current) { |
| workerRef.current = new Worker(new URL("./worker.js", import.meta.url), { |
| type: "module", |
| }); |
| } |
| const worker = workerRef.current; |
|
|
| const onMessage = (e) => { |
| const d = e.data; |
| switch (d.status) { |
| case "progress_total": |
| setLoadProgress(d.progress); |
| setLoadLoaded(d.loaded); |
| setLoadTotal(d.total); |
| if (Number(d.progress) >= 100) { |
| clearTimeout(optimizeMessageTimeoutRef.current); |
| optimizeMessageTimeoutRef.current = setTimeout(() => { |
| setLoadMessage("Optimizing model for 1-bit execution"); |
| }, 100); |
| } |
| break; |
| case "loading": |
| setLoadMessage(d.data); |
| break; |
| case "ready": |
| clearTimeout(optimizeMessageTimeoutRef.current); |
| setStage("chat"); |
| setTimeout(() => inputRef.current?.focus(), 200); |
| break; |
| case "start": |
| setIsThinking(false); |
| setIsStreaming(true); |
| setTps(null); |
| setMessages((m) => [...m, { role: "assistant", content: "" }]); |
| break; |
| case "update": |
| if (d.tps != null) setTps(d.tps); |
| setMessages((m) => { |
| const copy = [...m]; |
| const last = copy[copy.length - 1]; |
| copy[copy.length - 1] = { |
| ...last, |
| content: last.content + d.output, |
| }; |
| return copy; |
| }); |
| break; |
| case "complete": |
| setIsStreaming(false); |
| break; |
| case "error": |
| clearTimeout(optimizeMessageTimeoutRef.current); |
| setErrorMessage(d.data); |
| setStage("error"); |
| setIsThinking(false); |
| setIsStreaming(false); |
| break; |
| } |
| }; |
|
|
| worker.addEventListener("message", onMessage); |
| return () => { |
| clearTimeout(optimizeMessageTimeoutRef.current); |
| worker.removeEventListener("message", onMessage); |
| }; |
| }, []); |
|
|
| const startLoading = () => { |
| clearTimeout(optimizeMessageTimeoutRef.current); |
| setStage("loading"); |
| setLoadProgress(0); |
| setLoadMessage("Fetching weights"); |
| setLoadLoaded(0); |
| setLoadTotal(0); |
| workerRef.current?.postMessage({ type: "load", data: selectedId }); |
| }; |
|
|
| |
| useEffect(() => { |
| const canvas = canvasRef.current; |
| if (!canvas) return; |
| const ctx = canvas.getContext("2d"); |
| const dpr = window.devicePixelRatio || 1; |
|
|
| const CELL_W = 16; |
| const CELL_H = 22; |
|
|
| const PRISM = [ |
| [255, 122, 92], |
| [255, 184, 77], |
| [196, 217, 46], |
| [77, 208, 196], |
| [124, 142, 232], |
| [182, 123, 232], |
| ]; |
|
|
| const setupCells = () => { |
| const w = canvas.clientWidth; |
| const h = canvas.clientHeight; |
| canvas.width = w * dpr; |
| canvas.height = h * dpr; |
| ctx.scale(dpr, dpr); |
|
|
| const cols = Math.ceil(w / CELL_W); |
| const rows = Math.ceil(h / CELL_H); |
| const cells = []; |
| for (let r = 0; r < rows; r++) { |
| for (let c = 0; c < cols; c++) { |
| cells.push({ |
| x: c * CELL_W + CELL_W / 2, |
| y: r * CELL_H + CELL_H / 2 + 6, |
| val: Math.random() > 0.5 ? "1" : "0", |
| nextFlip: performance.now() + Math.random() * 8000 + 3000, |
| phase: Math.random() * Math.PI * 2, |
| }); |
| } |
| } |
| cellsRef.current = cells; |
| }; |
|
|
| const handleResize = () => { |
| ctx.setTransform(1, 0, 0, 1, 0, 0); |
| setupCells(); |
| }; |
| const handleMove = (e) => { |
| const rect = canvas.getBoundingClientRect(); |
| mouseRef.current.x = e.clientX - rect.left; |
| mouseRef.current.y = e.clientY - rect.top; |
| mouseRef.current.inside = true; |
| }; |
| const handleLeave = () => { |
| mouseRef.current.inside = false; |
| mouseRef.current.x = -9999; |
| mouseRef.current.y = -9999; |
| }; |
|
|
| setupCells(); |
| window.addEventListener("resize", handleResize); |
| window.addEventListener("mousemove", handleMove); |
| canvas.addEventListener("mouseleave", handleLeave); |
|
|
| let waveColorIdx = 0; |
| const spawnWave = () => { |
| const w = canvas.clientWidth; |
| const h = canvas.clientHeight; |
| const angle = (Math.random() - 0.5) * (Math.PI / 3) + Math.PI / 2; |
| const nx = Math.cos(angle); |
| const ny = Math.sin(angle); |
| const dots = [0, w * nx, h * ny, w * nx + h * ny]; |
| const minDot = Math.min(...dots); |
| const maxDot = Math.max(...dots); |
| const direction = Math.random() > 0.5 ? 1 : -1; |
| const startPos = direction > 0 ? minDot - 200 : maxDot + 200; |
| const endPos = direction > 0 ? maxDot + 200 : minDot - 200; |
| const speed = 220 + Math.random() * 140; |
| const color = PRISM[waveColorIdx % PRISM.length]; |
| waveColorIdx++; |
| wavesRef.current.push({ |
| nx, |
| ny, |
| pos: startPos, |
| endPos, |
| direction, |
| speed, |
| color, |
| bandWidth: 110, |
| startedAt: performance.now(), |
| flippedSet: new Set(), |
| }); |
| }; |
|
|
| let nextWaveAt = performance.now() + 2200; |
| let lastT = performance.now(); |
|
|
| const render = (now) => { |
| const dt = Math.min(now - lastT, 64); |
| lastT = now; |
|
|
| const w = canvas.clientWidth; |
| const h = canvas.clientHeight; |
| ctx.clearRect(0, 0, w, h); |
|
|
| ctx.font = "11px 'JetBrains Mono', monospace"; |
| ctx.textAlign = "center"; |
| ctx.textBaseline = "alphabetic"; |
|
|
| if (now > nextWaveAt) { |
| spawnWave(); |
| nextWaveAt = now + 5500 + Math.random() * 3500; |
| } |
|
|
| const activeWaves = []; |
| for (const wv of wavesRef.current) { |
| wv.pos += wv.direction * wv.speed * (dt / 1000); |
| const stillOnscreen = |
| (wv.direction > 0 && wv.pos < wv.endPos) || |
| (wv.direction < 0 && wv.pos > wv.endPos); |
| if (stillOnscreen) activeWaves.push(wv); |
| } |
| wavesRef.current = activeWaves; |
|
|
| const mx = mouseRef.current.x; |
| const my = mouseRef.current.y; |
| const LENS_R = 160; |
| const LENS_R2 = LENS_R * LENS_R; |
|
|
| const cells = cellsRef.current; |
| for (let i = 0; i < cells.length; i++) { |
| const cell = cells[i]; |
|
|
| if (now > cell.nextFlip) { |
| cell.val = cell.val === "1" ? "0" : "1"; |
| cell.nextFlip = now + 4000 + Math.random() * 9000; |
| } |
|
|
| const pulse = 0.5 + 0.5 * Math.sin(now * 0.0006 + cell.phase); |
| let opacity = 0.02 + pulse * 0.015; |
|
|
| let r = 235, |
| g = 229, |
| b = 216; |
| let waveBoost = 0; |
| let tintR = 0, |
| tintG = 0, |
| tintB = 0, |
| tintW = 0; |
|
|
| for (const wv of activeWaves) { |
| const cellPos = cell.x * wv.nx + cell.y * wv.ny; |
| const d = cellPos - wv.pos; |
| const ad = Math.abs(d); |
| if (ad < wv.bandWidth) { |
| const sigma = 30; |
| const glow = Math.exp(-(d * d) / (2 * sigma * sigma)); |
| const crest = Math.max(0, 1 - ad / wv.bandWidth); |
| const intensity = glow * 0.85 + crest * 0.15; |
|
|
| waveBoost += intensity * 0.85; |
| tintR += wv.color[0] * intensity; |
| tintG += wv.color[1] * intensity; |
| tintB += wv.color[2] * intensity; |
| tintW += intensity; |
|
|
| if (glow > 0.85 && !wv.flippedSet.has(i) && Math.random() < 0.18) { |
| cell.val = cell.val === "1" ? "0" : "1"; |
| wv.flippedSet.add(i); |
| } |
| } |
| } |
|
|
| let lensFalloff = 0; |
| if (mouseRef.current.inside) { |
| const dx = cell.x - mx; |
| const dy = cell.y - my; |
| const d2 = dx * dx + dy * dy; |
| if (d2 < LENS_R2) { |
| lensFalloff = 1 - Math.sqrt(d2) / LENS_R; |
| } |
| } |
| if (lensFalloff > 0) { |
| opacity = Math.max(opacity, 0.08 + lensFalloff * 0.4); |
| } |
|
|
| if (waveBoost > 0) { |
| opacity = Math.min(0.95, opacity + waveBoost); |
| if (tintW > 0) { |
| const tintMix = Math.min(1, waveBoost * 1.2); |
| const tr = tintR / tintW; |
| const tg = tintG / tintW; |
| const tb = tintB / tintW; |
| r = r * (1 - tintMix) + tr * tintMix; |
| g = g * (1 - tintMix) + tg * tintMix; |
| b = b * (1 - tintMix) + tb * tintMix; |
| } |
| } |
|
|
| ctx.fillStyle = `rgba(${r | 0}, ${g | 0}, ${b | 0}, ${opacity})`; |
| ctx.fillText(cell.val, cell.x, cell.y); |
| } |
|
|
| rafRef.current = requestAnimationFrame(render); |
| }; |
| rafRef.current = requestAnimationFrame(render); |
|
|
| return () => { |
| cancelAnimationFrame(rafRef.current); |
| window.removeEventListener("resize", handleResize); |
| window.removeEventListener("mousemove", handleMove); |
| canvas.removeEventListener("mouseleave", handleLeave); |
| }; |
| }, []); |
|
|
| useEffect(() => { |
| messagesHistoryRef.current = messages; |
| }, [messages]); |
|
|
| const sendMessage = (text) => { |
| const trimmed = text.trim(); |
| if (!trimmed || isStreaming || isThinking) return; |
| setInput(""); |
| const nextHistory = [ |
| ...messagesHistoryRef.current, |
| { role: "user", content: trimmed }, |
| ]; |
| setMessages(nextHistory); |
| setIsThinking(true); |
| workerRef.current?.postMessage({ type: "generate", data: nextHistory }); |
| }; |
|
|
| const interruptGeneration = () => { |
| workerRef.current?.postMessage({ type: "interrupt" }); |
| }; |
|
|
| const resetChat = () => { |
| setInput(""); |
| setMessages([]); |
| messagesHistoryRef.current = []; |
| setIsThinking(false); |
| setIsStreaming(false); |
| setTps(null); |
| autoScrollRef.current = true; |
| workerRef.current?.postMessage({ type: "reset" }); |
| setTimeout(() => inputRef.current?.focus(), 0); |
| }; |
|
|
| const handleMessagesScroll = useCallback(() => { |
| const container = messagesRef.current; |
| if (!container) return; |
|
|
| const distanceFromBottom = |
| container.scrollHeight - container.scrollTop - container.clientHeight; |
| autoScrollRef.current = distanceFromBottom < 96; |
| }, []); |
|
|
| useEffect(() => { |
| if (!autoScrollRef.current) return; |
|
|
| const container = messagesRef.current; |
| if (!container) return; |
|
|
| const id = requestAnimationFrame(() => { |
| container.scrollTo({ |
| top: container.scrollHeight, |
| behavior: isStreaming ? "auto" : "smooth", |
| }); |
| }); |
|
|
| return () => cancelAnimationFrame(id); |
| }, [messages, isThinking, isStreaming]); |
|
|
| const SUGGESTIONS = [ |
| "Who are you?", |
| "Write a short poem about AI.", |
| "What is the capital of France?", |
| "Solve x^2 - 5x + 6 = 0.", |
| ]; |
|
|
| const handleKeyDown = (e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| sendMessage(input); |
| } |
| }; |
|
|
| let tpsDisplay = "—"; |
| if (isThinking) tpsDisplay = "···"; |
| else if (tps !== null) tpsDisplay = tps.toFixed(1); |
|
|
| return ( |
| <> |
| <div className="prism-root"> |
| {/* ============= HERO ============= */} |
| <section className="hero"> |
| <canvas ref={canvasRef} className="digit-canvas" /> |
| <div className="grain" /> |
| <div className="vignette" /> |
| |
| <div className={`hero-content ${fontsReady ? "is-ready" : ""}`}> |
| <div className="hero-badge"> |
| 14× less memory · 8× faster · 5× less energy |
| </div> |
| |
| <h1> |
| 1-bit LLMs, |
| <br /> |
| in your <span className="accent italic-d">browser.</span> |
| </h1> |
| |
| <p className="lede"> |
| Run 1-bit Bonsai LLMs (1.7B, 4B, 8B) entirely locally in your |
| browser on WebGPU, powered by Transformers.js. |
| </p> |
| |
| <button className="btn btn-primary" onClick={scrollToPlayground}> |
| Try the demo |
| <span className="arrow">↓</span> |
| </button> |
| </div> |
| </section> |
| |
| {/* ============= PLAYGROUND ============= */} |
| <section className="playground" ref={playgroundRef}> |
| {stage === "select" && ( |
| <div className="select-view"> |
| <div className="select-head"> |
| <div className="eyebrow">Choose a model</div> |
| <h2> |
| Load <span className="em">locally.</span> |
| </h2> |
| <p> |
| Each Bonsai model runs entirely in your browser via WebGPU. |
| Pick a size — smaller loads faster, larger reasons better. |
| </p> |
| </div> |
| |
| <div className="model-grid"> |
| {MODELS.map((m) => ( |
| <div |
| key={m.id} |
| className={`model-card ${selectedId === m.id ? "selected" : ""} ${m.comingSoon ? "disabled" : ""}`} |
| onClick={() => !m.comingSoon && setSelectedId(m.id)} |
| > |
| {m.comingSoon && ( |
| <div className="mc-ribbon">Coming soon</div> |
| )} |
| <div className="check"> |
| {selectedId === m.id ? "✓" : ""} |
| </div> |
| <div className="mc-size"> |
| {m.params} |
| <span className="gb">{m.size}</span> |
| </div> |
| <div className="mc-name">{m.name}</div> |
| <div className="mc-blurb">{m.blurb}</div> |
| </div> |
| ))} |
| </div> |
| |
| <div className="select-actions"> |
| <button |
| className="btn btn-primary" |
| onClick={startLoading} |
| disabled={!selectedId} |
| > |
| Load {selected.name} |
| <span className="arrow">→</span> |
| </button> |
| <div className="note">No data leaves your device</div> |
| </div> |
| </div> |
| )} |
| |
| {stage === "loading" && ( |
| <div className="load-view"> |
| <div className="load-card"> |
| <div |
| className={`${PRISM_GLYPH_CLASS} mx-auto mb-7 animate-spin [animation-duration:8s]`} |
| /> |
| <div className="label">Initializing</div> |
| <h3> |
| {selected.name.split(" ")[0]}{" "} |
| <span className="em">{selected.name.split(" ")[1]}</span> |
| </h3> |
| <div className="progress"> |
| <div className="fill" style={{ width: `${loadProgress}%` }} /> |
| </div> |
| <div className="load-row"> |
| <span> |
| {loadTotal > 0 && loadProgress < 100 |
| ? `${formatBytes(loadLoaded)} / ${formatBytes(loadTotal)}` |
| : loadMessage} |
| </span> |
| <span className="pct">{Math.round(loadProgress)}%</span> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {stage === "error" && ( |
| <div className="load-view"> |
| <div className="load-card"> |
| <div className="label">Error</div> |
| <h3> |
| Could not <span className="em">load.</span> |
| </h3> |
| <p |
| style={{ |
| color: "var(--cream-dim)", |
| fontSize: 14, |
| margin: "0 0 18px", |
| }} |
| > |
| {errorMessage} |
| </p> |
| <button |
| className="btn btn-primary" |
| onClick={() => { |
| setErrorMessage(null); |
| setStage("select"); |
| }} |
| > |
| Back |
| </button> |
| </div> |
| </div> |
| )} |
| |
| {stage === "chat" && ( |
| <div className="chat-view"> |
| <div className="pg-header-bar"> |
| <h2 className="pg-title"> |
| The <span className="em">playground.</span> |
| </h2> |
| <div className="pg-mini"> |
| <span className="chip"> |
| <span className="k">model</span> |
| {selected.name} |
| </span> |
| <span className="chip"> |
| <span className="k">size</span> |
| {selected.size} |
| </span> |
| </div> |
| </div> |
| |
| <div className="chat"> |
| <div className="chat-bar"> |
| <div className="left"> |
| <span className="dots"> |
| <span /> |
| <span /> |
| <span /> |
| </span> |
| <span>BONSAI WebGPU</span> |
| </div> |
| <div className="right"> |
| <span className="tps-value">{tpsDisplay}</span> |
| <span className="tps-label">Tokens / sec</span> |
| </div> |
| </div> |
| |
| <div |
| ref={messagesRef} |
| className="messages" |
| onScroll={handleMessagesScroll} |
| > |
| {messages.length === 0 && !isThinking ? ( |
| <div className="flex flex-1 flex-col items-center justify-center gap-5 px-0 py-6 text-center"> |
| <div className={PRISM_GLYPH_CLASS} /> |
| <div |
| className="text-[32px] leading-none tracking-[-0.02em] text-[var(--cream)]" |
| style={{ fontFamily: '"Instrument Serif", serif' }} |
| > |
| How can I help you? |
| </div> |
| <div |
| className="mt-[-6px] text-[12px] leading-[1.25] tracking-[0.06em] text-[var(--muted)] uppercase" |
| style={{ fontFamily: '"JetBrains Mono", monospace' }} |
| > |
| Talk to a 1-bit model |
| </div> |
| <div className="mt-1 grid w-full max-w-[540px] grid-cols-1 gap-2.5 sm:grid-cols-2"> |
| {SUGGESTIONS.map((s) => ( |
| <button |
| key={s} |
| className="rounded-md border border-[var(--line)] bg-[var(--bg-3)] px-4 py-[13px] text-left text-[13px] leading-[1.3] text-[var(--cream-dim)] transition-all duration-200 hover:translate-y-[-1px] hover:border-[var(--line-2)] hover:bg-[rgba(235,229,216,0.015)] hover:text-[var(--cream)]" |
| style={{ fontFamily: '"Geist", sans-serif' }} |
| onClick={() => sendMessage(s)} |
| > |
| {s} |
| </button> |
| ))} |
| </div> |
| </div> |
| ) : ( |
| <> |
| {messages.map((m, i) => { |
| return ( |
| <div key={i} className={`message ${m.role}`}> |
| <span className="role"> |
| {m.role === "user" ? "You" : selected.name} |
| </span> |
| <div className="bubble"> |
| {m.role === "assistant" ? ( |
| <Streamdown |
| className="streamdown-content" |
| plugins={STREAMDOWN_PLUGINS} |
| controls={false} |
| parseIncompleteMarkdown |
| > |
| {m.content} |
| </Streamdown> |
| ) : ( |
| m.content |
| )} |
| </div> |
| </div> |
| ); |
| })} |
| {isThinking && ( |
| <div className="message assistant"> |
| <span className="role">{selected.name}</span> |
| <div className="thinking"> |
| <span>thinking</span> |
| <span className="bits"> |
| <span>1</span> |
| <span>0</span> |
| <span>1</span> |
| <span>1</span> |
| <span>0</span> |
| </span> |
| </div> |
| </div> |
| )} |
| </> |
| )} |
| </div> |
| |
| <div className="composer"> |
| <textarea |
| ref={inputRef} |
| value={input} |
| onChange={(e) => setInput(e.target.value)} |
| onKeyDown={handleKeyDown} |
| placeholder="Ask the 1-bit model anything…" |
| rows={1} |
| /> |
| {isStreaming || isThinking ? ( |
| <button className="send" onClick={interruptGeneration}> |
| Stop |
| <span className="send-icon"> |
| <StopIcon /> |
| </span> |
| </button> |
| ) : !input.trim() ? ( |
| <button className="send" onClick={resetChat}> |
| Reset |
| <span className="send-icon"> |
| <ResetIcon /> |
| </span> |
| </button> |
| ) : ( |
| <button |
| className="send" |
| onClick={() => sendMessage(input)} |
| > |
| Send |
| <span className="send-icon"> |
| <SendIcon /> |
| </span> |
| </button> |
| )} |
| </div> |
| </div> |
| </div> |
| )} |
| </section> |
| </div> |
| </> |
| ); |
| } |
|
|