mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
implement Nova chat attachments on the web side (#1004)
- Added attachment draft/shared types and validation in components/chat/attachments.ts. - Added paperclip upload UI to Nova composer with file chips, size/status, remove, retry, and per-file Save / Chat only toggle. - Wired uploads before send to /chat/attachments, then sends returned attachment references in chat message metadata. - Preserved attachment metadata when loading threads and rendered attachment chips on user messages. - Added attachment support from the home composer into the full chat view. - Extended chat analytics with attachment counts. <img width="1905" height="900" alt="image" src="https://github.com/user-attachments/assets/631001b8-7c68-4015-b36b-06c69cdad271" /> <img width="1323" height="1600" alt="image" src="https://github.com/user-attachments/assets/77eee08a-b235-41eb-b03a-f406b81b46e7" /> - ensured responsiveness
This commit is contained in:
parent
428d3ccefa
commit
70955480fd
11 changed files with 1211 additions and 137 deletions
|
|
@ -13,6 +13,7 @@ import { useQueryState } from "nuqs"
|
|||
import { Header, PublicHeader } from "@/components/header"
|
||||
import { MobileBottomNav } from "@/components/bottom-nav"
|
||||
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
|
||||
import type { ChatAttachmentDraft } from "@/components/chat/attachments"
|
||||
import { DashboardView } from "@/components/dashboard-view"
|
||||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
import { GraphLayoutView } from "@/components/graph-layout-view"
|
||||
|
|
@ -166,6 +167,9 @@ export default function NewPage() {
|
|||
const [queuedChatProject, setQueuedChatProject] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [queuedChatAttachments, setQueuedChatAttachments] = useState<
|
||||
ChatAttachmentDraft[] | null
|
||||
>(null)
|
||||
const [queuedHighlightContent, setQueuedHighlightContent] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
|
|
@ -494,6 +498,7 @@ export default function NewPage() {
|
|||
setQueuedChatModel(null)
|
||||
setQueuedChatReasoningEffort(null)
|
||||
setQueuedChatProject(null)
|
||||
setQueuedChatAttachments(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
void setViewMode("chat")
|
||||
},
|
||||
|
|
@ -506,12 +511,14 @@ export default function NewPage() {
|
|||
model: ModelId,
|
||||
projectId: string,
|
||||
reasoningEffort: ReasoningEffort,
|
||||
attachments?: ChatAttachmentDraft[],
|
||||
) => {
|
||||
setQueuedHighlightContent(null)
|
||||
setQueuedChatSeed(message)
|
||||
setQueuedChatModel(model)
|
||||
setQueuedChatReasoningEffort(reasoningEffort)
|
||||
setQueuedChatProject(projectId)
|
||||
setQueuedChatAttachments(attachments ?? null)
|
||||
setQueuedMessageSource("home")
|
||||
void setViewMode("chat")
|
||||
},
|
||||
|
|
@ -523,6 +530,7 @@ export default function NewPage() {
|
|||
setQueuedChatModel(null)
|
||||
setQueuedChatReasoningEffort(null)
|
||||
setQueuedChatProject(null)
|
||||
setQueuedChatAttachments(null)
|
||||
setQueuedHighlightContent(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
}, [])
|
||||
|
|
@ -642,6 +650,7 @@ export default function NewPage() {
|
|||
queuedHighlightContent={queuedHighlightContent}
|
||||
onConsumeQueuedMessage={consumeQueuedChat}
|
||||
queuedMessageSource={queuedMessageSource}
|
||||
queuedAttachments={queuedChatAttachments}
|
||||
initialSelectedModel={queuedChatModel}
|
||||
initialReasoningEffort={queuedChatReasoningEffort}
|
||||
initialChatProject={queuedChatProject}
|
||||
|
|
|
|||
82
apps/web/components/chat/attachments.ts
Normal file
82
apps/web/components/chat/attachments.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
export const CHAT_ATTACHMENT_ACCEPT =
|
||||
"image/*,.pdf,application/pdf,.doc,.docx,.txt,.md,.mdx,.markdown,text/markdown"
|
||||
|
||||
export const CHAT_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024
|
||||
|
||||
const SUPPORTED_EXTENSIONS = new Set([
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".txt",
|
||||
".md",
|
||||
".mdx",
|
||||
".markdown",
|
||||
])
|
||||
|
||||
export type ChatAttachment = {
|
||||
id: string
|
||||
documentId?: string
|
||||
filename: string
|
||||
mediaType: string
|
||||
size: number
|
||||
saveToMemory: boolean
|
||||
status: "ready" | "processing" | "failed"
|
||||
url?: string
|
||||
contentPreview?: string
|
||||
}
|
||||
|
||||
export type ChatAttachmentDraftStatus =
|
||||
| "queued"
|
||||
| "uploading"
|
||||
| "uploaded"
|
||||
| "error"
|
||||
|
||||
export type ChatAttachmentDraft = {
|
||||
id: string
|
||||
file: File
|
||||
saveToMemory: boolean
|
||||
status: ChatAttachmentDraftStatus
|
||||
errorMessage?: string
|
||||
uploaded?: ChatAttachment
|
||||
}
|
||||
|
||||
export type ChatAttachmentMessageMetadata = {
|
||||
attachments?: ChatAttachment[]
|
||||
}
|
||||
|
||||
export function isAcceptedChatAttachment(file: File): boolean {
|
||||
if (file.size > CHAT_ATTACHMENT_MAX_BYTES) return false
|
||||
const name = file.name.toLowerCase()
|
||||
const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : ""
|
||||
if (SUPPORTED_EXTENSIONS.has(ext)) return true
|
||||
if (file.type.startsWith("image/")) return true
|
||||
if (file.type === "application/pdf") return true
|
||||
if (file.type === "text/markdown") return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function chatAttachmentKey(file: File): string {
|
||||
return `${file.name}:${file.size}:${file.lastModified}`
|
||||
}
|
||||
|
||||
export function createChatAttachmentDraft(file: File): ChatAttachmentDraft {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
saveToMemory: true,
|
||||
status: "queued",
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAttachmentSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`
|
||||
const kb = size / 1024
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
||||
return `${(kb / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function getChatMessageAttachments(metadata: unknown): ChatAttachment[] {
|
||||
const attachments = (metadata as ChatAttachmentMessageMetadata | undefined)
|
||||
?.attachments
|
||||
return Array.isArray(attachments) ? attachments : []
|
||||
}
|
||||
|
|
@ -8,6 +8,14 @@ import { cn } from "@lib/utils"
|
|||
import type { ModelId } from "@/lib/models"
|
||||
import { SpaceSelector } from "@/components/space-selector"
|
||||
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
chatAttachmentKey,
|
||||
CHAT_ATTACHMENT_ACCEPT,
|
||||
createChatAttachmentDraft,
|
||||
type ChatAttachmentDraft,
|
||||
isAcceptedChatAttachment,
|
||||
} from "./attachments"
|
||||
import { ReasoningSelector } from "./reasoning-selector"
|
||||
import { getDefaultReasoningEffort, type ReasoningEffort } from "@/lib/models"
|
||||
|
||||
|
|
@ -20,10 +28,14 @@ export function HomeChatComposer({
|
|||
model: ModelId,
|
||||
projectId: string,
|
||||
reasoningEffort: ReasoningEffort,
|
||||
attachments?: ChatAttachmentDraft[],
|
||||
) => void
|
||||
className?: string
|
||||
}) {
|
||||
const [input, setInput] = useState("")
|
||||
const [attachmentDrafts, setAttachmentDrafts] = useState<
|
||||
ChatAttachmentDraft[]
|
||||
>([])
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
|
||||
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
|
||||
getDefaultReasoningEffort("gemini-2.5-pro"),
|
||||
|
|
@ -40,15 +52,18 @@ export function HomeChatComposer({
|
|||
|
||||
const send = useCallback(() => {
|
||||
const t = input.trim()
|
||||
if (!t) return
|
||||
if (!t && attachmentDrafts.length === 0) return
|
||||
onStartChat(
|
||||
t,
|
||||
selectedModel,
|
||||
chatSpaceProjects[0] ?? selectedProject,
|
||||
reasoningEffort,
|
||||
attachmentDrafts,
|
||||
)
|
||||
setInput("")
|
||||
setAttachmentDrafts([])
|
||||
}, [
|
||||
attachmentDrafts,
|
||||
chatSpaceProjects,
|
||||
input,
|
||||
onStartChat,
|
||||
|
|
@ -57,6 +72,51 @@ export function HomeChatComposer({
|
|||
selectedProject,
|
||||
])
|
||||
|
||||
const handleAddAttachmentFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
const incoming = Array.from(files)
|
||||
const accepted = incoming.filter(isAcceptedChatAttachment)
|
||||
const rejected = incoming.length - accepted.length
|
||||
if (rejected > 0) {
|
||||
toast.error(
|
||||
rejected === 1
|
||||
? "One attachment is not supported or is over 50MB"
|
||||
: `${rejected} attachments are not supported or are over 50MB`,
|
||||
)
|
||||
}
|
||||
if (accepted.length === 0) return
|
||||
|
||||
const existingKeys = new Set(
|
||||
attachmentDrafts.map((item) => chatAttachmentKey(item.file)),
|
||||
)
|
||||
const nextItems: ChatAttachmentDraft[] = []
|
||||
let duplicateCount = 0
|
||||
for (const file of accepted) {
|
||||
const key = chatAttachmentKey(file)
|
||||
if (existingKeys.has(key)) {
|
||||
duplicateCount++
|
||||
continue
|
||||
}
|
||||
existingKeys.add(key)
|
||||
nextItems.push(createChatAttachmentDraft(file))
|
||||
}
|
||||
if (duplicateCount > 0) {
|
||||
toast.message(
|
||||
duplicateCount === 1
|
||||
? "Skipped duplicate attachment"
|
||||
: `Skipped ${duplicateCount} duplicate attachments`,
|
||||
)
|
||||
}
|
||||
if (nextItems.length === 0) return
|
||||
setAttachmentDrafts((prev) => [...prev, ...nextItems])
|
||||
},
|
||||
[attachmentDrafts],
|
||||
)
|
||||
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setAttachmentDrafts((prev) => prev.filter((item) => item.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
|
|
@ -74,6 +134,11 @@ export function HomeChatComposer({
|
|||
onStop={() => {}}
|
||||
onKeyDown={handleKeyDown}
|
||||
isResponding={false}
|
||||
attachments={attachmentDrafts}
|
||||
onAddAttachmentFiles={handleAddAttachmentFiles}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
canSend={input.trim().length > 0 || attachmentDrafts.length > 0}
|
||||
attachmentAccept={CHAT_ATTACHMENT_ACCEPT}
|
||||
showStatusStrip={false}
|
||||
stackedToolbar={
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -57,8 +57,47 @@ import { useViewMode } from "@/lib/view-mode-context"
|
|||
import { threadParam } from "@/lib/search-params"
|
||||
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
|
||||
import { ChatEmptyStatePlaceholder } from "./chat-empty-state"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
chatAttachmentKey,
|
||||
CHAT_ATTACHMENT_ACCEPT,
|
||||
createChatAttachmentDraft,
|
||||
type ChatAttachment,
|
||||
type ChatAttachmentDraft,
|
||||
isAcceptedChatAttachment,
|
||||
} from "./attachments"
|
||||
import { cacheFileBlob, removeCachedFile } from "@/lib/file-cache"
|
||||
import { ReasoningSelector } from "./reasoning-selector"
|
||||
|
||||
type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home"
|
||||
|
||||
const DISCARD_ATTACHMENT_MAX_ATTEMPTS = 15
|
||||
const DISCARD_ATTACHMENT_RETRY_MS = 2000
|
||||
|
||||
type RawChatAttachmentResponse = Partial<ChatAttachment> & {
|
||||
attachment?: Partial<ChatAttachment>
|
||||
}
|
||||
|
||||
function normalizeChatAttachmentResponse(
|
||||
data: RawChatAttachmentResponse,
|
||||
draft: ChatAttachmentDraft,
|
||||
): ChatAttachment {
|
||||
const attachment = data.attachment ?? data
|
||||
const id = attachment.id ?? attachment.documentId ?? draft.id
|
||||
return {
|
||||
id,
|
||||
documentId: attachment.documentId,
|
||||
filename: attachment.filename ?? draft.file.name,
|
||||
mediaType:
|
||||
(attachment.mediaType ?? draft.file.type) || "application/octet-stream",
|
||||
size: attachment.size ?? draft.file.size,
|
||||
saveToMemory: attachment.saveToMemory ?? draft.saveToMemory,
|
||||
status: attachment.status ?? "ready",
|
||||
url: attachment.url,
|
||||
contentPreview: attachment.contentPreview,
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatLaunchFab({
|
||||
onOpen,
|
||||
isMobile,
|
||||
|
|
@ -106,6 +145,7 @@ type QueuedChatMessage = {
|
|||
text: string
|
||||
model: ModelId
|
||||
reasoningEffort: ReasoningEffort
|
||||
attachments?: ChatAttachment[]
|
||||
}
|
||||
|
||||
const CHAT_QUEUE_LIMIT = 3
|
||||
|
|
@ -133,6 +173,7 @@ export function ChatSidebar({
|
|||
queuedHighlightContent,
|
||||
onConsumeQueuedMessage,
|
||||
queuedMessageSource = "highlight",
|
||||
queuedAttachments = null,
|
||||
initialSelectedModel = null,
|
||||
initialReasoningEffort = null,
|
||||
initialChatProject = null,
|
||||
|
|
@ -145,6 +186,7 @@ export function ChatSidebar({
|
|||
queuedHighlightContent?: string | null
|
||||
onConsumeQueuedMessage?: () => void
|
||||
queuedMessageSource?: "highlight" | "home"
|
||||
queuedAttachments?: ChatAttachmentDraft[] | null
|
||||
initialSelectedModel?: ModelId | null
|
||||
initialReasoningEffort?: ReasoningEffort | null
|
||||
initialChatProject?: string | null
|
||||
|
|
@ -154,6 +196,9 @@ export function ChatSidebar({
|
|||
const isMobile = useIsMobile()
|
||||
const isPageDesktop = layout === "page" && !isMobile
|
||||
const [input, setInput] = useState("")
|
||||
const [attachmentDrafts, setAttachmentDrafts] = useState<
|
||||
ChatAttachmentDraft[]
|
||||
>([])
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>(
|
||||
initialSelectedModel ?? "claude-sonnet-4.6",
|
||||
)
|
||||
|
|
@ -222,6 +267,12 @@ export function ChatSidebar({
|
|||
const awaitingHighlightInjectionRef = useRef(false)
|
||||
const pendingHighlightMessageRef = useRef<UIMessage[] | null>(null)
|
||||
const targetHighlightChatIdRef = useRef<string | null>(null)
|
||||
const pendingRequestAttachmentsRef = useRef<ChatAttachment[]>([])
|
||||
const uploadPromisesRef = useRef<Map<string, Promise<ChatAttachment>>>(
|
||||
new Map(),
|
||||
)
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
|
||||
const discardedDraftIdsRef = useRef<Set<string>>(new Set())
|
||||
const { selectedProject } = useProject()
|
||||
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
|
||||
initialChatProject ?? AUTO_CHAT_SPACE_ID,
|
||||
|
|
@ -316,6 +367,9 @@ export function ChatSidebar({
|
|||
reasoningEffort:
|
||||
sendSettings?.reasoningEffort ?? reasoningEffortRef.current,
|
||||
truncateFromMessageId: truncateFromMessageIdRef.current,
|
||||
...(pendingRequestAttachmentsRef.current.length > 0 && {
|
||||
attachments: pendingRequestAttachmentsRef.current,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -408,6 +462,258 @@ export function ChatSidebar({
|
|||
[clearError],
|
||||
)
|
||||
|
||||
const setAttachmentDraftState = useCallback(
|
||||
(id: string, patch: Partial<ChatAttachmentDraft>) => {
|
||||
setAttachmentDrafts((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||
)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const discardUploadedAttachment = useCallback(
|
||||
(documentId: string) => {
|
||||
void removeCachedFile(documentId)
|
||||
|
||||
const run = async (attempt: number): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${chatApiBase}/chat/attachments/${documentId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
response.status === 409 &&
|
||||
attempt < DISCARD_ATTACHMENT_MAX_ATTEMPTS
|
||||
) {
|
||||
setTimeout(() => {
|
||||
void run(attempt + 1)
|
||||
}, DISCARD_ATTACHMENT_RETRY_MS)
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
console.warn("Failed to discard chat attachment", {
|
||||
documentId,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (attempt < DISCARD_ATTACHMENT_MAX_ATTEMPTS) {
|
||||
setTimeout(() => {
|
||||
void run(attempt + 1)
|
||||
}, DISCARD_ATTACHMENT_RETRY_MS)
|
||||
return
|
||||
}
|
||||
console.warn("Failed to discard chat attachment", {
|
||||
documentId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void run(1)
|
||||
},
|
||||
[chatApiBase],
|
||||
)
|
||||
|
||||
const uploadAttachmentDraft = useCallback(
|
||||
(
|
||||
draft: ChatAttachmentDraft,
|
||||
chatIdForUpload: string,
|
||||
): Promise<ChatAttachment> => {
|
||||
if (draft.status === "uploaded" && draft.uploaded) {
|
||||
return Promise.resolve(draft.uploaded)
|
||||
}
|
||||
|
||||
const inflight = uploadPromisesRef.current.get(draft.id)
|
||||
if (inflight) return inflight
|
||||
|
||||
const uploadPromise = (async (): Promise<ChatAttachment> => {
|
||||
const controller = new AbortController()
|
||||
abortControllersRef.current.set(draft.id, controller)
|
||||
|
||||
setAttachmentDraftState(draft.id, {
|
||||
status: "uploading",
|
||||
errorMessage: undefined,
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", draft.file)
|
||||
formData.append("threadId", chatIdForUpload)
|
||||
formData.append("projectId", selectedProjectRef.current)
|
||||
formData.append("saveToMemory", String(draft.saveToMemory))
|
||||
|
||||
try {
|
||||
const response = await fetch(`${chatApiBase}/chat/attachments`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let message = "Failed to upload attachment"
|
||||
try {
|
||||
const error = (await response.json()) as {
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
message = error.error ?? error.message ?? message
|
||||
} catch {
|
||||
// keep the fallback error
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as RawChatAttachmentResponse
|
||||
const attachment = normalizeChatAttachmentResponse(data, draft)
|
||||
|
||||
abortControllersRef.current.delete(draft.id)
|
||||
|
||||
if (discardedDraftIdsRef.current.has(draft.id)) {
|
||||
discardedDraftIdsRef.current.delete(draft.id)
|
||||
if (attachment.documentId) {
|
||||
discardUploadedAttachment(attachment.documentId)
|
||||
}
|
||||
return attachment
|
||||
}
|
||||
|
||||
if (attachment.documentId) {
|
||||
void cacheFileBlob(
|
||||
attachment.documentId,
|
||||
draft.file,
|
||||
draft.file.type,
|
||||
)
|
||||
}
|
||||
setAttachmentDraftState(draft.id, {
|
||||
status: "uploaded",
|
||||
uploaded: attachment,
|
||||
})
|
||||
return attachment
|
||||
} catch (error) {
|
||||
abortControllersRef.current.delete(draft.id)
|
||||
uploadPromisesRef.current.delete(draft.id)
|
||||
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (discardedDraftIdsRef.current.has(draft.id)) {
|
||||
discardedDraftIdsRef.current.delete(draft.id)
|
||||
throw error
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to upload attachment"
|
||||
setAttachmentDraftState(draft.id, {
|
||||
status: "error",
|
||||
errorMessage: message,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
|
||||
uploadPromisesRef.current.set(draft.id, uploadPromise)
|
||||
return uploadPromise
|
||||
},
|
||||
[chatApiBase, discardUploadedAttachment, setAttachmentDraftState],
|
||||
)
|
||||
|
||||
const uploadAttachmentDrafts = useCallback(
|
||||
async (drafts: ChatAttachmentDraft[], chatIdForUpload: string) => {
|
||||
const uploaded: ChatAttachment[] = []
|
||||
for (const draft of drafts) {
|
||||
uploaded.push(await uploadAttachmentDraft(draft, chatIdForUpload))
|
||||
}
|
||||
return uploaded
|
||||
},
|
||||
[uploadAttachmentDraft],
|
||||
)
|
||||
|
||||
const handleAddAttachmentFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
const incoming = Array.from(files)
|
||||
const accepted = incoming.filter(isAcceptedChatAttachment)
|
||||
const rejected = incoming.length - accepted.length
|
||||
if (rejected > 0) {
|
||||
toast.error(
|
||||
rejected === 1
|
||||
? "One attachment is not supported or is over 50MB"
|
||||
: `${rejected} attachments are not supported or are over 50MB`,
|
||||
)
|
||||
}
|
||||
if (accepted.length === 0) return
|
||||
|
||||
const existingKeys = new Set(
|
||||
attachmentDrafts.map((item) => chatAttachmentKey(item.file)),
|
||||
)
|
||||
const nextItems: ChatAttachmentDraft[] = []
|
||||
let duplicateCount = 0
|
||||
for (const file of accepted) {
|
||||
const key = chatAttachmentKey(file)
|
||||
if (existingKeys.has(key)) {
|
||||
duplicateCount++
|
||||
continue
|
||||
}
|
||||
existingKeys.add(key)
|
||||
nextItems.push(createChatAttachmentDraft(file))
|
||||
}
|
||||
if (duplicateCount > 0) {
|
||||
toast.message(
|
||||
duplicateCount === 1
|
||||
? "Skipped duplicate attachment"
|
||||
: `Skipped ${duplicateCount} duplicate attachments`,
|
||||
)
|
||||
}
|
||||
if (nextItems.length === 0) return
|
||||
setAttachmentDrafts((prev) => [...prev, ...nextItems])
|
||||
|
||||
for (const draft of nextItems) {
|
||||
void uploadAttachmentDraft(draft, currentChatId).catch(() => {
|
||||
// Upload errors are reflected on the draft state unless the draft was removed.
|
||||
})
|
||||
}
|
||||
},
|
||||
[attachmentDrafts, currentChatId, uploadAttachmentDraft],
|
||||
)
|
||||
|
||||
const handleRemoveAttachment = useCallback(
|
||||
(id: string) => {
|
||||
const draft = attachmentDrafts.find((item) => item.id === id)
|
||||
discardedDraftIdsRef.current.add(id)
|
||||
|
||||
const controller = abortControllersRef.current.get(id)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
abortControllersRef.current.delete(id)
|
||||
}
|
||||
uploadPromisesRef.current.delete(id)
|
||||
|
||||
const documentId = draft?.uploaded?.documentId
|
||||
if (draft?.status === "uploaded" && documentId) {
|
||||
discardUploadedAttachment(documentId)
|
||||
}
|
||||
|
||||
setAttachmentDrafts((prev) => prev.filter((item) => item.id !== id))
|
||||
},
|
||||
[attachmentDrafts, discardUploadedAttachment],
|
||||
)
|
||||
|
||||
const handleRetryAttachment = useCallback(
|
||||
(id: string) => {
|
||||
const draft = attachmentDrafts.find((item) => item.id === id)
|
||||
if (!draft) return
|
||||
void uploadAttachmentDraft(draft, currentChatId)
|
||||
},
|
||||
[attachmentDrafts, currentChatId, uploadAttachmentDraft],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) {
|
||||
setMessages(pendingThreadLoad.messages)
|
||||
|
|
@ -441,63 +747,136 @@ export function ChatSidebar({
|
|||
}
|
||||
}, [])
|
||||
|
||||
const submitChatMessage = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
source: ChatMessageSendSource,
|
||||
drafts = attachmentDrafts,
|
||||
): Promise<boolean> => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed && drafts.length === 0) return false
|
||||
|
||||
const hasBusy = drafts.some(
|
||||
(d) => d.status === "uploading" || d.status === "queued",
|
||||
)
|
||||
if (hasBusy) return false
|
||||
const hasErrored = drafts.some((d) => d.status === "error")
|
||||
if (hasErrored) return false
|
||||
|
||||
const chatIdForSend = threadId ?? fallbackChatId
|
||||
|
||||
try {
|
||||
const uploadedAttachments =
|
||||
drafts.length > 0
|
||||
? await uploadAttachmentDrafts(drafts, chatIdForSend)
|
||||
: []
|
||||
const messageText = trimmed || "Analyze the attached file(s)."
|
||||
const isRespondingNow = status === "submitted" || status === "streaming"
|
||||
|
||||
if (isRespondingNow) {
|
||||
if (messageQueue.length >= CHAT_QUEUE_LIMIT) return false
|
||||
setMessageQueue((prev) => {
|
||||
if (prev.length >= CHAT_QUEUE_LIMIT) return prev
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: generateId(),
|
||||
text: messageText,
|
||||
model: selectedModel,
|
||||
reasoningEffort,
|
||||
attachments: uploadedAttachments,
|
||||
},
|
||||
]
|
||||
})
|
||||
analytics.chatMessageSent({
|
||||
source,
|
||||
attachment_count: uploadedAttachments.length,
|
||||
saved_attachment_count: uploadedAttachments.filter(
|
||||
(attachment) => attachment.saveToMemory,
|
||||
).length,
|
||||
temporary_attachment_count: uploadedAttachments.filter(
|
||||
(attachment) => !attachment.saveToMemory,
|
||||
).length,
|
||||
})
|
||||
setInput("")
|
||||
setAttachmentDrafts([])
|
||||
uploadPromisesRef.current.clear()
|
||||
abortControllersRef.current.clear()
|
||||
discardedDraftIdsRef.current.clear()
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
return true
|
||||
}
|
||||
|
||||
truncateFromMessageIdRef.current = null
|
||||
if (!threadId) setThreadId(fallbackChatId)
|
||||
pendingRequestAttachmentsRef.current = uploadedAttachments
|
||||
analytics.chatMessageSent({
|
||||
source,
|
||||
attachment_count: uploadedAttachments.length,
|
||||
saved_attachment_count: uploadedAttachments.filter(
|
||||
(attachment) => attachment.saveToMemory,
|
||||
).length,
|
||||
temporary_attachment_count: uploadedAttachments.filter(
|
||||
(attachment) => !attachment.saveToMemory,
|
||||
).length,
|
||||
})
|
||||
|
||||
setInput("")
|
||||
setAttachmentDrafts([])
|
||||
uploadPromisesRef.current.clear()
|
||||
abortControllersRef.current.clear()
|
||||
discardedDraftIdsRef.current.clear()
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
pendingResponseModelsRef.current.push(selectedModel)
|
||||
|
||||
void sendMessage({
|
||||
text: messageText,
|
||||
metadata:
|
||||
uploadedAttachments.length > 0
|
||||
? { attachments: uploadedAttachments }
|
||||
: undefined,
|
||||
}).finally(() => {
|
||||
pendingRequestAttachmentsRef.current = []
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
pendingRequestAttachmentsRef.current = []
|
||||
toast.error("Failed to send message", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again.",
|
||||
})
|
||||
return false
|
||||
}
|
||||
},
|
||||
[
|
||||
attachmentDrafts,
|
||||
fallbackChatId,
|
||||
messageQueue.length,
|
||||
reasoningEffort,
|
||||
scrollToBottom,
|
||||
selectedModel,
|
||||
sendMessage,
|
||||
setThreadId,
|
||||
status,
|
||||
threadId,
|
||||
uploadAttachmentDrafts,
|
||||
],
|
||||
)
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim()
|
||||
if (!text) return
|
||||
|
||||
if (status === "submitted" || status === "streaming") {
|
||||
if (messageQueue.length >= CHAT_QUEUE_LIMIT) return
|
||||
|
||||
setMessageQueue((prev) => {
|
||||
if (prev.length >= CHAT_QUEUE_LIMIT) return prev
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: generateId(),
|
||||
text,
|
||||
model: selectedModel,
|
||||
reasoningEffort,
|
||||
},
|
||||
]
|
||||
})
|
||||
setInput("")
|
||||
analytics.chatMessageSent({ source: "typed" })
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
truncateFromMessageIdRef.current = null
|
||||
if (!threadId) setThreadId(fallbackChatId)
|
||||
analytics.chatMessageSent({ source: "typed" })
|
||||
pendingResponseModelsRef.current.push(selectedModel)
|
||||
sendMessage({ text })
|
||||
setInput("")
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
void submitChatMessage(input, "typed")
|
||||
}
|
||||
|
||||
const handleSuggestedQuestion = useCallback(
|
||||
(suggestion: string) => {
|
||||
if (status === "submitted" || status === "streaming") return
|
||||
truncateFromMessageIdRef.current = null
|
||||
if (!threadId) setThreadId(fallbackChatId)
|
||||
analytics.chatSuggestedQuestionClicked()
|
||||
analytics.chatMessageSent({ source: "suggested" })
|
||||
pendingResponseModelsRef.current.push(selectedModel)
|
||||
sendMessage({ text: suggestion })
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
void submitChatMessage(suggestion, "suggested", [])
|
||||
},
|
||||
[
|
||||
fallbackChatId,
|
||||
sendMessage,
|
||||
setThreadId,
|
||||
status,
|
||||
threadId,
|
||||
scrollToBottom,
|
||||
selectedModel,
|
||||
],
|
||||
[status, submitChatMessage],
|
||||
)
|
||||
|
||||
const handleRegenerateFromUserMessage = useCallback(
|
||||
|
|
@ -624,6 +1003,13 @@ export function ChatSidebar({
|
|||
setThreadId(null)
|
||||
setFallbackChatId(newChatId)
|
||||
setInput("")
|
||||
setAttachmentDrafts([])
|
||||
for (const controller of abortControllersRef.current.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
abortControllersRef.current.clear()
|
||||
discardedDraftIdsRef.current.clear()
|
||||
uploadPromisesRef.current.clear()
|
||||
setMessageQueue([])
|
||||
pendingResponseModelsRef.current = []
|
||||
seenAssistantMessageIdsRef.current = new Set()
|
||||
|
|
@ -791,9 +1177,9 @@ export function ChatSidebar({
|
|||
return
|
||||
}
|
||||
sentQueuedMessageRef.current = queuedMessage
|
||||
analytics.chatMessageSent({ source: queuedMessageSource })
|
||||
|
||||
if (queuedHighlightContent) {
|
||||
analytics.chatMessageSent({ source: queuedMessageSource })
|
||||
truncateFromMessageIdRef.current = null
|
||||
// Start a fresh thread for highlight-based chats to avoid overwriting existing conversations
|
||||
const newChatId = generateId()
|
||||
|
|
@ -825,12 +1211,23 @@ export function ChatSidebar({
|
|||
},
|
||||
]
|
||||
} else {
|
||||
truncateFromMessageIdRef.current = null
|
||||
if (!threadId) setThreadId(fallbackChatId)
|
||||
queuedDispatchInFlightRef.current = true
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
pendingResponseModelsRef.current.push(selectedModel)
|
||||
sendMessage({ text: queuedMessage })
|
||||
if (queuedAttachments?.length) {
|
||||
setAttachmentDrafts(queuedAttachments)
|
||||
}
|
||||
void submitChatMessage(
|
||||
queuedMessage,
|
||||
queuedMessageSource,
|
||||
queuedAttachments ?? [],
|
||||
).then((sent) => {
|
||||
if (!sent) {
|
||||
setInput(queuedMessage)
|
||||
if (queuedAttachments?.length) {
|
||||
setAttachmentDrafts(queuedAttachments)
|
||||
}
|
||||
}
|
||||
onConsumeQueuedMessage?.()
|
||||
})
|
||||
return
|
||||
}
|
||||
onConsumeQueuedMessage?.()
|
||||
}
|
||||
|
|
@ -839,16 +1236,15 @@ export function ChatSidebar({
|
|||
queuedMessage,
|
||||
queuedHighlightContent,
|
||||
queuedMessageSource,
|
||||
queuedAttachments,
|
||||
initialSelectedModel,
|
||||
initialReasoningEffort,
|
||||
selectedModel,
|
||||
reasoningEffort,
|
||||
status,
|
||||
sendMessage,
|
||||
onConsumeQueuedMessage,
|
||||
fallbackChatId,
|
||||
setThreadId,
|
||||
threadId,
|
||||
submitChatMessage,
|
||||
])
|
||||
|
||||
// Inject the pending highlight assistant message once the new Chat instance is ready.
|
||||
|
|
@ -935,7 +1331,16 @@ export function ChatSidebar({
|
|||
? prev.slice(1)
|
||||
: prev.filter((item) => item.id !== nextMessage.id),
|
||||
)
|
||||
sendMessage({ text: nextMessage.text })
|
||||
pendingRequestAttachmentsRef.current = nextMessage.attachments ?? []
|
||||
void sendMessage({
|
||||
text: nextMessage.text,
|
||||
metadata:
|
||||
nextMessage.attachments && nextMessage.attachments.length > 0
|
||||
? { attachments: nextMessage.attachments }
|
||||
: undefined,
|
||||
}).finally(() => {
|
||||
pendingRequestAttachmentsRef.current = []
|
||||
})
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
}, [
|
||||
|
|
@ -1064,6 +1469,17 @@ export function ChatSidebar({
|
|||
const isStackedInput = layout === "page"
|
||||
const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput
|
||||
const isResponding = status === "submitted" || status === "streaming"
|
||||
const hasBusyAttachment = attachmentDrafts.some(
|
||||
(attachment) =>
|
||||
attachment.status === "uploading" || attachment.status === "queued",
|
||||
)
|
||||
const hasErroredAttachment = attachmentDrafts.some(
|
||||
(attachment) => attachment.status === "error",
|
||||
)
|
||||
const canSendMessage =
|
||||
(input.trim().length > 0 || attachmentDrafts.length > 0) &&
|
||||
!hasBusyAttachment &&
|
||||
!hasErroredAttachment
|
||||
const showInputStatusStrip =
|
||||
!isStackedInput || isResponding || messages.length > 0
|
||||
const isQueueFull = messageQueue.length >= CHAT_QUEUE_LIMIT
|
||||
|
|
@ -1526,6 +1942,12 @@ export function ChatSidebar({
|
|||
onStop={handleStop}
|
||||
onKeyDown={handleKeyDown}
|
||||
isResponding={isResponding}
|
||||
attachments={attachmentDrafts}
|
||||
onAddAttachmentFiles={handleAddAttachmentFiles}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
onRetryAttachment={handleRetryAttachment}
|
||||
canSend={canSendMessage}
|
||||
attachmentAccept={CHAT_ATTACHMENT_ACCEPT}
|
||||
sendDisabled={isResponding && isQueueFull}
|
||||
sendDisabledTooltip={`Queue is full (${CHAT_QUEUE_LIMIT} max)`}
|
||||
activeStatus={
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
"use client"
|
||||
|
||||
import { BrainIcon, ChevronUpIcon, ZapIcon } from "lucide-react"
|
||||
import {
|
||||
BrainIcon,
|
||||
CheckIcon,
|
||||
ChevronUpIcon,
|
||||
Loader2Icon,
|
||||
PaperclipIcon,
|
||||
RotateCcwIcon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
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 { AnimatePresence, motion } from "motion/react"
|
||||
import { SendButton, StopButton } from "./actions"
|
||||
import {
|
||||
CHAT_ATTACHMENT_ACCEPT,
|
||||
type ChatAttachmentDraft,
|
||||
formatAttachmentSize,
|
||||
} from "../attachments"
|
||||
import { type ModelId, modelNames, type ReasoningEffort } from "@/lib/models"
|
||||
|
||||
export interface QueuedChatMessagePreview {
|
||||
|
|
@ -33,6 +48,12 @@ interface ChatInputProps {
|
|||
stackedToolbar?: ReactNode
|
||||
/** Nova status row + chain-of-thought toggle (off for e.g. home composer) */
|
||||
showStatusStrip?: boolean
|
||||
attachments?: ChatAttachmentDraft[]
|
||||
onAddAttachmentFiles?: (files: FileList | File[]) => void
|
||||
onRemoveAttachment?: (id: string) => void
|
||||
onRetryAttachment?: (id: string) => void
|
||||
canSend?: boolean
|
||||
attachmentAccept?: string
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
|
|
@ -50,16 +71,24 @@ export default function ChatInput({
|
|||
onExpandedChange,
|
||||
stackedToolbar,
|
||||
showStatusStrip = true,
|
||||
attachments = [],
|
||||
onAddAttachmentFiles,
|
||||
onRemoveAttachment,
|
||||
onRetryAttachment,
|
||||
canSend,
|
||||
attachmentAccept = CHAT_ATTACHMENT_ACCEPT,
|
||||
}: ChatInputProps) {
|
||||
const [isMultiline, setIsMultiline] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const isSendDisabled = !value.trim() || sendDisabled
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
const canSubmit = canSend ?? value.trim().length > 0
|
||||
const isSendDisabled = !canSubmit || sendDisabled
|
||||
const hasQueuedPreview = queuedMessages.length > 0
|
||||
const resolvedSendDisabledTooltip =
|
||||
sendDisabled && value.trim()
|
||||
? sendDisabledTooltip
|
||||
: "Type a message to send"
|
||||
sendDisabled && canSubmit ? sendDisabledTooltip : "Type a message to send"
|
||||
|
||||
useEffect(() => {
|
||||
if (!showStatusStrip && isExpanded) {
|
||||
|
|
@ -81,6 +110,95 @@ export default function ChatInput({
|
|||
setIsMultiline(textarea.scrollHeight > 52)
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (files?.length) onAddAttachmentFiles?.(files)
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
const canAttachFiles = Boolean(onAddAttachmentFiles) && !isResponding
|
||||
const hasDraggedFiles = (e: React.DragEvent) =>
|
||||
Array.from(e.dataTransfer.types).includes("Files")
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(e)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragDepthRef.current += 1
|
||||
if (canAttachFiles) setIsDraggingFiles(true)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(e)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = canAttachFiles ? "copy" : "none"
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(e)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) setIsDraggingFiles(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(e)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragDepthRef.current = 0
|
||||
setIsDraggingFiles(false)
|
||||
const files = e.dataTransfer.files
|
||||
if (canAttachFiles && files.length) onAddAttachmentFiles?.(files)
|
||||
}
|
||||
|
||||
const showAttachments = attachments.length > 0
|
||||
|
||||
const attachmentTray = showAttachments ? (
|
||||
<div className="scrollbar-none flex max-w-full gap-2 overflow-x-auto px-0 pb-1 sm:px-1">
|
||||
{attachments.map((attachment) => {
|
||||
return (
|
||||
<AttachmentPreviewChip
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
onRemove={onRemoveAttachment}
|
||||
onRetry={onRetryAttachment}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const attachmentButton = onAddAttachmentFiles ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={attachmentAccept}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isResponding}
|
||||
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-[#242832] bg-black text-[#A6B0BE] transition-colors hover:border-[#3A4049] hover:bg-[#111418] hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Attach files"
|
||||
title="Attach files"
|
||||
>
|
||||
<PaperclipIcon className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
) : null
|
||||
|
||||
const dropOverlay = isDraggingFiles ? (
|
||||
<div className="pointer-events-none absolute inset-1 z-10 grid place-items-center rounded-lg border border-dashed border-[#4B5563] bg-black/70 text-sm font-medium text-fg-primary backdrop-blur-sm">
|
||||
Drop files to attach
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn("relative z-20!")}
|
||||
|
|
@ -195,7 +313,16 @@ export default function ChatInput({
|
|||
</>
|
||||
) : null}
|
||||
{stackedToolbar ? (
|
||||
<div className="relative z-30 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">
|
||||
<fieldset
|
||||
aria-label="Chat input with file drop zone"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className="relative z-30 m-0 flex min-w-0 flex-col gap-2 rounded-xl border-0 bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10"
|
||||
>
|
||||
{dropOverlay}
|
||||
{attachmentTray}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
|
|
@ -207,12 +334,13 @@ export default function ChatInput({
|
|||
rows={1}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{attachmentButton}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{stackedToolbar}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{isResponding && <StopButton onClick={onStop} />}
|
||||
{(!isResponding || value.trim()) && (
|
||||
{(!isResponding || canSubmit) && (
|
||||
<SendButton
|
||||
onClick={onSend}
|
||||
disabled={isSendDisabled}
|
||||
|
|
@ -221,14 +349,21 @@ export default function ChatInput({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
) : (
|
||||
<div
|
||||
<fieldset
|
||||
aria-label="Chat input with file drop zone"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"flex items-end 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",
|
||||
"relative m-0 flex min-w-0 flex-col gap-2 rounded-xl border-0 bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10",
|
||||
isMultiline && "flex-col",
|
||||
)}
|
||||
>
|
||||
{dropOverlay}
|
||||
{attachmentTray}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
|
|
@ -239,9 +374,10 @@ export default function ChatInput({
|
|||
style={{ minHeight: "36px" }}
|
||||
rows={1}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 transition-all duration-200">
|
||||
<div className="flex w-full items-center justify-end gap-2 transition-all duration-200">
|
||||
{attachmentButton}
|
||||
{isResponding && <StopButton onClick={onStop} />}
|
||||
{(!isResponding || value.trim()) && (
|
||||
{(!isResponding || canSubmit) && (
|
||||
<SendButton
|
||||
onClick={onSend}
|
||||
disabled={isSendDisabled}
|
||||
|
|
@ -249,8 +385,197 @@ export default function ChatInput({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentPreviewChip({
|
||||
attachment,
|
||||
onRemove,
|
||||
onRetry,
|
||||
}: {
|
||||
attachment: ChatAttachmentDraft
|
||||
onRemove?: (id: string) => void
|
||||
onRetry?: (id: string) => void
|
||||
}) {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null)
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
|
||||
const isUploading = attachment.status === "uploading"
|
||||
const isUploaded = attachment.status === "uploaded"
|
||||
const isError = attachment.status === "error"
|
||||
const isImage = attachment.file.type.startsWith("image/")
|
||||
const extension = getAttachmentExtension(attachment.file)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) {
|
||||
setObjectUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(attachment.file)
|
||||
setObjectUrl(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [attachment.file, isImage])
|
||||
|
||||
const statusLabel = isError
|
||||
? attachment.errorMessage || "Upload failed"
|
||||
: isUploading
|
||||
? "Uploading..."
|
||||
: isUploaded
|
||||
? "Uploaded"
|
||||
: formatAttachmentSize(attachment.file.size)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex h-11 w-[min(280px,calc(100vw-4.5rem))] shrink-0 items-center gap-2 overflow-hidden rounded-xl border border-[#1A1D22] bg-[#050607] px-2 text-sm text-fg-primary shadow-[0_6px_18px_rgba(0,0,0,0.22)] transition-colors hover:border-[#30343B] hover:bg-[#080A0D] focus-within:border-[#30343B] sm:w-auto sm:min-w-[220px] sm:max-w-[300px] sm:hover:border-[#2261CA66] sm:hover:bg-[#041127] sm:focus-within:border-[#2261CA66] sm:focus-within:bg-[#041127]",
|
||||
isImage && objectUrl && "cursor-pointer",
|
||||
isError && "border-red-400/40 bg-red-950/20 hover:border-red-400/50",
|
||||
)}
|
||||
title={statusLabel}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isImage && objectUrl && setIsPreviewOpen(true)}
|
||||
disabled={!isImage || !objectUrl}
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-[#242832] bg-black",
|
||||
isImage && objectUrl && "cursor-pointer hover:border-[#4B5563]",
|
||||
(!isImage || !objectUrl) && "cursor-default",
|
||||
)}
|
||||
aria-label={
|
||||
isImage ? `Preview ${attachment.file.name}` : attachment.file.name
|
||||
}
|
||||
>
|
||||
{isImage && objectUrl ? (
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt=""
|
||||
className="size-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<DocumentFileGlyph label={extension} />
|
||||
)}
|
||||
{isUploading || isUploaded ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
{isUploading ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin text-[#C8D1DC]" />
|
||||
) : (
|
||||
<CheckIcon className="size-3.5 text-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 pr-1">
|
||||
<div
|
||||
className="truncate font-medium leading-none text-fg-primary"
|
||||
title={attachment.file.name}
|
||||
>
|
||||
{attachment.file.name}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] leading-none text-fg-faint">
|
||||
{statusLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden shrink-0 items-center gap-1 opacity-0 transition-opacity sm:flex sm:group-hover:opacity-100 sm:group-focus-within:opacity-100">
|
||||
{isError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onRetry?.(attachment.id)
|
||||
}}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-[#242832] bg-black text-fg-faint transition-colors hover:border-[#3A4049] hover:bg-[#111418] hover:text-fg-primary"
|
||||
aria-label={`Retry ${attachment.file.name}`}
|
||||
title={statusLabel}
|
||||
>
|
||||
<RotateCcwIcon className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onRemove?.(attachment.id)
|
||||
}}
|
||||
disabled={isUploading}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-[#111418] hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Remove ${attachment.file.name}`}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="w-[calc(100vw-32px)] max-w-none gap-0 overflow-hidden rounded-xl border border-[#1D222A] bg-[#050607] p-0 text-fg-primary shadow-[0_24px_80px_rgba(0,0,0,0.65)] sm:w-[min(92vw,980px)] sm:max-w-[980px]"
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Preview {attachment.file.name}
|
||||
</DialogTitle>
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_32px] items-center gap-2 border-[#171B22] border-b px-3 py-2 sm:px-4 sm:py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-fg-primary text-sm">
|
||||
{attachment.file.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-fg-faint">
|
||||
{formatAttachmentSize(attachment.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(false)}
|
||||
className="flex size-8 items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-[#111418] hover:text-fg-primary focus:outline-none focus:ring-2 focus:ring-[#3374FF]/40"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close preview</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid h-[min(58dvh,380px)] place-items-center bg-black px-4 py-5 sm:h-[min(76dvh,680px)] sm:px-6 sm:py-6">
|
||||
{objectUrl ? (
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={attachment.file.name}
|
||||
className="block max-h-full max-w-full rounded-lg object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentFileGlyph({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="relative flex size-6 items-end justify-center rounded-[4px] border border-[#30343B] bg-[#07090C] pb-1">
|
||||
<div className="absolute right-0 top-0 size-2.5 border-[#30343B] border-b border-l bg-black" />
|
||||
<span className="max-w-[22px] truncate text-[8px] font-bold uppercase leading-none text-[#AAB2BD]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getAttachmentExtension(file: File): string {
|
||||
const name = file.name
|
||||
const dotIndex = name.lastIndexOf(".")
|
||||
if (dotIndex > -1 && dotIndex < name.length - 1) {
|
||||
const extension = name.slice(dotIndex + 1).toLowerCase()
|
||||
if (extension === "md" || extension === "markdown") return "MD"
|
||||
if (extension === "pdf") return "PDF"
|
||||
return extension.slice(0, 3)
|
||||
}
|
||||
if (file.type === "text/markdown") return "MD"
|
||||
if (file.type.includes("pdf")) return "PDF"
|
||||
if (file.type.startsWith("text/")) return "TXT"
|
||||
return "FILE"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { Copy, Check, PencilIcon, PencilOffIcon } from "lucide-react"
|
||||
import { Check, Copy, FileIcon, PencilIcon, PencilOffIcon } from "lucide-react"
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import ChatModelSelector from "../model-selector"
|
||||
import { ReasoningSelector } from "../reasoning-selector"
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
type ModelId,
|
||||
type ReasoningEffort,
|
||||
} from "@/lib/models"
|
||||
import { formatAttachmentSize, getChatMessageAttachments } from "../attachments"
|
||||
|
||||
interface UserMessageProps {
|
||||
message: UIMessage
|
||||
|
|
@ -45,6 +46,7 @@ export const UserMessage = memo(function UserMessage({
|
|||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
const attachments = getChatMessageAttachments(message.metadata)
|
||||
|
||||
const startEditing = () => {
|
||||
setDraft(text)
|
||||
|
|
@ -71,7 +73,31 @@ export const UserMessage = memo(function UserMessage({
|
|||
}, [isEditing])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end w-full">
|
||||
<div className="flex w-full flex-col items-end">
|
||||
{attachments.length > 0 ? (
|
||||
<div className="mb-2 flex w-full max-w-[80%] flex-col items-end gap-1.5">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex max-w-full min-w-0 items-center gap-2 rounded-lg border border-[#303949] bg-[#0D121A]/80 px-2.5 py-2 text-left shadow-[0_6px_18px_rgba(0,0,0,0.18)]"
|
||||
>
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-[#111A27]">
|
||||
<FileIcon className="size-3.5 text-[#8FC3FF]" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium text-white">
|
||||
{attachment.filename}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-white/50">
|
||||
{formatAttachmentSize(attachment.size)}
|
||||
{" | "}
|
||||
{attachment.saveToMemory ? "Saved" : "Chat only"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
|
|
@ -126,7 +152,9 @@ export const UserMessage = memo(function UserMessage({
|
|||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="max-w-[80%] origin-top-right rounded-[12px] bg-[#1B1F24] p-3 px-[14px]"
|
||||
>
|
||||
<p className="text-sm text-white whitespace-pre-wrap">{text}</p>
|
||||
{text ? (
|
||||
<p className="text-sm text-white whitespace-pre-wrap">{text}</p>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,60 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { getCachedFileBlob } from "@/lib/file-cache"
|
||||
|
||||
interface ImagePreviewProps {
|
||||
url: string
|
||||
title?: string | null
|
||||
documentId?: string | null
|
||||
}
|
||||
|
||||
export function ImagePreview({ url, title }: ImagePreviewProps) {
|
||||
export function ImagePreview({ url, title, documentId }: ImagePreviewProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
const [activeSrc, setActiveSrc] = useState(url)
|
||||
const objectUrlRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current)
|
||||
objectUrlRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// On first failure, wait briefly then force a re-render with a new key to
|
||||
// retry the fetch (covers transient R2 timing issues).
|
||||
// On second failure, give up and show the error state.
|
||||
const handleImageError = useCallback(() => {
|
||||
if (retryKey === 0) {
|
||||
setTimeout(() => setRetryKey(1), 500)
|
||||
return
|
||||
}
|
||||
|
||||
if (retryKey === 1 && documentId) {
|
||||
getCachedFileBlob(documentId).then((blob) => {
|
||||
if (blob) {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current)
|
||||
}
|
||||
const objUrl = URL.createObjectURL(blob)
|
||||
objectUrlRef.current = objUrl
|
||||
setActiveSrc(objUrl)
|
||||
setRetryKey(2)
|
||||
} else {
|
||||
setImageError(true)
|
||||
setIsLoading(false)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setImageError(true)
|
||||
setIsLoading(false)
|
||||
}, [retryKey])
|
||||
}, [retryKey, documentId])
|
||||
|
||||
if (imageError || !url) {
|
||||
if (imageError || !activeSrc) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-[#737373]">
|
||||
<p>Failed to load image</p>
|
||||
|
|
@ -43,7 +72,7 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
|
|||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundImage: `url(${activeSrc})`,
|
||||
filter: "blur(100px)",
|
||||
transform: "scale(1.1)",
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
|
|
@ -52,7 +81,7 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
|
|||
<div className="absolute inset-0 bg-black/30" />
|
||||
<img
|
||||
key={retryKey}
|
||||
src={url}
|
||||
src={activeSrc}
|
||||
alt={title || "Image preview"}
|
||||
className={cn(
|
||||
"relative max-w-full max-h-full size-auto object-contain z-10",
|
||||
|
|
|
|||
|
|
@ -88,7 +88,13 @@ export function DocumentContent({
|
|||
|
||||
switch (contentType) {
|
||||
case "image":
|
||||
return <ImagePreview url={document.url ?? ""} title={document.title} />
|
||||
return (
|
||||
<ImagePreview
|
||||
url={document.url ?? ""}
|
||||
title={document.title}
|
||||
documentId={document.id}
|
||||
/>
|
||||
)
|
||||
|
||||
case "tweet":
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,23 +1,55 @@
|
|||
"use client"
|
||||
|
||||
import { Document, Page, pdfjs } from "react-pdf"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css"
|
||||
import "react-pdf/dist/Page/TextLayer.css"
|
||||
import { getCachedFileBlob } from "@/lib/file-cache"
|
||||
|
||||
// Configure PDF.js worker to use local package
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString()
|
||||
|
||||
type FileSource = string | { url: string; withCredentials: boolean } | null
|
||||
|
||||
interface PdfViewerProps {
|
||||
url: string | null | undefined
|
||||
documentId?: string | null
|
||||
}
|
||||
|
||||
export function PdfViewer({ url, documentId }: PdfViewerProps) {
|
||||
const fileSource = useMemo(() => {
|
||||
const [cachedUrl, setCachedUrl] = useState<string | null>(null)
|
||||
const [cacheChecked, setCacheChecked] = useState(false)
|
||||
const objectUrlRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let revoked = false
|
||||
if (!documentId) {
|
||||
setCacheChecked(true)
|
||||
return
|
||||
}
|
||||
|
||||
getCachedFileBlob(documentId).then((blob) => {
|
||||
if (revoked) return
|
||||
if (blob) {
|
||||
const objUrl = URL.createObjectURL(blob)
|
||||
objectUrlRef.current = objUrl
|
||||
setCachedUrl(objUrl)
|
||||
}
|
||||
setCacheChecked(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
revoked = true
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current)
|
||||
objectUrlRef.current = null
|
||||
}
|
||||
}
|
||||
}, [documentId])
|
||||
|
||||
const remoteFileSource: FileSource = useMemo(() => {
|
||||
if (!url) return null
|
||||
try {
|
||||
if (new URL(url).hostname === "www.googleapis.com" && documentId) {
|
||||
|
|
@ -32,12 +64,34 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
|
|||
return url
|
||||
}, [url, documentId])
|
||||
|
||||
const backendProxySource: FileSource = useMemo(() => {
|
||||
if (!documentId) return null
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
return { url: `${base}/v3/file-proxy/${documentId}`, withCredentials: true }
|
||||
}, [documentId])
|
||||
|
||||
const [numPages, setNumPages] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
const [failedSources, setFailedSources] = useState(0)
|
||||
|
||||
if (!url) {
|
||||
const fileSource = useMemo((): FileSource => {
|
||||
if (cachedUrl) return cachedUrl
|
||||
if (failedSources === 0) return remoteFileSource
|
||||
if (failedSources === 1 && backendProxySource) return backendProxySource
|
||||
return null
|
||||
}, [cachedUrl, failedSources, remoteFileSource, backendProxySource])
|
||||
|
||||
if (!cacheChecked) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
Loading PDF…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!url && !cachedUrl) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
No PDF URL provided
|
||||
|
|
@ -51,24 +105,26 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
|
|||
setError(null)
|
||||
}
|
||||
|
||||
// On first failure, wait briefly then force a re-mount of the Document
|
||||
// component to retry (covers transient R2 timing issues).
|
||||
// On second failure, give up and show the error state.
|
||||
const onDocumentLoadError = useCallback(
|
||||
(err: Error) => {
|
||||
if (retryKey === 0) {
|
||||
setTimeout(() => {
|
||||
setRetryKey(1)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
}, 500)
|
||||
return
|
||||
}
|
||||
function onDocumentLoadError(err: Error) {
|
||||
if (cachedUrl) {
|
||||
setError(err.message || "Failed to load PDF")
|
||||
setLoading(false)
|
||||
},
|
||||
[retryKey],
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const nextFailed = failedSources + 1
|
||||
const hasMoreSources =
|
||||
(nextFailed === 1 && backendProxySource !== null) || nextFailed < 1
|
||||
|
||||
if (hasMoreSources) {
|
||||
setFailedSources(nextFailed)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(err.message || "Failed to load PDF")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col size-full overflow-hidden scrollbar-thin">
|
||||
|
|
@ -82,35 +138,33 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
|
|||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto w-full">
|
||||
<Document
|
||||
key={retryKey}
|
||||
file={
|
||||
fileSource ||
|
||||
"https://corsproxy.io/?" +
|
||||
encodeURIComponent("http://www.pdf995.com/samples/pdf.pdf")
|
||||
}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
loading={null}
|
||||
className="w-full"
|
||||
>
|
||||
{numPages && (
|
||||
<div className="flex flex-col items-center gap-4 py-4 w-full">
|
||||
{Array.from(new Array(numPages), (_, index) => (
|
||||
<Page
|
||||
key={`page_${index + 1}`}
|
||||
pageNumber={index + 1}
|
||||
renderTextLayer
|
||||
renderAnnotationLayer
|
||||
className="shadow-lg"
|
||||
width={630}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Document>
|
||||
</div>
|
||||
{fileSource && (
|
||||
<div className="flex-1 overflow-auto w-full">
|
||||
<Document
|
||||
key={`${failedSources}-${cachedUrl ? "cache" : "remote"}`}
|
||||
file={fileSource}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
loading={null}
|
||||
className="w-full"
|
||||
>
|
||||
{numPages && (
|
||||
<div className="flex flex-col items-center gap-4 py-4 w-full">
|
||||
{Array.from(new Array(numPages), (_, index) => (
|
||||
<Page
|
||||
key={`page_${index + 1}`}
|
||||
pageNumber={index + 1}
|
||||
renderTextLayer
|
||||
renderAnnotationLayer
|
||||
className="shadow-lg"
|
||||
width={630}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Document>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,9 @@ export const analytics = {
|
|||
// chat analytics
|
||||
chatMessageSent: (props: {
|
||||
source: "typed" | "suggested" | "highlight" | "home"
|
||||
attachment_count?: number
|
||||
saved_attachment_count?: number
|
||||
temporary_attachment_count?: number
|
||||
}) => safeCapture("chat_message_sent", props),
|
||||
|
||||
chatSuggestedQuestionClicked: () =>
|
||||
|
|
|
|||
51
apps/web/lib/file-cache.ts
Normal file
51
apps/web/lib/file-cache.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { createStore, get, set, del } from "idb-keyval"
|
||||
|
||||
const fileCacheStore = createStore("supermemory-file-cache", "blobs")
|
||||
|
||||
interface CachedFile {
|
||||
blob: Blob
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export async function cacheFileBlob(
|
||||
documentId: string,
|
||||
blob: Blob,
|
||||
mimeType: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await set(
|
||||
documentId,
|
||||
{ blob, mimeType } satisfies CachedFile,
|
||||
fileCacheStore,
|
||||
)
|
||||
} catch {
|
||||
// Storage full or unavailable — non-critical, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedFileBlob(
|
||||
documentId: string,
|
||||
): Promise<Blob | null> {
|
||||
try {
|
||||
const cached = await get<CachedFile>(documentId, fileCacheStore)
|
||||
return cached?.blob ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedFileUrl(
|
||||
documentId: string,
|
||||
): Promise<string | null> {
|
||||
const blob = await getCachedFileBlob(documentId)
|
||||
if (!blob) return null
|
||||
return URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
export async function removeCachedFile(documentId: string): Promise<void> {
|
||||
try {
|
||||
await del(documentId, fileCacheStore)
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue