diff --git a/examples/prototype/src/ChatbotDemo.tsx b/examples/prototype/src/ChatbotDemo.tsx new file mode 100644 index 000000000..e96dfd075 --- /dev/null +++ b/examples/prototype/src/ChatbotDemo.tsx @@ -0,0 +1,196 @@ +import { SchemaRenderer } from '@object-ui/react'; +import '@object-ui/components'; + +const chatbotSchema = { + type: 'div', + className: 'min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-8', + body: [ + { + type: 'div', + className: 'max-w-4xl mx-auto space-y-8', + body: [ + // Header + { + type: 'div', + className: 'text-center space-y-4', + body: [ + { + type: 'div', + className: 'text-4xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent', + body: { + type: 'text', + content: 'Object UI Chatbot Component' + } + }, + { + type: 'div', + className: 'text-lg text-muted-foreground', + body: { + type: 'text', + content: 'A fully functional, schema-driven chatbot component' + } + } + ] + }, + + // Chatbot Demo Card + { + type: 'card', + className: 'shadow-2xl', + body: [ + { + type: 'div', + className: 'p-6 border-b', + body: { + type: 'div', + className: 'text-xl font-semibold', + body: { + type: 'text', + content: 'Interactive Demo' + } + } + }, + { + type: 'div', + className: 'p-6', + body: { + type: 'chatbot', + messages: [ + { + id: 'welcome', + role: 'assistant', + content: 'Hello! 👋 Welcome to Object UI Chatbot. I\'m here to help you explore this component. Try sending me a message!', + }, + { + id: 'info', + role: 'assistant', + content: 'This chatbot is built entirely from JSON schema. No React components needed!', + } + ], + placeholder: 'Type your message here...', + showTimestamp: true, + userAvatarFallback: 'You', + assistantAvatarFallback: 'AI', + maxHeight: '600px', + autoResponse: true, + autoResponseText: 'Thanks for your message! This is an automated response demonstrating the chatbot functionality. In a real application, you would connect this to your backend API or AI service.', + autoResponseDelay: 1500, + className: 'w-full' + } + } + ] + }, + + // Features Section + { + type: 'div', + className: 'grid md:grid-cols-2 gap-6', + body: [ + { + type: 'card', + className: 'p-6 shadow-lg', + body: [ + { + type: 'div', + className: 'text-lg font-semibold mb-3', + body: { + type: 'text', + content: '✨ Key Features' + } + }, + { + type: 'list', + items: [ + 'Message bubbles with user/assistant roles', + 'Avatar support for participants', + 'Scrollable message history', + 'Input field with send button', + 'Timestamp display (optional)', + 'Auto-response for demos', + 'Fully customizable styling' + ], + className: 'text-sm' + } + ] + }, + { + type: 'card', + className: 'p-6 shadow-lg', + body: [ + { + type: 'div', + className: 'text-lg font-semibold mb-3', + body: { + type: 'text', + content: '🎨 Schema-Driven' + } + }, + { + type: 'div', + className: 'text-sm space-y-2', + body: [ + { + type: 'text', + content: 'This entire chatbot is defined using pure JSON schema. Configure messages, styling, behavior, and more without writing any React code.' + }, + { + type: 'div', + className: 'mt-4 p-3 bg-muted rounded-lg font-mono text-xs', + body: { + type: 'text', + content: '{ type: "chatbot", messages: [...] }' + } + } + ] + } + ] + } + ] + }, + + // Usage Example + { + type: 'card', + className: 'p-6 shadow-lg', + body: [ + { + type: 'div', + className: 'text-lg font-semibold mb-3', + body: { + type: 'text', + content: '📝 Usage Example' + } + }, + { + type: 'div', + className: 'bg-slate-900 text-slate-50 p-4 rounded-lg overflow-x-auto', + body: { + type: 'text', + content: `{ + "type": "chatbot", + "messages": [ + { + "id": "msg-1", + "role": "assistant", + "content": "Hello! How can I help?" + } + ], + "placeholder": "Type your message...", + "showTimestamp": true, + "autoResponse": true, + "className": "w-full" +}` + } + } + ] + } + ] + } + ] +}; + +function ChatbotDemo() { + return ; +} + +export default ChatbotDemo; diff --git a/packages/components/src/renderers/complex/chatbot.test.ts b/packages/components/src/renderers/complex/chatbot.test.ts new file mode 100644 index 000000000..2fe407f8d --- /dev/null +++ b/packages/components/src/renderers/complex/chatbot.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; + +describe('Chatbot Component', () => { + // Import all renderers to register them + beforeAll(async () => { + await import('./index'); + }); + + it('should be registered in ComponentRegistry', () => { + const chatbotRenderer = ComponentRegistry.get('chatbot'); + expect(chatbotRenderer).toBeDefined(); + }); + + it('should have proper metadata', () => { + const config = ComponentRegistry.getConfig('chatbot'); + expect(config).toBeDefined(); + expect(config?.label).toBe('Chatbot'); + expect(config?.inputs).toBeDefined(); + expect(config?.defaultProps).toBeDefined(); + }); + + it('should have expected inputs', () => { + const config = ComponentRegistry.getConfig('chatbot'); + const inputNames = config?.inputs?.map((input: any) => input.name) || []; + + expect(inputNames).toContain('messages'); + expect(inputNames).toContain('placeholder'); + expect(inputNames).toContain('showTimestamp'); + expect(inputNames).toContain('userAvatarUrl'); + expect(inputNames).toContain('assistantAvatarUrl'); + }); + + it('should have sensible default props', () => { + const config = ComponentRegistry.getConfig('chatbot'); + const defaults = config?.defaultProps; + + expect(defaults).toBeDefined(); + expect(defaults?.placeholder).toBe('Type your message...'); + expect(defaults?.showTimestamp).toBe(false); + expect(defaults?.messages).toBeDefined(); + expect(Array.isArray(defaults?.messages)).toBe(true); + }); +}); diff --git a/packages/components/src/renderers/complex/chatbot.tsx b/packages/components/src/renderers/complex/chatbot.tsx new file mode 100644 index 000000000..cab9e847e --- /dev/null +++ b/packages/components/src/renderers/complex/chatbot.tsx @@ -0,0 +1,184 @@ +import { ComponentRegistry } from '@object-ui/core'; +import { Chatbot, type ChatMessage } from '@/ui'; +import { useState } from 'react'; + +/** + * Chatbot component for Object UI + * + * @remarks + * This component supports an optional `onSend` callback in the schema: + * - Signature: `onSend(content: string, messages: ChatMessage[]): void` + * - Parameters: + * - content: The message text that was sent + * - messages: Array of all messages including the newly added message + * - Called when: User sends a message via the input field + * - Use case: Connect to backend API or trigger custom actions on message send + */ +ComponentRegistry.register('chatbot', + ({ schema, className, ...props }) => { + // Initialize messages from schema or use empty array + const [messages, setMessages] = useState( + schema.messages?.map((msg: Partial, idx: number) => ({ + id: msg.id || `msg-${idx}`, + role: msg.role || 'user', + content: msg.content || '', + timestamp: msg.timestamp, + avatar: msg.avatar, + avatarFallback: msg.avatarFallback, + })) || [] + ); + + // Handle sending new messages + const handleSendMessage = (content: string) => { + // Create user message with robust ID generation + const userMessage: ChatMessage = { + id: crypto.randomUUID(), + role: 'user', + content, + timestamp: schema.showTimestamp ? new Date().toLocaleTimeString() : undefined, + }; + + const updatedMessages = [...messages, userMessage]; + setMessages(updatedMessages); + + // If onSend callback is provided in schema, call it with updated messages + if (schema.onSend) { + schema.onSend(content, updatedMessages); + } + + // Auto-response feature for demo purposes + if (schema.autoResponse) { + setTimeout(() => { + const assistantMessage: ChatMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: schema.autoResponseText || 'Thank you for your message!', + timestamp: schema.showTimestamp ? new Date().toLocaleTimeString() : undefined, + }; + setMessages((prev) => [...prev, assistantMessage]); + }, schema.autoResponseDelay || 1000); + } + }; + + return ( + + ); + }, + { + label: 'Chatbot', + inputs: [ + { + name: 'messages', + type: 'array', + label: 'Initial Messages', + description: 'Array of message objects with id, role, content, and optional timestamp' + }, + { + name: 'placeholder', + type: 'string', + label: 'Input Placeholder', + defaultValue: 'Type your message...' + }, + { + name: 'showTimestamp', + type: 'boolean', + label: 'Show Timestamps', + defaultValue: false + }, + { + name: 'disabled', + type: 'boolean', + label: 'Disabled', + defaultValue: false + }, + { + name: 'userAvatarUrl', + type: 'string', + label: 'User Avatar URL', + description: 'URL of the user avatar image' + }, + { + name: 'userAvatarFallback', + type: 'string', + label: 'User Avatar Fallback', + defaultValue: 'You', + description: 'Fallback text shown when user avatar image is not available' + }, + { + name: 'assistantAvatarUrl', + type: 'string', + label: 'Assistant Avatar URL', + description: 'URL of the assistant avatar image' + }, + { + name: 'assistantAvatarFallback', + type: 'string', + label: 'Assistant Avatar Fallback', + defaultValue: 'AI', + description: 'Fallback text shown when assistant avatar image is not available' + }, + { + name: 'maxHeight', + type: 'string', + label: 'Max Height', + defaultValue: '500px' + }, + { + name: 'autoResponse', + type: 'boolean', + label: 'Enable Auto Response (Demo)', + defaultValue: false, + description: 'Automatically send a response after user message (for demo purposes)' + }, + { + name: 'autoResponseText', + type: 'string', + label: 'Auto Response Text', + defaultValue: 'Thank you for your message!' + }, + { + name: 'autoResponseDelay', + type: 'number', + label: 'Auto Response Delay (ms)', + defaultValue: 1000 + }, + { + name: 'className', + type: 'string', + label: 'CSS Class' + } + ], + defaultProps: { + messages: [ + { + id: 'welcome', + role: 'assistant', + content: 'Hello! How can I help you today?', + } + ], + placeholder: 'Type your message...', + showTimestamp: false, + disabled: false, + userAvatarFallback: 'You', + assistantAvatarFallback: 'AI', + maxHeight: '500px', + autoResponse: true, + autoResponseText: 'Thank you for your message! This is an automated response.', + autoResponseDelay: 1000, + className: 'w-full max-w-2xl' + } + } +); diff --git a/packages/components/src/renderers/complex/index.ts b/packages/components/src/renderers/complex/index.ts index c8a3ba307..0fa598c01 100644 --- a/packages/components/src/renderers/complex/index.ts +++ b/packages/components/src/renderers/complex/index.ts @@ -5,6 +5,7 @@ import './filter-builder'; import './scroll-area'; import './resizable'; import './table'; +import './chatbot'; import './data-table'; import './timeline'; diff --git a/packages/components/src/ui/chatbot.tsx b/packages/components/src/ui/chatbot.tsx new file mode 100644 index 000000000..3bd52a7b1 --- /dev/null +++ b/packages/components/src/ui/chatbot.tsx @@ -0,0 +1,241 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { Button } from "./button" +import { Input } from "./input" +import { ScrollArea } from "./scroll-area" +import { Avatar, AvatarFallback, AvatarImage } from "./avatar" +import { Send } from "lucide-react" + +// Message type definition +export interface ChatMessage { + id: string + role: "user" | "assistant" | "system" + content: string + timestamp?: string + avatar?: string + avatarFallback?: string +} + +// Chatbot container props +export interface ChatbotProps extends React.HTMLAttributes { + messages?: ChatMessage[] + placeholder?: string + onSendMessage?: (message: string) => void + disabled?: boolean + showTimestamp?: boolean + userAvatarUrl?: string + userAvatarFallback?: string + assistantAvatarUrl?: string + assistantAvatarFallback?: string + maxHeight?: string +} + +// Chatbot container component +const Chatbot = React.forwardRef( + ( + { + className, + messages = [], + placeholder = "Type your message...", + onSendMessage, + disabled = false, + showTimestamp = false, + userAvatarUrl, + userAvatarFallback = "You", + assistantAvatarUrl, + assistantAvatarFallback = "AI", + maxHeight = "500px", + ...props + }, + ref + ) => { + const [inputValue, setInputValue] = React.useState("") + const scrollRef = React.useRef(null) + const inputRef = React.useRef(null) + + // Auto-scroll to bottom when new messages arrive + React.useEffect(() => { + if (scrollRef.current) { + const scrollElement = scrollRef.current.querySelector('[data-radix-scroll-area-viewport]') + if (scrollElement) { + scrollElement.scrollTop = scrollElement.scrollHeight + } + } + }, [messages]) + + const handleSend = () => { + if (inputValue.trim() && onSendMessage) { + onSendMessage(inputValue.trim()) + setInputValue("") + inputRef.current?.focus() + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + return ( +
+ {/* Messages area */} + +
+ {messages.length === 0 ? ( +
+ No messages yet. Start a conversation! +
+ ) : ( + messages.map((message) => ( + + )) + )} +
+
+ + {/* Input area */} +
+
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + className="flex-1" + /> + +
+
+
+ ) + } +) +Chatbot.displayName = "Chatbot" + +// Individual message component +export interface ChatMessageProps { + message: ChatMessage + showTimestamp?: boolean + userAvatarUrl?: string + userAvatarFallback?: string + assistantAvatarUrl?: string + assistantAvatarFallback?: string +} + +const ChatMessage: React.FC = ({ + message, + showTimestamp, + userAvatarUrl, + userAvatarFallback, + assistantAvatarUrl, + assistantAvatarFallback, +}) => { + const isUser = message.role === "user" + const isSystem = message.role === "system" + + if (isSystem) { + return ( +
+
+ {message.content} +
+
+ ) + } + + const avatar = isUser + ? (message.avatar || userAvatarUrl) + : (message.avatar || assistantAvatarUrl) + + const avatarFallback = isUser + ? (message.avatarFallback || userAvatarFallback) + : (message.avatarFallback || assistantAvatarFallback) + + return ( +
+ + + {avatarFallback} + + +
+
+

{message.content}

+
+ {showTimestamp && message.timestamp && ( + + {message.timestamp} + + )} +
+
+ ) +} + +// Typing indicator component +export interface TypingIndicatorProps extends React.HTMLAttributes { + avatarSrc?: string + avatarFallback?: string +} + +const TypingIndicator = React.forwardRef( + ({ className, avatarSrc, avatarFallback = "AI", ...props }, ref) => { + return ( +
+ + + {avatarFallback} + +
+
+ + + +
+
+
+ ) + } +) +TypingIndicator.displayName = "TypingIndicator" + +export { Chatbot, TypingIndicator } +export type { ChatMessage } diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index 2a71099ad..6c972d179 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -12,6 +12,7 @@ export * from './calendar-view'; export * from './card'; export * from './carousel'; export * from './chart'; +export * from './chatbot'; export * from './checkbox'; export * from './collapsible'; export * from './command';