Files

14 lines
17 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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"
}