cleaned up code base

This commit is contained in:
will
2026-04-01 20:53:47 +01:00
parent cac01c0590
commit e160df20da
19 changed files with 1591 additions and 1219 deletions

View File

@@ -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 [
{

35
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -2,30 +2,51 @@
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
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) {
return new Response(`OpenClaw error: ${res.status}`, { status: 502 });
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,
});
}
return new Response(res.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"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 }
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,279 @@
"use client";
import { useRef } from "react";
import type { AppSettings, UserProfile, Theme } from "@/types";
import { THEMES } from "@/lib/constants";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
source?: "voice" | "text";
}
interface ChatViewProps {
theme: (typeof THEMES)[Theme];
messages: Message[];
activeSessionId: string | null;
profile: UserProfile | null;
settings: AppSettings;
liveMode: boolean;
isSpeaking: boolean;
isLoading: boolean;
isRecording: boolean;
whisperStatus: string;
textInput: string;
onTextChange: (v: string) => void;
onTextSubmit: (e?: React.FormEvent) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onPTTDown: () => void;
onPTTUp: () => void;
onNewSession: () => void;
onOpenHistory: () => void;
}
const FONT_CLASS: Record<AppSettings["fontSize"], string> = {
base: "text-base",
lg: "text-lg",
xl: "text-xl",
};
const DENSITY_PY: Record<AppSettings["density"], string> = {
compact: "py-1.5",
comfortable: "py-2.5",
spacious: "py-4",
};
export function ChatView({
theme, messages, activeSessionId, profile, settings,
liveMode, isSpeaking, isLoading, isRecording, whisperStatus,
textInput, onTextChange, onTextSubmit, onKeyDown,
onPTTDown, onPTTUp, onNewSession, onOpenHistory,
}: ChatViewProps) {
const bottomRef = useRef<HTMLDivElement>(null);
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 liveBannerCls = [
"flex items-center justify-center gap-2 py-2 text-[11px] font-medium transition-all",
isSpeaking ? "bg-emerald-600/80 text-white" : "bg-emerald-900/20 text-emerald-500/60",
].join(" ");
const liveDotCls = [
"w-1.5 h-1.5 rounded-full",
isSpeaking ? "bg-white animate-ping" : "bg-emerald-500",
].join(" ");
const pttBtnCls = [
"relative w-16 h-16 rounded-full text-2xl transition-all duration-150 select-none",
isRecording
? "scale-110 ring-4 ring-red-500/25"
: whisperStatus === "transcribing"
? "cursor-wait"
: pttDisabled
? "opacity-20 cursor-not-allowed"
: "hover:scale-105 active:scale-95",
].join(" ");
const pttBg =
isRecording ? "#ef4444"
: whisperStatus === "transcribing" ? "#f59e0b"
: pttDisabled ? "rgba(255,255,255,0.05)"
: theme.accent;
const pttShadow =
isRecording ? "0 0 30px rgba(239,68,68,0.4)"
: !pttDisabled ? `0 0 20px ${theme.accent}40`
: "none";
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Live mode banner */}
{liveMode && (
<div className={liveBannerCls}>
<span className={liveDotCls} />
{isSpeaking ? "Speech detected" : "Waiting for speech…"}
</div>
)}
{/* Message list */}
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-4">
{/* No active session */}
{!activeSessionId ? (
<div className="flex flex-col items-center justify-center h-full gap-5 pb-16">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center text-3xl border shadow-2xl"
style={{ background: `${theme.accent}15`, borderColor: `${theme.accent}25` }}
>
🔨
</div>
<div className="text-center">
<p className="text-base font-semibold text-white/50 mb-2">
Welcome back, {profile?.name}
</p>
<p className="text-sm text-white/20">Start a new session or open one from history</p>
</div>
<div className="flex gap-2">
<button
onClick={onNewSession}
className="px-5 py-2.5 rounded-xl text-[13px] font-medium text-white/80 hover:text-white transition-all border hover:bg-white/[0.06]"
style={{ borderColor: theme.border }}
>
New session
</button>
<button
onClick={onOpenHistory}
className="px-5 py-2.5 rounded-xl text-[13px] font-medium transition-all hover:opacity-90"
style={{ background: theme.accent }}
>
🕐 Open history
</button>
</div>
</div>
/* Session open but no messages */
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-4 pb-16 text-center">
<span className="text-5xl opacity-20">💬</span>
<p className="text-sm text-white/25">
{liveMode ? "Live mode is on — just start talking" : "Start talking or type below"}
</p>
</div>
/* Messages */
) : (
messages.map((msg) => {
const isUser = msg.role === "user";
const bubbleCls = [
"max-w-[70%] rounded-2xl px-5",
DENSITY_PY[settings.density],
FONT_CLASS[settings.fontSize],
"leading-relaxed",
isUser
? "text-white rounded-br-sm shadow-lg"
: "text-white/75 rounded-bl-sm border",
].join(" ");
const bubbleStyle = isUser
? { background: theme.accent, boxShadow: `0 4px 20px ${theme.accent}35` }
: { background: "rgba(255,255,255,0.04)", borderColor: theme.border };
return (
<div
key={msg.id}
className={`flex items-end gap-3 ${isUser ? "justify-end" : "justify-start"}`}
>
{/* Assistant avatar */}
{!isUser && (
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-[13px] shrink-0 mb-0.5 border"
style={{ background: `${theme.accent}20`, borderColor: theme.border }}
>
🔨
</div>
)}
{/* Bubble */}
<div className={bubbleCls} style={bubbleStyle}>
{settings.showSourceBadge && msg.source && (
<span className="text-[10px] opacity-30 block mb-1">
{isUser
? (msg.source === "voice" ? "🎙 voice" : "⌨️ typed")
: "🔊 spoken"}
</span>
)}
{msg.content || (
<span className="flex gap-1 items-center opacity-40 py-0.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 bg-white rounded-full animate-bounce"
style={{ animationDelay: `${i * 0.12}s` }}
/>
))}
</span>
)}
</div>
{/* User avatar */}
{isUser && profile && (
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-sm shrink-0 mb-0.5"
style={{ background: `${theme.accent}20` }}
>
{profile.avatar}
</div>
)}
</div>
);
})
)}
<div ref={bottomRef} />
</div>
{/* Controls bar */}
<div
className="shrink-0 border-t px-6 py-5 space-y-4"
style={{ borderColor: theme.border, background: `${theme.surface}ee` }}
>
{/* PTT button */}
{!liveMode && (
<div className="flex justify-center">
<button
onMouseDown={onPTTDown}
onMouseUp={onPTTUp}
onTouchStart={(e) => { e.preventDefault(); onPTTDown(); }}
onTouchEnd={onPTTUp}
disabled={pttDisabled}
className={pttBtnCls}
style={{ background: pttBg, boxShadow: pttShadow }}
>
{isRecording ? "🔴" : whisperStatus === "transcribing" ? "💬" : "🎙"}
{isRecording && (
<span className="absolute inset-0 rounded-full bg-red-400/20 animate-ping pointer-events-none" />
)}
</button>
</div>
)}
<p className="text-center text-[12px] text-white/25">{statusLine()}</p>
{/* Text input */}
<form onSubmit={onTextSubmit} className="flex gap-2">
<textarea
value={textInput}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={activeSessionId ? "Type a message…" : "Open a session first…"}
disabled={isLoading || isRecording || !activeSessionId}
rows={1}
className="flex-1 bg-white/[0.04] border rounded-xl px-4 py-3 text-[14px] outline-none focus:ring-1 placeholder-white/15 disabled:opacity-20 transition-all resize-none leading-snug"
style={{ borderColor: theme.border, caretColor: theme.accent }}
/>
<button
type="submit"
disabled={isLoading || !textInput.trim() || !activeSessionId}
className="px-4 py-3 rounded-xl text-sm font-semibold transition-all active:scale-95 disabled:opacity-20 shadow-lg self-end"
style={{ background: theme.accent, boxShadow: `0 4px 14px ${theme.accent}40` }}
>
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import type { ChatSession, Theme } from "@/types";
import { THEMES } from "@/lib/constants";
interface HistoryViewProps {
theme: (typeof THEMES)[Theme];
sessions: ChatSession[];
sortedSessions: ChatSession[];
viewingHistory: ChatSession | null;
onView: (session: ChatSession) => void;
onBack: () => void;
onOpen: (id: string) => void;
onDelete: (id: string) => void;
onPin: (id: string) => void;
onClearAll: () => void;
}
export function HistoryView({
theme, sessions, sortedSessions, viewingHistory,
onView, onBack, onOpen, onDelete, onPin, onClearAll,
}: HistoryViewProps) {
// ── Drill-down: single session reader ─────────────────────────────────────
if (viewingHistory) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-3 px-6 py-3.5 border-b shrink-0"
style={{ borderColor: theme.border }}>
<button onClick={onBack}
className="text-white/25 hover:text-white/60 transition-colors flex items-center gap-1.5 text-[13px]">
Back
</button>
<div className="min-w-0 flex-1 px-2">
<p className="text-[13px] font-semibold text-white/70 truncate">{viewingHistory.title}</p>
<p className="text-[11px] text-white/25 mt-0.5">
{new Date(viewingHistory.updatedAt).toLocaleString()} · {viewingHistory.messages.length} messages
</p>
</div>
<div className="flex gap-2 shrink-0">
<button onClick={() => onOpen(viewingHistory.id)}
className="text-[12px] px-3 py-1.5 rounded-lg font-medium transition-all"
style={{ background: `${theme.accent}20`, color: theme.accent }}>
Open
</button>
<button onClick={() => onDelete(viewingHistory.id)}
className="text-[12px] text-red-400/40 hover:text-red-400 transition-colors px-2">
Delete
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
{viewingHistory.messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[70%] rounded-2xl px-5 py-3 text-[14px] leading-relaxed
${msg.role === "user" ? "text-white rounded-br-sm" : "text-white/60 rounded-bl-sm border"}`}
style={msg.role === "user"
? { background: `${theme.accent}90` }
: { background: "rgba(255,255,255,0.03)", borderColor: theme.border }}>
{msg.content}
</div>
</div>
))}
</div>
</div>
);
}
// ── Session list ──────────────────────────────────────────────────────────
return (
<div className="px-6 py-6">
<div className="flex items-center justify-between mb-5">
<div>
<p className="text-[15px] font-semibold text-white/70">Conversation History</p>
<p className="text-[12px] text-white/25 mt-0.5">
{sessions.length} saved session{sessions.length !== 1 ? "s" : ""}
</p>
</div>
{sessions.length > 0 && (
<button onClick={onClearAll}
className="text-[12px] text-red-400/40 hover:text-red-400 transition-colors">
Clear all
</button>
)}
</div>
{/* Empty state */}
{sessions.length === 0 ? (
<div className="flex flex-col items-center py-28 gap-3 text-center">
<span className="text-5xl opacity-20">🕐</span>
<p className="text-sm text-white/25">No history yet</p>
<p className="text-xs text-white/15">Conversations are saved automatically</p>
</div>
) : (
<div className="space-y-2">
{sortedSessions.map((session) => (
<div key={session.id} onClick={() => onView(session)}
className="group relative rounded-xl p-4 cursor-pointer transition-all border hover:bg-white/[0.04]"
style={{ background: "rgba(255,255,255,0.02)", borderColor: theme.border }}>
<div className="flex items-start gap-3">
{session.pinned && <span className="text-[11px] text-amber-400/60 mt-0.5 shrink-0">📌</span>}
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-white/70 truncate">{session.title}</p>
<p className="text-[12px] text-white/25 mt-0.5 line-clamp-1">{session.preview}</p>
<div className="flex items-center gap-2 text-[11px] text-white/20 mt-2">
<span>
{new Date(session.updatedAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
</span>
<span>·</span>
<span>{session.messages.length} messages</span>
</div>
</div>
{/* Hover actions */}
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-all shrink-0">
<button onClick={(e) => { e.stopPropagation(); onOpen(session.id); }}
className="text-[11px] px-2 py-1 rounded-lg transition-all"
style={{ background: `${theme.accent}20`, color: theme.accent }}>
Open
</button>
<button onClick={(e) => { e.stopPropagation(); onPin(session.id); }}
className="text-[11px] px-2 py-1 rounded-lg bg-white/5 text-white/30 hover:text-white/60 transition-all">
{session.pinned ? "Unpin" : "Pin"}
</button>
<button onClick={(e) => { e.stopPropagation(); onDelete(session.id); }}
className="text-[11px] px-2 py-1 rounded-lg text-red-400/30 hover:text-red-400 transition-all">
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import type { Tab, ChatSession, Theme } from "@/types";
import { THEMES } from "@/lib/constants";
interface HeaderProps {
theme: (typeof THEMES)[Theme];
activeTab: Tab;
openSessionIds: string[];
sessions: ChatSession[];
activeSessionId: string | null;
onSessionClick: (id: string) => void;
onSessionClose: (id: string) => void;
onNewSession: () => void;
whisperStatus: string;
liveMode: boolean;
isSpeaking: boolean;
isLoading: boolean;
}
export function Header({ theme, activeTab, openSessionIds, sessions, activeSessionId,
onSessionClick, onSessionClose, onNewSession, whisperStatus, liveMode, isSpeaking, isLoading }: HeaderProps) {
return (
<header className="flex items-center gap-3 px-6 py-3.5 border-b shrink-0 backdrop-blur-xl"
style={{ background: `${theme.surface}dd`, borderColor: theme.border }}>
{activeTab === "chat" && openSessionIds.length > 0 ? (
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto no-scrollbar">
{openSessionIds.map((id) => {
const s = sessions.find((x) => x.id === id);
const isActive = id === activeSessionId;
return (
<button key={id} onClick={() => onSessionClick(id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px]
whitespace-nowrap transition-all max-w-[160px] group
${isActive ? "text-white/80 font-medium" : "text-white/30 hover:text-white/55 hover:bg-white/[0.04]"}`}
style={isActive ? { background: `${theme.accent}18` } : {}}>
<span className="truncate">{s?.title ?? "New session"}</span>
<span onClick={(e) => { e.stopPropagation(); onSessionClose(id); }}
className="opacity-0 group-hover:opacity-100 text-white/25 hover:text-white/60 transition-all text-[10px] ml-0.5">
</span>
</button>
);
})}
<button onClick={onNewSession}
className="text-white/20 hover:text-white/50 transition-colors px-2 py-1.5 text-[13px] shrink-0">
+
</button>
</div>
) : (
<span className="text-[13px] font-semibold text-white/40 capitalize">{activeTab}</span>
)}
{/* Status indicators */}
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium
${whisperStatus === "ready" ? "bg-emerald-500/10 text-emerald-400"
: whisperStatus === "loading" ? "bg-amber-500/10 text-amber-400"
: whisperStatus === "transcribing" ? "bg-blue-500/10 text-blue-400"
: "bg-white/5 text-white/20"}`}>
<span className={`w-1.5 h-1.5 rounded-full
${whisperStatus === "ready" ? "bg-emerald-400"
: whisperStatus === "loading" ? "bg-amber-400 animate-pulse"
: whisperStatus === "transcribing" ? "bg-blue-400 animate-ping"
: "bg-white/15"}`} />
{whisperStatus}
</span>
{liveMode && (
<span className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium
${isSpeaking ? "bg-emerald-500/20 text-emerald-300" : "bg-emerald-500/8 text-emerald-500/60"}`}>
<span className={`w-1.5 h-1.5 rounded-full bg-emerald-400 ${isSpeaking ? "animate-ping" : ""}`} />
{isSpeaking ? "Speaking" : "Live"}
</span>
)}
{isLoading && (
<div className="flex gap-0.5 items-center px-1">
{[0, 1, 2].map((i) => (
<span key={i} className="w-1 h-1 rounded-full animate-bounce"
style={{ background: theme.accent, animationDelay: `${i * 0.12}s` }} />
))}
</div>
)}
</div>
</header>
);
}

