nextjs / app /page.tsx
diamond-in's picture
Update app/page.tsx
64867cc verified
"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>
);
}