mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
feat: add image and PDF attachment support to Nova chat
- Add paperclip button to ChatInput (both stacked and inline variants) - File picker accepts images (jpeg/png/gif/webp) and PDFs up to 10MB - Attachments are converted to FileUIPart (data URLs) via FileReader - Preview chips shown in input: thumbnails for images, filename for PDFs - Remove-chip button on hover - handleSend forwards FileUIPart[] to sendMessage via AI SDK - UserMessage renders image thumbnails and PDF file chips inline - canSend allows sending with attachments even when text is empty
This commit is contained in:
parent
2684990688
commit
154ddc07c2
3 changed files with 183 additions and 14 deletions
|
|
@ -332,12 +332,12 @@ export function ChatSidebar({
|
|||
}
|
||||
}, [])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || status === "submitted" || status === "streaming")
|
||||
const handleSend = (attachments?: import("ai").FileUIPart[]) => {
|
||||
if ((!input.trim() && (!attachments || attachments.length === 0)) || status === "submitted" || status === "streaming")
|
||||
return
|
||||
if (!threadId) setThreadId(fallbackChatId)
|
||||
analytics.chatMessageSent({ source: "typed" })
|
||||
sendMessage({ text: input })
|
||||
sendMessage({ text: input, files: attachments })
|
||||
setInput("")
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
"use client"
|
||||
|
||||
import { ChevronUpIcon } from "lucide-react"
|
||||
import { ChevronUpIcon, Paperclip, X, FileText } from "lucide-react"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import type { FileUIPart } from "ai"
|
||||
import { SendButton, StopButton } from "./actions"
|
||||
|
||||
const ACCEPTED_FILE_TYPES = "image/jpeg,image/png,image/gif,image/webp,application/pdf"
|
||||
const MAX_FILE_SIZE_MB = 10
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onSend: () => void
|
||||
onSend: (attachments?: FileUIPart[]) => void
|
||||
onStop: () => void
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void
|
||||
isResponding?: boolean
|
||||
|
|
@ -39,7 +43,41 @@ export default function ChatInput({
|
|||
}: ChatInputProps) {
|
||||
const [isMultiline, setIsMultiline] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [attachments, setAttachments] = useState<FileUIPart[]>([])
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
e.target.value = ""
|
||||
const parts = await Promise.all(
|
||||
files
|
||||
.filter((f) => f.size <= MAX_FILE_SIZE_MB * 1024 * 1024)
|
||||
.map(
|
||||
(file) =>
|
||||
new Promise<FileUIPart>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () =>
|
||||
resolve({
|
||||
type: "file" as const,
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
url: reader.result as string,
|
||||
})
|
||||
reader.readAsDataURL(file)
|
||||
}),
|
||||
),
|
||||
)
|
||||
setAttachments((prev) => [...prev, ...parts])
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
onSend(attachments.length > 0 ? attachments : undefined)
|
||||
setAttachments([])
|
||||
}, [onSend, attachments])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showStatusStrip && isExpanded) {
|
||||
|
|
@ -62,6 +100,15 @@ export default function ChatInput({
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
accept={ACCEPTED_FILE_TYPES}
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<motion.div
|
||||
className={cn("relative z-20!")}
|
||||
animate={{
|
||||
|
|
@ -130,6 +177,18 @@ export default function ChatInput({
|
|||
) : null}
|
||||
{stackedToolbar ? (
|
||||
<div className="flex flex-col gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-1 pt-1">
|
||||
{attachments.map((att, i) => (
|
||||
<AttachmentChip
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list
|
||||
key={i}
|
||||
attachment={att}
|
||||
onRemove={() => setAttachments((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
|
|
@ -145,11 +204,20 @@ export default function ChatInput({
|
|||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{stackedToolbar}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Attach file"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isResponding}
|
||||
className="shrink-0 rounded-lg p-1.5 text-fg-faint hover:text-fg-primary hover:bg-white/5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</button>
|
||||
<div className="shrink-0">
|
||||
{isResponding ? (
|
||||
<StopButton onClick={onStop} />
|
||||
) : (
|
||||
<SendButton onClick={onSend} disabled={!value.trim()} />
|
||||
<SendButton onClick={handleSend} disabled={!value.trim() && attachments.length === 0} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -161,6 +229,18 @@ export default function ChatInput({
|
|||
isMultiline && "flex-col",
|
||||
)}
|
||||
>
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 w-full px-1 pt-1">
|
||||
{attachments.map((att, i) => (
|
||||
<AttachmentChip
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list
|
||||
key={i}
|
||||
attachment={att}
|
||||
onRemove={() => setAttachments((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
|
|
@ -172,15 +252,72 @@ export default function ChatInput({
|
|||
rows={1}
|
||||
disabled={isResponding}
|
||||
/>
|
||||
<div className="transition-all duration-200">
|
||||
<div className={cn("flex items-center gap-1 transition-all duration-200", isMultiline && "w-full justify-end")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Attach file"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isResponding}
|
||||
className="rounded-lg p-1.5 text-fg-faint hover:text-fg-primary hover:bg-white/5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</button>
|
||||
{isResponding ? (
|
||||
<StopButton onClick={onStop} />
|
||||
) : (
|
||||
<SendButton onClick={onSend} disabled={!value.trim()} />
|
||||
<SendButton onClick={handleSend} disabled={!value.trim() && attachments.length === 0} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentChip({
|
||||
attachment,
|
||||
onRemove,
|
||||
}: {
|
||||
attachment: FileUIPart
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const isImage = attachment.mediaType.startsWith("image/")
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative group flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 overflow-hidden",
|
||||
isImage ? "size-12" : "px-2 py-1 max-w-[140px]",
|
||||
)}
|
||||
>
|
||||
{isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={attachment.url}
|
||||
alt={attachment.filename ?? "attachment"}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="size-3 shrink-0 text-fg-faint" />
|
||||
<span className="text-xs text-fg-secondary truncate">
|
||||
{attachment.filename ?? "file"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove attachment"
|
||||
onClick={onRemove}
|
||||
className={cn(
|
||||
"absolute top-0.5 right-0.5 size-4 flex items-center justify-center",
|
||||
"rounded-full bg-black/60 text-white/60",
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
"hover:text-white cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import { Copy, Check } from "lucide-react"
|
||||
import { Copy, Check, FileText } from "lucide-react"
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
|
||||
interface UserMessageProps {
|
||||
|
|
@ -20,11 +20,43 @@ export const UserMessage = memo(function UserMessage({
|
|||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
|
||||
const files = message.parts.filter(
|
||||
(part): part is Extract<typeof part, { type: "file" }> => part.type === "file",
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end w-full">
|
||||
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
|
||||
<p className="text-sm text-white">{text}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end w-full gap-2">
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-2 max-w-[80%]">
|
||||
{files.map((f, i) => {
|
||||
const isImage = f.mediaType.startsWith("image/")
|
||||
return isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list
|
||||
key={i}
|
||||
src={f.url}
|
||||
alt={f.filename ?? "attachment"}
|
||||
className="max-h-48 max-w-48 rounded-xl border border-white/10 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list
|
||||
key={i}
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-[#1B1F24] px-3 py-2 text-xs text-white/60"
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">{f.filename ?? "file"}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{text && (
|
||||
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
|
||||
<p className="text-sm text-white">{text}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(message.id, text)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue