Add bulk multi-URL paste to create separate memories (#1109)

This commit is contained in:
Mahesh Sanikommu 2026-06-14 23:07:09 -07:00 committed by GitHub
parent 3b1fac2d6c
commit 5eed4bcbf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 467 additions and 145 deletions

View file

@ -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}{" "}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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,

View file

@ -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.
*/

View file

@ -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({