mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-06 08:16:03 +00:00
Fixes all following sentry issues - CONSUMER-APP-FF - CONSUMER-APP-1T - CONSUMER-APP-86 - CONSUMER-APP-7H - CONSUMER-APP-4F - CONSUMER-APP-7X
705 lines
20 KiB
TypeScript
705 lines
20 KiB
TypeScript
"use client"
|
|
|
|
import { useChat, useCompletion, type UIMessage } from "@ai-sdk/react"
|
|
import { cn } from "@lib/utils"
|
|
import { Button } from "@ui/components/button"
|
|
import { DefaultChatTransport } from "ai"
|
|
import {
|
|
ArrowUp,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Copy,
|
|
RotateCcw,
|
|
X,
|
|
} from "lucide-react"
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import { toast } from "sonner"
|
|
import { Streamdown } from "streamdown"
|
|
import { TextShimmer } from "@/components/text-shimmer"
|
|
import { usePersistentChat, useProject } from "@/stores"
|
|
import { useGraphHighlights } from "@/stores/highlights"
|
|
import { modelNames, ModelIcon } from "@/lib/models"
|
|
import { Spinner } from "../../spinner"
|
|
import { areUIMessageArraysEqual } from "@/stores/chat"
|
|
|
|
interface MemoryResult {
|
|
documentId?: string
|
|
title?: string
|
|
content?: string
|
|
url?: string
|
|
score?: number
|
|
}
|
|
|
|
interface ExpandableMemoriesProps {
|
|
foundCount: number
|
|
results: MemoryResult[]
|
|
}
|
|
|
|
interface MessagePart {
|
|
type: string
|
|
state?: string
|
|
text?: string
|
|
output?: {
|
|
count?: number
|
|
results?: Array<{
|
|
documentId?: string
|
|
title?: string
|
|
content?: string
|
|
url?: string
|
|
score?: number
|
|
}>
|
|
}
|
|
}
|
|
|
|
interface ChatMessage {
|
|
id: string
|
|
role: "user" | "assistant"
|
|
parts: MessagePart[]
|
|
}
|
|
|
|
function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
|
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
|
|
if (foundCount === 0) {
|
|
return (
|
|
<div className="text-sm flex items-center gap-2 text-muted-foreground">
|
|
<Check className="size-4" /> No memories found
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="text-sm">
|
|
<button
|
|
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
type="button"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="size-4" />
|
|
) : (
|
|
<ChevronRight className="size-4" />
|
|
)}
|
|
Related memories
|
|
</button>
|
|
|
|
{isExpanded && results.length > 0 && (
|
|
<div className="mt-2 ml-6 space-y-2 max-h-48 overflow-y-auto grid grid-cols-3 gap-2">
|
|
{results.map((result, index) => {
|
|
const isClickable =
|
|
result.url &&
|
|
(result.url.startsWith("http://") ||
|
|
result.url.startsWith("https://"))
|
|
|
|
const content = (
|
|
<>
|
|
{result.title && (
|
|
<div className="font-medium text-sm mb-1 text-foreground">
|
|
{result.title}
|
|
</div>
|
|
)}
|
|
{result.content && (
|
|
<div className="text-xs text-muted-foreground line-clamp-2">
|
|
{result.content}
|
|
</div>
|
|
)}
|
|
{result.url && (
|
|
<div className="text-xs text-blue-600 dark:text-blue-400 mt-1 truncate">
|
|
{result.url}
|
|
</div>
|
|
)}
|
|
{result.score && (
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
Score: {(result.score * 100).toFixed(1)}%
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
if (isClickable) {
|
|
return (
|
|
<a
|
|
className="block p-2 bg-accent/50 rounded-md border border-border hover:bg-accent transition-colors cursor-pointer"
|
|
href={result.url}
|
|
key={result.documentId || index}
|
|
rel="noopener noreferrer"
|
|
target="_blank"
|
|
>
|
|
{content}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="p-2 bg-accent/50 rounded-md border border-border"
|
|
key={result.documentId || index}
|
|
>
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function useStickyAutoScroll(triggerKeys: ReadonlyArray<unknown>) {
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
const [isAutoScroll, setIsAutoScroll] = useState(true)
|
|
const [isFarFromBottom, setIsFarFromBottom] = useState(false)
|
|
|
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
|
|
const node = bottomRef.current
|
|
if (node) node.scrollIntoView({ behavior, block: "end" })
|
|
}, [])
|
|
|
|
useEffect(function observeBottomVisibility() {
|
|
const container = scrollContainerRef.current
|
|
const sentinel = bottomRef.current
|
|
if (!container || !sentinel) return
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (!entries || entries.length === 0) return
|
|
const isIntersecting = entries.some((e) => e.isIntersecting)
|
|
setIsAutoScroll(isIntersecting)
|
|
},
|
|
{ root: container, rootMargin: "0px 0px 80px 0px", threshold: 0 },
|
|
)
|
|
observer.observe(sentinel)
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
useEffect(
|
|
function observeContentResize() {
|
|
const container = scrollContainerRef.current
|
|
if (!container) return
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
if (isAutoScroll) scrollToBottom("auto")
|
|
const distanceFromBottom =
|
|
container.scrollHeight - container.scrollTop - container.clientHeight
|
|
setIsFarFromBottom(distanceFromBottom > 100)
|
|
})
|
|
resizeObserver.observe(container)
|
|
return () => resizeObserver.disconnect()
|
|
},
|
|
[isAutoScroll, scrollToBottom],
|
|
)
|
|
|
|
function enableAutoScroll() {
|
|
setIsAutoScroll(true)
|
|
}
|
|
|
|
useEffect(
|
|
function autoScrollOnNewContent() {
|
|
if (isAutoScroll) scrollToBottom("auto")
|
|
},
|
|
[isAutoScroll, scrollToBottom, ...triggerKeys],
|
|
)
|
|
|
|
const recomputeDistanceFromBottom = useCallback(() => {
|
|
const container = scrollContainerRef.current
|
|
if (!container) return
|
|
const distanceFromBottom =
|
|
container.scrollHeight - container.scrollTop - container.clientHeight
|
|
setIsFarFromBottom(distanceFromBottom > 100)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
recomputeDistanceFromBottom()
|
|
}, [recomputeDistanceFromBottom, ...triggerKeys])
|
|
|
|
function onScroll() {
|
|
recomputeDistanceFromBottom()
|
|
}
|
|
|
|
return {
|
|
scrollContainerRef,
|
|
bottomRef,
|
|
isAutoScroll,
|
|
isFarFromBottom,
|
|
onScroll,
|
|
enableAutoScroll,
|
|
scrollToBottom,
|
|
} as const
|
|
}
|
|
|
|
export function ChatMessages() {
|
|
const { selectedProject } = useProject()
|
|
const {
|
|
currentChatId,
|
|
setCurrentChatId,
|
|
setConversation,
|
|
getCurrentConversation,
|
|
setConversationTitle,
|
|
getCurrentChat,
|
|
} = usePersistentChat()
|
|
|
|
const storageKey = `chat-model-${currentChatId}`
|
|
|
|
const [input, setInput] = useState("")
|
|
const [selectedModel, setSelectedModel] = useState<
|
|
"gpt-5" | "claude-sonnet-4.5" | "gemini-2.5-pro"
|
|
>("gemini-2.5-pro")
|
|
const activeChatIdRef = useRef<string | null>(null)
|
|
const shouldGenerateTitleRef = useRef<boolean>(false)
|
|
const hasRunInitialMessageRef = useRef<boolean>(false)
|
|
const lastSavedMessagesRef = useRef<UIMessage[] | null>(null)
|
|
const lastSavedActiveIdRef = useRef<string | null>(null)
|
|
const lastLoadedChatIdRef = useRef<string | null>(null)
|
|
const lastLoadedMessagesRef = useRef<UIMessage[] | null>(null)
|
|
|
|
const { setDocumentIds } = useGraphHighlights()
|
|
|
|
const { messages, sendMessage, status, stop, setMessages, id, regenerate } =
|
|
useChat({
|
|
id: currentChatId ?? undefined,
|
|
transport: new DefaultChatTransport({
|
|
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat`,
|
|
credentials: "include",
|
|
body: {
|
|
metadata: {
|
|
projectId: selectedProject,
|
|
model: selectedModel,
|
|
},
|
|
},
|
|
}),
|
|
maxSteps: 10,
|
|
onFinish: (result) => {
|
|
const activeId = activeChatIdRef.current
|
|
if (!activeId) return
|
|
if (result.message.role !== "assistant") return
|
|
|
|
if (shouldGenerateTitleRef.current) {
|
|
const textPart = result.message.parts.find(
|
|
(p: { type?: string; text?: string }) => p?.type === "text",
|
|
) as { text?: string } | undefined
|
|
const text = textPart?.text?.trim()
|
|
if (text) {
|
|
shouldGenerateTitleRef.current = false
|
|
complete(text)
|
|
}
|
|
}
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
lastLoadedMessagesRef.current = messages
|
|
}, [messages])
|
|
|
|
useEffect(() => {
|
|
activeChatIdRef.current = currentChatId ?? id ?? null
|
|
}, [currentChatId, id])
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return
|
|
if (currentChatId) {
|
|
const savedModel = sessionStorage.getItem(storageKey) as
|
|
| "gpt-5"
|
|
| "claude-sonnet-4.5"
|
|
| "gemini-2.5-pro"
|
|
|
|
if (
|
|
savedModel &&
|
|
["gpt-5", "claude-sonnet-4.5", "gemini-2.5-pro"].includes(savedModel)
|
|
) {
|
|
setSelectedModel(savedModel)
|
|
}
|
|
}
|
|
}, [currentChatId, storageKey])
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return
|
|
if (currentChatId && !hasRunInitialMessageRef.current) {
|
|
// Check if there's an initial message from the home page in sessionStorage
|
|
const storageKey = `chat-initial-${currentChatId}`
|
|
const initialMessage = sessionStorage.getItem(storageKey)
|
|
|
|
if (initialMessage) {
|
|
// Clean up the storage and send the message
|
|
sessionStorage.removeItem(storageKey)
|
|
sendMessage({ text: initialMessage })
|
|
hasRunInitialMessageRef.current = true
|
|
}
|
|
}
|
|
}, [currentChatId, sendMessage])
|
|
|
|
useEffect(() => {
|
|
if (id && id !== currentChatId) {
|
|
setCurrentChatId(id)
|
|
}
|
|
}, [id, currentChatId, setCurrentChatId])
|
|
|
|
useEffect(() => {
|
|
if (currentChatId !== lastLoadedChatIdRef.current) {
|
|
lastLoadedMessagesRef.current = null
|
|
lastSavedMessagesRef.current = null
|
|
}
|
|
|
|
if (currentChatId === lastLoadedChatIdRef.current) {
|
|
setInput("")
|
|
return
|
|
}
|
|
|
|
const msgs = getCurrentConversation()
|
|
|
|
if (msgs && msgs.length > 0) {
|
|
const currentMessages = lastLoadedMessagesRef.current
|
|
if (!currentMessages || !areUIMessageArraysEqual(currentMessages, msgs)) {
|
|
lastLoadedMessagesRef.current = msgs
|
|
setMessages(msgs)
|
|
}
|
|
} else if (!currentChatId) {
|
|
if (
|
|
lastLoadedMessagesRef.current &&
|
|
lastLoadedMessagesRef.current.length > 0
|
|
) {
|
|
lastLoadedMessagesRef.current = []
|
|
setMessages([])
|
|
}
|
|
}
|
|
|
|
lastLoadedChatIdRef.current = currentChatId
|
|
setInput("")
|
|
}, [currentChatId, getCurrentConversation, setMessages])
|
|
|
|
useEffect(() => {
|
|
const activeId = currentChatId ?? id
|
|
if (!activeId || messages.length === 0) {
|
|
return
|
|
}
|
|
|
|
if (activeId !== lastSavedActiveIdRef.current) {
|
|
lastSavedMessagesRef.current = null
|
|
lastSavedActiveIdRef.current = activeId
|
|
}
|
|
|
|
const lastSaved = lastSavedMessagesRef.current
|
|
if (lastSaved && areUIMessageArraysEqual(lastSaved, messages)) {
|
|
return
|
|
}
|
|
|
|
lastSavedMessagesRef.current = messages
|
|
setConversation(activeId, messages)
|
|
}, [messages, currentChatId, id, setConversation])
|
|
|
|
const { complete } = useCompletion({
|
|
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/title`,
|
|
credentials: "include",
|
|
onFinish: (_, completion) => {
|
|
const activeId = activeChatIdRef.current
|
|
if (!completion || !activeId) return
|
|
setConversationTitle(activeId, completion.trim())
|
|
},
|
|
})
|
|
|
|
// Update graph highlights from the most recent tool-searchMemories output
|
|
useEffect(() => {
|
|
try {
|
|
const lastAssistant = [...messages]
|
|
.reverse()
|
|
.find((m) => m.role === "assistant") as ChatMessage | undefined
|
|
if (!lastAssistant) return
|
|
const lastSearchPart = [...(lastAssistant.parts as MessagePart[])]
|
|
.reverse()
|
|
.find(
|
|
(p) =>
|
|
p?.type === "tool-searchMemories" &&
|
|
p?.state === "output-available",
|
|
) as MessagePart | undefined
|
|
if (!lastSearchPart) return
|
|
const output = lastSearchPart.output
|
|
const ids = Array.isArray(output?.results)
|
|
? ((output.results as MemoryResult[])
|
|
.map((r) => r?.documentId)
|
|
.filter(Boolean) as string[])
|
|
: []
|
|
if (ids.length > 0) {
|
|
setDocumentIds(ids)
|
|
}
|
|
} catch {}
|
|
}, [messages, setDocumentIds])
|
|
|
|
useEffect(() => {
|
|
const currentSummary = getCurrentChat()
|
|
const hasTitle = Boolean(
|
|
currentSummary?.title && currentSummary.title.trim().length > 0,
|
|
)
|
|
shouldGenerateTitleRef.current = !hasTitle
|
|
}, [getCurrentChat])
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
sendMessage({ text: input })
|
|
setInput("")
|
|
}
|
|
}
|
|
|
|
const {
|
|
scrollContainerRef,
|
|
bottomRef,
|
|
isFarFromBottom,
|
|
onScroll,
|
|
enableAutoScroll,
|
|
scrollToBottom,
|
|
} = useStickyAutoScroll([messages, status])
|
|
|
|
return (
|
|
<div className="h-full flex flex-col w-full">
|
|
<div className="flex-1 relative">
|
|
<div
|
|
className="absolute inset-0 overflow-y-auto custom-scrollbar"
|
|
onScroll={onScroll}
|
|
ref={scrollContainerRef}
|
|
>
|
|
<div className="flex flex-col gap-2 max-w-4xl mx-auto px-4 md:px-2 pt-4 pb-7 scroll-pb-7">
|
|
{messages.map((message) => (
|
|
<div
|
|
className={cn(
|
|
"flex my-2",
|
|
message.role === "user"
|
|
? "items-center flex-row-reverse gap-2"
|
|
: "flex-col",
|
|
)}
|
|
key={message.id}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex flex-col gap-2 ",
|
|
message.role === "user"
|
|
? "bg-accent/50 px-3 py-1.5 border border-border rounded-lg"
|
|
: "",
|
|
)}
|
|
>
|
|
{message.parts
|
|
.filter((part) =>
|
|
[
|
|
"text",
|
|
"tool-searchMemories",
|
|
"tool-addMemory",
|
|
].includes(part.type),
|
|
)
|
|
.map((part, index) => {
|
|
switch (part.type) {
|
|
case "text":
|
|
return (
|
|
<div key={`${message.id}-${part.type}-${index}`}>
|
|
<Streamdown>{part.text}</Streamdown>
|
|
</div>
|
|
)
|
|
case "tool-searchMemories": {
|
|
switch (part.state) {
|
|
case "input-available":
|
|
case "input-streaming":
|
|
return (
|
|
<div
|
|
className="text-sm flex items-center gap-2 text-muted-foreground"
|
|
key={`${message.id}-${part.type}-${index}`}
|
|
>
|
|
<Spinner className="size-4" /> Searching
|
|
memories...
|
|
</div>
|
|
)
|
|
case "output-error":
|
|
return (
|
|
<div
|
|
className="text-sm flex items-center gap-2 text-muted-foreground"
|
|
key={`${message.id}-${part.type}-${index}`}
|
|
>
|
|
<X className="size-4" /> Error recalling
|
|
memories
|
|
</div>
|
|
)
|
|
case "output-available": {
|
|
const output = part.output
|
|
const foundCount =
|
|
typeof output === "object" &&
|
|
output !== null &&
|
|
"count" in output
|
|
? Number(output.count) || 0
|
|
: 0
|
|
// @ts-expect-error
|
|
const results = Array.isArray(output?.results)
|
|
? // @ts-expect-error
|
|
output.results
|
|
: []
|
|
|
|
return (
|
|
<ExpandableMemories
|
|
foundCount={foundCount}
|
|
key={`${message.id}-${part.type}-${index}`}
|
|
results={results}
|
|
/>
|
|
)
|
|
}
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
case "tool-addMemory": {
|
|
switch (part.state) {
|
|
case "input-available":
|
|
return (
|
|
<div
|
|
className="text-sm flex items-center gap-2 text-muted-foreground"
|
|
key={`${message.id}-${part.type}-${index}`}
|
|
>
|
|
<Spinner className="size-4" /> Adding
|
|
memory...
|
|
</div>
|
|
)
|
|
case "output-error":
|
|
return (
|
|
<div
|
|
className="text-sm flex items-center gap-2 text-muted-foreground"
|
|
key={`${message.id}-${part.type}-${index}`}
|
|
>
|
|
<X className="size-4" /> Error adding memory
|
|
</div>
|
|
)
|
|
case "output-available":
|
|
return (
|
|
<div
|
|
className="text-sm flex items-center gap-2 text-muted-foreground"
|
|
key={`${message.id}-${part.type}-${index}`}
|
|
>
|
|
<Check className="size-4" /> Memory added
|
|
</div>
|
|
)
|
|
case "input-streaming":
|
|
return (
|
|
<div
|
|
className="text-sm flex items-center gap-2 text-muted-foreground"
|
|
key={`${message.id}-${part.type}-${index}`}
|
|
>
|
|
<Spinner className="size-4" /> Adding
|
|
memory...
|
|
</div>
|
|
)
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
default:
|
|
return null
|
|
}
|
|
})}
|
|
</div>
|
|
{message.role === "assistant" && (
|
|
<div className="flex items-center gap-0.5 mt-0.5">
|
|
<Button
|
|
className="size-7 text-muted-foreground hover:text-foreground"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(
|
|
message.parts
|
|
.filter((p) => p.type === "text")
|
|
?.map((p) => (p as MessagePart).text ?? "")
|
|
.join("\n") ?? "",
|
|
)
|
|
toast.success("Copied to clipboard")
|
|
}}
|
|
size="icon"
|
|
variant="ghost"
|
|
>
|
|
<Copy className="size-3.5" />
|
|
</Button>
|
|
<Button
|
|
className="size-6 text-muted-foreground hover:text-foreground"
|
|
onClick={() => regenerate({ messageId: message.id })}
|
|
size="icon"
|
|
variant="ghost"
|
|
>
|
|
<RotateCcw className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{status === "submitted" && (
|
|
<div className="flex text-muted-foreground justify-start gap-2 px-4 py-3 items-center w-full">
|
|
<Spinner className="size-4" />
|
|
<TextShimmer className="text-sm" duration={1.5}>
|
|
Thinking...
|
|
</TextShimmer>
|
|
</div>
|
|
)}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
className={cn(
|
|
"rounded-full w-fit mx-auto shadow-md z-10 absolute inset-x-0 bottom-4 flex justify-center",
|
|
"transition-all duration-200 ease-out",
|
|
isFarFromBottom
|
|
? "opacity-100 scale-100 pointer-events-auto"
|
|
: "opacity-0 scale-95 pointer-events-none",
|
|
)}
|
|
onClick={() => {
|
|
enableAutoScroll()
|
|
scrollToBottom("smooth")
|
|
}}
|
|
size="sm"
|
|
type="button"
|
|
variant="default"
|
|
>
|
|
Scroll to bottom
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="pb-4 px-4 md:px-2 max-w-4xl mx-auto w-full">
|
|
<form
|
|
className="flex flex-col items-end gap-3 border border-border rounded-[22px] p-3 relative shadow-lg dark:shadow-2xl"
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
if (status === "submitted") return
|
|
if (status === "streaming") {
|
|
stop()
|
|
return
|
|
}
|
|
if (input.trim()) {
|
|
enableAutoScroll()
|
|
scrollToBottom("auto")
|
|
sendMessage({ text: input })
|
|
setInput("")
|
|
}
|
|
}}
|
|
>
|
|
<textarea
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Ask your follow-up question..."
|
|
className="w-full text-foreground placeholder:text-muted-foreground rounded-md outline-none resize-none text-base leading-relaxed px-3 py-3 bg-transparent"
|
|
rows={3}
|
|
/>
|
|
<div className="absolute bottom-2 right-2 flex items-center gap-4">
|
|
<div className="flex items-center gap-1.5">
|
|
<ModelIcon
|
|
width={16}
|
|
height={16}
|
|
className="text-muted-foreground"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">
|
|
{modelNames[selectedModel]}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
disabled={!input.trim()}
|
|
className="text-primary-foreground rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed bg-primary hover:bg-primary/90"
|
|
size="icon"
|
|
>
|
|
<ArrowUp className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|