cleaned up code base
This commit is contained in:
@@ -5,6 +5,13 @@ const nextConfig: NextConfig = {
|
|||||||
turbopack: {
|
turbopack: {
|
||||||
// Turbopack handles WASM and workers natively — no extra rules needed
|
// 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() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -12,7 +12,8 @@
|
|||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"openai": "^6.33.0",
|
"openai": "^6.33.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@types/node": "^20.19.37",
|
"@types/node": "^20.19.37",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.1",
|
"eslint-config-next": "16.2.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
@@ -1680,6 +1682,16 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.57.2",
|
"version": "8.57.2",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
|
||||||
@@ -6927,6 +6939,27 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"openai": "^6.33.0",
|
"openai": "^6.33.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"@types/node": "^20.19.37",
|
"@types/node": "^20.19.37",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.1",
|
"eslint-config-next": "16.2.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
@@ -2,30 +2,51 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { messages } = await req.json();
|
try {
|
||||||
|
const { messages } = await req.json();
|
||||||
|
|
||||||
const res = await fetch(
|
const base = process.env.OPENCLAW_BASE_URL ?? "http://127.0.0.1:18789";
|
||||||
`${process.env.OPENCLAW_BASE_URL}/v1/chat/completions`,
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: `openclaw:${process.env.OPENCLAW_AGENT_ID}`,
|
model,
|
||||||
messages,
|
|
||||||
stream: true,
|
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(res.body, {
|
||||||
return new Response(`OpenClaw error: ${res.status}`, { status: 502 });
|
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
1301
src/app/page.tsx
1301
src/app/page.tsx
File diff suppressed because it is too large
Load Diff
279
src/components/chat/ChatView.tsx
Normal file
279
src/components/chat/ChatView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/history/HistoryView.tsx
Normal file
135
src/components/history/HistoryView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/layout/Header.tsx
Normal file
87
src/components/layout/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/components/layout/Sidebar.tsx
Normal file
145
src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
src/components/settings/SettingsView.tsx
Normal file
293
src/components/settings/SettingsView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/setup/LoadingScreen.tsx
Normal file
15
src/components/setup/LoadingScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/setup/SetupScreen.tsx
Normal file
73
src/components/setup/SetupScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/components/ui/SettingRow.tsx
Normal file
17
src/components/ui/SettingRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/ui/SliderRow.tsx
Normal file
30
src/components/ui/SliderRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/components/ui/Toggle.tsx
Normal file
17
src/components/ui/Toggle.tsx
Normal 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
230
src/hooks/useAppState.ts
Normal 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
27
src/lib/constants.ts
Normal 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
11
src/lib/storage.ts
Normal 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
47
src/types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user