moxieTalking/src/components/echo/chat-header.tsx
Z User e4408a63e6 feat: add Echo voice assistant web UI
- Conversation sidebar with create/delete/history
- Chat area with streaming LLM responses (z-ai-web-dev-sdk)
- Voice input via Web Speech API with recording indicator
- Browser TTS auto-speak for assistant responses
- Settings panel (voice, TTS, sidebar toggle)
- Prisma schema: Conversation + Message models
- API routes: /api/chat/stream, /api/conversations, /api/messages
- Zustand store for state management
- Web Speech API type declarations
2026-03-31 00:42:10 +00:00

101 lines
2.7 KiB
TypeScript

"use client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useEchoStore } from "@/stores/echo-store";
import { Settings2, Mic, Volume2 } from "lucide-react";
import { cn } from "@/lib/utils";
export function ChatHeader() {
const {
activeConversationId,
conversations,
isRecording,
isSpeaking,
isGenerating,
setSettingsOpen,
setSidebarOpen,
sidebarOpen,
} = useEchoStore();
const activeConv = conversations.find(
(c) => c.id === activeConversationId
);
return (
<header className="h-14 border-b border-border bg-card/80 backdrop-blur-sm flex items-center justify-between px-4">
<div className="flex items-center gap-3 min-w-0">
{!sidebarOpen && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSidebarOpen(true)}
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</Button>
)}
<div className="min-w-0">
<h1 className="text-sm font-semibold truncate">
{activeConv ? activeConv.title : "Echo"}
</h1>
{activeConversationId && (
<p className="text-xs text-muted-foreground truncate">
{isGenerating
? "Thinking..."
: isRecording
? "Listening..."
: isSpeaking
? "Speaking..."
: "Ready"}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
{/* Status indicators */}
<Badge
variant={isRecording ? "destructive" : "secondary"}
className={cn(
"text-xs gap-1 mr-1",
isRecording && "animate-pulse"
)}
>
<Mic className="h-3 w-3" />
{isRecording ? "Recording" : "Mic"}
</Badge>
<Badge
variant={isSpeaking ? "default" : "secondary"}
className="text-xs gap-1 mr-2"
>
<Volume2 className="h-3 w-3" />
{isSpeaking ? "Speaking" : "TTS"}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setSettingsOpen(true)}
>
<Settings2 className="h-4 w-4" />
</Button>
</div>
</header>
);
}