Browse Source
- Add @ai-sdk/react package for useChat hook - Create floating chat panel with streaming messages - Display messages with role-based styling (user/assistant) - Auto-scroll to bottom on new messages - Show loading indicator during AI responses - Dark theme matching app (neutral-800 bg)handoff-20260429-1057
3 changed files with 176 additions and 4 deletions
@ -0,0 +1,128 @@ |
|||
"use client"; |
|||
|
|||
import { useRef, useEffect, useState } from "react"; |
|||
import { useChat } from "@ai-sdk/react"; |
|||
import { DefaultChatTransport } from "ai"; |
|||
|
|||
interface ChatPanelProps { |
|||
isOpen: boolean; |
|||
onClose: () => void; |
|||
} |
|||
|
|||
export function ChatPanel({ isOpen, onClose }: ChatPanelProps) { |
|||
const messagesEndRef = useRef<HTMLDivElement>(null); |
|||
const [input, setInput] = useState(""); |
|||
|
|||
const { messages, sendMessage, status } = useChat({ |
|||
transport: new DefaultChatTransport({ api: "/api/chat" }), |
|||
}); |
|||
|
|||
const isLoading = status === "streaming" || status === "submitted"; |
|||
|
|||
// Auto-scroll to bottom on new messages
|
|||
useEffect(() => { |
|||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
|||
}, [messages]); |
|||
|
|||
if (!isOpen) return null; |
|||
|
|||
return ( |
|||
<div className="fixed bottom-[220px] right-5 w-[380px] h-[450px] bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl flex flex-col z-40"> |
|||
{/* Header */} |
|||
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-700"> |
|||
<h3 className="text-sm font-medium text-neutral-200">Workflow Assistant</h3> |
|||
<button |
|||
onClick={onClose} |
|||
className="text-neutral-400 hover:text-neutral-200 transition-colors" |
|||
aria-label="Close chat" |
|||
> |
|||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> |
|||
</svg> |
|||
</button> |
|||
</div> |
|||
|
|||
{/* Messages area */} |
|||
<div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0"> |
|||
{messages.length === 0 && ( |
|||
<div className="text-center text-neutral-500 text-sm py-8"> |
|||
<p>Ask me anything about creating workflows!</p> |
|||
<p className="text-xs mt-2">e.g., "How do I create product photos with different backgrounds?"</p> |
|||
</div> |
|||
)} |
|||
|
|||
{messages.map((message) => { |
|||
// Extract text from message parts
|
|||
const textContent = message.parts |
|||
?.filter((part): part is { type: "text"; text: string } => part.type === "text") |
|||
.map((part) => part.text) |
|||
.join("") || ""; |
|||
|
|||
return ( |
|||
<div |
|||
key={message.id} |
|||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`} |
|||
> |
|||
<div |
|||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${ |
|||
message.role === "user" |
|||
? "bg-blue-600 text-white" |
|||
: "bg-neutral-700 text-neutral-200" |
|||
}`}
|
|||
> |
|||
<p className="whitespace-pre-wrap">{textContent}</p> |
|||
</div> |
|||
</div> |
|||
); |
|||
})} |
|||
|
|||
{/* Loading indicator */} |
|||
{isLoading && ( |
|||
<div className="flex justify-start"> |
|||
<div className="bg-neutral-700 rounded-lg px-3 py-2"> |
|||
<div className="flex space-x-1"> |
|||
<div className="w-2 h-2 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} /> |
|||
<div className="w-2 h-2 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} /> |
|||
<div className="w-2 h-2 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
<div ref={messagesEndRef} /> |
|||
</div> |
|||
|
|||
{/* Input area */} |
|||
<form |
|||
onSubmit={(e) => { |
|||
e.preventDefault(); |
|||
if (input.trim() && !isLoading) { |
|||
sendMessage({ text: input }); |
|||
setInput(""); |
|||
} |
|||
}} |
|||
className="p-3 border-t border-neutral-700" |
|||
> |
|||
<div className="flex gap-2"> |
|||
<input |
|||
type="text" |
|||
value={input} |
|||
onChange={(e) => setInput(e.target.value)} |
|||
placeholder="Type a message..." |
|||
className="flex-1 bg-neutral-700 border border-neutral-600 rounded-lg px-3 py-2 text-sm text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-blue-500" |
|||
disabled={isLoading} |
|||
/> |
|||
<button |
|||
type="submit" |
|||
disabled={isLoading || !input.trim()} |
|||
className="px-3 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" |
|||
> |
|||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /> |
|||
</svg> |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
); |
|||
} |
|||
Loading…
Reference in new issue