View File

@@ -0,0 +1,145 @@
import type { Tab, ChatSession, UserProfile, Theme } from "@/types";
import { THEMES } from "@/lib/constants";
interface SidebarProps {
theme: (typeof THEMES)[Theme];
sidebarOpen: boolean;
onToggleSidebar: () => void;
activeTab: Tab;
onTabChange: (tab: Tab) => void;
openSessionIds: string[];
sessions: ChatSession[];
activeSessionId: string | null;
onSessionClick: (id: string) => void;
onSessionClose: (id: string) => void;
onNewSession: () => void;
liveMode: boolean;
isSpeaking: boolean;
onLiveToggle: () => void;
profile: UserProfile | null;
}
export function Sidebar({ theme, sidebarOpen, onToggleSidebar, activeTab, onTabChange,
openSessionIds, sessions, activeSessionId, onSessionClick, onSessionClose,
onNewSession, liveMode, isSpeaking, onLiveToggle, profile }: SidebarProps) {
const NAV_ITEMS = [
{ id: "chat" as Tab, icon: "💬", label: "Chat", badge: openSessionIds.length },
{ id: "history" as Tab, icon: "🕐", label: "History", badge: sessions.length },
{ id: "settings" as Tab, icon: "⚙️", label: "Settings", badge: 0 },
];
return (
<aside className="flex flex-col shrink-0 border-r transition-all duration-300 ease-in-out"
style={{ background: theme.surface, borderColor: theme.border, width: sidebarOpen ? "260px" : "56px" }}>
{/* Logo */}
<div className="flex items-center gap-3 px-3.5 py-4 border-b shrink-0" style={{ borderColor: theme.border }}>
<div className="w-8 h-8 rounded-xl flex items-center justify-center text-lg shrink-0"
style={{ background: `${theme.accent}22` }}>🔨</div>
{sidebarOpen && (
<div className="flex-1 min-w-0">
<p className="text-[14px] font-bold tracking-tight">Nail</p>
<p className="text-[10px] text-white/20 truncate">Voice Assistant</p>
</div>
)}
<button onClick={onToggleSidebar}
className="text-white/20 hover:text-white/60 transition-colors shrink-0 p-1 ml-auto">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<path d={sidebarOpen ? "M8 2L3 6.5L8 11" : "M5 2L10 6.5L5 11"}
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
{/* New session */}
<div className="p-2 shrink-0">
<button onClick={onNewSession}
className="flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg text-[13px]
transition-all font-medium text-white/50 hover:text-white/80 hover:bg-white/[0.06]">
<span className="text-base shrink-0"></span>
{sidebarOpen && <span>New Session</span>}
</button>
</div>
{/* Nav tabs */}
<nav className="flex flex-col gap-0.5 px-2 shrink-0">
{NAV_ITEMS.map(({ id, icon, label, badge }) => (
<button key={id} onClick={() => onTabChange(id)}
className={`flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] w-full transition-all
${activeTab === id ? "font-medium" : "text-white/30 hover:text-white/60 hover:bg-white/[0.04]"}`}
style={activeTab === id ? { background: `${theme.accent}1a`, color: `${theme.accent}dd` } : {}}>
<span className="text-sm shrink-0">{icon}</span>
{sidebarOpen && (
<>
<span className="flex-1 text-left truncate">{label}</span>
{badge > 0 && (
<span className="text-[10px] rounded-full px-1.5 py-0.5 leading-none bg-white/[0.07] text-white/30">
{badge}
</span>
)}
</>
)}
</button>
))}
</nav>
{/* Open sessions list */}
{sidebarOpen && activeTab === "chat" && openSessionIds.length > 0 && (
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5 min-h-0">
<p className="text-[10px] font-semibold text-white/20 uppercase tracking-widest px-2 mb-1.5 mt-1">Open</p>
{openSessionIds.map((id) => {
const s = sessions.find((x) => x.id === id);
const isActive = id === activeSessionId;
return (
<div key={id} onClick={() => onSessionClick(id)}
className={`group flex items-center gap-2 px-2.5 py-2 rounded-lg cursor-pointer transition-all
${isActive ? "bg-white/[0.07]" : "hover:bg-white/[0.04]"}`}>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 transition-all
${isActive ? "bg-indigo-400" : "bg-white/[0.12]"}`} />
<span className="text-[12px] text-white/60 truncate flex-1">{s?.title ?? "New session"}</span>
<button onClick={(e) => { e.stopPropagation(); onSessionClose(id); }}
className="opacity-0 group-hover:opacity-100 text-white/20 hover:text-white/60 transition-all text-[11px] shrink-0">
</button>
</div>
);
})}
</div>
)}
<div className="flex-1" />
{/* Live mode toggle */}
<div className="p-2 border-t shrink-0" style={{ borderColor: theme.border }}>
<button onClick={onLiveToggle}
className={`flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg text-[13px] transition-all
${liveMode ? "font-medium" : "text-white/30 hover:text-white/60 hover:bg-white/[0.04]"}`}
style={liveMode ? { background: "#10b98115", color: "#34d399" } : {}}>
<span className={`w-2 h-2 rounded-full shrink-0 transition-all
${liveMode ? "bg-emerald-400 shadow-[0_0_6px_#34d399] animate-pulse" : "bg-white/15"}`} />
{sidebarOpen && <span>{liveMode ? "Live · On" : "Live Mode"}</span>}
</button>
</div>
{/* Profile */}
<div className="px-3 pb-3 shrink-0">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg flex items-center justify-center text-base shrink-0"
style={{ background: `${theme.accent}15` }}>
{profile?.avatar ?? "🧑"}
</div>
{sidebarOpen && (
<div className="min-w-0 flex-1">
<p className="text-[12px] font-medium text-white/60 truncate">{profile?.name}</p>
<button onClick={() => onTabChange("settings")}
className="text-[10px] text-white/20 hover:text-white/40 transition-colors">
Manage profile
</button>
</div>
)}
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,293 @@
import type { AppSettings, UserProfile, ChatSession, Theme } from "@/types";
import { THEMES, AVATARS } from "@/lib/constants";
import { Toggle } from "@/components/ui/Toggle";
import { SettingRow } from "@/components/ui/SettingRow";
import { SliderRow } from "@/components/ui/SliderRow";
import { S } from "@/lib/storage";
import OpenClawSettings from "@/components/OpenClawSettings";
type SettingsSection = "voice" | "model" | "ui" | "behaviour" | "account" | "storage" | "openclaw";
interface SettingsViewProps {
theme: (typeof THEMES)[Theme];
settings: AppSettings;
settingsSection: SettingsSection;
profile: UserProfile | null;
sessions: ChatSession[];
onSectionChange: (s: SettingsSection) => void;
onSettingChange: <K extends keyof AppSettings>(key: K, val: AppSettings[K]) => void;
onProfileUpdate: (p: UserProfile) => void;
onResetProfile: () => void;
onClearSessions: () => void;
}
const SECTIONS = [
{ id: "voice" as SettingsSection, icon: "🎙", label: "Voice & TTS" },
{ id: "model" as SettingsSection, icon: "🤖", label: "Model" },
{ id: "ui" as SettingsSection, icon: "🎨", label: "Appearance" },
{ id: "behaviour" as SettingsSection, icon: "⚡", label: "Behaviour" },
{ id: "account" as SettingsSection, icon: "👤", label: "Profile" },
{ id: "storage" as SettingsSection, icon: "💾", label: "Storage" },
{ id: "openclaw" as SettingsSection, icon: "🔨", label: "OpenClaw" },
];
export function SettingsView({
theme, settings, settingsSection, profile, sessions,
onSectionChange, onSettingChange, onProfileUpdate, onResetProfile, onClearSessions,
}: SettingsViewProps) {
return (
<div className="flex flex-1 min-h-0">
{/* Sub-nav */}
<div className="w-48 border-r flex-col py-4 px-2 shrink-0 hidden md:flex"
style={{ borderColor: theme.border }}>
{SECTIONS.map(({ id, icon, label }) => (
<button key={id} onClick={() => onSectionChange(id)}
className={`flex items-center gap-2 px-3 py-2.5 rounded-lg text-[13px] transition-all text-left w-full
${settingsSection === id
? "font-medium"
: "text-white/30 hover:text-white/55 hover:bg-white/[0.04]"}`}
style={settingsSection === id
? { background: `${theme.accent}1a`, color: theme.accent } : {}}>
<span className="text-sm">{icon}</span>
{label}
</button>
))}
</div>
{/* Content pane */}
<div className="flex-1 overflow-y-auto px-8 py-6">
<p className="text-[11px] font-bold text-white/20 uppercase tracking-widest mb-5">
{SECTIONS.find(s => s.id === settingsSection)?.label}
</p>
{/* ── Voice & TTS ─────────────────────────────────────────── */}
{settingsSection === "voice" && (
<div className="divide-y divide-white/[0.05]">
<SettingRow label="Text-to-Speech" sub="Read assistant replies aloud">
<Toggle on={settings.ttsEnabled} onToggle={() => onSettingChange("ttsEnabled", !settings.ttsEnabled)} />
</SettingRow>
<SliderRow label="Speech Rate" sub="How fast the assistant speaks"
value={settings.ttsRate} min={0.5} max={2} step={0.1}
display={`${settings.ttsRate.toFixed(1)}×`}
onChange={(v) => onSettingChange("ttsRate", v)} />
<SliderRow label="Pitch" sub="Voice pitch level"
value={settings.ttsPitch} min={0.5} max={2} step={0.1}
display={`${settings.ttsPitch.toFixed(1)}`}
onChange={(v) => onSettingChange("ttsPitch", v)} />
<SettingRow label="Live Mode by Default" sub="Start in live voice mode on launch">
<Toggle on={settings.liveModeDefault} onToggle={() => onSettingChange("liveModeDefault", !settings.liveModeDefault)} />
</SettingRow>
</div>
)}
{/* ── Model ───────────────────────────────────────────────── */}
{settingsSection === "model" && (
<div className="divide-y divide-white/[0.05]">
<SettingRow label="Whisper Model" sub="Speech recognition model">
<select value={settings.whisperModel}
onChange={(e) => onSettingChange("whisperModel", e.target.value)}
className="bg-white/[0.06] border rounded-lg px-3 py-1.5 text-[13px] text-white/70 outline-none cursor-pointer"
style={{ borderColor: theme.border }}>
<option value="Xenova/whisper-tiny.en">Whisper Tiny (EN)</option>
<option value="Xenova/whisper-base.en">Whisper Base (EN)</option>
<option value="Xenova/whisper-small.en">Whisper Small (EN)</option>
<option value="Xenova/whisper-tiny">Whisper Tiny (multilingual)</option>
</select>
</SettingRow>
<SettingRow label="Quantization" sub="Lower = faster, higher = more accurate">
<select value={settings.whisperDtype}
onChange={(e) => onSettingChange("whisperDtype", e.target.value as AppSettings["whisperDtype"])}
className="bg-white/[0.06] border rounded-lg px-3 py-1.5 text-[13px] text-white/70 outline-none cursor-pointer"
style={{ borderColor: theme.border }}>
<option value="q8">q8 (recommended)</option>
<option value="fp16">fp16</option>
<option value="fp32">fp32</option>
</select>
</SettingRow>
<SliderRow label="Chunk Length (seconds)" sub="Audio chunk size for processing"
value={settings.chunkLength} min={10} max={60} step={5}
display={`${settings.chunkLength}s`}
onChange={(v) => onSettingChange("chunkLength", v)} />
<SettingRow label="Language" sub="Transcription language">
<select value={settings.language}
onChange={(e) => onSettingChange("language", e.target.value)}
className="bg-white/[0.06] border rounded-lg px-3 py-1.5 text-[13px] text-white/70 outline-none cursor-pointer"
style={{ borderColor: theme.border }}>
<option value="en">English</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="es">Spanish</option>
<option value="ja">Japanese</option>
<option value="zh">Chinese</option>
</select>
</SettingRow>
</div>
)}
{/* ── Appearance ──────────────────────────────────────────── */}
{settingsSection === "ui" && (
<div className="divide-y divide-white/[0.05]">
<SettingRow label="Theme" sub="Background colour scheme">
<div className="flex gap-1.5">
{(["midnight", "slate", "obsidian"] as Theme[]).map((t) => (
<button key={t} onClick={() => onSettingChange("theme", t)}
className={`w-7 h-7 rounded-lg border transition-all
${settings.theme === t ? "scale-110 ring-2 ring-white/20" : "opacity-50 hover:opacity-80"}`}
style={{ background: THEMES[t].bg, borderColor: THEMES[t].border }}
title={t} />
))}
</div>
</SettingRow>
<SettingRow label="Font Size" sub="Message text size">
<div className="flex gap-1">
{(["base", "lg", "xl"] as AppSettings["fontSize"][]).map((s) => (
<button key={s} onClick={() => onSettingChange("fontSize", s)}
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-all
${settings.fontSize === s ? "font-semibold" : "text-white/25 hover:text-white/50 hover:bg-white/[0.06]"}`}
style={settings.fontSize === s ? { background: `${theme.accent}20`, color: theme.accent } : {}}>
{s === "base" ? "Md" : s === "lg" ? "Lg" : "XL"}
</button>
))}
</div>
</SettingRow>
<SettingRow label="Density" sub="Message padding and spacing">
<div className="flex gap-1">
{(["compact", "comfortable", "spacious"] as AppSettings["density"][]).map((d) => (
<button key={d} onClick={() => onSettingChange("density", d)}
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-all capitalize
${settings.density === d ? "font-semibold" : "text-white/25 hover:text-white/50 hover:bg-white/[0.06]"}`}
style={settings.density === d ? { background: `${theme.accent}20`, color: theme.accent } : {}}>
{d === "compact" ? "Tight" : d === "comfortable" ? "Normal" : "Airy"}
</button>
))}
</div>
</SettingRow>
</div>
)}
{/* ── Behaviour ───────────────────────────────────────────── */}
{settingsSection === "behaviour" && (
<div className="divide-y divide-white/[0.05]">
<SettingRow label="Auto-scroll" sub="Scroll to latest message automatically">
<Toggle on={settings.autoScroll} onToggle={() => onSettingChange("autoScroll", !settings.autoScroll)} />
</SettingRow>
<SettingRow label="Send on Enter" sub="Press Enter to send (Shift+Enter for newline)">
<Toggle on={settings.sendOnEnter} onToggle={() => onSettingChange("sendOnEnter", !settings.sendOnEnter)} />
</SettingRow>
<SettingRow label="Show Source Badges" sub="Show voice/typed label on messages">
<Toggle on={settings.showSourceBadge} onToggle={() => onSettingChange("showSourceBadge", !settings.showSourceBadge)} />
</SettingRow>
<SliderRow label="Max Saved Sessions" sub="Older sessions are removed automatically"
value={settings.maxSessions} min={10} max={200} step={10}
display={`${settings.maxSessions}`}
onChange={(v) => onSettingChange("maxSessions", v)} />
</div>
)}
{/* ── Profile ─────────────────────────────────────────────── */}
{settingsSection === "account" && profile && (
<div className="space-y-4">
<div className="flex items-center gap-4 p-5 rounded-2xl border"
style={{ background: "rgba(255,255,255,0.02)", borderColor: theme.border }}>
<div className="w-14 h-14 rounded-2xl flex items-center justify-center text-3xl"
style={{ background: `${theme.accent}15` }}>
{profile.avatar}
</div>
<div>
<p className="text-base font-semibold text-white/80">{profile.name}</p>
<p className="text-[12px] text-white/25 mt-0.5">
Local profile · since {new Date(profile.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="divide-y divide-white/[0.05]">
<div className="py-4">
<p className="text-[13px] font-medium text-white/70 mb-2.5">Display Name</p>
<input type="text" defaultValue={profile.name}
onBlur={(e) => {
const updated = { ...profile, name: e.target.value };
onProfileUpdate(updated); S.set("nail_profile", updated);
}}
className="bg-white/[0.04] border rounded-xl px-4 py-2.5 text-[14px] text-white
outline-none focus:ring-1 transition-all w-full max-w-sm"
style={{ borderColor: theme.border }} />
</div>
<div className="py-4">
<p className="text-[13px] font-medium text-white/70 mb-2.5">Avatar</p>
<div className="flex flex-wrap gap-2">
{AVATARS.map((av) => (
<button key={av}
onClick={() => {
const updated = { ...profile, avatar: av };
onProfileUpdate(updated); S.set("nail_profile", updated);
}}
className={`w-10 h-10 rounded-xl text-xl transition-all
${profile.avatar === av
? "ring-2 scale-110"
: "bg-white/[0.04] hover:bg-white/[0.08] opacity-60 hover:opacity-100"}`}
style={profile.avatar === av
? { background: `${theme.accent}20`, boxShadow: `0 0 0 2px ${theme.accent}` } : {}}>
{av}
</button>
))}
</div>
</div>
</div>
<button onClick={onResetProfile}
className="text-[13px] text-red-400/40 hover:text-red-400 transition-colors">
Reset profile and data
</button>
</div>
)}
{/* ── Storage ─────────────────────────────────────────────── */}
{settingsSection === "storage" && (
<div className="space-y-4">
<div className="p-5 rounded-2xl border"
style={{ background: "rgba(255,255,255,0.02)", borderColor: theme.border }}>
<div className="flex justify-between text-[13px] mb-3">
<span className="font-medium text-white/70">Saved Sessions</span>
<span className="text-white/30 font-mono">{sessions.length} / {settings.maxSessions} sessions</span>
</div>
<div className="h-1.5 rounded-full overflow-hidden bg-white/[0.06]">
<div className="h-full rounded-full transition-all"
style={{
width: `${Math.min((sessions.length / settings.maxSessions) * 100, 100)}%`,
background: theme.accent,
}} />
</div>
</div>
<div className="p-5 rounded-2xl border space-y-3"
style={{ background: "rgba(255,255,255,0.02)", borderColor: theme.border }}>
<p className="text-[13px] font-medium text-white/70">Storage Keys</p>
{["nail_profile", "nail_settings", "nail_sessions"].map((key) => {
const raw = typeof window !== "undefined" ? localStorage.getItem(key) : null;
const size = raw ? new Blob([raw]).size : 0;
return (
<div key={key} className="flex justify-between text-[12px]">
<span className="text-white/25 font-mono">{key}</span>
<span className="text-white/40">
{size > 1024 ? `${(size / 1024).toFixed(1)} KB` : `${size} B`}
</span>
</div>
);
})}
</div>
<button onClick={onClearSessions}
className="text-[13px] text-red-400/40 hover:text-red-400 transition-colors">
Delete all conversation history
</button>
</div>
)}
{/* ── OpenClaw ────────────────────────────────────────────── */}
{settingsSection === "openclaw" && (
<div className="-mx-8 -my-6">
<OpenClawSettings />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
export function LoadingScreen() {
return (
<div className="flex h-screen items-center justify-center bg-[#08080f]">
<div className="flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { AVATARS } from "@/lib/constants";
interface SetupScreenProps {
setupName: string;
setupAvatar: string;
onNameChange: (name: string) => void;
onAvatarChange: (avatar: string) => void;
onSubmit: () => void;
}
export function SetupScreen({ setupName, setupAvatar, onNameChange, onAvatarChange, onSubmit }: SetupScreenProps) {
return (
<div className="flex h-screen items-center justify-center bg-[#08080f] p-6">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-indigo-500/15 border border-indigo-500/20
flex items-center justify-center text-3xl mx-auto mb-5 shadow-xl shadow-indigo-500/10">
🔨
</div>
<h1 className="text-2xl font-bold text-white tracking-tight">Welcome to Nail</h1>
<p className="text-sm text-white/30 mt-2">Set up your local profile to get started</p>
</div>
<div className="space-y-5 bg-white/[0.03] border border-white/[0.07] rounded-2xl p-6">
{/* Avatar picker */}
<div>
<label className="text-xs font-semibold text-white/30 uppercase tracking-widest block mb-2.5">
Avatar
</label>
<div className="flex flex-wrap gap-2">
{AVATARS.map((av) => (
<button key={av} onClick={() => onAvatarChange(av)}
className={`w-9 h-9 rounded-xl text-lg transition-all
${setupAvatar === av
? "bg-indigo-500/25 ring-1 ring-indigo-400/50 scale-110"
: "bg-white/[0.04] hover:bg-white/[0.08]"}`}>
{av}
</button>
))}
</div>
</div>
{/* Name */}
<div>
<label className="text-xs font-semibold text-white/30 uppercase tracking-widest block mb-2">
Your Name
</label>
<input
type="text" value={setupName}
onChange={(e) => onNameChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSubmit()}
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"
/>
</div>
<button onClick={onSubmit} disabled={!setupName.trim()}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-25
py-3 rounded-xl text-sm font-semibold transition-all active:scale-[0.98]
shadow-lg shadow-indigo-500/20">
Get Started
</button>
<p className="text-[11px] text-white/20 text-center leading-relaxed">
Your profile and conversations are stored locally in your browser.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
interface SettingRowProps {
label: string;
sub?: string;
children: React.ReactNode;
}
export function SettingRow({ label, sub, children }: SettingRowProps) {
return (
<div className="flex items-center justify-between gap-4 py-3.5 border-b border-white/[0.05] last:border-0">
<div className="min-w-0">
<p className="text-[13px] font-medium text-white/75">{label}</p>
{sub && <p className="text-[11px] text-white/25 mt-0.5 leading-relaxed">{sub}</p>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
interface SliderRowProps {
label: string;
sub?: string;
value: number;
min: number;
max: number;
step: number;
display: string;
onChange: (v: number) => void;
}
export function SliderRow({ label, sub, value, min, max, step, display, onChange }: SliderRowProps) {
return (
<div className="py-3.5 border-b border-white/[0.05] last:border-0">
<div className="flex justify-between mb-2">
<div>
<p className="text-[13px] font-medium text-white/75">{label}</p>
{sub && <p className="text-[11px] text-white/25 mt-0.5">{sub}</p>}
</div>
<span className="text-[12px] text-indigo-300 font-mono">{display}</span>
</div>
<input
type="range"
min={min} max={max} step={step} value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 rounded-full appearance-none cursor-pointer accent-indigo-500 bg-white/[0.08]"
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
interface ToggleProps {
on: boolean;
onToggle: () => void;
}
export function Toggle({ on, onToggle }: ToggleProps) {
return (
<button
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200
${on ? "bg-indigo-500" : "bg-white/10"}`}
>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow
transition-transform duration-200 ${on ? "translate-x-4" : "translate-x-0.5"}`} />
</button>
);
}

230
src/hooks/useAppState.ts Normal file
View File

@@ -0,0 +1,230 @@
// ─── useAppState ──────────────────────────────────────────────────────────────
// Central state hook. Owns all session/settings/auth state and the handlers
// that mutate it. Extracted from page.tsx so the component tree stays thin.
import { useState, 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 { S } from "@/lib/storage";
import { DEFAULT_SETTINGS, AVATARS, THEMES } from "@/lib/constants";
import type { AppSettings, ChatSession, UserProfile, Tab } from "@/types";
export type SettingsSection = "voice" | "model" | "ui" | "behaviour" | "account" | "storage" | "openclaw";
export function useAppState() {
// ── Auth ──────────────────────────────────────────────────────────────────
const [profile, setProfile] = useState<UserProfile | null>(null);
const [authStep, setAuthStep] = useState<"loading" | "setup" | "app">("loading");
const [setupName, setSetupName] = useState("");
const [setupAvatar, setSetupAvatar] = useState(AVATARS[0]);
// ── Settings ──────────────────────────────────────────────────────────────
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const updateSettings = useCallback(<K extends keyof AppSettings>(key: K, val: AppSettings[K]) => {
setSettings((prev) => {
const next = { ...prev, [key]: val };
S.set("nail_settings", next);
return next;
});
}, []);
// ── Sessions ──────────────────────────────────────────────────────────────
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [openSessionIds, setOpenSessionIds] = useState<string[]>([]);
const [viewingHistory, setViewingHistory] = useState<ChatSession | null>(null);
// ── UI ────────────────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<Tab>("chat");
const [sidebarOpen, setSidebarOpen] = useState(true);
const [liveMode, setLiveMode] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [textInput, setTextInput] = useState("");
const [settingsSection, setSettingsSection] = useState<SettingsSection>("voice");
// ── External hooks ────────────────────────────────────────────────────────
const { messages, isLoading, sendMessage } = useChat();
const { status: whisperStatus, transcribe } = useWhisper();
const { isRecording, startRecording, stopRecording } = useVoiceRecorder();
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]);
// ── Bootstrap ─────────────────────────────────────────────────────────────
useEffect(() => {
const p = S.get<UserProfile | null>("nail_profile", null);
const s = S.get<AppSettings>("nail_settings", DEFAULT_SETTINGS);
const sv = S.get<ChatSession[]>("nail_sessions", []);
setSettings({ ...DEFAULT_SETTINGS, ...s });
setSessions(sv);
if (p) { setProfile(p); setAuthStep("app"); }
else setAuthStep("setup");
}, []);
// ── 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;
});
}, []);
const clearAllSessions = useCallback(() => {
S.set("nail_sessions", []);
setSessions([]); setOpenSessionIds([]); setActiveSessionId(null);
}, []);
// ── Live mode ─────────────────────────────────────────────────────────────
const handleLiveToggle = () => {
if (!liveMode) { setLiveMode(true); startLive(); }
else { setLiveMode(false); stopLive(); setIsSpeaking(false); }
};
// ── Text input ────────────────────────────────────────────────────────────
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();
}
};
// ── PTT ───────────────────────────────────────────────────────────────────
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");
};
// ── Setup ─────────────────────────────────────────────────────────────────
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 resetProfile = () => {
S.set("nail_profile", null);
setProfile(null); setAuthStep("setup");
setSetupName(""); setSetupAvatar(AVATARS[0]);
};
// ── Derived ───────────────────────────────────────────────────────────────
const sortedSessions = useMemo(() =>
[...sessions].sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return b.updatedAt - a.updatedAt;
}), [sessions]);
const theme = THEMES[settings.theme];
const pttDisabled = whisperStatus !== "ready" || isLoading || liveMode || !activeSessionId;
return {
// Auth
profile, setProfile, authStep, setupName, setSetupName,
setupAvatar, setSetupAvatar, handleSetupSubmit, resetProfile,
// Settings
settings, updateSettings, settingsSection, setSettingsSection,
// Sessions
sessions, setSessions, activeSessionId, setActiveSessionId,
openSessionIds, setOpenSessionIds, viewingHistory, setViewingHistory,
createSession, openSession, closeSession, deleteSession, togglePin, clearAllSessions,
sortedSessions,
// UI
activeTab, setActiveTab, sidebarOpen, setSidebarOpen,
liveMode, isSpeaking, handleLiveToggle,
textInput, setTextInput, handleTextSubmit, handleKeyDown,
// Voice
messages, isLoading, sendMessage,
whisperStatus, isRecording, handlePTTDown, handlePTTUp,
// Derived
theme, pttDisabled,
};
}

27
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { AppSettings, Theme } from "@/types";
export 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",
};
export const AVATARS = ["🧑", "👩", "🧔", "👨‍💻", "👩‍💻", "🧑‍🎤", "🤖", "🦊", "🐺", "🦁"];
export const THEMES: Record<Theme, { bg: string; surface: string; border: string; accent: string }> = {
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" },
};

11
src/lib/storage.ts Normal file
View File

@@ -0,0 +1,11 @@
export const S = {
get<T>(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));
},
};

47
src/types/index.ts Normal file
View File

@@ -0,0 +1,47 @@
export type Tab = "chat" | "history" | "settings";
export type FontSize = "base" | "lg" | "xl";
export type Density = "compact" | "comfortable" | "spacious";
export type Theme = "midnight" | "slate" | "obsidian";
export interface StoredMessage {
id: string;
role: "user" | "assistant";
content: string;
source?: "voice" | "text";
timestamp: number;
}
export interface ChatSession {
id: string;
title: string;
createdAt: number;
updatedAt: number;
pinned: boolean;
preview: string;
messages: StoredMessage[];
}
export interface UserProfile {
id: string;
name: string;
avatar: string;
createdAt: number;
}
export 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;
}