Spaces:
Paused
Paused
| "use client"; | |
| import React, { useEffect, useMemo, useRef, useState } from "react"; | |
| type Role = "system" | "user" | "assistant"; | |
| type ChatMsg = { id: string; role: Role; content: string; ts: number }; | |
| const DEFAULT_MODELS = [ | |
| { id: "Qwen/Qwen2.5-7B-Instruct", label: "Qwen 2.5 7B (Instruct)" }, | |
| { id: "Qwen/Qwen2.5-Coder-32B-Instruct", label: "Qwen 2.5 Coder 32B" }, | |
| { id: "google/gemma-2-2b-it", label: "Gemma 2 2B IT" }, | |
| { id: "meta-llama/Llama-3.1-8B-Instruct", label: "Llama 3.1 8B Instruct" }, | |
| ]; | |
| function uid() { | |
| return Math.random().toString(16).slice(2) + "-" + Date.now().toString(16); | |
| } | |
| /** Minimal SSE parser for OpenAI-style streaming */ | |
| async function readSSE( | |
| res: Response, | |
| onDelta: (text: string) => void, | |
| signal?: AbortSignal | |
| ) { | |
| if (!res.body) throw new Error("No response body to stream."); | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder("utf-8"); | |
| let buf = ""; | |
| while (true) { | |
| if (signal?.aborted) throw new Error("aborted"); | |
| const { value, done } = await reader.read(); | |
| if (done) break; | |
| buf += decoder.decode(value, { stream: true }); | |
| // SSE events are separated by \n\n | |
| let idx: number; | |
| while ((idx = buf.indexOf("\n\n")) !== -1) { | |
| const rawEvent = buf.slice(0, idx); | |
| buf = buf.slice(idx + 2); | |
| const lines = rawEvent.split("\n"); | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| if (!trimmed.startsWith("data:")) continue; | |
| const data = trimmed.slice(5).trim(); | |
| if (!data) continue; | |
| if (data === "[DONE]") return; | |
| let json: any; | |
| try { | |
| json = JSON.parse(data); | |
| } catch { | |
| continue; | |
| } | |
| // OpenAI-style: choices[0].delta.content | |
| const delta = | |
| json?.choices?.[0]?.delta?.content ?? | |
| json?.choices?.[0]?.message?.content ?? | |
| ""; | |
| if (typeof delta === "string" && delta.length) onDelta(delta); | |
| } | |
| } | |
| } | |
| } | |
| function PixelFireworks({ | |
| show, | |
| onSkip, | |
| }: { | |
| show: boolean; | |
| onSkip: () => void; | |
| }) { | |
| const canvasRef = useRef<HTMLCanvasElement | null>(null); | |
| const rafRef = useRef<number | null>(null); | |
| const particlesRef = useRef< | |
| { x: number; y: number; vx: number; vy: number; life: number; c: string }[] | |
| >([]); | |
| const year = useMemo(() => { | |
| const d = new Date(); | |
| // If it's December, show next year; otherwise current year | |
| return d.getMonth() === 11 ? d.getFullYear() + 1 : d.getFullYear(); | |
| }, []); | |
| useEffect(() => { | |
| if (!show) return; | |
| const canvas = canvasRef.current!; | |
| const ctx = canvas.getContext("2d", { alpha: true })!; | |
| const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); | |
| function resize() { | |
| const w = window.innerWidth; | |
| const h = window.innerHeight; | |
| canvas.width = Math.floor(w * DPR); | |
| canvas.height = Math.floor(h * DPR); | |
| canvas.style.width = `${w}px`; | |
| canvas.style.height = `${h}px`; | |
| ctx.setTransform(DPR, 0, 0, DPR, 0, 0); | |
| } | |
| resize(); | |
| window.addEventListener("resize", resize); | |
| const colors = ["#ffd300", "#00ff6a", "#57a7ff", "#ff5a5a", "#c35bff"]; | |
| function burst() { | |
| const w = window.innerWidth; | |
| const h = window.innerHeight; | |
| const x = w * (0.2 + Math.random() * 0.6); | |
| const y = h * (0.2 + Math.random() * 0.45); | |
| const c = colors[(Math.random() * colors.length) | 0]; | |
| const n = 70; | |
| for (let i = 0; i < n; i++) { | |
| const a = (Math.PI * 2 * i) / n; | |
| const s = 1.2 + Math.random() * 2.8; | |
| particlesRef.current.push({ | |
| x, | |
| y, | |
| vx: Math.cos(a) * s, | |
| vy: Math.sin(a) * s, | |
| life: 60 + (Math.random() * 35) | 0, | |
| c, | |
| }); | |
| } | |
| } | |
| let t = 0; | |
| function frame() { | |
| const w = window.innerWidth; | |
| const h = window.innerHeight; | |
| // fade | |
| ctx.fillStyle = "rgba(0,0,0,0.22)"; | |
| ctx.fillRect(0, 0, w, h); | |
| // spawn bursts | |
| t++; | |
| if (t % 18 === 0) burst(); | |
| // draw particles as pixel blocks | |
| const p = particlesRef.current; | |
| for (let i = p.length - 1; i >= 0; i--) { | |
| const q = p[i]; | |
| q.x += q.vx; | |
| q.y += q.vy; | |
| q.vy += 0.03; // gravity | |
| q.life -= 1; | |
| if (q.life <= 0 || q.x < -50 || q.y < -50 || q.x > w + 50 || q.y > h + 50) { | |
| p.splice(i, 1); | |
| continue; | |
| } | |
| const size = q.life > 40 ? 3 : 2; | |
| ctx.fillStyle = q.c; | |
| ctx.fillRect((q.x | 0), (q.y | 0), size, size); | |
| } | |
| rafRef.current = requestAnimationFrame(frame); | |
| } | |
| // initial clear | |
| ctx.fillStyle = "black"; | |
| ctx.fillRect(0, 0, window.innerWidth, window.innerHeight); | |
| rafRef.current = requestAnimationFrame(frame); | |
| return () => { | |
| window.removeEventListener("resize", resize); | |
| if (rafRef.current) cancelAnimationFrame(rafRef.current); | |
| rafRef.current = null; | |
| particlesRef.current = []; | |
| }; | |
| }, [show]); | |
| if (!show) return null; | |
| return ( | |
| <div className="nyOverlay" role="dialog" aria-modal="true"> | |
| <canvas className="nyCanvas" ref={canvasRef} /> | |
| <div className="nyPanel"> | |
| <div className="nyTitle">HAPPY NEW YEAR</div> | |
| <div className="nyYear">{year}</div> | |
| <div className="nySub">Pixel fireworks • Loading chat…</div> | |
| <button className="btn pixelBtn" onClick={onSkip}> | |
| SKIP | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default function Page() { | |
| const [messages, setMessages] = useState<ChatMsg[]>([]); | |
| const [input, setInput] = useState(""); | |
| const [streaming, setStreaming] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [settingsOpen, setSettingsOpen] = useState(false); | |
| const [model, setModel] = useState(DEFAULT_MODELS[0].id); | |
| const [customModel, setCustomModel] = useState(""); | |
| const [temperature, setTemperature] = useState(0.7); | |
| const [maxTokens, setMaxTokens] = useState(512); | |
| const [systemPrompt, setSystemPrompt] = useState( | |
| "You are a helpful assistant. Keep answers clear and practical." | |
| ); | |
| const [useStreaming, setUseStreaming] = useState(true); | |
| const [showPrompts, setShowPrompts] = useState(true); | |
| const [showIntro, setShowIntro] = useState(false); | |
| const abortRef = useRef<AbortController | null>(null); | |
| const listRef = useRef<HTMLDivElement | null>(null); | |
| const inputRef = useRef<HTMLTextAreaElement | null>(null); | |
| // Intro once per session | |
| useEffect(() => { | |
| try { | |
| const seen = sessionStorage.getItem("ny_seen"); | |
| if (seen === "1") return; | |
| sessionStorage.setItem("ny_seen", "1"); | |
| setShowIntro(true); | |
| const t = window.setTimeout(() => setShowIntro(false), 5000); | |
| return () => window.clearTimeout(t); | |
| } catch { | |
| // no-op | |
| } | |
| }, []); | |
| // Load persisted settings | |
| useEffect(() => { | |
| try { | |
| const raw = localStorage.getItem("mc_chat_settings"); | |
| if (!raw) return; | |
| const s = JSON.parse(raw); | |
| if (typeof s.model === "string") setModel(s.model); | |
| if (typeof s.customModel === "string") setCustomModel(s.customModel); | |
| if (typeof s.temperature === "number") setTemperature(s.temperature); | |
| if (typeof s.maxTokens === "number") setMaxTokens(s.maxTokens); | |
| if (typeof s.systemPrompt === "string") setSystemPrompt(s.systemPrompt); | |
| if (typeof s.useStreaming === "boolean") setUseStreaming(s.useStreaming); | |
| } catch { | |
| // ignore | |
| } | |
| }, []); | |
| // Persist settings | |
| useEffect(() => { | |
| try { | |
| localStorage.setItem( | |
| "mc_chat_settings", | |
| JSON.stringify({ | |
| model, | |
| customModel, | |
| temperature, | |
| maxTokens, | |
| systemPrompt, | |
| useStreaming, | |
| }) | |
| ); | |
| } catch { | |
| // ignore | |
| } | |
| }, [model, customModel, temperature, maxTokens, systemPrompt, useStreaming]); | |
| // Auto-scroll | |
| useEffect(() => { | |
| const el = listRef.current; | |
| if (!el) return; | |
| el.scrollTop = el.scrollHeight; | |
| }, [messages, streaming]); | |
| // Hide prompt overlay after first user message | |
| useEffect(() => { | |
| if (messages.some((m) => m.role === "user")) setShowPrompts(false); | |
| }, [messages]); | |
| // Keyboard shortcuts | |
| useEffect(() => { | |
| function onKeyDown(e: KeyboardEvent) { | |
| if (e.key === "Escape") { | |
| if (streaming) stopStreaming(); | |
| return; | |
| } | |
| // Ctrl+L -> clear | |
| if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l") { | |
| e.preventDefault(); | |
| doClear(); | |
| } | |
| } | |
| window.addEventListener("keydown", onKeyDown); | |
| return () => window.removeEventListener("keydown", onKeyDown); | |
| }, [streaming]); | |
| function activeModel() { | |
| return (customModel.trim() || model).trim(); | |
| } | |
| function stopStreaming() { | |
| abortRef.current?.abort(); | |
| abortRef.current = null; | |
| setStreaming(false); | |
| } | |
| function doClear() { | |
| stopStreaming(); | |
| setMessages([]); | |
| setShowPrompts(true); | |
| setError(null); | |
| setInput(""); | |
| inputRef.current?.focus(); | |
| } | |
| function exportChat() { | |
| const payload = { | |
| model: activeModel(), | |
| temperature, | |
| max_tokens: maxTokens, | |
| created_at: new Date().toISOString(), | |
| messages, | |
| }; | |
| const blob = new Blob([JSON.stringify(payload, null, 2)], { | |
| type: "application/json", | |
| }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = `chat-${Date.now()}.json`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| async function runChat(userText: string, replaceLastUser?: boolean) { | |
| setError(null); | |
| // Slash commands | |
| const t = userText.trim(); | |
| if (t === "/clear") { | |
| doClear(); | |
| return; | |
| } | |
| if (t === "/export") { | |
| exportChat(); | |
| return; | |
| } | |
| if (t === "/help") { | |
| setMessages((prev) => [ | |
| ...prev, | |
| { | |
| id: uid(), | |
| role: "assistant", | |
| ts: Date.now(), | |
| content: | |
| "Commands:\n/clear — clear chat\n/export — download JSON\n/help — show this\n\nShortcuts:\nEnter = send • Shift+Enter = new line • Esc = stop • Ctrl+L = clear", | |
| }, | |
| ]); | |
| return; | |
| } | |
| const now = Date.now(); | |
| const userMsg: ChatMsg = { id: uid(), role: "user", ts: now, content: userText }; | |
| setMessages((prev) => { | |
| if (replaceLastUser) { | |
| const copy = [...prev]; | |
| // remove trailing assistant message if present | |
| if (copy.length && copy[copy.length - 1].role === "assistant") copy.pop(); | |
| // remove trailing user message if present | |
| if (copy.length && copy[copy.length - 1].role === "user") copy.pop(); | |
| return [...copy, userMsg, { id: uid(), role: "assistant", ts: now, content: "" }]; | |
| } | |
| return [...prev, userMsg, { id: uid(), role: "assistant", ts: now, content: "" }]; | |
| }); | |
| const ac = new AbortController(); | |
| abortRef.current = ac; | |
| const packedMessages = [ | |
| { role: "system", content: systemPrompt }, | |
| // include the entire chat so far (excluding the empty assistant placeholder) | |
| ...(() => { | |
| const base = replaceLastUser | |
| ? (() => { | |
| // after state update, easiest is to build from current messages but remove last assistant/user as needed | |
| const copy = [...messages]; | |
| if (copy.length && copy[copy.length - 1].role === "assistant") copy.pop(); | |
| if (copy.length && copy[copy.length - 1].role === "user") copy.pop(); | |
| return [...copy, userMsg]; | |
| })() | |
| : [...messages, userMsg]; | |
| return base | |
| .filter((m) => m.role === "user" || m.role === "assistant") | |
| .map((m) => ({ role: m.role, content: m.content })); | |
| })(), | |
| ]; | |
| try { | |
| setStreaming(true); | |
| const res = await fetch("/api/chat", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| signal: ac.signal, | |
| body: JSON.stringify({ | |
| model: activeModel(), | |
| messages: packedMessages, | |
| temperature, | |
| max_tokens: maxTokens, | |
| stream: useStreaming, | |
| }), | |
| }); | |
| if (!res.ok) { | |
| const text = await res.text(); | |
| throw new Error(text || `HTTP ${res.status}`); | |
| } | |
| if (!useStreaming) { | |
| const json = await res.json(); | |
| const out = json?.choices?.[0]?.message?.content ?? ""; | |
| setMessages((prev) => { | |
| const copy = [...prev]; | |
| const last = copy[copy.length - 1]; | |
| if (last?.role === "assistant") last.content = String(out); | |
| return copy; | |
| }); | |
| setStreaming(false); | |
| abortRef.current = null; | |
| return; | |
| } | |
| await readSSE( | |
| res, | |
| (delta) => { | |
| setMessages((prev) => { | |
| const copy = [...prev]; | |
| const last = copy[copy.length - 1]; | |
| if (last?.role === "assistant") last.content += delta; | |
| return copy; | |
| }); | |
| }, | |
| ac.signal | |
| ); | |
| setStreaming(false); | |
| abortRef.current = null; | |
| } catch (e: any) { | |
| const msg = String(e?.message ?? e ?? "Unknown error"); | |
| if (msg === "aborted") { | |
| setStreaming(false); | |
| abortRef.current = null; | |
| return; | |
| } | |
| setStreaming(false); | |
| abortRef.current = null; | |
| setError(msg); | |
| // put error in assistant bubble | |
| setMessages((prev) => { | |
| const copy = [...prev]; | |
| const last = copy[copy.length - 1]; | |
| if (last?.role === "assistant" && !last.content.trim()) { | |
| last.content = `⚠️ ${msg}`; | |
| } else { | |
| copy.push({ id: uid(), role: "assistant", ts: Date.now(), content: `⚠️ ${msg}` }); | |
| } | |
| return copy; | |
| }); | |
| } | |
| } | |
| function onSend() { | |
| if (streaming) return; | |
| const text = input.trim(); | |
| if (!text) return; | |
| setInput(""); | |
| runChat(text); | |
| } | |
| function onRegen() { | |
| if (streaming) return; | |
| // find last user message | |
| const lastUser = [...messages].reverse().find((m) => m.role === "user"); | |
| if (!lastUser) return; | |
| runChat(lastUser.content, true); | |
| } | |
| const promptChips = [ | |
| "Explain black holes like I'm 10 explaining to a friend.", | |
| "Make a 7-day study plan for Java + DSA.", | |
| "Write a scary 6-line story with a twist ending.", | |
| "Give 5 business ideas for students in India.", | |
| ]; | |
| return ( | |
| <div className="mcRoot"> | |
| <PixelFireworks show={showIntro} onSkip={() => setShowIntro(false)} /> | |
| <div className="mcShell"> | |
| <div className="mcFrame"> | |
| <div className="mcTop"> | |
| <div className="mcBrand"> | |
| <div className="mcIcon" aria-hidden="true"> | |
| 🤖 | |
| </div> | |
| <div className="mcTitleWrap"> | |
| <div className="mcTitle">AI CHAT</div> | |
| <div className="mcSub"> | |
| READY • {useStreaming ? "STREAM" : "NO-STREAM"} •{" "} | |
| {(customModel.trim() || model).split("/").pop()} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="mcTopRight"> | |
| <button | |
| className="btn pixelBtn" | |
| onClick={() => setSettingsOpen(true)} | |
| aria-label="Open settings" | |
| title="Settings" | |
| > | |
| ⚙ | |
| </button> | |
| </div> | |
| </div> | |
| <div className="mcBody"> | |
| <div className="mcList" ref={listRef}> | |
| {messages.length === 0 && showPrompts && ( | |
| <div className="mcOverlay"> | |
| <div className="mcOverlayBox"> | |
| <div className="mcOverlayHead"> | |
| <div className="mcOverlayBadge">TIP</div> | |
| <div className="mcOverlayText"> | |
| Tap a prompt to start (it disappears after you chat). | |
| </div> | |
| </div> | |
| <div className="mcChips"> | |
| {promptChips.map((p) => ( | |
| <button | |
| key={p} | |
| className="mcChip" | |
| onClick={() => { | |
| setInput(p); | |
| inputRef.current?.focus(); | |
| }} | |
| > | |
| {p} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {messages.map((m) => ( | |
| <div | |
| key={m.id} | |
| className={`mcMsg ${m.role === "user" ? "isUser" : m.role === "assistant" ? "isAI" : "isSys" | |
| }`} | |
| > | |
| <div className="mcMsgMeta"> | |
| <span className="mcWho">{m.role === "user" ? "YOU" : m.role === "assistant" ? "AI" : "SYS"}</span> | |
| <span className="mcDot">•</span> | |
| <span className="mcTime"> | |
| {new Date(m.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} | |
| </span> | |
| {m.role === "assistant" && m.content.trim() && ( | |
| <button | |
| className="mcMiniBtn" | |
| title="Copy" | |
| onClick={() => navigator.clipboard.writeText(m.content)} | |
| > | |
| COPY | |
| </button> | |
| )} | |
| </div> | |
| <div className="mcBubble"> | |
| <pre className="mcText">{m.content}</pre> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="mcComposer"> | |
| <textarea | |
| ref={inputRef} | |
| className="mcInput" | |
| value={input} | |
| placeholder="Type… (Enter=send, Shift+Enter=new line, Esc=stop) • /help" | |
| maxLength={2000} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| onSend(); | |
| } | |
| }} | |
| /> | |
| <div className="mcActions"> | |
| <button className="btn pixelBtn" onClick={onSend} disabled={streaming}> | |
| {streaming ? "..." : "SEND"} | |
| </button> | |
| <button className="btn pixelBtn" onClick={onRegen} disabled={streaming || messages.length === 0}> | |
| REGEN | |
| </button> | |
| {streaming ? ( | |
| <button className="btn pixelBtn danger" onClick={stopStreaming}> | |
| STOP | |
| </button> | |
| ) : ( | |
| <button className="btn pixelBtn ghost" onClick={doClear}> | |
| CLEAR | |
| </button> | |
| )} | |
| </div> | |
| <div className="mcFooter"> | |
| <div className="mcHint"> | |
| Tip: Esc stops streaming • /export downloads chat | |
| </div> | |
| <div className="mcCount">{input.length}/2000</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* SETTINGS MODAL */} | |
| {settingsOpen && ( | |
| <div className="mcModal" role="dialog" aria-modal="true"> | |
| <div className="mcModalBox"> | |
| <div className="mcModalTop"> | |
| <div className="mcModalTitle">SETTINGS</div> | |
| <button className="btn pixelBtn" onClick={() => setSettingsOpen(false)}> | |
| ✕ | |
| </button> | |
| </div> | |
| <div className="mcGrid"> | |
| <div className="mcField"> | |
| <div className="mcLabel">Model</div> | |
| <select | |
| className="mcSelect" | |
| value={model} | |
| onChange={(e) => setModel(e.target.value)} | |
| > | |
| {DEFAULT_MODELS.map((m) => ( | |
| <option key={m.id} value={m.id}> | |
| {m.label} | |
| </option> | |
| ))} | |
| </select> | |
| <div className="mcSmall"> | |
| Optional: paste a custom model id below (overrides dropdown). | |
| </div> | |
| <input | |
| className="mcTextIn" | |
| value={customModel} | |
| onChange={(e) => setCustomModel(e.target.value)} | |
| placeholder="Custom model id (optional)" | |
| /> | |
| </div> | |
| <div className="mcField"> | |
| <div className="mcLabel">Temperature (0 → 2)</div> | |
| <input | |
| className="mcRange" | |
| type="range" | |
| min={0} | |
| max={2} | |
| step={0.05} | |
| value={temperature} | |
| onChange={(e) => setTemperature(Number(e.target.value))} | |
| /> | |
| <div className="mcRow"> | |
| <div className="mcPill">{temperature.toFixed(2)}</div> | |
| <label className="mcCheck"> | |
| <input | |
| type="checkbox" | |
| checked={useStreaming} | |
| onChange={(e) => setUseStreaming(e.target.checked)} | |
| /> | |
| <span>Streaming</span> | |
| </label> | |
| </div> | |
| </div> | |
| <div className="mcField"> | |
| <div className="mcLabel">Max Tokens</div> | |
| <input | |
| className="mcTextIn" | |
| type="number" | |
| min={16} | |
| max={4096} | |
| value={maxTokens} | |
| onChange={(e) => setMaxTokens(Number(e.target.value))} | |
| /> | |
| <div className="mcSmall"> | |
| (2 is a common max temperature; tokens depends on model.) | |
| </div> | |
| </div> | |
| <div className="mcField mcWide"> | |
| <div className="mcLabel">System Prompt</div> | |
| <textarea | |
| className="mcTextArea" | |
| value={systemPrompt} | |
| onChange={(e) => setSystemPrompt(e.target.value)} | |
| /> | |
| </div> | |
| <div className="mcField mcWide"> | |
| <div className="mcRow"> | |
| <button className="btn pixelBtn" onClick={exportChat}> | |
| EXPORT CHAT | |
| </button> | |
| <button | |
| className="btn pixelBtn ghost" | |
| onClick={() => { | |
| setModel(DEFAULT_MODELS[0].id); | |
| setCustomModel(""); | |
| setTemperature(0.7); | |
| setMaxTokens(512); | |
| setSystemPrompt( | |
| "You are a helpful assistant. Keep answers clear and practical." | |
| ); | |
| setUseStreaming(true); | |
| }} | |
| > | |
| RESET DEFAULTS | |
| </button> | |
| </div> | |
| {error && <div className="mcError">Last error: {error}</div>} | |
| </div> | |
| </div> | |
| <div className="mcModalHint"> | |
| Note: don’t paste any “citation text” into your code (it can break TypeScript). | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |