diff --git a/src/app/page.tsx b/src/app/page.tsx index e7192c2..9ba4993 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,66 +1,319 @@ // src/app/page.tsx "use client"; -import { useState, useRef, useEffect, useCallback } from "react"; +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() { - const [textInput, setTextInput] = useState(""); - const [liveMode, setLiveMode] = useState(false); - const [isSpeaking, setIsSpeaking] = useState(false); + // 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 { status: whisperStatus, transcribe } = useWhisper(); const { isRecording, startRecording, stopRecording } = useVoiceRecorder(); const bottomRef = useRef(null); - const handleUtterance = useCallback( - (text: string) => { - stopSpeaking(); - sendMessage(text, "voice"); - }, - [sendMessage] - ); + const theme = THEMES[settings.theme]; - const { isListening, isSpeaking: vadSpeaking, start: startLive, stop: stopLive } = - useLiveVoice({ - onUtterance: handleUtterance, - onSpeechStart: () => setIsSpeaking(true), - }); + // ── Bootstrap ──────────────────────────────────────────────────────────────── - // Sync VAD speaking state useEffect(() => { - setIsSpeaking(vadSpeaking); - }, [vadSpeaking]); + 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); - } + if (!liveMode) { setLiveMode(true); startLive(); } + else { setLiveMode(false); stopLive(); setIsSpeaking(false); } }; - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); - - const handleTextSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!textInput.trim()) return; + 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(); + stopSpeaking(); await startRecording(); }; const handlePTTUp = async () => { @@ -70,141 +323,884 @@ export default function Home() { if (text) sendMessage(text, "voice"); }; - const pttDisabled = whisperStatus !== "ready" || isLoading || liveMode; - - const statusLine = () => { - if (liveMode && isSpeaking) return "🎙 Hearing you…"; - if (liveMode && isLoading) return "⏳ Claw is thinking…"; - if (liveMode) return "👂 Listening — just speak naturally"; - if (whisperStatus === "transcribing") return "💬 Transcribing…"; - if (isRecording) return "🔴 Recording… release to send"; - return "Hold to talk"; + 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(); }; - return ( -
- {/* Header */} -
- 🦞 -
-

OpenClaw Voice

-

On-device Whisper · No API keys

-
+ const pttDisabled = whisperStatus !== "ready" || isLoading || liveMode || !activeSessionId; - {/* Live Mode Toggle */} -
- - Live - - + ))} +
+ + + {/* 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 ══════════ */} +
- {/* Live mode indicator bar */} - {liveMode && ( -
- - {isSpeaking ? "Speech detected" : "Waiting for speech…"} + {/* New session button */} +
+
- )} - {/* Messages */} -
- {messages.length === 0 && ( -

- {liveMode - ? "Live mode on — just start talking" - : "Hold the button to talk, or type below."} -

- )} - {messages.map((msg) => ( -
-
- {msg.source === "voice" && ( - - {msg.role === "user" ? "🎙 live" : "🔊 spoken"} - + {/* Nav tabs */} +
-
- ))} -
-
+ + ))} + - {/* Controls */} -
- {!liveMode && ( - <> -
- +
+ ); + })} +
+ )} + +
+ + {/* Live mode toggle */} +
+ +
+ + {/* Profile */} +
+
+
+ {profile?.avatar ?? "🧑"} +
+ {sidebarOpen && ( +
+

{profile?.name}

+ +
+ )} +
+
+ + + {/* ══════════ 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…"} +
)} -

{statusLine()}

+ {/* ══ 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} +
+ )} +
+ )) + )} +
+
- {/* Text Input */} -
- setTextInput(e.target.value)} - placeholder="Or type a message…" - disabled={isLoading || isRecording} - className="flex-1 bg-gray-800 rounded-xl px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50" - /> - -
+ {/* Controls */} +
+ {!liveMode && ( +
+ +
+ )} + +

{statusLine()}

+ +
+