|
|
import * as React from "react"; |
|
|
import { cn } from "@/lib/utils"; |
|
|
import { Button } from "@/components/ui/button"; |
|
|
import { Textarea } from "@/components/ui/textarea"; |
|
|
import { Send, Square, User, Bot } from "lucide-react"; |
|
|
import ReactMarkdown from "react-markdown"; |
|
|
|
|
|
import { AssistantInfo } from "@/types/chat"; |
|
|
|
|
|
export interface ChatProps extends React.HTMLAttributes<HTMLDivElement> { |
|
|
messages: Array<{ |
|
|
id: string; |
|
|
role: "user" | "assistant" | "system"; |
|
|
content: string; |
|
|
createdAt?: Date; |
|
|
assistantInfo?: AssistantInfo; |
|
|
}>; |
|
|
input: string; |
|
|
handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; |
|
|
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void; |
|
|
isGenerating?: boolean; |
|
|
stop?: () => void; |
|
|
} |
|
|
|
|
|
const Chat = React.forwardRef<HTMLDivElement, ChatProps>( |
|
|
( |
|
|
{ |
|
|
className, |
|
|
messages, |
|
|
input, |
|
|
handleInputChange, |
|
|
handleSubmit, |
|
|
isGenerating, |
|
|
stop, |
|
|
...props |
|
|
}, |
|
|
ref |
|
|
) => { |
|
|
const messagesEndRef = React.useRef<HTMLDivElement>(null); |
|
|
const messagesContainerRef = React.useRef<HTMLDivElement>(null); |
|
|
|
|
|
const scrollToBottom = () => { |
|
|
if (messagesContainerRef.current) { |
|
|
const element = messagesContainerRef.current; |
|
|
element.scrollTop = element.scrollHeight; |
|
|
} |
|
|
}; |
|
|
|
|
|
React.useEffect(() => { |
|
|
console.log( |
|
|
"Chat component - messages updated:", |
|
|
messages.length, |
|
|
messages.map((m) => ({ |
|
|
id: m.id, |
|
|
role: m.role, |
|
|
content: m.content.slice(0, 50) + "...", |
|
|
})) |
|
|
); |
|
|
|
|
|
const timeoutId = setTimeout(() => { |
|
|
scrollToBottom(); |
|
|
}, 50); |
|
|
|
|
|
return () => clearTimeout(timeoutId); |
|
|
}, [messages]); |
|
|
|
|
|
return ( |
|
|
<div |
|
|
className={cn("flex flex-col h-full max-h-screen", className)} |
|
|
ref={ref} |
|
|
{...props} |
|
|
> |
|
|
{/* Messages */} |
|
|
<div |
|
|
ref={messagesContainerRef} |
|
|
className="flex-1 overflow-y-auto p-4 space-y-4 max-h-96 min-h-0" |
|
|
> |
|
|
{messages.length === 0 ? ( |
|
|
<div className="flex items-center justify-center h-full text-muted-foreground"> |
|
|
<p>No messages yet. Start a conversation!</p> |
|
|
</div> |
|
|
) : ( |
|
|
messages.map((message, index) => ( |
|
|
<div |
|
|
key={`${message.id}-${index}`} |
|
|
className={cn( |
|
|
"flex gap-3 w-full items-start", |
|
|
message.role === "user" ? "justify-end" : "justify-start" |
|
|
)} |
|
|
> |
|
|
{/* Avatar for assistant */} |
|
|
{message.role !== "user" && ( |
|
|
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center flex-shrink-0 mt-1"> |
|
|
<Bot className="h-4 w-4 text-primary-foreground" /> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Message content */} |
|
|
<div |
|
|
className={cn( |
|
|
"max-w-[75%] min-w-0 flex flex-col gap-2 rounded-lg px-3 py-2 text-sm break-words", |
|
|
message.role === "user" |
|
|
? "bg-primary text-primary-foreground" |
|
|
: "bg-muted" |
|
|
)} |
|
|
> |
|
|
<div className="text-xs opacity-70 flex items-center gap-2"> |
|
|
<span> |
|
|
{message.role === "user" ? ( |
|
|
"You" |
|
|
) : message.assistantInfo ? ( |
|
|
<> |
|
|
<span className="font-medium"> |
|
|
{message.assistantInfo.name} |
|
|
</span> |
|
|
{message.assistantInfo.type !== "default" && ( |
|
|
<span |
|
|
className={cn( |
|
|
"inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ml-1", |
|
|
message.assistantInfo.type === "user" && |
|
|
"bg-blue-100 text-blue-700", |
|
|
message.assistantInfo.type === "template" && |
|
|
"bg-gray-100 text-gray-700", |
|
|
message.assistantInfo.type === "new" && |
|
|
"bg-green-100 text-green-700" |
|
|
)} |
|
|
> |
|
|
{message.assistantInfo.type === "user" |
|
|
? "Mine" |
|
|
: message.assistantInfo.type === "template" |
|
|
? "Template" |
|
|
: "New"} |
|
|
</span> |
|
|
)} |
|
|
{message.assistantInfo.originalTemplate && ( |
|
|
<span className="text-[10px] text-muted-foreground"> |
|
|
(from {message.assistantInfo.originalTemplate}) |
|
|
</span> |
|
|
)} |
|
|
</> |
|
|
) : ( |
|
|
"Assistant" |
|
|
)} |
|
|
</span> |
|
|
<span>•</span> |
|
|
<span>#{index + 1}</span> |
|
|
</div> |
|
|
<div className="leading-relaxed prose prose-sm dark:prose-invert max-w-none overflow-hidden"> |
|
|
<ReactMarkdown |
|
|
components={{ |
|
|
// Customize components for better styling |
|
|
p: ({ children }) => ( |
|
|
<p className="mb-2 last:mb-0 break-words"> |
|
|
{children} |
|
|
</p> |
|
|
), |
|
|
ul: ({ children }) => ( |
|
|
<ul className="mb-2 last:mb-0 list-disc pl-4 break-words"> |
|
|
{children} |
|
|
</ul> |
|
|
), |
|
|
ol: ({ children }) => ( |
|
|
<ol className="mb-2 last:mb-0 list-decimal pl-4 break-words"> |
|
|
{children} |
|
|
</ol> |
|
|
), |
|
|
li: ({ children }) => ( |
|
|
<li className="mb-1 break-words">{children}</li> |
|
|
), |
|
|
code: ({ children, className }) => { |
|
|
const isInline = !className; |
|
|
return isInline ? ( |
|
|
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono break-words"> |
|
|
{children} |
|
|
</code> |
|
|
) : ( |
|
|
<code className="block bg-muted p-2 rounded text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all"> |
|
|
{children} |
|
|
</code> |
|
|
); |
|
|
}, |
|
|
pre: ({ children }) => ( |
|
|
<div className="mb-2 last:mb-0 overflow-hidden"> |
|
|
{children} |
|
|
</div> |
|
|
), |
|
|
strong: ({ children }) => ( |
|
|
<strong className="font-semibold">{children}</strong> |
|
|
), |
|
|
em: ({ children }) => ( |
|
|
<em className="italic">{children}</em> |
|
|
), |
|
|
blockquote: ({ children }) => ( |
|
|
<blockquote className="border-l-4 border-muted pl-4 italic mb-2 last:mb-0"> |
|
|
{children} |
|
|
</blockquote> |
|
|
), |
|
|
h1: ({ children }) => ( |
|
|
<h1 className="text-lg font-bold mb-2 last:mb-0"> |
|
|
{children} |
|
|
</h1> |
|
|
), |
|
|
h2: ({ children }) => ( |
|
|
<h2 className="text-base font-bold mb-2 last:mb-0"> |
|
|
{children} |
|
|
</h2> |
|
|
), |
|
|
h3: ({ children }) => ( |
|
|
<h3 className="text-sm font-bold mb-2 last:mb-0"> |
|
|
{children} |
|
|
</h3> |
|
|
), |
|
|
}} |
|
|
> |
|
|
{message.content} |
|
|
</ReactMarkdown> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Avatar for user */} |
|
|
{message.role === "user" && ( |
|
|
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-1"> |
|
|
<User className="h-4 w-4" /> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
)) |
|
|
)} |
|
|
<div ref={messagesEndRef} /> |
|
|
</div> |
|
|
|
|
|
{} |
|
|
<div className="border-t p-4"> |
|
|
<form onSubmit={handleSubmit} className="flex gap-2"> |
|
|
<Textarea |
|
|
value={input} |
|
|
onChange={handleInputChange} |
|
|
placeholder="Type your message..." |
|
|
className="min-h-[60px] resize-none" |
|
|
onKeyDown={(e) => { |
|
|
if (e.key === "Enter" && !e.shiftKey) { |
|
|
e.preventDefault(); |
|
|
handleSubmit(e as any); |
|
|
} |
|
|
}} |
|
|
/> |
|
|
{isGenerating ? ( |
|
|
<Button |
|
|
type="button" |
|
|
onClick={stop} |
|
|
variant="outline" |
|
|
size="icon" |
|
|
> |
|
|
<Square className="h-4 w-4" /> |
|
|
</Button> |
|
|
) : ( |
|
|
<Button type="submit" disabled={!input.trim()} size="icon"> |
|
|
<Send className="h-4 w-4" /> |
|
|
</Button> |
|
|
)} |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
); |
|
|
Chat.displayName = "Chat"; |
|
|
|
|
|
export { Chat }; |
|
|
|