cleaned up code base
This commit is contained in:
@@ -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
35
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,30 +2,51 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { messages } = await req.json();
|
||||
try {
|
||||
const { messages } = await req.json();
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.OPENCLAW_BASE_URL}/v1/chat/completions`,
|
||||
{
|
||||
const base = process.env.OPENCLAW_BASE_URL ?? "http://127.0.0.1:18789";
|
||||
const model = process.env.OPENCLAW_AGENT_ID
|
||||
? `openclaw/${process.env.OPENCLAW_AGENT_ID}`
|
||||
: "openclaw/default";
|
||||
|
||||
const res = await fetch(`${base}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: `openclaw:${process.env.OPENCLAW_AGENT_ID}`,
|
||||
messages,
|
||||
model,
|
||||
stream: true,
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("OpenClaw upstream failed", {
|
||||
status: res.status,
|
||||
body: text,
|
||||
base,
|
||||
model,
|
||||
});
|
||||
|
||||
return new Response(`OpenClaw error: ${res.status} ${text}`, {
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
return new Response(`OpenClaw error: ${res.status}`, { status: 502 });
|
||||
return new Response(res.body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return new Response(
|
||||
`Route error: ${err instanceof Error ? err.message : "unknown error"}`,
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(res.body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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