mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-12 23:01:07 +00:00
feat: multi-file upload queue for Add Document (#789)
Adds multi-select and multi-file drag-and-drop on the Add Document Upload files tab, a per-file queue with status, retry for failed rows, and batched uploads to the existing `POST /v3/documents/file` endpoint with a client-side concurrency limit of 3. Optional title/description apply only when a single file is queued. .md / .mdx (and text/markdown) are accepted. Upload progress is shown as a bottom strip with a slow width animation
This commit is contained in:
parent
4033087232
commit
bc9120f751
4 changed files with 476 additions and 154 deletions
|
|
@ -1,84 +1,161 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useEffect, useCallback, useRef, useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { FileIcon } from "lucide-react"
|
||||
import { FileIcon, XIcon, AlertCircleIcon, CheckIcon } from "lucide-react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const FILE_ACCEPT =
|
||||
"image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,text/markdown"
|
||||
|
||||
export type FileQueueItemStatus = "pending" | "uploading" | "success" | "error"
|
||||
|
||||
export interface FileQueueItem {
|
||||
id: string
|
||||
file: File
|
||||
status: FileQueueItemStatus
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface FileData {
|
||||
file: File | null
|
||||
items: FileQueueItem[]
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface FileContentProps {
|
||||
onSubmit?: (data: { file: File; title: string; description: string }) => void
|
||||
onDataChange?: (data: FileData) => void
|
||||
data: FileData
|
||||
onDataChange: (data: FileData) => void
|
||||
onRequestSubmit: () => void
|
||||
isSubmitting?: boolean
|
||||
isOpen?: boolean
|
||||
}
|
||||
|
||||
function isAcceptedFile(file: File): boolean {
|
||||
const name = file.name.toLowerCase()
|
||||
const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : ""
|
||||
const allowedExt = new Set([
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".csv",
|
||||
".txt",
|
||||
".md",
|
||||
".mdx",
|
||||
])
|
||||
if (allowedExt.has(ext)) return true
|
||||
if (file.type.startsWith("image/")) return true
|
||||
if (file.type === "text/markdown") return true
|
||||
return false
|
||||
}
|
||||
|
||||
function fileQueueKey(file: File): string {
|
||||
return `${file.name}:${file.size}:${file.lastModified}`
|
||||
}
|
||||
|
||||
export function FileContent({
|
||||
onSubmit,
|
||||
data,
|
||||
onDataChange,
|
||||
onRequestSubmit,
|
||||
isSubmitting,
|
||||
isOpen,
|
||||
}: FileContentProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
|
||||
const canSubmit = selectedFile !== null && !isSubmitting
|
||||
const anyUploading = data.items.some((i) => i.status === "uploading")
|
||||
const canSubmit =
|
||||
data.items.some((i) => i.status === "pending") &&
|
||||
!isSubmitting &&
|
||||
!anyUploading
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (canSubmit && onSubmit && selectedFile) {
|
||||
onSubmit({ file: selectedFile, title, description })
|
||||
}
|
||||
}
|
||||
const updateData = useCallback(
|
||||
(partial: Partial<FileData>) => {
|
||||
onDataChange({ ...data, ...partial })
|
||||
},
|
||||
[data, onDataChange],
|
||||
)
|
||||
|
||||
const updateData = (
|
||||
newFile: File | null,
|
||||
newTitle: string,
|
||||
newDescription: string,
|
||||
) => {
|
||||
onDataChange?.({
|
||||
file: newFile,
|
||||
title: newTitle,
|
||||
description: newDescription,
|
||||
})
|
||||
}
|
||||
const addFiles = useCallback(
|
||||
(fileList: FileList | File[]) => {
|
||||
const incoming = Array.from(fileList)
|
||||
const accepted = incoming.filter(isAcceptedFile)
|
||||
const rejected = incoming.length - accepted.length
|
||||
if (rejected > 0) {
|
||||
toast.error(
|
||||
rejected === 1
|
||||
? "One file type is not supported"
|
||||
: `${rejected} files are not supported`,
|
||||
)
|
||||
}
|
||||
if (accepted.length === 0) return
|
||||
|
||||
const handleFileChange = (file: File | null) => {
|
||||
setSelectedFile(file)
|
||||
updateData(file, title, description)
|
||||
}
|
||||
const existingKeys = new Set(data.items.map((i) => fileQueueKey(i.file)))
|
||||
let duplicateCount = 0
|
||||
const toAdd: FileQueueItem[] = []
|
||||
for (const file of accepted) {
|
||||
const key = fileQueueKey(file)
|
||||
if (existingKeys.has(key)) {
|
||||
duplicateCount++
|
||||
continue
|
||||
}
|
||||
existingKeys.add(key)
|
||||
toAdd.push({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
status: "pending",
|
||||
})
|
||||
}
|
||||
if (duplicateCount > 0) {
|
||||
toast.message(
|
||||
duplicateCount === 1
|
||||
? "Skipped duplicate file"
|
||||
: `Skipped ${duplicateCount} duplicate files`,
|
||||
)
|
||||
}
|
||||
if (toAdd.length === 0) return
|
||||
onDataChange({
|
||||
...data,
|
||||
items: [...data.items, ...toAdd],
|
||||
})
|
||||
},
|
||||
[data, onDataChange],
|
||||
)
|
||||
|
||||
const handleTitleChange = (newTitle: string) => {
|
||||
setTitle(newTitle)
|
||||
updateData(selectedFile, newTitle, description)
|
||||
}
|
||||
const removeItem = useCallback(
|
||||
(id: string) => {
|
||||
onDataChange({
|
||||
...data,
|
||||
items: data.items.filter((i) => i.id !== id),
|
||||
})
|
||||
},
|
||||
[data, onDataChange],
|
||||
)
|
||||
|
||||
const handleDescriptionChange = (newDescription: string) => {
|
||||
setDescription(newDescription)
|
||||
updateData(selectedFile, title, newDescription)
|
||||
}
|
||||
const handleTitleChange = useCallback(
|
||||
(title: string) => updateData({ title }),
|
||||
[updateData],
|
||||
)
|
||||
|
||||
useHotkeys("mod+enter", handleSubmit, {
|
||||
enabled: isOpen && canSubmit,
|
||||
const handleDescriptionChange = useCallback(
|
||||
(description: string) => updateData({ description }),
|
||||
[updateData],
|
||||
)
|
||||
|
||||
useHotkeys("mod+enter", onRequestSubmit, {
|
||||
enabled: Boolean(isOpen && canSubmit),
|
||||
enableOnFormTags: ["INPUT", "TEXTAREA"],
|
||||
})
|
||||
|
||||
// Reset content when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedFile(null)
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
onDataChange?.({ file: null, title: "", description: "" })
|
||||
if (!isOpen && inputRef.current) {
|
||||
inputRef.current.value = ""
|
||||
}
|
||||
}, [isOpen, onDataChange])
|
||||
}, [isOpen])
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
|
|
@ -93,24 +170,27 @@ export function FileContent({
|
|||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) {
|
||||
handleFileChange(file)
|
||||
if (e.dataTransfer.files?.length) {
|
||||
addFiles(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleFileChange(file)
|
||||
const list = e.target.files
|
||||
if (list?.length) {
|
||||
addFiles(list)
|
||||
}
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
const showTitleDescription = data.items.length <= 1
|
||||
const hasItems = data.items.length > 0
|
||||
|
||||
return (
|
||||
<div className={cn("h-full flex flex-col gap-6 pt-4", dmSansClassName())}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[16px] font-medium pl-2">
|
||||
Upload a file (image, pdf, document, sheet)
|
||||
Upload files (images, PDF, documents, sheets, markdown)
|
||||
</p>
|
||||
<label
|
||||
onDragOver={handleDragOver}
|
||||
|
|
@ -121,57 +201,167 @@ export function FileContent({
|
|||
isDragging
|
||||
? "border-[#4BA0FA] bg-[#4BA0FA]/10"
|
||||
: "border-[#737373]/30 hover:border-[#737373]/50",
|
||||
isSubmitting && "opacity-50 pointer-events-none",
|
||||
(isSubmitting || anyUploading) && "opacity-50 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
disabled={isSubmitting}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt"
|
||||
accept={FILE_ACCEPT}
|
||||
/>
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-[#0F1217]">
|
||||
<FileIcon className="size-6 text-[#737373]" />
|
||||
</div>
|
||||
{selectedFile ? (
|
||||
<div className="text-center">
|
||||
<p className="text-white font-medium">{selectedFile.name}</p>
|
||||
<p className="text-[#737373] text-sm">
|
||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
{hasItems ? (
|
||||
<p className="text-center text-[#737373] text-sm pointer-events-none">
|
||||
Add more files or use the list below
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="text-center pointer-events-none">
|
||||
<p className="text-white">
|
||||
<span className="text-[#4BA0FA]">Click to upload</span> or drag
|
||||
and drop
|
||||
</p>
|
||||
<p className="text-[#737373] text-sm mt-1">
|
||||
Multiple files allowed
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[14px] font-semibold pl-2">Title (optional)</p>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Give this file a title"
|
||||
disabled={isSubmitting}
|
||||
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[14px] font-semibold pl-2">Description (optional)</p>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder="Add notes or context about this file"
|
||||
disabled={isSubmitting}
|
||||
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasItems ? (
|
||||
<ul
|
||||
id="file-upload-queue"
|
||||
className="flex flex-col gap-2 max-h-[220px] overflow-y-auto scrollbar-thin pr-1"
|
||||
>
|
||||
{data.items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="relative overflow-hidden rounded-[12px] bg-[#14161A] shadow-inside-out text-sm"
|
||||
>
|
||||
{item.status === "uploading" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 h-0.5 overflow-hidden pointer-events-none",
|
||||
"bg-[rgb(46_53_61/0.65)]",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-0 bg-linear-to-r from-[#3580c4] via-[#4ba0fa] to-[#6ec5fc]",
|
||||
"animate-file-upload-grow motion-reduce:animate-none",
|
||||
"motion-reduce:w-[88%] motion-reduce:opacity-85",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{item.status === "success" ? (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 pointer-events-none bg-emerald-500/80"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex items-start gap-3 p-3 pb-3.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
{item.status === "uploading" ? (
|
||||
<span className="sr-only">Uploading {item.file.name}</span>
|
||||
) : null}
|
||||
<p className="text-white font-medium truncate">
|
||||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-[#737373] text-xs">
|
||||
{(item.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
{item.status === "error" && item.errorMessage ? (
|
||||
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||
{item.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{item.status === "pending" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item.id)}
|
||||
disabled={isSubmitting}
|
||||
className="p-1.5 rounded-lg text-[#737373] hover:text-white hover:bg-[#2E353D] disabled:opacity-50"
|
||||
aria-label={`Remove ${item.file.name}`}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{item.status === "success" ? (
|
||||
<CheckIcon className="size-4 text-green-500" aria-hidden />
|
||||
) : null}
|
||||
{item.status === "error" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDataChange({
|
||||
...data,
|
||||
items: data.items.map((i) =>
|
||||
i.id === item.id
|
||||
? {
|
||||
...i,
|
||||
status: "pending" as const,
|
||||
errorMessage: undefined,
|
||||
}
|
||||
: i,
|
||||
),
|
||||
})
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs text-[#4BA0FA] hover:underline disabled:opacity-50"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{showTitleDescription ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[14px] font-semibold pl-2">Title (optional)</p>
|
||||
<input
|
||||
type="text"
|
||||
value={data.title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Give this file a title"
|
||||
disabled={isSubmitting}
|
||||
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[14px] font-semibold pl-2">
|
||||
Description (optional)
|
||||
</p>
|
||||
<textarea
|
||||
value={data.description}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder="Add notes or context about this file"
|
||||
disabled={isSubmitting}
|
||||
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[#737373] text-sm pl-2">
|
||||
Title and description apply only when uploading a single file. With
|
||||
multiple files, each document uses its file name.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
|
|
@ -72,8 +72,8 @@ const tabs = [
|
|||
{
|
||||
id: "file" as const,
|
||||
icon: FileTextIcon,
|
||||
title: "Upload a file",
|
||||
description: "Turn any image, PDF or document into contextual memories",
|
||||
title: "Upload files",
|
||||
description: "Turn images, PDFs, documents, and markdown into memories",
|
||||
},
|
||||
{
|
||||
id: "connect" as const,
|
||||
|
|
@ -113,10 +113,12 @@ export function AddDocument({
|
|||
description: "",
|
||||
})
|
||||
const [fileData, setFileData] = useState<FileData>({
|
||||
file: null,
|
||||
items: [],
|
||||
title: "",
|
||||
description: "",
|
||||
})
|
||||
const fileDataRef = useRef(fileData)
|
||||
fileDataRef.current = fileData
|
||||
|
||||
const { noteMutation, linkMutation, fileMutation } = useDocumentMutations({
|
||||
onClose,
|
||||
|
|
@ -139,6 +141,12 @@ export function AddDocument({
|
|||
setLocalSelectedProject(globalSelectedProject)
|
||||
}, [globalSelectedProject])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setFileData({ items: [], title: "", description: "" })
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Submit handlers
|
||||
const handleNoteSubmit = useCallback(
|
||||
(content: string) => {
|
||||
|
|
@ -163,17 +171,55 @@ export function AddDocument({
|
|||
)
|
||||
|
||||
const handleFileSubmit = useCallback(
|
||||
(data: { file: File; title: string; description: string }) => {
|
||||
if (!data.file) {
|
||||
toast.error("Please select a file")
|
||||
async (data: FileData) => {
|
||||
const pending = data.items.filter((i) => i.status === "pending")
|
||||
if (pending.length === 0) {
|
||||
toast.error("Please add at least one file")
|
||||
return
|
||||
}
|
||||
fileMutation.mutate({
|
||||
file: data.file,
|
||||
title: data.title || undefined,
|
||||
description: data.description || undefined,
|
||||
project: localSelectedProject,
|
||||
})
|
||||
const applyMeta = pending.length === 1
|
||||
setFileData((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.map((i) =>
|
||||
i.status === "pending" ? { ...i, status: "uploading" as const } : i,
|
||||
),
|
||||
}))
|
||||
try {
|
||||
const result = await fileMutation.mutateAsync({
|
||||
fileEntries: pending.map((i) => ({ id: i.id, file: i.file })),
|
||||
title: applyMeta ? data.title || undefined : undefined,
|
||||
description: applyMeta ? data.description || undefined : undefined,
|
||||
project: localSelectedProject,
|
||||
})
|
||||
setFileData((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.map((i) => {
|
||||
if (i.status !== "uploading") return i
|
||||
const fail = result.failures.find((f) => f.id === i.id)
|
||||
if (fail) {
|
||||
return {
|
||||
...i,
|
||||
status: "error" as const,
|
||||
errorMessage: fail.message,
|
||||
}
|
||||
}
|
||||
return { ...i, status: "success" as const }
|
||||
}),
|
||||
}))
|
||||
} catch {
|
||||
setFileData((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.map((i) =>
|
||||
i.status === "uploading"
|
||||
? {
|
||||
...i,
|
||||
status: "error" as const,
|
||||
errorMessage: "Upload failed",
|
||||
}
|
||||
: i,
|
||||
),
|
||||
}))
|
||||
}
|
||||
},
|
||||
[fileMutation, localSelectedProject],
|
||||
)
|
||||
|
|
@ -200,13 +246,7 @@ export function AddDocument({
|
|||
handleLinkSubmit(linkData)
|
||||
break
|
||||
case "file":
|
||||
if (fileData.file) {
|
||||
handleFileSubmit(
|
||||
fileData as { file: File; title: string; description: string },
|
||||
)
|
||||
} else {
|
||||
toast.error("Please select a file")
|
||||
}
|
||||
void handleFileSubmit(fileData)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -214,6 +254,10 @@ export function AddDocument({
|
|||
const isSubmitting =
|
||||
noteMutation.isPending || linkMutation.isPending || fileMutation.isPending
|
||||
|
||||
const fileTabHasPending = fileData.items.some((i) => i.status === "pending")
|
||||
const fileTabSubmitDisabled =
|
||||
activeTab === "file" && (!fileTabHasPending || isSubmitting)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col md:flex-row text-white md:space-x-5 space-y-3 md:space-y-0">
|
||||
<div
|
||||
|
|
@ -393,8 +437,11 @@ export function AddDocument({
|
|||
)}
|
||||
{activeTab === "file" && (
|
||||
<FileContent
|
||||
onSubmit={handleFileSubmit}
|
||||
data={fileData}
|
||||
onDataChange={handleFileDataChange}
|
||||
onRequestSubmit={() => {
|
||||
void handleFileSubmit(fileDataRef.current)
|
||||
}}
|
||||
isSubmitting={fileMutation.isPending}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
|
@ -434,7 +481,9 @@ export function AddDocument({
|
|||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleButtonClick}
|
||||
disabled={isSubmitting}
|
||||
disabled={
|
||||
activeTab === "file" ? fileTabSubmitDisabled : isSubmitting
|
||||
}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
@theme {
|
||||
--color-onboarding: #525966;
|
||||
--animate-file-upload-grow: file-upload-grow 6s cubic-bezier(0.22, 1, 0.36, 1)
|
||||
forwards;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
|
@ -131,3 +133,12 @@
|
|||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@keyframes file-upload-grow {
|
||||
0% {
|
||||
width: 0%;
|
||||
}
|
||||
100% {
|
||||
width: 92%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,6 +211,15 @@ function restoreQueriesFromSnapshot(
|
|||
}
|
||||
}
|
||||
|
||||
const FILE_UPLOAD_CONCURRENCY = 3
|
||||
|
||||
export type FileUploadEntry = { id: string; file: File }
|
||||
|
||||
export type FileUploadBatchResult = {
|
||||
failures: { id: string; message: string }[]
|
||||
successCount: number
|
||||
}
|
||||
|
||||
export function useDocumentMutations({
|
||||
onClose,
|
||||
}: UseDocumentMutationsOptions = {}) {
|
||||
|
|
@ -353,71 +362,119 @@ export function useDocumentMutations({
|
|||
|
||||
const fileMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
file,
|
||||
fileEntries,
|
||||
title,
|
||||
description,
|
||||
project,
|
||||
}: {
|
||||
file: File
|
||||
fileEntries: FileUploadEntry[]
|
||||
title?: string
|
||||
description?: string
|
||||
project: string
|
||||
}) => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("containerTags", JSON.stringify([project]))
|
||||
formData.append("entityContext", entityContext)
|
||||
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
|
||||
}): Promise<FileUploadBatchResult> => {
|
||||
const applyMeta = fileEntries.length === 1
|
||||
const failures: { id: string; message: string }[] = []
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
},
|
||||
)
|
||||
const uploadOne = async (entry: FileUploadEntry) => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", entry.file)
|
||||
formData.append("containerTags", JSON.stringify([project]))
|
||||
formData.append("entityContext", entityContext)
|
||||
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || "Failed to upload file")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (title || description) {
|
||||
await $fetch(`@patch/documents/${data.id}`, {
|
||||
body: {
|
||||
metadata: {
|
||||
...(title && { title }),
|
||||
...(description && { description }),
|
||||
sm_source: "consumer",
|
||||
},
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
let message = "Failed to upload file"
|
||||
try {
|
||||
const error = (await response.json()) as { error?: string }
|
||||
if (error.error) message = error.error
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string }
|
||||
|
||||
if (applyMeta && (title || description)) {
|
||||
await $fetch(`@patch/documents/${data.id}`, {
|
||||
body: {
|
||||
metadata: {
|
||||
...(title && { title }),
|
||||
...(description && { description }),
|
||||
sm_source: "consumer",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
for (let i = 0; i < fileEntries.length; i += FILE_UPLOAD_CONCURRENCY) {
|
||||
const slice = fileEntries.slice(i, i + FILE_UPLOAD_CONCURRENCY)
|
||||
await Promise.all(
|
||||
slice.map(async (entry) => {
|
||||
try {
|
||||
await uploadOne(entry)
|
||||
} catch (e) {
|
||||
failures.push({
|
||||
id: entry.id,
|
||||
message: e instanceof Error ? e.message : "Upload failed",
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const successCount = fileEntries.length - failures.length
|
||||
if (successCount === 0) {
|
||||
const firstFailure = failures[0]
|
||||
throw new Error(
|
||||
failures.length === 1 && firstFailure
|
||||
? firstFailure.message
|
||||
: `All ${failures.length} uploads failed`,
|
||||
)
|
||||
}
|
||||
|
||||
return { failures, successCount }
|
||||
},
|
||||
onMutate: async ({ file, title, description, project }) => {
|
||||
onMutate: async ({ fileEntries, title, description, project }) => {
|
||||
if (fileEntries.length !== 1) {
|
||||
return {
|
||||
previousQueries: undefined as [unknown, unknown][] | undefined,
|
||||
}
|
||||
}
|
||||
const previousQueries = await cancelAndSnapshotQueries(queryClient)
|
||||
const entry = fileEntries[0]
|
||||
if (!entry) {
|
||||
return {
|
||||
previousQueries: undefined as [unknown, unknown][] | undefined,
|
||||
}
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const optimisticMemory: OptimisticMemory = {
|
||||
id: `temp-file-${crypto.randomUUID()}`,
|
||||
content: "",
|
||||
url: null,
|
||||
title: title || file.name,
|
||||
description: description || `Uploading ${file.name}...`,
|
||||
title: title || entry.file.name,
|
||||
description: description || `Uploading ${entry.file.name}...`,
|
||||
containerTags: [project],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: "processing",
|
||||
type: "file",
|
||||
metadata: {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
fileName: entry.file.name,
|
||||
fileSize: entry.file.size,
|
||||
mimeType: entry.file.type,
|
||||
},
|
||||
memoryEntries: [],
|
||||
}
|
||||
|
|
@ -429,19 +486,34 @@ export function useDocumentMutations({
|
|||
|
||||
return { previousQueries }
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
|
||||
onError: (error, variables, context) => {
|
||||
if (variables.fileEntries.length === 1) {
|
||||
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
|
||||
}
|
||||
toast.error("Failed to upload file", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
analytics.documentAdded({ type: "file", project_id: variables.project })
|
||||
toast.success("File uploaded successfully!", {
|
||||
description: "Your file is being processed",
|
||||
})
|
||||
onSuccess: (data, variables) => {
|
||||
for (let i = 0; i < data.successCount; i++) {
|
||||
analytics.documentAdded({ type: "file", project_id: variables.project })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
onClose?.()
|
||||
if (data.failures.length === 0) {
|
||||
toast.success(
|
||||
data.successCount === 1
|
||||
? "File uploaded successfully!"
|
||||
: `${data.successCount} files uploaded successfully!`,
|
||||
{
|
||||
description: "Your files are being processed",
|
||||
},
|
||||
)
|
||||
onClose?.()
|
||||
return
|
||||
}
|
||||
toast.warning("Some uploads failed", {
|
||||
description: `${data.successCount} uploaded, ${data.failures.length} failed — fix or retry below`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue