From c8de3d180f167dde516135e112ab3702db08c3b3 Mon Sep 17 00:00:00 2001 From: areeb hammad Date: Mon, 27 Jul 2026 02:22:06 +0530 Subject: [PATCH 001/128] Add ai-sdk attachments component, wire prompt-input demo layout Add the attachments module (Attachments/Attachment/AttachmentPreview/ AttachmentRemove) from elements.ai-sdk.dev and update AiChatPanel to match the current PromptInput demo: header with inline attachment previews, action menu with attachment upload + screenshot capture, footer toolbar. Model-select and web-search controls from the demo are omitted since the chat route uses a single fixed Bedrock model. --- apps/editor/components/ai-chat-panel.tsx | 133 +++++++++ .../components/ai-elements/attachments.tsx | 257 ++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 apps/editor/components/ai-chat-panel.tsx create mode 100644 apps/editor/components/ai-elements/attachments.tsx diff --git a/apps/editor/components/ai-chat-panel.tsx b/apps/editor/components/ai-chat-panel.tsx new file mode 100644 index 0000000000..a9fb8fde29 --- /dev/null +++ b/apps/editor/components/ai-chat-panel.tsx @@ -0,0 +1,133 @@ +'use client' + +import { DefaultChatTransport, isDynamicToolUIPart, isToolUIPart } from 'ai' +import { useChat } from '@ai-sdk/react' +import { useState } from 'react' +import { Attachment, AttachmentPreview, AttachmentRemove, Attachments } from '@/components/ai-elements/attachments' +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from '@/components/ai-elements/conversation' +import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message' +import { + PromptInput, + PromptInputActionAddAttachments, + PromptInputActionAddScreenshot, + PromptInputActionMenu, + PromptInputActionMenuContent, + PromptInputActionMenuTrigger, + PromptInputBody, + PromptInputFooter, + PromptInputHeader, + type PromptInputMessage, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + usePromptInputAttachments, +} from '@/components/ai-elements/prompt-input' +import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool' + +const PromptInputAttachmentsDisplay = () => { + const attachments = usePromptInputAttachments() + if (attachments.files.length === 0) return null + return ( + + {attachments.files.map((attachment) => ( + attachments.remove(attachment.id)}> + + + + ))} + + ) +} + +export function AiChatPanel({ sceneId }: { sceneId: string }) { + const [input, setInput] = useState('') + const { messages, sendMessage, status } = useChat({ + transport: new DefaultChatTransport({ + api: '/api/chat', + body: { sceneId }, + }), + }) + const busy = status === 'submitted' || status === 'streaming' + + return ( +
+ + + {messages.length === 0 && ( + + )} + {messages.map((message) => ( + + + {message.parts.map((part, i) => { + if (part.type === 'text') { + return {part.text} + } + if (isToolUIPart(part) || isDynamicToolUIPart(part)) { + return ( + + {isDynamicToolUIPart(part) ? ( + + ) : ( + + )} + + + {part.state === 'output-available' && {String(part.output)}} />} + {part.state === 'output-error' && } + + + ) + } + return null + })} + + + ))} + + + + { + const text = message.text?.trim() + if ((!text && message.files.length === 0) || busy) return + sendMessage({ files: message.files, text: text || 'Sent with attachments' }) + setInput('') + }} + > + + + + + setInput(e.target.value)} + placeholder="Ask the construction AI…" + value={input} + /> + + + + + + + + + + + + + + +
+ ) +} diff --git a/apps/editor/components/ai-elements/attachments.tsx b/apps/editor/components/ai-elements/attachments.tsx new file mode 100644 index 0000000000..ad497b22db --- /dev/null +++ b/apps/editor/components/ai-elements/attachments.tsx @@ -0,0 +1,257 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' +import { cn } from '@/lib/utils' +import type { FileUIPart, SourceDocumentUIPart } from 'ai' +import { FileTextIcon, GlobeIcon, ImageIcon, Music2Icon, PaperclipIcon, VideoIcon, XIcon } from 'lucide-react' +import type { ComponentProps, HTMLAttributes, ReactNode } from 'react' +import { createContext, useCallback, useContext, useMemo } from 'react' + +export type AttachmentData = (FileUIPart & { id: string }) | (SourceDocumentUIPart & { id: string }) + +export type AttachmentMediaCategory = 'image' | 'video' | 'audio' | 'document' | 'source' | 'unknown' + +export type AttachmentVariant = 'grid' | 'inline' | 'list' + +const mediaCategoryIcons: Record = { + audio: Music2Icon, + document: FileTextIcon, + image: ImageIcon, + source: GlobeIcon, + unknown: PaperclipIcon, + video: VideoIcon, +} + +export const getMediaCategory = (data: AttachmentData): AttachmentMediaCategory => { + if (data.type === 'source-document') return 'source' + const mediaType = data.mediaType ?? '' + if (mediaType.startsWith('image/')) return 'image' + if (mediaType.startsWith('video/')) return 'video' + if (mediaType.startsWith('audio/')) return 'audio' + if (mediaType.startsWith('application/') || mediaType.startsWith('text/')) return 'document' + return 'unknown' +} + +export const getAttachmentLabel = (data: AttachmentData): string => { + if (data.type === 'source-document') return data.title || data.filename || 'Source' + const category = getMediaCategory(data) + return data.filename || (category === 'image' ? 'Image' : 'Attachment') +} + +const renderAttachmentImage = (url: string, filename: string | undefined, isGrid: boolean) => + isGrid ? ( + {filename + ) : ( + {filename + ) + +interface AttachmentsContextValue { + variant: AttachmentVariant +} + +const AttachmentsContext = createContext(null) + +interface AttachmentContextValue { + data: AttachmentData + mediaCategory: AttachmentMediaCategory + onRemove?: () => void + variant: AttachmentVariant +} + +const AttachmentContext = createContext(null) + +export const useAttachmentsContext = () => useContext(AttachmentsContext) ?? { variant: 'grid' as const } + +export const useAttachmentContext = () => { + const ctx = useContext(AttachmentContext) + if (!ctx) throw new Error('Attachment components must be used within ') + return ctx +} + +export type AttachmentsProps = HTMLAttributes & { + variant?: AttachmentVariant +} + +export const Attachments = ({ variant = 'grid', className, children, ...props }: AttachmentsProps) => { + const contextValue = useMemo(() => ({ variant }), [variant]) + return ( + +
+ {children} +
+
+ ) +} + +export type AttachmentProps = HTMLAttributes & { + data: AttachmentData + onRemove?: () => void +} + +export const Attachment = ({ data, onRemove, className, children, ...props }: AttachmentProps) => { + const { variant } = useAttachmentsContext() + const mediaCategory = getMediaCategory(data) + const contextValue = useMemo( + () => ({ data, mediaCategory, onRemove, variant }), + [data, mediaCategory, onRemove, variant], + ) + + return ( + +
+ {children} +
+
+ ) +} + +export type AttachmentPreviewProps = HTMLAttributes & { + fallbackIcon?: ReactNode +} + +export const AttachmentPreview = ({ fallbackIcon, className, ...props }: AttachmentPreviewProps) => { + const { data, mediaCategory, variant } = useAttachmentContext() + const iconSize = variant === 'inline' ? 'size-3' : 'size-4' + + const renderIcon = (Icon: typeof ImageIcon) => + + const renderContent = () => { + if (mediaCategory === 'image' && data.type === 'file' && data.url) { + return renderAttachmentImage(data.url, data.filename, variant === 'grid') + } + if (mediaCategory === 'video' && data.type === 'file' && data.url) { + return