From ee921d67c5350e363e3f009238f6e58d0492c10e Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Tue, 14 Jan 2025 12:45:42 +0100 Subject: [PATCH] feat(message-input): add interrupt feature --- .../content/docs/components/message-input.mdx | 34 ++++++++ apps/www/public/r/chat.json | 2 +- apps/www/public/r/message-input.json | 2 +- apps/www/registry/default/ui/chat.tsx | 11 +-- .../www/registry/default/ui/message-input.tsx | 80 +++++++++++++++++-- 5 files changed, 113 insertions(+), 16 deletions(-) diff --git a/apps/www/content/docs/components/message-input.mdx b/apps/www/content/docs/components/message-input.mdx index 1266958..0d6bffd 100644 --- a/apps/www/content/docs/components/message-input.mdx +++ b/apps/www/content/docs/components/message-input.mdx @@ -19,6 +19,7 @@ The MessageInput component provides a rich textarea experience with support for - Drag and drop file uploads - Submit on Enter (configurable) - Stop generation button +- Double-enter interrupt behavior - Fully customizable styling ## Installation @@ -86,6 +87,24 @@ export function BasicMessageInput() { } ``` +### With Interrupt Behavior + +```tsx +export function MessageInputWithInterrupt() { + return ( + + ) +} +``` + +When `enableInterrupt` is enabled and `isGenerating` is true, pressing Enter once will show a prompt asking the user to press Enter again to interrupt the generation. The prompt will disappear either when the user presses Enter again (triggering the stop function) or when the generation completes. + ### With File Attachments ```tsx @@ -140,6 +159,7 @@ The MessageInput component accepts two sets of props depending on whether file a | `isGenerating` | `boolean` | Whether AI is generating | Required | | `placeholder` | `string` | Input placeholder text | "Ask AI..." | | `allowAttachments` | `boolean` | Enable file attachments | +| `enableInterrupt` | `boolean` | Enable double-enter interrupt | `true` | ### With Attachments @@ -205,6 +225,20 @@ Show stop button during generation: /> ``` +### Interrupt Behavior + +The double-enter interrupt behavior is enabled by default. To disable it: + +```tsx + +``` + ## Theming The Chat component is using theme colors and fully themable with CSS variables. diff --git a/apps/www/public/r/chat.json b/apps/www/public/r/chat.json index 3230e15..b0cffba 100644 --- a/apps/www/public/r/chat.json +++ b/apps/www/public/r/chat.json @@ -12,7 +12,7 @@ "files": [ { "path": "ui/chat.tsx", - "content": "\"use client\"\n\nimport { forwardRef, useCallback, useState, type ReactElement } from \"react\"\nimport { ArrowDown, ThumbsDown, ThumbsUp } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useAutoScroll } from \"@/registry/default/hooks/use-auto-scroll\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport { type Message } from \"@/registry/default/ui/chat-message\"\nimport { CopyButton } from \"@/registry/default/ui/copy-button\"\nimport { MessageInput } from \"@/registry/default/ui/message-input\"\nimport { MessageList } from \"@/registry/default/ui/message-list\"\nimport { PromptSuggestions } from \"@/registry/default/ui/prompt-suggestions\"\n\ninterface ChatPropsBase {\n handleSubmit: (\n event?: { preventDefault?: () => void },\n options?: { experimental_attachments?: FileList }\n ) => void\n messages: Array\n input: string\n className?: string\n handleInputChange: React.ChangeEventHandler\n isGenerating: boolean\n stop?: () => void\n onRateResponse?: (\n messageId: string,\n rating: \"thumbs-up\" | \"thumbs-down\"\n ) => void\n}\n\ninterface ChatPropsWithoutSuggestions extends ChatPropsBase {\n append?: never\n suggestions?: never\n}\n\ninterface ChatPropsWithSuggestions extends ChatPropsBase {\n append: (message: { role: \"user\"; content: string }) => void\n suggestions: string[]\n}\n\ntype ChatProps = ChatPropsWithoutSuggestions | ChatPropsWithSuggestions\n\nexport function Chat({\n messages,\n handleSubmit,\n input,\n handleInputChange,\n stop,\n isGenerating,\n append,\n suggestions,\n className,\n onRateResponse,\n}: ChatProps) {\n const lastMessage = messages.at(-1)\n const isEmpty = messages.length === 0\n const isTyping = lastMessage?.role === \"user\"\n\n const messageOptions = useCallback(\n (message: Message) => ({\n actions: onRateResponse ? (\n <>\n
\n \n
\n onRateResponse(message.id, \"thumbs-up\")}\n >\n \n \n onRateResponse(message.id, \"thumbs-down\")}\n >\n \n \n \n ) : (\n \n ),\n }),\n [onRateResponse]\n )\n\n return (\n \n {isEmpty && append && suggestions ? (\n \n ) : null}\n\n {messages.length > 0 ? (\n \n \n \n ) : null}\n\n \n {({ files, setFiles }) => (\n \n )}\n \n \n )\n}\nChat.displayName = \"Chat\"\n\nexport function ChatMessages({\n messages,\n children,\n}: React.PropsWithChildren<{\n messages: Message[]\n}>) {\n const {\n containerRef,\n scrollToBottom,\n handleScroll,\n shouldAutoScroll,\n handleTouchStart,\n } = useAutoScroll([messages])\n\n return (\n \n
\n {children}\n
\n\n
\n {!shouldAutoScroll && (\n
\n \n \n \n
\n )}\n
\n \n )\n}\n\nexport const ChatContainer = forwardRef<\n HTMLDivElement,\n React.HTMLAttributes\n>(({ className, ...props }, ref) => {\n return (\n \n )\n})\nChatContainer.displayName = \"ChatContainer\"\n\ninterface ChatFormProps {\n className?: string\n isPending: boolean\n handleSubmit: (\n event?: { preventDefault?: () => void },\n options?: { experimental_attachments?: FileList }\n ) => void\n children: (props: {\n files: File[] | null\n setFiles: React.Dispatch>\n }) => ReactElement\n}\n\nexport const ChatForm = forwardRef(\n ({ children, handleSubmit, isPending, className }, ref) => {\n const [files, setFiles] = useState(null)\n\n const onSubmit = (event: React.FormEvent) => {\n if (isPending) {\n event.preventDefault()\n return\n }\n\n if (!files) {\n handleSubmit(event)\n return\n }\n\n const fileList = createFileList(files)\n handleSubmit(event, { experimental_attachments: fileList })\n setFiles(null)\n }\n\n return (\n
\n {children({ files, setFiles })}\n
\n )\n }\n)\nChatForm.displayName = \"ChatForm\"\n\nfunction createFileList(files: File[] | FileList): FileList {\n const dataTransfer = new DataTransfer()\n for (const file of Array.from(files)) {\n dataTransfer.items.add(file)\n }\n return dataTransfer.files\n}\n", + "content": "\"use client\"\n\nimport { forwardRef, useCallback, useState, type ReactElement } from \"react\"\nimport { ArrowDown, ThumbsDown, ThumbsUp } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useAutoScroll } from \"@/registry/default/hooks/use-auto-scroll\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport { type Message } from \"@/registry/default/ui/chat-message\"\nimport { CopyButton } from \"@/registry/default/ui/copy-button\"\nimport { MessageInput } from \"@/registry/default/ui/message-input\"\nimport { MessageList } from \"@/registry/default/ui/message-list\"\nimport { PromptSuggestions } from \"@/registry/default/ui/prompt-suggestions\"\n\ninterface ChatPropsBase {\n handleSubmit: (\n event?: { preventDefault?: () => void },\n options?: { experimental_attachments?: FileList }\n ) => void\n messages: Array\n input: string\n className?: string\n handleInputChange: React.ChangeEventHandler\n isGenerating: boolean\n stop?: () => void\n onRateResponse?: (\n messageId: string,\n rating: \"thumbs-up\" | \"thumbs-down\"\n ) => void\n}\n\ninterface ChatPropsWithoutSuggestions extends ChatPropsBase {\n append?: never\n suggestions?: never\n}\n\ninterface ChatPropsWithSuggestions extends ChatPropsBase {\n append: (message: { role: \"user\"; content: string }) => void\n suggestions: string[]\n}\n\ntype ChatProps = ChatPropsWithoutSuggestions | ChatPropsWithSuggestions\n\nexport function Chat({\n messages,\n handleSubmit,\n input,\n handleInputChange,\n stop,\n isGenerating,\n append,\n suggestions,\n className,\n onRateResponse,\n}: ChatProps) {\n const lastMessage = messages.at(-1)\n const isEmpty = messages.length === 0\n const isTyping = lastMessage?.role === \"user\"\n\n const messageOptions = useCallback(\n (message: Message) => ({\n actions: onRateResponse ? (\n <>\n
\n \n
\n onRateResponse(message.id, \"thumbs-up\")}\n >\n \n \n onRateResponse(message.id, \"thumbs-down\")}\n >\n \n \n \n ) : (\n \n ),\n }),\n [onRateResponse]\n )\n\n return (\n \n {isEmpty && append && suggestions ? (\n \n ) : null}\n\n {messages.length > 0 ? (\n \n \n \n ) : null}\n\n \n {({ files, setFiles }) => (\n \n )}\n \n \n )\n}\nChat.displayName = \"Chat\"\n\nexport function ChatMessages({\n messages,\n children,\n}: React.PropsWithChildren<{\n messages: Message[]\n}>) {\n const {\n containerRef,\n scrollToBottom,\n handleScroll,\n shouldAutoScroll,\n handleTouchStart,\n } = useAutoScroll([messages])\n\n return (\n \n
\n {children}\n
\n\n
\n {!shouldAutoScroll && (\n
\n \n \n \n
\n )}\n
\n \n )\n}\n\nexport const ChatContainer = forwardRef<\n HTMLDivElement,\n React.HTMLAttributes\n>(({ className, ...props }, ref) => {\n return (\n \n )\n})\nChatContainer.displayName = \"ChatContainer\"\n\ninterface ChatFormProps {\n className?: string\n isPending: boolean\n handleSubmit: (\n event?: { preventDefault?: () => void },\n options?: { experimental_attachments?: FileList }\n ) => void\n children: (props: {\n files: File[] | null\n setFiles: React.Dispatch>\n }) => ReactElement\n}\n\nexport const ChatForm = forwardRef(\n ({ children, handleSubmit, isPending, className }, ref) => {\n const [files, setFiles] = useState(null)\n\n const onSubmit = (event: React.FormEvent) => {\n if (!files) {\n handleSubmit(event)\n return\n }\n\n const fileList = createFileList(files)\n handleSubmit(event, { experimental_attachments: fileList })\n setFiles(null)\n }\n\n return (\n
\n {children({ files, setFiles })}\n
\n )\n }\n)\nChatForm.displayName = \"ChatForm\"\n\nfunction createFileList(files: File[] | FileList): FileList {\n const dataTransfer = new DataTransfer()\n for (const file of Array.from(files)) {\n dataTransfer.items.add(file)\n }\n return dataTransfer.files\n}\n", "type": "registry:ui", "target": "" } diff --git a/apps/www/public/r/message-input.json b/apps/www/public/r/message-input.json index 9ff9a48..6f76fe4 100644 --- a/apps/www/public/r/message-input.json +++ b/apps/www/public/r/message-input.json @@ -13,7 +13,7 @@ "files": [ { "path": "ui/message-input.tsx", - "content": "\"use client\"\n\nimport React, { useRef, useState } from \"react\"\nimport { AnimatePresence, motion } from \"framer-motion\"\nimport { ArrowUp, Paperclip, Square } from \"lucide-react\"\nimport { omit } from \"remeda\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useAutosizeTextArea } from \"@/registry/default/hooks/use-autosize-textarea\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport { FilePreview } from \"@/registry/default/ui/file-preview\"\n\ninterface MessageInputBaseProps\n extends React.TextareaHTMLAttributes {\n value: string\n submitOnEnter?: boolean\n stop?: () => void\n isGenerating: boolean\n}\n\ninterface MessageInputWithoutAttachmentProps extends MessageInputBaseProps {\n allowAttachments?: false\n}\n\ninterface MessageInputWithAttachmentsProps extends MessageInputBaseProps {\n allowAttachments: true\n files: File[] | null\n setFiles: React.Dispatch>\n}\n\ntype MessageInputProps =\n | MessageInputWithoutAttachmentProps\n | MessageInputWithAttachmentsProps\n\nexport function MessageInput({\n placeholder = \"Ask AI...\",\n className,\n onKeyDown: onKeyDownProp,\n submitOnEnter = true,\n stop,\n isGenerating,\n ...props\n}: MessageInputProps) {\n const [isDragging, setIsDragging] = useState(false)\n\n const addFiles = (files: File[] | null) => {\n if (props.allowAttachments) {\n props.setFiles((currentFiles) => {\n if (currentFiles === null) {\n return files\n }\n\n if (files === null) {\n return currentFiles\n }\n\n return [...currentFiles, ...files]\n })\n }\n }\n\n const onDragOver = (event: React.DragEvent) => {\n if (props.allowAttachments !== true) return\n event.preventDefault()\n setIsDragging(true)\n }\n\n const onDragLeave = (event: React.DragEvent) => {\n if (props.allowAttachments !== true) return\n event.preventDefault()\n setIsDragging(false)\n }\n\n const onDrop = (event: React.DragEvent) => {\n setIsDragging(false)\n if (props.allowAttachments !== true) return\n event.preventDefault()\n const dataTransfer = event.dataTransfer\n if (dataTransfer.files.length) {\n addFiles(Array.from(dataTransfer.files))\n }\n }\n\n const onPaste = (event: React.ClipboardEvent) => {\n const items = event.clipboardData?.items\n if (!items) return\n\n const files = Array.from(items)\n .map((item) => item.getAsFile())\n .filter((file) => file !== null)\n\n if (props.allowAttachments && files.length > 0) {\n addFiles(files)\n }\n }\n\n const onKeyDown = (event: React.KeyboardEvent) => {\n if (submitOnEnter && event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault()\n event.currentTarget.form?.requestSubmit()\n }\n\n onKeyDownProp?.(event)\n }\n\n const textAreaRef = useRef(null)\n\n const showFileList =\n props.allowAttachments && props.files && props.files.length > 0\n\n useAutosizeTextArea({\n ref: textAreaRef,\n maxHeight: 240,\n borderWidth: 1,\n dependencies: [props.value, showFileList],\n })\n\n return (\n \n \n\n {props.allowAttachments && (\n
\n
\n \n {props.files?.map((file) => {\n return (\n {\n props.setFiles((files) => {\n if (!files) return null\n\n const filtered = Array.from(files).filter(\n (f) => f !== file\n )\n if (filtered.length === 0) return null\n return filtered\n })\n }}\n />\n )\n })}\n \n
\n
\n )}\n\n
\n {props.allowAttachments && (\n {\n const files = await showFileUploadDialog()\n addFiles(files)\n }}\n >\n \n \n )}\n {isGenerating && stop ? (\n \n \n \n ) : (\n \n \n \n )}\n
\n\n {props.allowAttachments && }\n \n )\n}\nMessageInput.displayName = \"MessageInput\"\n\ninterface FileUploadOverlayProps {\n isDragging: boolean\n}\n\nfunction FileUploadOverlay({ isDragging }: FileUploadOverlayProps) {\n return (\n \n {isDragging && (\n \n \n Drop your files here to attach them.\n \n )}\n \n )\n}\n\nfunction showFileUploadDialog() {\n const input = document.createElement(\"input\")\n\n input.type = \"file\"\n input.multiple = true\n input.accept = \"*/*\"\n input.click()\n\n return new Promise((resolve) => {\n input.onchange = (e) => {\n const files = (e.currentTarget as HTMLInputElement).files\n\n if (files) {\n resolve(Array.from(files))\n return\n }\n\n resolve(null)\n }\n })\n}\n", + "content": "\"use client\"\n\nimport React, { useEffect, useRef, useState } from \"react\"\nimport { AnimatePresence, motion } from \"framer-motion\"\nimport { ArrowUp, Paperclip, Square, X } from \"lucide-react\"\nimport { omit } from \"remeda\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useAutosizeTextArea } from \"@/registry/default/hooks/use-autosize-textarea\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport { FilePreview } from \"@/registry/default/ui/file-preview\"\n\ninterface MessageInputBaseProps\n extends React.TextareaHTMLAttributes {\n value: string\n submitOnEnter?: boolean\n stop?: () => void\n isGenerating: boolean\n enableInterrupt?: boolean\n}\n\ninterface MessageInputWithoutAttachmentProps extends MessageInputBaseProps {\n allowAttachments?: false\n}\n\ninterface MessageInputWithAttachmentsProps extends MessageInputBaseProps {\n allowAttachments: true\n files: File[] | null\n setFiles: React.Dispatch>\n}\n\ntype MessageInputProps =\n | MessageInputWithoutAttachmentProps\n | MessageInputWithAttachmentsProps\n\nexport function MessageInput({\n placeholder = \"Ask AI...\",\n className,\n onKeyDown: onKeyDownProp,\n submitOnEnter = true,\n stop,\n isGenerating,\n enableInterrupt = true,\n ...props\n}: MessageInputProps) {\n const [isDragging, setIsDragging] = useState(false)\n const [showInterruptPrompt, setShowInterruptPrompt] = useState(false)\n\n useEffect(() => {\n if (!isGenerating) {\n setShowInterruptPrompt(false)\n }\n }, [isGenerating])\n\n const addFiles = (files: File[] | null) => {\n if (props.allowAttachments) {\n props.setFiles((currentFiles) => {\n if (currentFiles === null) {\n return files\n }\n\n if (files === null) {\n return currentFiles\n }\n\n return [...currentFiles, ...files]\n })\n }\n }\n\n const onDragOver = (event: React.DragEvent) => {\n if (props.allowAttachments !== true) return\n event.preventDefault()\n setIsDragging(true)\n }\n\n const onDragLeave = (event: React.DragEvent) => {\n if (props.allowAttachments !== true) return\n event.preventDefault()\n setIsDragging(false)\n }\n\n const onDrop = (event: React.DragEvent) => {\n setIsDragging(false)\n if (props.allowAttachments !== true) return\n event.preventDefault()\n const dataTransfer = event.dataTransfer\n if (dataTransfer.files.length) {\n addFiles(Array.from(dataTransfer.files))\n }\n }\n\n const onPaste = (event: React.ClipboardEvent) => {\n const items = event.clipboardData?.items\n if (!items) return\n\n const files = Array.from(items)\n .map((item) => item.getAsFile())\n .filter((file) => file !== null)\n\n if (props.allowAttachments && files.length > 0) {\n addFiles(files)\n }\n }\n\n const onKeyDown = (event: React.KeyboardEvent) => {\n if (submitOnEnter && event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault()\n\n if (isGenerating && stop && enableInterrupt) {\n if (showInterruptPrompt) {\n stop()\n setShowInterruptPrompt(false)\n event.currentTarget.form?.requestSubmit()\n } else if (\n props.value ||\n (props.allowAttachments && props.files?.length)\n ) {\n setShowInterruptPrompt(true)\n return\n }\n }\n\n event.currentTarget.form?.requestSubmit()\n }\n\n onKeyDownProp?.(event)\n }\n\n const textAreaRef = useRef(null)\n\n const showFileList =\n props.allowAttachments && props.files && props.files.length > 0\n\n useAutosizeTextArea({\n ref: textAreaRef,\n maxHeight: 240,\n borderWidth: 1,\n dependencies: [props.value, showFileList],\n })\n\n return (\n \n {enableInterrupt && (\n setShowInterruptPrompt(false)}\n />\n )}\n\n \n\n {props.allowAttachments && (\n
\n
\n \n {props.files?.map((file) => {\n return (\n {\n props.setFiles((files) => {\n if (!files) return null\n\n const filtered = Array.from(files).filter(\n (f) => f !== file\n )\n if (filtered.length === 0) return null\n return filtered\n })\n }}\n />\n )\n })}\n \n
\n
\n )}\n\n
\n {props.allowAttachments && (\n {\n const files = await showFileUploadDialog()\n addFiles(files)\n }}\n >\n \n \n )}\n {isGenerating && stop ? (\n \n \n \n ) : (\n \n \n \n )}\n
\n\n {props.allowAttachments && }\n \n )\n}\nMessageInput.displayName = \"MessageInput\"\n\ninterface InterruptPromptProps {\n isOpen: boolean\n close: () => void\n}\n\nfunction InterruptPrompt({ isOpen, close }: InterruptPromptProps) {\n return (\n \n {isOpen && (\n \n Press Enter again to interrupt\n \n \n \n \n )}\n \n )\n}\n\ninterface FileUploadOverlayProps {\n isDragging: boolean\n}\n\nfunction FileUploadOverlay({ isDragging }: FileUploadOverlayProps) {\n return (\n \n {isDragging && (\n \n \n Drop your files here to attach them.\n \n )}\n \n )\n}\n\nfunction showFileUploadDialog() {\n const input = document.createElement(\"input\")\n\n input.type = \"file\"\n input.multiple = true\n input.accept = \"*/*\"\n input.click()\n\n return new Promise((resolve) => {\n input.onchange = (e) => {\n const files = (e.currentTarget as HTMLInputElement).files\n\n if (files) {\n resolve(Array.from(files))\n return\n }\n\n resolve(null)\n }\n })\n}\n", "type": "registry:ui", "target": "" } diff --git a/apps/www/registry/default/ui/chat.tsx b/apps/www/registry/default/ui/chat.tsx index 3f2fd04..ed6d37e 100644 --- a/apps/www/registry/default/ui/chat.tsx +++ b/apps/www/registry/default/ui/chat.tsx @@ -152,16 +152,16 @@ export function ChatMessages({ return (
-
+
{children}
-
+
{!shouldAutoScroll && (