mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-11-03 03:18:52 +00:00
14 lines
17 KiB
JSON
14 lines
17 KiB
JSON
{
|
||
"name": "ai-chat",
|
||
"description": "Complete chat interface with streaming support",
|
||
"dependencies": [
|
||
"@hanzo/ui"
|
||
],
|
||
"files": [
|
||
{
|
||
"name": "ai-chat.tsx",
|
||
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Grid } from \"@hanzo/ui/grid\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { format } from \"date-fns\"\nimport {\n Bot,\n Copy,\n MoreHorizontal,\n Paperclip,\n RotateCcw,\n Send,\n User,\n} from \"lucide-react\"\nimport { toast } from \"sonner\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n Avatar,\n AvatarFallback,\n AvatarImage,\n} from \"@/registry/default/ui/avatar\"\nimport { Badge } from \"@/registry/default/ui/badge\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/registry/default/ui/dropdown-menu\"\nimport { ScrollArea } from \"@/registry/default/ui/scroll-area\"\nimport { Separator } from \"@/registry/default/ui/separator\"\nimport { Textarea } from \"@/registry/default/ui/textarea\"\n\n// Types\nexport interface ChatMessage {\n id: string\n content: string\n role: \"user\" | \"assistant\" | \"system\"\n timestamp: Date\n attachments?: ChatAttachment[]\n isGenerating?: boolean\n}\n\nexport interface ChatAttachment {\n id: string\n name: string\n size: number\n type: string\n url?: string\n file?: File\n}\n\nexport interface AIChatProps extends React.HTMLAttributes<HTMLDivElement> {\n messages?: ChatMessage[]\n onSendMessage?: (content: string, attachments?: ChatAttachment[]) => void\n onRegenerateMessage?: (messageId: string) => void\n onCopyMessage?: (content: string) => void\n isGenerating?: boolean\n placeholder?: string\n maxLength?: number\n disabled?: boolean\n showTimestamps?: boolean\n showAvatars?: boolean\n userAvatar?: string\n assistantAvatar?: string\n userName?: string\n assistantName?: string\n allowAttachments?: boolean\n maxAttachments?: number\n maxAttachmentSize?: number\n acceptedFileTypes?: string[]\n}\n\n// Message variants\nconst messageVariants = cva(\"group relative rounded-lg p-4 transition-colors\", {\n variants: {\n role: {\n user: \"ml-8 bg-primary text-primary-foreground\",\n assistant: \"mr-8 bg-muted\",\n system: \"mx-4 bg-accent text-accent-foreground text-sm\",\n },\n },\n defaultVariants: {\n role: \"assistant\",\n },\n})\n\n// Typing indicator component\nconst TypingIndicator = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn(\"flex items-center gap-1 text-muted-foreground\", className)}\n {...props}\n >\n <div className=\"flex gap-1\">\n <div className=\"h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]\" />\n <div className=\"h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]\" />\n <div className=\"h-2 w-2 animate-bounce rounded-full bg-current\" />\n </div>\n <span className=\"text-sm\">AI is typing...</span>\n </div>\n))\nTypingIndicator.displayName = \"TypingIndicator\"\n\n// Message component\nconst ChatMessageComponent = React.forwardRef<\n HTMLDivElement,\n {\n message: ChatMessage\n onCopy?: (content: string) => void | Promise<void>\n onRegenerate?: (messageId: string) => void\n showTimestamp?: boolean\n showAvatar?: boolean\n userAvatar?: string\n assistantAvatar?: string\n userName?: string\n assistantName?: string\n } & Omit<React.HTMLAttributes<HTMLDivElement>, \"onCopy\">\n>(\n (\n {\n message,\n onCopy,\n onRegenerate,\n showTimestamp = true,\n showAvatar = true,\n userAvatar,\n assistantAvatar,\n userName,\n assistantName,\n className,\n ...props\n },\n ref\n ) => {\n const [isCopied, setIsCopied] = React.useState(false)\n\n const handleCopy = async () => {\n if (onCopy) {\n onCopy(message.content)\n setIsCopied(true)\n setTimeout(() => setIsCopied(false), 2000)\n }\n }\n\n const handleRegenerate = () => {\n if (onRegenerate && message.role === \"assistant\") {\n onRegenerate(message.id)\n }\n }\n\n const formatContent = (content: string) => {\n // Basic markdown rendering - in a real implementation, you'd use a proper markdown library\n return content\n .replace(/\\*\\*(.*?)\\*\\*/g, \"<strong>$1</strong>\")\n .replace(/\\*(.*?)\\*/g, \"<em>$1</em>\")\n .replace(/`(.*?)`/g, '<code class=\"bg-muted px-1 rounded\">$1</code>')\n .replace(\n /```([\\s\\S]*?)```/g,\n '<pre class=\"bg-muted p-3 rounded-lg overflow-x-auto\"><code>$1</code></pre>'\n )\n }\n\n const avatar = showAvatar && message.role !== \"system\"\n\n return (\n <Grid\n ref={ref}\n columns={avatar ? \"auto minmax(0, 1fr)\" : 1}\n gap={12}\n className={cn(messageVariants({ role: message.role }), className)}\n {...props}\n >\n {avatar && (\n <Avatar className=\"h-8 w-8\">\n <AvatarImage\n src={message.role === \"user\" ? userAvatar : assistantAvatar}\n alt={message.role === \"user\" ? userName : assistantName}\n />\n <AvatarFallback>\n {message.role === \"user\" ? (\n <User className=\"h-4 w-4\" />\n ) : (\n <Bot className=\"h-4 w-4\" />\n )}\n </AvatarFallback>\n </Avatar>\n )}\n\n <Grid columns={1} gap={8}>\n <div className=\"flex items-center justify-between\">\n <div className=\"flex items-center gap-2\">\n {showTimestamp && (\n <time className=\"text-xs text-muted-foreground\">\n {format(message.timestamp, \"HH:mm\")}\n </time>\n )}\n {message.role !== \"system\" && (\n <span className=\"text-xs font-medium\">\n {message.role === \"user\"\n ? userName || \"You\"\n : assistantName || \"AI Assistant\"}\n </span>\n )}\n </div>\n\n {message.role !== \"system\" && (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100\"\n >\n <MoreHorizontal className=\"h-3 w-3\" />\n <span className=\"sr-only\">Message actions</span>\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n <DropdownMenuItem onClick={handleCopy}>\n <Copy className=\"mr-2 h-3 w-3\" />\n {isCopied ? \"Copied!\" : \"Copy\"}\n </DropdownMenuItem>\n {message.role === \"assistant\" && onRegenerate && (\n <DropdownMenuItem onClick={handleRegenerate}>\n <RotateCcw className=\"mr-2 h-3 w-3\" />\n Regenerate\n </DropdownMenuItem>\n )}\n </DropdownMenuContent>\n </DropdownMenu>\n )}\n </div>\n\n <div\n className=\"prose prose-sm max-w-none dark:prose-invert\"\n dangerouslySetInnerHTML={{ __html: formatContent(message.content) }}\n />\n\n {message.attachments && message.attachments.length > 0 && (\n <div className=\"flex flex-wrap gap-2\">\n {message.attachments.map((attachment) => (\n <Badge\n key={attachment.id}\n variant=\"secondary\"\n className=\"gap-1\"\n >\n <Paperclip className=\"h-3 w-3\" />\n {attachment.name}\n </Badge>\n ))}\n </div>\n )}\n\n {message.isGenerating && <TypingIndicator className=\"mt-2\" />}\n </Grid>\n </Grid>\n )\n }\n)\nChatMessageComponent.displayName = \"ChatMessage\"\n\n// Attachment preview component\nconst AttachmentPreview = React.forwardRef<\n HTMLDivElement,\n {\n attachment: ChatAttachment\n onRemove?: (id: string) => void\n } & React.HTMLAttributes<HTMLDivElement>\n>(({ attachment, onRemove, className, ...props }, ref) => (\n <Grid\n ref={ref}\n columns={onRemove ? \"auto minmax(0, 1fr) auto\" : \"auto minmax(0, 1fr)\"}\n gap={8}\n className={cn(\"rounded-lg border p-2 text-sm\", className)}\n style={{ alignItems: \"center\" }}\n {...props}\n >\n <Paperclip className=\"h-4 w-4\" />\n <div className=\"truncate\">\n <div className=\"font-medium truncate\">{attachment.name}</div>\n <div className=\"text-xs text-muted-foreground\">\n {(attachment.size / 1024).toFixed(1)} KB\n </div>\n </div>\n {onRemove && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-6 w-6\"\n onClick={() => onRemove(attachment.id)}\n >\n <span className=\"sr-only\">Remove attachment</span>×\n </Button>\n )}\n </Grid>\n))\nAttachmentPreview.displayName = \"AttachmentPreview\"\n\n// Main AI Chat component\nconst AIChat = React.forwardRef<HTMLDivElement, AIChatProps>(\n (\n {\n messages = [],\n onSendMessage,\n onRegenerateMessage,\n onCopyMessage,\n isGenerating = false,\n placeholder = \"Type your message...\",\n maxLength = 4000,\n disabled = false,\n showTimestamps = true,\n showAvatars = true,\n userAvatar,\n assistantAvatar,\n userName,\n assistantName,\n allowAttachments = true,\n maxAttachments = 5,\n maxAttachmentSize = 10 * 1024 * 1024, // 10MB\n acceptedFileTypes = [\"image/*\", \".pdf\", \".txt\", \".doc\", \".docx\"],\n className,\n ...props\n },\n ref\n ) => {\n const [input, setInput] = React.useState(\"\")\n const [attachments, setAttachments] = React.useState<ChatAttachment[]>([])\n const scrollAreaRef = React.useRef<HTMLDivElement>(null)\n const textareaRef = React.useRef<HTMLTextAreaElement>(null)\n const fileInputRef = React.useRef<HTMLInputElement>(null)\n\n // Auto-scroll to bottom when new messages arrive\n React.useEffect(() => {\n if (scrollAreaRef.current) {\n const scrollElement = scrollAreaRef.current.querySelector(\n \"[data-radix-scroll-area-viewport]\"\n )\n if (scrollElement) {\n scrollElement.scrollTop = scrollElement.scrollHeight\n }\n }\n }, [messages])\n\n // Handle file selection\n const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {\n const files = event.target.files\n if (!files || !allowAttachments) return\n\n const newAttachments: ChatAttachment[] = []\n\n for (let i = 0; i < files.length; i++) {\n const file = files[i]\n\n if (attachments.length + newAttachments.length >= maxAttachments) {\n break\n }\n\n if (file.size > maxAttachmentSize) {\n continue\n }\n\n newAttachments.push({\n id: `${Date.now()}-${i}`,\n name: file.name,\n size: file.size,\n type: file.type,\n file,\n })\n }\n\n setAttachments((prev) => [...prev, ...newAttachments])\n\n // Reset file input\n if (fileInputRef.current) {\n fileInputRef.current.value = \"\"\n }\n }\n\n // Handle sending message\n const handleSend = () => {\n if (!input.trim() && attachments.length === 0) return\n if (disabled || isGenerating) return\n\n onSendMessage?.(input, attachments.length > 0 ? attachments : undefined)\n setInput(\"\")\n setAttachments([])\n\n // Focus back to textarea\n setTimeout(() => {\n textareaRef.current?.focus()\n }, 0)\n }\n\n // Handle key press\n const handleKeyPress = (event: React.KeyboardEvent) => {\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault()\n handleSend()\n }\n }\n\n // Remove attachment\n const removeAttachment = (id: string) => {\n setAttachments((prev) => prev.filter((att) => att.id !== id))\n }\n\n // Handle copy\n const handleCopy = async (content: string) => {\n try {\n await navigator.clipboard.writeText(content)\n onCopyMessage?.(content)\n toast.success(\"Message copied to clipboard\")\n } catch (err) {\n console.error(\"Failed to copy message:\", err)\n toast.error(\"Failed to copy message\")\n }\n }\n\n return (\n <Grid\n ref={ref}\n columns={1}\n rows=\"minmax(0, 1fr) auto auto\"\n gap={0}\n className={cn(\n \"h-[600px] w-full overflow-hidden rounded-lg border bg-background\",\n className\n )}\n {...props}\n >\n {/* Messages area */}\n <ScrollArea ref={scrollAreaRef} className=\"p-4\">\n <Grid columns={1} gap={16}>\n {messages.map((message) => (\n <ChatMessageComponent\n key={message.id}\n message={message}\n onCopy={handleCopy}\n onRegenerate={onRegenerateMessage}\n showTimestamp={showTimestamps}\n showAvatar={showAvatars}\n userAvatar={userAvatar}\n assistantAvatar={assistantAvatar}\n userName={userName}\n assistantName={assistantName}\n />\n ))}\n {isGenerating && (\n <Grid\n columns={showAvatars ? \"auto minmax(0, 1fr)\" : 1}\n gap={12}\n className=\"mr-8 rounded-lg bg-muted p-4\"\n >\n {showAvatars && (\n <Avatar className=\"h-8 w-8\">\n <AvatarImage src={assistantAvatar} alt={assistantName} />\n <AvatarFallback>\n <Bot className=\"h-4 w-4\" />\n </AvatarFallback>\n </Avatar>\n )}\n <TypingIndicator />\n </Grid>\n )}\n </Grid>\n </ScrollArea>\n\n <Separator />\n\n {/* Input area */}\n <div className=\"p-4\">\n {/* Attachments preview */}\n {attachments.length > 0 && (\n <Grid columns={1} gap={8} className=\"mb-3\">\n {attachments.map((attachment) => (\n <AttachmentPreview\n key={attachment.id}\n attachment={attachment}\n onRemove={removeAttachment}\n />\n ))}\n </Grid>\n )}\n\n <Grid columns=\"minmax(0, 1fr) auto\" gap={8}>\n <Textarea\n ref={textareaRef}\n value={input}\n onChange={(e) => setInput(e.target.value)}\n onKeyPress={handleKeyPress}\n placeholder={placeholder}\n disabled={disabled || isGenerating}\n maxLength={maxLength}\n className=\"min-h-[60px] resize-none\"\n rows={3}\n />\n <Grid columns={1} gap={8} style={{ alignContent: \"start\" }}>\n {allowAttachments && (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n onClick={() => fileInputRef.current?.click()}\n disabled={\n disabled ||\n isGenerating ||\n attachments.length >= maxAttachments\n }\n >\n <Paperclip className=\"h-4 w-4\" />\n <span className=\"sr-only\">Attach file</span>\n </Button>\n )}\n <Button\n onClick={handleSend}\n disabled={\n disabled ||\n isGenerating ||\n (!input.trim() && attachments.length === 0)\n }\n size=\"icon\"\n >\n <Send className=\"h-4 w-4\" />\n <span className=\"sr-only\">Send message</span>\n </Button>\n </Grid>\n </Grid>\n\n {/* Character count */}\n {maxLength && (\n <div className=\"mt-2 text-right text-xs text-muted-foreground\">\n {input.length}/{maxLength}\n </div>\n )}\n </div>\n\n {/* Hidden file input */}\n {allowAttachments && (\n <input\n ref={fileInputRef}\n type=\"file\"\n multiple\n accept={acceptedFileTypes.join(\",\")}\n className=\"hidden\"\n onChange={handleFileSelect}\n />\n )}\n </Grid>\n )\n }\n)\nAIChat.displayName = \"AIChat\"\n\nexport {\n AIChat,\n ChatMessageComponent as ChatMessage,\n TypingIndicator,\n AttachmentPreview,\n type ChatMessage as ChatMessageType,\n}\n"
|
||
}
|
||
],
|
||
"type": "components:ai"
|
||
} |