From e160df20da788ece11edbbfbcd80bae53d6ba905 Mon Sep 17 00:00:00 2001 From: will Date: Wed, 1 Apr 2026 20:53:47 +0100 Subject: [PATCH] cleaned up code base --- next.config.ts | 7 + package-lock.json | 35 +- package.json | 4 +- src/app/api/chat/route.ts | 57 +- src/app/page.tsx | 1301 ++-------------------- src/components/chat/ChatView.tsx | 279 +++++ src/components/history/HistoryView.tsx | 135 +++ src/components/layout/Header.tsx | 87 ++ src/components/layout/Sidebar.tsx | 145 +++ src/components/settings/SettingsView.tsx | 293 +++++ src/components/setup/LoadingScreen.tsx | 15 + src/components/setup/SetupScreen.tsx | 73 ++ src/components/ui/SettingRow.tsx | 17 + src/components/ui/SliderRow.tsx | 30 + src/components/ui/Toggle.tsx | 17 + src/hooks/useAppState.ts | 230 ++++ src/lib/constants.ts | 27 + src/lib/storage.ts | 11 + src/types/index.ts | 47 + 19 files changed, 1591 insertions(+), 1219 deletions(-) create mode 100644 src/components/chat/ChatView.tsx create mode 100644 src/components/history/HistoryView.tsx create mode 100644 src/components/layout/Header.tsx create mode 100644 src/components/layout/Sidebar.tsx create mode 100644 src/components/settings/SettingsView.tsx create mode 100644 src/components/setup/LoadingScreen.tsx create mode 100644 src/components/setup/SetupScreen.tsx create mode 100644 src/components/ui/SettingRow.tsx create mode 100644 src/components/ui/SliderRow.tsx create mode 100644 src/components/ui/Toggle.tsx create mode 100644 src/hooks/useAppState.ts create mode 100644 src/lib/constants.ts create mode 100644 src/lib/storage.ts create mode 100644 src/types/index.ts diff --git a/next.config.ts b/next.config.ts index 5473241..19f29b2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,13 @@ const nextConfig: NextConfig = { turbopack: { // Turbopack handles WASM and workers natively — no extra rules needed }, + webpack: (config: any) => { + config.externals.push({ + 'bufferutil': 'bufferutil', + 'utf-8-validate': 'utf-8-validate', + }) + return config + }, async headers() { return [ { diff --git a/package-lock.json b/package-lock.json index 969fdd0..a26727e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "next": "16.2.1", "openai": "^6.33.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "ws": "^8.20.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -20,6 +21,7 @@ "@types/node": "^20.19.37", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "eslint": "^9", "eslint-config-next": "16.2.1", "tailwindcss": "^4", @@ -1680,6 +1682,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", @@ -6927,6 +6939,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 901727f..e43fa57 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "next": "16.2.1", "openai": "^6.33.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "ws": "^8.20.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -21,6 +22,7 @@ "@types/node": "^20.19.37", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "eslint": "^9", "eslint-config-next": "16.2.1", "tailwindcss": "^4", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index a660a16..44e5eea 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -2,30 +2,51 @@ import { NextRequest } from "next/server"; export async function POST(req: NextRequest) { - const { messages } = await req.json(); + try { + const { messages } = await req.json(); - const res = await fetch( - `${process.env.OPENCLAW_BASE_URL}/v1/chat/completions`, - { + const base = process.env.OPENCLAW_BASE_URL ?? "http://127.0.0.1:18789"; + const model = process.env.OPENCLAW_AGENT_ID + ? `openclaw/${process.env.OPENCLAW_AGENT_ID}` + : "openclaw/default"; + + const res = await fetch(`${base}/v1/chat/completions`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + }, body: JSON.stringify({ - model: `openclaw:${process.env.OPENCLAW_AGENT_ID}`, - messages, + model, stream: true, + messages, }), + }); + + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ""); + console.error("OpenClaw upstream failed", { + status: res.status, + body: text, + base, + model, + }); + + return new Response(`OpenClaw error: ${res.status} ${text}`, { + status: 502, + }); } - ); - if (!res.ok || !res.body) { - return new Response(`OpenClaw error: ${res.status}`, { status: 502 }); + return new Response(res.body, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + }, + }); + } catch (err) { + return new Response( + `Route error: ${err instanceof Error ? err.message : "unknown error"}`, + { status: 500 } + ); } - - return new Response(res.body, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); } diff --git a/src/app/page.tsx b/src/app/page.tsx index ee0743a..7121c6c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,1222 +1,125 @@ -/// 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"; -import OpenClawSettings from "@/components/OpenClawSettings"; - - -// ─── 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 ────────────────────────────────────────────────────────────────────── +import { useAppState } from "@/hooks/useAppState"; +import { LoadingScreen } from "@/components/setup/LoadingScreen"; +import { SetupScreen } from "@/components/setup/SetupScreen"; +import { Sidebar } from "@/components/layout/Sidebar"; +import { Header } from "@/components/layout/Header"; +import { ChatView } from "@/components/chat/ChatView"; +import { HistoryView } from "@/components/history/HistoryView"; +import { SettingsView } from "@/components/settings/SettingsView"; 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]); + const app = useAppState(); - // Settings - const [settings, setSettings] = useState(DEFAULT_SETTINGS); + // ── Auth gates ──────────────────────────────────────────────────────────── + if (app.authStep === "loading") return ; - // 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" | "openclaw" - >("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") { + if (app.authStep === "setup") { 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 ─────────────────────────────────────────────────────────────────────── + // ── App shell ───────────────────────────────────────────────────────────── return ( -
+
+ app.setSidebarOpen((v) => !v)} + activeTab={app.activeTab} + onTabChange={app.setActiveTab} + openSessionIds={app.openSessionIds} + sessions={app.sessions} + activeSessionId={app.activeSessionId} + onSessionClick={(id) => { app.setActiveSessionId(id); app.setActiveTab("chat"); }} + onSessionClose={app.closeSession} + onNewSession={app.createSession} + liveMode={app.liveMode} + isSpeaking={app.isSpeaking} + onLiveToggle={app.handleLiveToggle} + profile={app.profile} + /> - {/* ══════════ 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…"} -
+ {app.activeTab === "chat" && ( + app.setActiveTab("history")} + /> )} - {/* ══ 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()}

- -
-