mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
feat: chat history and fetching previous chats to chat pane (#698)
This commit is contained in:
parent
8c195cece6
commit
ebd083a65b
3 changed files with 307 additions and 12 deletions
|
|
@ -47,6 +47,7 @@ import { ScrollArea } from "@ui/components/scroll-area"
|
|||
import { formatDistanceToNow } from "date-fns"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
|
||||
export function Header({ onAddMemory }: { onAddMemory?: () => void }) {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -98,7 +99,7 @@ export function Header({ onAddMemory }: { onAddMemory?: () => void }) {
|
|||
|
||||
function handleNewChat() {
|
||||
analytics.newChatStarted()
|
||||
const newId = crypto.randomUUID()
|
||||
const newId = generateId()
|
||||
setCurrentChatId(newId)
|
||||
router.push(`/chat/${newId}`)
|
||||
setIsDialogOpen(false)
|
||||
|
|
@ -129,7 +130,7 @@ export function Header({ onAddMemory }: { onAddMemory?: () => void }) {
|
|||
>
|
||||
{getCurrentChat()?.title && pathname.includes("/chat") ? (
|
||||
<div className="flex items-center gap-2 md:gap-4 min-w-0 max-w-[200px] md:max-w-md">
|
||||
<Logo className="h-6 block text-foreground flex-shrink-0" />
|
||||
<Logo className="h-6 block text-foreground shrink-0" />
|
||||
<span className="truncate text-sm md:text-base">
|
||||
{getCurrentChat()?.title}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -7,19 +7,33 @@ import { DefaultChatTransport } from "ai"
|
|||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@ui/components/dialog"
|
||||
import { ScrollArea } from "@ui/components/scroll-area"
|
||||
import {
|
||||
Check,
|
||||
ChevronDownIcon,
|
||||
HistoryIcon,
|
||||
PanelRightCloseIcon,
|
||||
Plus,
|
||||
SearchIcon,
|
||||
SquarePenIcon,
|
||||
Trash2,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import ChatInput from "./input"
|
||||
import ChatModelSelector from "./model-selector"
|
||||
import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo"
|
||||
import { useProject, usePersistentChat } from "@/stores"
|
||||
import { areUIMessageArraysEqual } from "@/stores/chat"
|
||||
import type { ModelId } from "@/lib/models"
|
||||
import { SuperLoader } from "../../superloader"
|
||||
import { UserMessage } from "./message/user-message"
|
||||
|
|
@ -27,6 +41,14 @@ import { AgentMessage } from "./message/agent-message"
|
|||
import { ChainOfThought } from "./input/chain-of-thought"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
|
||||
const DEFAULT_SUGGESTIONS = [
|
||||
"Show me all content related to Supermemory.",
|
||||
"Summarize the key ideas from My Gita.",
|
||||
"Which memories connect design and AI?",
|
||||
"What are the main themes across my memories?",
|
||||
]
|
||||
|
||||
const DEFAULT_SUGGESTIONS = [
|
||||
"Show me all content related to Supermemory.",
|
||||
|
|
@ -109,10 +131,34 @@ export function ChatSidebar({
|
|||
const [isInputExpanded, setIsInputExpanded] = useState(false)
|
||||
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true)
|
||||
const [heightOffset, setHeightOffset] = useState(95)
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
||||
const [threads, setThreads] = useState<
|
||||
Array<{ id: string; title: string; createdAt: string; updatedAt: string }>
|
||||
>([])
|
||||
const [isLoadingThreads, setIsLoadingThreads] = useState(false)
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const pendingFollowUpGenerations = useRef<Set<string>>(new Set())
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { selectedProject } = useProject()
|
||||
const { setCurrentChatId } = usePersistentChat()
|
||||
const {
|
||||
currentChatId,
|
||||
setCurrentChatId,
|
||||
setConversation,
|
||||
getCurrentConversation,
|
||||
} = usePersistentChat()
|
||||
const lastSavedMessagesRef = useRef<typeof messages | null>(null)
|
||||
const lastSavedActiveIdRef = useRef<string | null>(null)
|
||||
const lastLoadedChatIdRef = useRef<string | null>(null)
|
||||
const lastLoadedMessagesRef = useRef<typeof messages | null>(null)
|
||||
|
||||
// Initialize chat ID if none exists
|
||||
useEffect(() => {
|
||||
if (!currentChatId) {
|
||||
setCurrentChatId(generateId())
|
||||
}
|
||||
}, [currentChatId, setCurrentChatId])
|
||||
|
||||
// Adjust chat height based on scroll position (desktop only)
|
||||
useEffect(() => {
|
||||
|
|
@ -133,6 +179,7 @@ export function ChatSidebar({
|
|||
}, [isMobile])
|
||||
|
||||
const { messages, sendMessage, status, setMessages, stop } = useChat({
|
||||
id: currentChatId ?? undefined,
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`,
|
||||
credentials: "include",
|
||||
|
|
@ -140,6 +187,7 @@ export function ChatSidebar({
|
|||
metadata: {
|
||||
projectId: selectedProject,
|
||||
model: selectedModel,
|
||||
chatId: currentChatId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -154,6 +202,59 @@ export function ChatSidebar({
|
|||
},
|
||||
})
|
||||
|
||||
// Restore messages from store when currentChatId changes
|
||||
useEffect(() => {
|
||||
if (currentChatId !== lastLoadedChatIdRef.current) {
|
||||
lastLoadedMessagesRef.current = null
|
||||
lastSavedMessagesRef.current = null
|
||||
}
|
||||
|
||||
if (currentChatId === lastLoadedChatIdRef.current) {
|
||||
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
|
||||
}, [currentChatId, getCurrentConversation, setMessages])
|
||||
|
||||
// Persist messages to store whenever they change
|
||||
useEffect(() => {
|
||||
const activeId = currentChatId
|
||||
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, setConversation])
|
||||
|
||||
// Generate follow-up questions after assistant messages are complete
|
||||
useEffect(() => {
|
||||
const generateFollowUps = async () => {
|
||||
|
|
@ -310,12 +411,92 @@ export function ChatSidebar({
|
|||
|
||||
const handleNewChat = useCallback(() => {
|
||||
analytics.newChatCreated()
|
||||
const newId = crypto.randomUUID()
|
||||
const newId = generateId()
|
||||
setCurrentChatId(newId)
|
||||
setMessages([])
|
||||
setInput("")
|
||||
}, [setCurrentChatId, setMessages])
|
||||
|
||||
const fetchThreads = useCallback(async () => {
|
||||
setIsLoadingThreads(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?projectId=${selectedProject}`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setThreads(data.threads || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch threads:", error)
|
||||
} finally {
|
||||
setIsLoadingThreads(false)
|
||||
}
|
||||
}, [selectedProject])
|
||||
|
||||
const loadThread = useCallback(
|
||||
async (threadId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCurrentChatId(threadId)
|
||||
// Convert API messages to UIMessage format
|
||||
const uiMessages = data.messages.map(
|
||||
(m: {
|
||||
id: string
|
||||
role: string
|
||||
parts: unknown
|
||||
createdAt: string
|
||||
}) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
parts: m.parts || [],
|
||||
createdAt: new Date(m.createdAt),
|
||||
}),
|
||||
)
|
||||
setMessages(uiMessages)
|
||||
setConversation(threadId, uiMessages) // persist messages to store
|
||||
setIsHistoryOpen(false)
|
||||
setConfirmingDeleteId(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load thread:", error)
|
||||
}
|
||||
},
|
||||
[setCurrentChatId, setMessages, setConversation],
|
||||
)
|
||||
|
||||
const deleteThread = useCallback(
|
||||
async (threadId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
)
|
||||
if (response.ok) {
|
||||
setThreads((prev) => prev.filter((t) => t.id !== threadId))
|
||||
if (currentChatId === threadId) {
|
||||
handleNewChat()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete thread:", error)
|
||||
} finally {
|
||||
setConfirmingDeleteId(null)
|
||||
}
|
||||
},
|
||||
[currentChatId, handleNewChat],
|
||||
)
|
||||
|
||||
const formatRelativeTime = (isoString: string): string => {
|
||||
return formatDistanceToNow(new Date(isoString), { addSuffix: true })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement as HTMLElement | null
|
||||
|
|
@ -476,15 +657,127 @@ export function ChatSidebar({
|
|||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isMobile && (
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10! border-[#73737333] bg-[#0D121A]"
|
||||
style={{
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
<Dialog
|
||||
open={isHistoryOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsHistoryOpen(open)
|
||||
if (open) {
|
||||
fetchThreads()
|
||||
analytics.chatHistoryViewed?.()
|
||||
} else {
|
||||
setConfirmingDeleteId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HistoryIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10! border-[#73737333] bg-[#0D121A] cursor-pointer"
|
||||
style={{
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}}
|
||||
>
|
||||
<HistoryIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg bg-[#0A0E14] border-[#17181AB2] text-white">
|
||||
<DialogHeader className="pb-4 border-b border-[#17181AB2]">
|
||||
<DialogTitle>Chat History</DialogTitle>
|
||||
<DialogDescription className="text-[#737373]">
|
||||
Project: {selectedProject}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-96">
|
||||
{isLoadingThreads ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<SuperLoader label="Loading..." />
|
||||
</div>
|
||||
) : threads.length === 0 ? (
|
||||
<div className="text-sm text-[#737373] text-center py-8">
|
||||
No conversations yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{threads.map((thread) => {
|
||||
const isActive = thread.id === currentChatId
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
onClick={() => loadThread(thread.id)}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-md px-3 py-2 w-full text-left transition-colors",
|
||||
isActive
|
||||
? "bg-[#267BF1]/10"
|
||||
: "hover:bg-[#17181A]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{thread.title || "Untitled Chat"}
|
||||
</div>
|
||||
<div className="text-xs text-[#737373]">
|
||||
{formatRelativeTime(thread.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
{confirmingDeleteId === thread.id ? (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteThread(thread.id)
|
||||
}}
|
||||
className="bg-red-500 text-white hover:bg-red-600 h-7 w-7"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmingDeleteId(null)
|
||||
}}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<XIcon className="size-3 text-[#737373]" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmingDeleteId(thread.id)
|
||||
}}
|
||||
className="h-7 w-7 ml-2"
|
||||
>
|
||||
<Trash2 className="size-3 text-[#737373]" />
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-dashed border-[#73737333] bg-transparent hover:bg-[#17181A]"
|
||||
onClick={() => {
|
||||
handleNewChat()
|
||||
setIsHistoryOpen(false)
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 mr-1" /> New Conversation
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<Button
|
||||
variant="headers"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { useMemo, useState } from "react"
|
|||
import { analytics } from "@/lib/analytics"
|
||||
import { useChatOpen, usePersistentChat, useProject } from "@/stores"
|
||||
import { ChatMessages } from "./chat-messages"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
|
||||
export function ChatRewrite() {
|
||||
const { setIsOpen } = useChatOpen()
|
||||
|
|
@ -34,7 +35,7 @@ export function ChatRewrite() {
|
|||
|
||||
function handleNewChat() {
|
||||
analytics.newChatStarted()
|
||||
const newId = crypto.randomUUID()
|
||||
const newId = generateId()
|
||||
setCurrentChatId(newId)
|
||||
setIsDialogOpen(false)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue