// src/app/page.tsx "use client"; import { useState, useRef, useEffect, useCallback, useMemo, } from "react"; import { useChat } from "@/hooks/useChat"; import { useWhisper } from "@/hooks/useWhisper"; import { useVoiceRecorder } from "@/hooks/useVoiceRecorder"; import { useLiveVoice } from "@/hooks/useLiveVoice"; import { stopSpeaking } from "@/lib/tts"; // ─── Types ───────────────────────────────────────────────────────────────────── type Tab = "chat" | "history" | "settings"; type FontSize = "base" | "lg" | "xl"; type Density = "compact" | "comfortable" | "spacious"; type Theme = "midnight" | "slate" | "obsidian"; interface StoredMessage { id: string; role: "user" | "assistant"; content: string; source?: "voice" | "text"; timestamp: number; } interface ChatSession { id: string; title: string; createdAt: number; updatedAt: number; pinned: boolean; preview: string; messages: StoredMessage[]; } interface UserProfile { id: string; name: string; avatar: string; createdAt: number; } interface AppSettings { ttsEnabled: boolean; ttsRate: number; ttsPitch: number; whisperModel: string; whisperDtype: "q8" | "fp16" | "fp32"; chunkLength: number; fontSize: FontSize; density: Density; theme: Theme; autoScroll: boolean; sendOnEnter: boolean; showSourceBadge: boolean; liveModeDefault: boolean; maxSessions: number; language: string; } const DEFAULT_SETTINGS: AppSettings = { ttsEnabled: true, ttsRate: 1, ttsPitch: 1, whisperModel: "Xenova/whisper-tiny.en", whisperDtype: "q8", chunkLength: 30, fontSize: "base", density: "comfortable", theme: "midnight", autoScroll: true, sendOnEnter: true, showSourceBadge: true, liveModeDefault: false, maxSessions: 50, language: "en", }; const AVATARS = ["🧑", "👩", "🧔", "👨‍💻", "👩‍💻", "🧑‍🎤", "🤖", "🦊", "🐺", "🦁"]; // ─── Storage ─────────────────────────────────────────────────────────────────── const S = { get(key: string, fallback: T): T { if (typeof window === "undefined") return fallback; try { return JSON.parse(localStorage.getItem(key) ?? "null") ?? fallback; } catch { return fallback; } }, set(key: string, val: unknown) { if (typeof window === "undefined") return; localStorage.setItem(key, JSON.stringify(val)); }, }; // ─── Themes ──────────────────────────────────────────────────────────────────── const THEMES: Record = { midnight: { bg: "#08080f", surface: "#0d0d18", border: "rgba(255,255,255,0.06)", accent: "#6366f1" }, slate: { bg: "#0a0f1a", surface: "#0f1624", border: "rgba(255,255,255,0.07)", accent: "#3b82f6" }, obsidian: { bg: "#0c0c0c", surface: "#141414", border: "rgba(255,255,255,0.05)", accent: "#8b5cf6" }, }; // ─── Subcomponents ───────────────────────────────────────────────────────────── function Toggle({ on, onToggle }: { on: boolean; onToggle: () => void }) { return ( ); } function SettingRow({ label, sub, children, }: { label: string; sub?: string; children: React.ReactNode }) { return (

{label}

{sub &&

{sub}

}
{children}
); } function SliderRow({ label, sub, value, min, max, step, display, onChange, }: { label: string; sub?: string; value: number; min: number; max: number; step: number; display: string; onChange: (v: number) => void; }) { return (

{label}

{sub &&

{sub}

}
{display}
onChange(Number(e.target.value))} className="w-full h-1.5 rounded-full appearance-none cursor-pointer accent-indigo-500 bg-white/[0.08]" />
); } // ─── Main ────────────────────────────────────────────────────────────────────── export default function Home() { // Auth const [profile, setProfile] = useState(null); const [authStep, setAuthStep] = useState<"loading" | "setup" | "app">("loading"); const [setupName, setSetupName] = useState(""); const [setupAvatar, setSetupAvatar] = useState(AVATARS[0]); // Settings const [settings, setSettings] = useState(DEFAULT_SETTINGS); // Sessions const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); const [openSessionIds, setOpenSessionIds] = useState([]); const [viewingHistory, setViewingHistory] = useState(null); // UI const [activeTab, setActiveTab] = useState("chat"); const [sidebarOpen, setSidebarOpen] = useState(true); const [liveMode, setLiveMode] = useState(false); const [isSpeaking, setIsSpeaking] = useState(false); const [textInput, setTextInput] = useState(""); const [settingsSection, setSettingsSection] = useState< "voice" | "model" | "ui" | "behaviour" | "account" | "storage" >("voice"); const { messages, isLoading, sendMessage } = useChat(); const { status: whisperStatus, transcribe } = useWhisper(); const { isRecording, startRecording, stopRecording } = useVoiceRecorder(); const bottomRef = useRef(null); const theme = THEMES[settings.theme]; // ── Bootstrap ──────────────────────────────────────────────────────────────── useEffect(() => { const p = S.get("nail_profile", null); const s = S.get("nail_settings", DEFAULT_SETTINGS); const sv = S.get("nail_sessions", []); setSettings({ ...DEFAULT_SETTINGS, ...s }); setSessions(sv); if (p) { setProfile(p); setAuthStep("app"); } else setAuthStep("setup"); }, []); // ── Persist settings ────────────────────────────────────────────────────────── const updateSettings = useCallback((key: K, val: AppSettings[K]) => { setSettings((prev) => { const next = { ...prev, [key]: val }; S.set("nail_settings", next); return next; }); }, []); // ── Auto-save active session ───────────────────────────────────────────────── useEffect(() => { if (messages.length === 0 || !activeSessionId) return; const firstUser = messages.find((m) => m.role === "user"); const last = messages[messages.length - 1]; const session: ChatSession = { id: activeSessionId, title: (firstUser?.content ?? "New conversation").slice(0, 50), createdAt: Date.now(), updatedAt: Date.now(), pinned: false, preview: (last?.content ?? "").slice(0, 100), messages: messages.map((m) => ({ id: m.id, role: m.role, content: m.content, source: m.source, timestamp: Date.now(), })), }; setSessions((prev) => { const others = prev.filter((s) => s.id !== activeSessionId); const pinned = others.filter((s) => s.pinned); const unpinned = others.filter((s) => !s.pinned); const updated = [...pinned, session, ...unpinned].slice(0, settings.maxSessions); S.set("nail_sessions", updated); return updated; }); }, [messages, activeSessionId, settings.maxSessions]); // ── Session management ─────────────────────────────────────────────────────── const createSession = useCallback(() => { const id = `s_${Date.now()}`; setActiveSessionId(id); setOpenSessionIds((prev) => [...prev.filter((x) => x !== id), id]); setActiveTab("chat"); }, []); const openSession = useCallback((id: string) => { setActiveSessionId(id); setOpenSessionIds((prev) => prev.includes(id) ? prev : [...prev, id]); setActiveTab("chat"); setViewingHistory(null); }, []); const closeSession = useCallback((id: string) => { setOpenSessionIds((prev) => { const next = prev.filter((x) => x !== id); if (activeSessionId === id) setActiveSessionId(next[next.length - 1] ?? null); return next; }); }, [activeSessionId]); const deleteSession = useCallback((id: string) => { setSessions((prev) => { const next = prev.filter((s) => s.id !== id); S.set("nail_sessions", next); return next; }); closeSession(id); if (viewingHistory?.id === id) setViewingHistory(null); }, [closeSession, viewingHistory]); const togglePin = useCallback((id: string) => { setSessions((prev) => { const next = prev.map((s) => s.id === id ? { ...s, pinned: !s.pinned } : s); S.set("nail_sessions", next); return next; }); }, []); // ── Live voice ─────────────────────────────────────────────────────────────── const handleUtterance = useCallback((text: string) => { stopSpeaking(); sendMessage(text, "voice"); }, [sendMessage]); const { isSpeaking: vadSpeaking, start: startLive, stop: stopLive } = useLiveVoice({ onUtterance: handleUtterance, onSpeechStart: () => setIsSpeaking(true) }); useEffect(() => { setIsSpeaking(vadSpeaking); }, [vadSpeaking]); useEffect(() => { if (settings.autoScroll) bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, settings.autoScroll]); // ── Handlers ───────────────────────────────────────────────────────────────── const handleLiveToggle = () => { if (!liveMode) { setLiveMode(true); startLive(); } else { setLiveMode(false); stopLive(); setIsSpeaking(false); } }; const handleTextSubmit = (e?: React.FormEvent) => { e?.preventDefault(); if (!textInput.trim() || !activeSessionId) return; sendMessage(textInput, "text"); setTextInput(""); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey && settings.sendOnEnter) { e.preventDefault(); handleTextSubmit(); } }; const handlePTTDown = async () => { if (whisperStatus !== "ready") return; stopSpeaking(); await startRecording(); }; const handlePTTUp = async () => { if (!isRecording) return; const audioData = await stopRecording(); const text = await transcribe(audioData); if (text) sendMessage(text, "voice"); }; const handleSetupSubmit = () => { if (!setupName.trim()) return; const p: UserProfile = { id: `u_${Date.now()}`, name: setupName.trim(), avatar: setupAvatar, createdAt: Date.now(), }; S.set("nail_profile", p); setProfile(p); setAuthStep("app"); createSession(); }; const pttDisabled = whisperStatus !== "ready" || isLoading || liveMode || !activeSessionId; const statusLine = () => { if (!activeSessionId) return "Create or open a session to start"; if (liveMode && isSpeaking) return "Hearing you…"; if (liveMode && isLoading) return "Thinking…"; if (liveMode) return "Listening — just speak"; if (whisperStatus === "transcribing") return "Transcribing…"; if (isRecording) return "Recording — release to send"; if (whisperStatus === "loading") return "Loading Whisper model…"; return "Hold to talk, or type below"; }; const fontClass: Record = { base: "text-base", lg: "text-lg", xl: "text-xl", }; const densityPy: Record = { compact: "py-1.5", comfortable: "py-2.5", spacious: "py-4", }; const sortedSessions = useMemo(() => [...sessions].sort((a, b) => { if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; return b.updatedAt - a.updatedAt; }), [sessions]); // ── Auth gate ───────────────────────────────────────────────────────────────── if (authStep === "loading") { return (
{[0,1,2].map((i) => ( ))}
); } if (authStep === "setup") { return (
🔨

Welcome to Nail

Set up your local profile to get started

{/* Avatar picker */}
{AVATARS.map((av) => ( ))}
{/* Name */}
setSetupName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSetupSubmit()} placeholder="e.g. Alex" autoFocus className="w-full bg-white/[0.04] border border-white/[0.08] rounded-xl px-4 py-3 text-base text-white outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/30 placeholder-white/15 transition-all" />

Your profile and conversations are stored locally in your browser.

); } // ── App ─────────────────────────────────────────────────────────────────────── return (
{/* ══════════ SIDEBAR ══════════ */} {/* ══════════ MAIN ══════════ */}
{/* Top bar */}
{/* Session tabs (chat tab) */} {activeTab === "chat" && openSessionIds.length > 0 ? (
{openSessionIds.map((id) => { const s = sessions.find((x) => x.id === id); const isActive = id === activeSessionId; return ( ); })}
) : ( {activeTab} )}
{/* Whisper status pill */} {whisperStatus} {liveMode && ( {isSpeaking ? "Speaking" : "Live"} )} {isLoading && (
{[0,1,2].map((i) => ( ))}
)}
{/* Live mode bar */} {liveMode && (
{isSpeaking ? "Speech detected" : "Waiting for speech…"}
)} {/* ══ CHAT ══ */} {activeTab === "chat" && (
{!activeSessionId ? (
🔨

Welcome back, {profile?.name}

Start a new session or open one from history

) : messages.length === 0 ? (
💬

{liveMode ? "Live mode is on — just start talking" : "Start talking or type below"}

) : ( messages.map((msg) => (
{msg.role === "assistant" && (
🔨
)}
{settings.showSourceBadge && msg.source && ( {msg.role === "user" ? (msg.source === "voice" ? "🎙 voice" : "⌨️ typed") : "🔊 spoken"} )} {msg.content || ( {[0,1,2].map((i) => ( ))} )}
{msg.role === "user" && profile && (
{profile.avatar}
)}
)) )}
{/* Controls */}
{!liveMode && (
)}

{statusLine()}