mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-07-09 17:18:51 +00:00
Add bulk multi-URL paste to create separate memories (#1109)
This commit is contained in:
parent
3b1fac2d6c
commit
5eed4bcbf3
6 changed files with 467 additions and 145 deletions
|
|
@ -147,9 +147,10 @@ export function AddDocument({
|
|||
const fileDataRef = useRef(fileData)
|
||||
fileDataRef.current = fileData
|
||||
|
||||
const { noteMutation, linkMutation, fileMutation } = useDocumentMutations({
|
||||
onClose,
|
||||
})
|
||||
const { noteMutation, linkMutation, bulkLinkMutation, fileMutation } =
|
||||
useDocumentMutations({
|
||||
onClose,
|
||||
})
|
||||
|
||||
const autumn = useCustomer()
|
||||
const {
|
||||
|
|
@ -187,13 +188,30 @@ export function AddDocument({
|
|||
|
||||
const handleLinkSubmit = useCallback(
|
||||
(data: LinkData) => {
|
||||
// In bulk mode the selection is the source of truth, not the raw textarea.
|
||||
if (data.bulkUrls) {
|
||||
if (data.bulkUrls.length >= 2) {
|
||||
bulkLinkMutation.mutate({
|
||||
urls: data.bulkUrls,
|
||||
project: localSelectedProject,
|
||||
})
|
||||
return
|
||||
}
|
||||
const [onlyUrl] = data.bulkUrls
|
||||
if (onlyUrl) {
|
||||
linkMutation.mutate({ url: onlyUrl, project: localSelectedProject })
|
||||
return
|
||||
}
|
||||
toast.error("Select at least one link")
|
||||
return
|
||||
}
|
||||
if (!data.url.trim()) {
|
||||
toast.error("Please enter a URL")
|
||||
return
|
||||
}
|
||||
linkMutation.mutate({ url: data.url, project: localSelectedProject })
|
||||
},
|
||||
[linkMutation, localSelectedProject],
|
||||
[linkMutation, bulkLinkMutation, localSelectedProject],
|
||||
)
|
||||
|
||||
const handleFileSubmit = useCallback(
|
||||
|
|
@ -263,6 +281,14 @@ export function AddDocument({
|
|||
setFileData(data)
|
||||
}, [])
|
||||
|
||||
const handleNoteImportLinks = useCallback(
|
||||
(urls: string[]) => {
|
||||
setLinkData({ url: urls.join("\n"), title: "", description: "" })
|
||||
setActiveTab("link")
|
||||
},
|
||||
[setActiveTab],
|
||||
)
|
||||
|
||||
const handleButtonClick = () => {
|
||||
switch (activeTab) {
|
||||
case "note":
|
||||
|
|
@ -278,12 +304,21 @@ export function AddDocument({
|
|||
}
|
||||
|
||||
const isSubmitting =
|
||||
noteMutation.isPending || linkMutation.isPending || fileMutation.isPending
|
||||
noteMutation.isPending ||
|
||||
linkMutation.isPending ||
|
||||
bulkLinkMutation.isPending ||
|
||||
fileMutation.isPending
|
||||
|
||||
const fileTabHasPending = fileData.items.some((i) => i.status === "pending")
|
||||
const fileTabSubmitDisabled =
|
||||
activeTab === "file" && (!fileTabHasPending || isSubmitting)
|
||||
|
||||
const linkBulkCount = linkData.bulkUrls?.length ?? 0
|
||||
const linkTabSubmitDisabled =
|
||||
activeTab === "link" &&
|
||||
linkData.bulkUrls !== undefined &&
|
||||
linkBulkCount === 0
|
||||
|
||||
const spaceSelector = (
|
||||
<SpaceSelector
|
||||
selectedProjects={[localSelectedProject]}
|
||||
|
|
@ -479,6 +514,7 @@ export function AddDocument({
|
|||
<NoteContent
|
||||
onSubmit={handleNoteSubmit}
|
||||
onContentChange={handleNoteContentChange}
|
||||
onImportLinks={handleNoteImportLinks}
|
||||
isSubmitting={noteMutation.isPending}
|
||||
isOpen={isOpen}
|
||||
initialContent={noteContent}
|
||||
|
|
@ -488,7 +524,9 @@ export function AddDocument({
|
|||
<LinkContent
|
||||
onSubmit={handleLinkSubmit}
|
||||
onDataChange={handleLinkDataChange}
|
||||
isSubmitting={linkMutation.isPending}
|
||||
isSubmitting={
|
||||
linkMutation.isPending || bulkLinkMutation.isPending
|
||||
}
|
||||
isOpen={isOpen}
|
||||
initialData={linkData}
|
||||
/>
|
||||
|
|
@ -539,7 +577,9 @@ export function AddDocument({
|
|||
variant="insideOut"
|
||||
onClick={handleButtonClick}
|
||||
disabled={
|
||||
activeTab === "file" ? fileTabSubmitDisabled : isSubmitting
|
||||
activeTab === "file"
|
||||
? fileTabSubmitDisabled
|
||||
: isSubmitting || linkTabSubmitDisabled
|
||||
}
|
||||
className={cn(isMobile && "h-12 flex-1 px-5 text-[15px]")}
|
||||
>
|
||||
|
|
@ -548,6 +588,8 @@ export function AddDocument({
|
|||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Adding…
|
||||
</>
|
||||
) : activeTab === "link" && linkBulkCount >= 2 ? (
|
||||
`+ Add ${linkBulkCount} memories`
|
||||
) : (
|
||||
<>
|
||||
+ Add {activeTab}{" "}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { Image as ImageIcon, Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { extractUrls, getFaviconUrl, normalizeUrl } from "@/lib/url-helpers"
|
||||
|
||||
export interface LinkData {
|
||||
url: string
|
||||
title: string
|
||||
description: string
|
||||
image?: string
|
||||
bulkUrls?: string[]
|
||||
}
|
||||
|
||||
interface LinkContentProps {
|
||||
|
|
@ -23,6 +25,9 @@ interface LinkContentProps {
|
|||
initialData?: LinkData
|
||||
}
|
||||
|
||||
const prettyUrl = (url: string) =>
|
||||
url.replace(/^https?:\/\//, "").replace(/\/$/, "")
|
||||
|
||||
export function LinkContent({
|
||||
onSubmit,
|
||||
onDataChange,
|
||||
|
|
@ -35,50 +40,48 @@ export function LinkContent({
|
|||
const [description, setDescription] = useState(initialData?.description ?? "")
|
||||
const [image, setImage] = useState<string | undefined>(initialData?.image)
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false)
|
||||
const [deselected, setDeselected] = useState<Set<string>>(new Set())
|
||||
|
||||
const canSubmit = url.trim().length > 0 && !isSubmitting
|
||||
const { urls: detectedUrls, duplicates } = useMemo(
|
||||
() => extractUrls(url),
|
||||
[url],
|
||||
)
|
||||
const isBulk = detectedUrls.length >= 2
|
||||
const selectedUrls = useMemo(
|
||||
() => detectedUrls.filter((u) => !deselected.has(u)),
|
||||
[detectedUrls, deselected],
|
||||
)
|
||||
|
||||
const canSubmit =
|
||||
(isBulk ? selectedUrls.length > 0 : url.trim().length > 0) && !isSubmitting
|
||||
|
||||
useEffect(() => {
|
||||
const selected = detectedUrls.filter((u) => !deselected.has(u))
|
||||
onDataChange?.({
|
||||
url: url.trim(),
|
||||
title,
|
||||
description,
|
||||
...(image && { image }),
|
||||
...(detectedUrls.length >= 2 && { bulkUrls: selected }),
|
||||
})
|
||||
}, [url, title, description, image, detectedUrls, deselected, onDataChange])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (canSubmit && onSubmit) {
|
||||
let normalizedUrl = url.trim()
|
||||
if (
|
||||
!normalizedUrl.startsWith("http://") &&
|
||||
!normalizedUrl.startsWith("https://")
|
||||
) {
|
||||
normalizedUrl = `https://${normalizedUrl}`
|
||||
}
|
||||
onSubmit({ url: normalizedUrl, title, description })
|
||||
if (!canSubmit || !onSubmit) return
|
||||
if (isBulk) {
|
||||
onSubmit({ url: "", title, description, bulkUrls: selectedUrls })
|
||||
return
|
||||
}
|
||||
onSubmit({ url: normalizeUrl(url.trim()), title, description })
|
||||
}
|
||||
|
||||
const updateData = (
|
||||
newUrl: string,
|
||||
newTitle: string,
|
||||
newDescription: string,
|
||||
newImage?: string,
|
||||
) => {
|
||||
onDataChange?.({
|
||||
url: newUrl,
|
||||
title: newTitle,
|
||||
description: newDescription,
|
||||
...(newImage && { image: newImage }),
|
||||
const toggleUrl = (u: string) =>
|
||||
setDeselected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(u)) next.delete(u)
|
||||
else next.add(u)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleUrlChange = (newUrl: string) => {
|
||||
setUrl(newUrl)
|
||||
updateData(newUrl, title, description, image)
|
||||
}
|
||||
|
||||
const handleTitleChange = (newTitle: string) => {
|
||||
setTitle(newTitle)
|
||||
updateData(url, newTitle, description)
|
||||
}
|
||||
|
||||
const handleDescriptionChange = (newDescription: string) => {
|
||||
setDescription(newDescription)
|
||||
updateData(url, title, newDescription, image)
|
||||
}
|
||||
|
||||
const handlePreviewLink = async () => {
|
||||
if (!url.trim()) {
|
||||
|
|
@ -86,15 +89,8 @@ export function LinkContent({
|
|||
return
|
||||
}
|
||||
|
||||
let normalizedUrl = url.trim()
|
||||
if (
|
||||
!normalizedUrl.startsWith("http://") &&
|
||||
!normalizedUrl.startsWith("https://")
|
||||
) {
|
||||
normalizedUrl = `https://${normalizedUrl}`
|
||||
setUrl(normalizedUrl)
|
||||
updateData(normalizedUrl, title, description, image)
|
||||
}
|
||||
const normalizedUrl = normalizeUrl(url.trim())
|
||||
if (normalizedUrl !== url) setUrl(normalizedUrl)
|
||||
|
||||
setIsPreviewLoading(true)
|
||||
try {
|
||||
|
|
@ -109,16 +105,11 @@ export function LinkContent({
|
|||
|
||||
const data = await response.json()
|
||||
|
||||
const newTitle = data.title || ""
|
||||
const newDescription = data.description || ""
|
||||
const newImage = data.image || undefined
|
||||
setTitle(data.title || "")
|
||||
setDescription(data.description || "")
|
||||
setImage(data.image || undefined)
|
||||
|
||||
setTitle(newTitle)
|
||||
setDescription(newDescription)
|
||||
setImage(newImage)
|
||||
updateData(url, newTitle, newDescription, newImage)
|
||||
|
||||
if (!newTitle && !newDescription && !newImage) {
|
||||
if (!data.title && !data.description && !data.image) {
|
||||
toast.info("No Open Graph data found for this URL")
|
||||
} else {
|
||||
toast.success("Preview loaded successfully")
|
||||
|
|
@ -138,13 +129,13 @@ export function LinkContent({
|
|||
enableOnFormTags: ["INPUT", "TEXTAREA"],
|
||||
})
|
||||
|
||||
// Reset content when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setUrl("")
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
setImage(undefined)
|
||||
setDeselected(new Set())
|
||||
onDataChange?.({ url: "", title: "", description: "" })
|
||||
}
|
||||
}, [isOpen, onDataChange])
|
||||
|
|
@ -160,86 +151,160 @@ export function LinkContent({
|
|||
<p
|
||||
className={cn("text-[16px] font-medium pl-2 pb-2", dmSansClassName())}
|
||||
>
|
||||
Paste a link to turn it into a memory
|
||||
Paste one link — or many — to turn them into memories
|
||||
</p>
|
||||
<div className="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
disabled={isSubmitting}
|
||||
className="w-full p-4 rounded-xl bg-[#14161A] shadow-inside-out disabled:opacity-50 outline-1 outline-transparent focus:outline-[#525D6EB2]"
|
||||
/>
|
||||
<Button
|
||||
variant="linkPreview"
|
||||
className="absolute right-2 top-2"
|
||||
disabled={isSubmitting || isPreviewLoading || !url.trim()}
|
||||
onClick={handlePreviewLink}
|
||||
>
|
||||
{isPreviewLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading…
|
||||
</>
|
||||
) : (
|
||||
"Preview Link"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#14161A] rounded-[14px] py-6 px-4 space-y-4 shadow-inside-out">
|
||||
<div>
|
||||
<p className="pl-2 pb-2 font-semibold text-[16px] text-[#737373]">
|
||||
Link title
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Mahesh Sanikommu - Portfolio"
|
||||
disabled
|
||||
className="w-full px-4 py-3 bg-[#0F1217] rounded-xl disabled:opacity-50 outline-1 outline-transparent focus:outline-[#525D6EB2]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="pl-2 pb-2 font-semibold text-[16px] text-[#737373]">
|
||||
Link description
|
||||
</p>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder="Portfolio website of Mahesh Sanikommu"
|
||||
disabled
|
||||
className="w-full px-4 py-3 bg-[#0F1217] rounded-xl resize-none disabled:opacity-50 outline-1 outline-transparent focus:outline-[#525D6EB2]"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com — paste multiple links to save them all"
|
||||
disabled={isSubmitting}
|
||||
rows={isBulk ? 3 : 1}
|
||||
className="w-full resize-none p-4 rounded-xl bg-[#14161A] shadow-inside-out disabled:opacity-50 outline-1 outline-transparent focus:outline-[#525D6EB2]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="pl-2 pb-2 font-semibold text-[16px] text-[#737373]">
|
||||
Link Preview Image
|
||||
</p>
|
||||
{image ? (
|
||||
<div className="w-full max-w-md aspect-4/2 bg-[#0F1217] rounded-xl overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={title || "Link preview"}
|
||||
className="size-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none"
|
||||
e.currentTarget.parentElement?.classList.add("opacity-50")
|
||||
e.currentTarget.parentElement?.classList.add("flex")
|
||||
e.currentTarget.parentElement?.classList.add("items-center")
|
||||
e.currentTarget.parentElement?.classList.add("justify-center")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-md aspect-4/2 bg-[#0F1217] opacity-50 rounded-xl flex items-center justify-center">
|
||||
<ImageIcon className="size-8 text-[#737373]" />
|
||||
</div>
|
||||
{!isBulk && (
|
||||
<Button
|
||||
variant="linkPreview"
|
||||
className="absolute right-2 top-2"
|
||||
disabled={isSubmitting || isPreviewLoading || !url.trim()}
|
||||
onClick={handlePreviewLink}
|
||||
>
|
||||
{isPreviewLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading…
|
||||
</>
|
||||
) : (
|
||||
"Preview Link"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBulk ? (
|
||||
<div className="bg-[#14161A] rounded-[14px] py-4 px-3 space-y-2 shadow-inside-out">
|
||||
<div className="flex items-center justify-between px-1 pb-1">
|
||||
<p className="font-semibold text-[15px]">
|
||||
Found {detectedUrls.length} links
|
||||
{duplicates > 0 && (
|
||||
<span className="text-[#737373] font-normal">
|
||||
{" "}
|
||||
· {duplicates} duplicate{duplicates === 1 ? "" : "s"} removed
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-3 text-[12px] text-[#4BA0FA]">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
onClick={() => setDeselected(new Set())}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
onClick={() => setDeselected(new Set(detectedUrls))}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[38dvh] overflow-y-auto scrollbar-thin space-y-1 pr-1">
|
||||
{detectedUrls.map((u) => {
|
||||
const favicon = getFaviconUrl(u)
|
||||
return (
|
||||
<label
|
||||
key={u}
|
||||
className="flex items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-white/5 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!deselected.has(u)}
|
||||
onChange={() => toggleUrl(u)}
|
||||
disabled={isSubmitting}
|
||||
className="size-4 shrink-0 accent-[#4BA0FA]"
|
||||
/>
|
||||
{favicon ? (
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
className="size-4 shrink-0 rounded-sm"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.visibility = "hidden"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="size-4 shrink-0 text-[#737373]" />
|
||||
)}
|
||||
<span className="truncate text-[14px] text-[#E5E5E5]">
|
||||
{prettyUrl(u)}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="px-1 pt-1 text-[12px] text-[#737373]">
|
||||
{selectedUrls.length} selected · each link becomes its own memory
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#14161A] rounded-[14px] py-6 px-4 space-y-4 shadow-inside-out">
|
||||
<div>
|
||||
<p className="pl-2 pb-2 font-semibold text-[16px] text-[#737373]">
|
||||
Link title
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Mahesh Sanikommu - Portfolio"
|
||||
disabled
|
||||
className="w-full px-4 py-3 bg-[#0F1217] rounded-xl disabled:opacity-50 outline-1 outline-transparent focus:outline-[#525D6EB2]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="pl-2 pb-2 font-semibold text-[16px] text-[#737373]">
|
||||
Link description
|
||||
</p>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Portfolio website of Mahesh Sanikommu"
|
||||
disabled
|
||||
className="w-full px-4 py-3 bg-[#0F1217] rounded-xl resize-none disabled:opacity-50 outline-1 outline-transparent focus:outline-[#525D6EB2]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="pl-2 pb-2 font-semibold text-[16px] text-[#737373]">
|
||||
Link Preview Image
|
||||
</p>
|
||||
{image ? (
|
||||
<div className="w-full max-w-md aspect-4/2 bg-[#0F1217] rounded-xl overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={title || "Link preview"}
|
||||
className="size-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none"
|
||||
e.currentTarget.parentElement?.classList.add("opacity-50")
|
||||
e.currentTarget.parentElement?.classList.add("flex")
|
||||
e.currentTarget.parentElement?.classList.add("items-center")
|
||||
e.currentTarget.parentElement?.classList.add(
|
||||
"justify-center",
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-md aspect-4/2 bg-[#0F1217] opacity-50 rounded-xl flex items-center justify-center">
|
||||
<ImageIcon className="size-8 text-[#737373]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { LinkIcon } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { TextEditor } from "../text-editor"
|
||||
import { extractUrls } from "@/lib/url-helpers"
|
||||
|
||||
interface NoteContentProps {
|
||||
onSubmit?: (content: string) => void
|
||||
onContentChange?: (content: string) => void
|
||||
onImportLinks?: (urls: string[]) => void
|
||||
isSubmitting?: boolean
|
||||
isOpen?: boolean
|
||||
initialContent?: string
|
||||
|
|
@ -14,11 +20,16 @@ interface NoteContentProps {
|
|||
export function NoteContent({
|
||||
onSubmit,
|
||||
onContentChange,
|
||||
onImportLinks,
|
||||
isSubmitting,
|
||||
initialContent,
|
||||
}: NoteContentProps) {
|
||||
const [content, setContent] = useState(initialContent ?? "")
|
||||
const [seededContent] = useState(initialContent || undefined)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
const { urls: detectedUrls } = useMemo(() => extractUrls(content), [content])
|
||||
const showBulkOffer = detectedUrls.length >= 2 && !dismissed
|
||||
|
||||
const canSubmit = content.trim().length > 0 && !isSubmitting
|
||||
|
||||
|
|
@ -34,13 +45,53 @@ export function NoteContent({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[45dvh] w-full flex-1 overflow-y-auto rounded-[14px] bg-[#10151C] p-3 shadow-inside-out ring-1 ring-[#202A36] md:mb-4! md:bg-[#14161A] md:p-4 md:ring-0">
|
||||
<TextEditor
|
||||
content={seededContent}
|
||||
onContentChange={handleContentChange}
|
||||
onSubmit={handleSubmit}
|
||||
debounceMs={0}
|
||||
/>
|
||||
<div className="flex h-full min-h-[45dvh] w-full flex-1 flex-col gap-2 md:mb-4!">
|
||||
{showBulkOffer && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-3 rounded-[14px] bg-[#14161A] px-3 py-2.5 shadow-inside-out",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text-[13px] text-[#737373]">
|
||||
<LinkIcon className="size-4 shrink-0" />
|
||||
<p className="truncate">
|
||||
<span className="font-medium text-white">
|
||||
{detectedUrls.length} links
|
||||
</span>{" "}
|
||||
detected — save each as its own memory?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDismissed(true)}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-full px-3 text-[#737373]/80 hover:text-white"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
size="sm"
|
||||
onClick={() => onImportLinks?.(detectedUrls)}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-full px-4"
|
||||
>
|
||||
Save as {detectedUrls.length} memories
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 w-full flex-1 overflow-y-auto rounded-[14px] bg-[#10151C] p-3 shadow-inside-out ring-1 ring-[#202A36] md:bg-[#14161A] md:p-4 md:ring-0">
|
||||
<TextEditor
|
||||
content={seededContent}
|
||||
onContentChange={handleContentChange}
|
||||
onSubmit={handleSubmit}
|
||||
debounceMs={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ function restoreQueriesFromSnapshot(
|
|||
}
|
||||
|
||||
const FILE_UPLOAD_CONCURRENCY = 3
|
||||
const BULK_LINK_BATCH_SIZE = 500
|
||||
const fullDocumentQueryKey = (documentId: string) =>
|
||||
["document-full", documentId] as const
|
||||
|
||||
|
|
@ -380,6 +381,100 @@ export function useDocumentMutations({
|
|||
},
|
||||
})
|
||||
|
||||
const bulkLinkMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
urls,
|
||||
project,
|
||||
}: {
|
||||
urls: string[]
|
||||
project: string
|
||||
}): Promise<{ success: number; failed: number }> => {
|
||||
let success = 0
|
||||
let failed = 0
|
||||
|
||||
for (let i = 0; i < urls.length; i += BULK_LINK_BATCH_SIZE) {
|
||||
const chunk = urls.slice(i, i + BULK_LINK_BATCH_SIZE)
|
||||
const response = await $fetch("@post/documents/batch", {
|
||||
body: {
|
||||
documents: chunk.map((url) => ({
|
||||
content: url,
|
||||
containerTags: [project],
|
||||
entityContext,
|
||||
metadata: { sm_source: "consumer" },
|
||||
})),
|
||||
},
|
||||
})
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to add links")
|
||||
}
|
||||
success += response.data?.success ?? 0
|
||||
failed += response.data?.failed ?? 0
|
||||
}
|
||||
|
||||
return { success, failed }
|
||||
},
|
||||
onMutate: async ({ urls, project }) => {
|
||||
const previousQueries = await cancelAndSnapshotQueries(queryClient)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
for (const url of urls) {
|
||||
const optimisticMemory: OptimisticMemory = {
|
||||
id: `temp-${crypto.randomUUID()}`,
|
||||
content: "",
|
||||
url,
|
||||
title: "Processing...",
|
||||
description: "Extracting content...",
|
||||
containerTags: [project],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: "queued",
|
||||
type: "link",
|
||||
metadata: {
|
||||
processingStage: "queued",
|
||||
processingMessage: "Added to processing queue",
|
||||
},
|
||||
memoryEntries: [],
|
||||
isOptimistic: true,
|
||||
}
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["documents-with-memories"] },
|
||||
(old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
|
||||
)
|
||||
}
|
||||
|
||||
return { previousQueries }
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
|
||||
toast.error("Failed to add links", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
for (let i = 0; i < data.success; i++) {
|
||||
analytics.documentAdded({ type: "link", project_id: variables.project })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
|
||||
if (data.failed === 0) {
|
||||
toast.success(`${data.success} links added!`, {
|
||||
description: "Your links are being processed",
|
||||
})
|
||||
onClose?.()
|
||||
return
|
||||
}
|
||||
if (data.success === 0) {
|
||||
toast.error("Failed to add links", {
|
||||
description: `All ${data.failed} links failed`,
|
||||
})
|
||||
return
|
||||
}
|
||||
toast.warning("Some links failed", {
|
||||
description: `${data.success} added, ${data.failed} failed`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const fileMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
fileEntries,
|
||||
|
|
@ -669,6 +764,7 @@ export function useDocumentMutations({
|
|||
return {
|
||||
noteMutation,
|
||||
linkMutation,
|
||||
bulkLinkMutation,
|
||||
fileMutation,
|
||||
updateMutation,
|
||||
deleteMutation,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,43 @@ export const normalizeUrl = (url: string): string => {
|
|||
return `https://${url}`
|
||||
}
|
||||
|
||||
const URL_TOKEN_REGEX =
|
||||
/(?:https?:\/\/)?(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:[/?#][^\s<>[\]"'`]*)?/g
|
||||
const MARKDOWN_LINK_REGEX = /\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g
|
||||
const ANGLE_LINK_REGEX = /<(https?:\/\/[^\s>]+)>/g
|
||||
|
||||
/** Pull every distinct URL out of a free-text blob; handles markdown `[t](url)`, `<url>`, and bare links. */
|
||||
export const extractUrls = (
|
||||
text: string,
|
||||
): { urls: string[]; duplicates: number } => {
|
||||
if (!text.trim()) return { urls: [], duplicates: 0 }
|
||||
const unwrapped = text
|
||||
.replace(MARKDOWN_LINK_REGEX, " $1 ")
|
||||
.replace(ANGLE_LINK_REGEX, " $1 ")
|
||||
const matches = unwrapped.match(URL_TOKEN_REGEX) ?? []
|
||||
const seen = new Set<string>()
|
||||
const urls: string[] = []
|
||||
let duplicates = 0
|
||||
for (const match of matches) {
|
||||
let trimmed = match.trim().replace(/[.,;!]+$/, "")
|
||||
const opens = (trimmed.match(/\(/g) ?? []).length
|
||||
const closes = (trimmed.match(/\)/g) ?? []).length
|
||||
if (closes > opens && trimmed.endsWith(")")) {
|
||||
trimmed = trimmed.replace(/\)+$/, "").replace(/[.,;!]+$/, "")
|
||||
}
|
||||
const normalized = normalizeUrl(trimmed)
|
||||
if (!isValidUrl(normalized)) continue
|
||||
const key = normalized.toLowerCase().replace(/\/+$/, "")
|
||||
if (seen.has(key)) {
|
||||
duplicates++
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
urls.push(normalized)
|
||||
}
|
||||
return { urls, duplicates }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is a Twitter/X URL.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -191,6 +191,37 @@ export const apiSchema = createSchema({
|
|||
input: MemoryAddSchema,
|
||||
output: MemoryResponseSchema,
|
||||
},
|
||||
"@post/documents/batch": {
|
||||
input: z.object({
|
||||
documents: z
|
||||
.array(
|
||||
z.object({
|
||||
content: z.string(),
|
||||
containerTags: z.array(z.string()).optional(),
|
||||
containerTag: z.string().optional(),
|
||||
entityContext: z.string().max(1500).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(600),
|
||||
containerTag: z.string().optional(),
|
||||
entityContext: z.string().max(1500).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
}),
|
||||
output: z.object({
|
||||
results: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
error: z.string().optional(),
|
||||
details: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
success: z.number(),
|
||||
failed: z.number(),
|
||||
}),
|
||||
},
|
||||
"@post/documents/list": {
|
||||
body: z
|
||||
.object({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue