mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-09 22:31:10 +00:00
456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react"
|
|
import { motion, AnimatePresence } from "motion/react"
|
|
import { useChat } from "@ai-sdk/react"
|
|
import { DefaultChatTransport } from "ai"
|
|
import NovaOrb from "@/components/nova/nova-orb"
|
|
import { Button } from "@ui/components/button"
|
|
import {
|
|
HistoryIcon,
|
|
PanelRightCloseIcon,
|
|
SearchIcon,
|
|
SquarePenIcon,
|
|
} from "lucide-react"
|
|
import { cn } from "@lib/utils"
|
|
import { dmSansClassName } from "@/utils/fonts"
|
|
import ChatInput from "./input"
|
|
import ChatModelSelector from "./model-selector"
|
|
import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo"
|
|
import { useProject, usePersistentChat } from "@/stores"
|
|
import type { ModelId } from "@/lib/models"
|
|
import { SuperLoader } from "../superloader"
|
|
import { UserMessage } from "./message/user-message"
|
|
import { AgentMessage } from "./message/agent-message"
|
|
import { ChainOfThought } from "./input/chain-of-thought"
|
|
|
|
export function ChatSidebar() {
|
|
const [input, setInput] = useState("")
|
|
const [isChatOpen, setIsChatOpen] = useState(true)
|
|
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
|
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
|
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null)
|
|
const [chainOfThought, setChainOfThought] = useState<any[] | null>(null)
|
|
const [messageFeedback, setMessageFeedback] = useState<
|
|
Record<string, "like" | "dislike" | null>
|
|
>({})
|
|
const [expandedMemories, setExpandedMemories] = useState<string | null>(null)
|
|
const [followUpQuestions, setFollowUpQuestions] = useState<
|
|
Record<string, string[]>
|
|
>({})
|
|
const [loadingFollowUps, setLoadingFollowUps] = useState<
|
|
Record<string, boolean>
|
|
>({})
|
|
const pendingFollowUpGenerations = useRef<Set<string>>(new Set())
|
|
const { selectedProject } = useProject()
|
|
const { setCurrentChatId } = usePersistentChat()
|
|
|
|
const { messages, sendMessage, status, setMessages, stop } = useChat({
|
|
transport: new DefaultChatTransport({
|
|
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat`,
|
|
credentials: "include",
|
|
body: {
|
|
metadata: {
|
|
projectId: selectedProject,
|
|
model: selectedModel,
|
|
},
|
|
},
|
|
}),
|
|
maxSteps: 10,
|
|
onFinish: async (result) => {
|
|
if (result.message.role !== "assistant") return
|
|
|
|
// Mark this message as needing follow-up generation
|
|
// We'll generate it after the message is fully in the messages array
|
|
if (result.message.id) {
|
|
pendingFollowUpGenerations.current.add(result.message.id)
|
|
}
|
|
},
|
|
})
|
|
|
|
// Generate follow-up questions after assistant messages are complete
|
|
useEffect(() => {
|
|
const generateFollowUps = async () => {
|
|
// Find assistant messages that need follow-up generation
|
|
const messagesToProcess = messages.filter(
|
|
(msg) =>
|
|
msg.role === "assistant" &&
|
|
pendingFollowUpGenerations.current.has(msg.id) &&
|
|
!followUpQuestions[msg.id] &&
|
|
!loadingFollowUps[msg.id],
|
|
)
|
|
|
|
for (const message of messagesToProcess) {
|
|
// Get complete text from the message
|
|
const assistantText = message.parts
|
|
.filter((p) => p.type === "text")
|
|
.map((p) => p.text)
|
|
.join(" ")
|
|
.trim()
|
|
|
|
// Only generate if we have substantial text (at least 50 chars)
|
|
// This ensures the message is complete, not just the first chunk
|
|
// Also check if status is idle to ensure streaming is complete
|
|
if (
|
|
assistantText.length < 50 ||
|
|
status === "streaming" ||
|
|
status === "submitted"
|
|
) {
|
|
continue
|
|
}
|
|
|
|
// Mark as processing
|
|
pendingFollowUpGenerations.current.delete(message.id)
|
|
setLoadingFollowUps((prev) => ({
|
|
...prev,
|
|
[message.id]: true,
|
|
}))
|
|
|
|
try {
|
|
// Get recent messages for context
|
|
const recentMessages = messages.slice(-5).map((msg) => ({
|
|
role: msg.role,
|
|
content: msg.parts
|
|
.filter((p) => p.type === "text")
|
|
.map((p) => p.text)
|
|
.join(" "),
|
|
}))
|
|
|
|
const response = await fetch(
|
|
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/follow-ups`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
messages: recentMessages,
|
|
assistantResponse: assistantText,
|
|
}),
|
|
},
|
|
)
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
if (data.questions && Array.isArray(data.questions)) {
|
|
setFollowUpQuestions((prev) => ({
|
|
...prev,
|
|
[message.id]: data.questions,
|
|
}))
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to generate follow-up questions:", error)
|
|
} finally {
|
|
setLoadingFollowUps((prev) => ({
|
|
...prev,
|
|
[message.id]: false,
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only generate if not currently streaming or submitted
|
|
// Small delay to ensure message is fully processed
|
|
if (status !== "streaming" && status !== "submitted") {
|
|
const timeoutId = setTimeout(() => {
|
|
generateFollowUps()
|
|
}, 300)
|
|
|
|
return () => clearTimeout(timeoutId)
|
|
}
|
|
}, [messages, followUpQuestions, loadingFollowUps, status])
|
|
|
|
console.log(messages)
|
|
|
|
const handleSend = () => {
|
|
if (!input.trim() || status === "submitted" || status === "streaming")
|
|
return
|
|
sendMessage({ text: input })
|
|
setInput("")
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}
|
|
|
|
const toggleChat = () => {
|
|
setIsChatOpen(!isChatOpen)
|
|
}
|
|
|
|
const handleCopyMessage = useCallback((messageId: string, text: string) => {
|
|
navigator.clipboard.writeText(text)
|
|
setCopiedMessageId(messageId)
|
|
setTimeout(() => setCopiedMessageId(null), 2000)
|
|
}, [])
|
|
|
|
const handleLikeMessage = useCallback((messageId: string) => {
|
|
setMessageFeedback((prev) => ({
|
|
...prev,
|
|
[messageId]: prev[messageId] === "like" ? null : "like",
|
|
}))
|
|
}, [])
|
|
|
|
const handleDislikeMessage = useCallback((messageId: string) => {
|
|
setMessageFeedback((prev) => ({
|
|
...prev,
|
|
[messageId]: prev[messageId] === "dislike" ? null : "dislike",
|
|
}))
|
|
}, [])
|
|
|
|
const handleToggleMemories = useCallback((messageId: string) => {
|
|
setExpandedMemories((prev) => (prev === messageId ? null : messageId))
|
|
}, [])
|
|
|
|
const handleNewChat = useCallback(() => {
|
|
console.log("handleNewChat")
|
|
const newId = crypto.randomUUID()
|
|
setCurrentChatId(newId)
|
|
setMessages([])
|
|
setInput("")
|
|
}, [setCurrentChatId, setMessages])
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (
|
|
e.key.toLowerCase() === "t" &&
|
|
!e.metaKey &&
|
|
!e.ctrlKey &&
|
|
!e.altKey &&
|
|
isChatOpen &&
|
|
document.activeElement?.tagName !== "INPUT" &&
|
|
document.activeElement?.tagName !== "TEXTAREA"
|
|
) {
|
|
e.preventDefault()
|
|
handleNewChat()
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", handleKeyDown)
|
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
|
}, [isChatOpen, handleNewChat])
|
|
|
|
return (
|
|
<AnimatePresence mode="wait">
|
|
{!isChatOpen ? (
|
|
<motion.div
|
|
key="closed"
|
|
className={cn(
|
|
"absolute top-0 right-0 flex items-start justify-start m-4",
|
|
dmSansClassName(),
|
|
)}
|
|
layoutId="chat-toggle-button"
|
|
>
|
|
<motion.button
|
|
onClick={toggleChat}
|
|
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border-[1px] border-[#17181A] text-white cursor-pointer"
|
|
style={{
|
|
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
|
}}
|
|
>
|
|
<NovaOrb size={24} className="!blur-none z-10" />
|
|
Chat with Nova
|
|
</motion.button>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="open"
|
|
className={cn(
|
|
"w-[450px] h-[calc(100vh-95px)] bg-[#05070A] backdrop-blur-md flex flex-col rounded-2xl m-4 border border-[#17181AB2] relative",
|
|
dmSansClassName(),
|
|
)}
|
|
initial={{ x: "100px", opacity: 0 }}
|
|
animate={{ x: 0, opacity: 1 }}
|
|
exit={{ x: "100px", opacity: 0 }}
|
|
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
|
|
>
|
|
<div className="flex items-center justify-between pt-4 px-6 pb-3 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.4)]">
|
|
<ChatModelSelector
|
|
selectedModel={selectedModel}
|
|
onModelChange={setSelectedModel}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<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",
|
|
}}
|
|
>
|
|
<HistoryIcon className="size-4 text-[#737373]" />
|
|
</Button>
|
|
<Button
|
|
variant="headers"
|
|
className="rounded-full text-base gap-3 !h-10 border-[#73737333] bg-[#0D121A] cursor-pointer"
|
|
style={{
|
|
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
|
}}
|
|
onClick={handleNewChat}
|
|
title="New chat (T)"
|
|
>
|
|
<SquarePenIcon className="size-4 text-[#737373]" />
|
|
<span
|
|
className={cn(
|
|
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
T
|
|
</span>
|
|
</Button>
|
|
<motion.button
|
|
onClick={toggleChat}
|
|
className="flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer"
|
|
layoutId="chat-toggle-button"
|
|
>
|
|
<PanelRightCloseIcon className="size-4" />
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"flex-1 overflow-y-auto px-4 scrollbar-thin",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
{messages.length === 0 && <ChatEmptyStatePlaceholder />}
|
|
<div
|
|
className={cn(
|
|
messages.length > 0
|
|
? "flex flex-col space-y-3 min-h-full justify-end"
|
|
: "",
|
|
)}
|
|
>
|
|
{messages.map((message, index) => (
|
|
// biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
|
|
<div
|
|
key={message.id}
|
|
className={cn(
|
|
"flex gap-2 w-full",
|
|
message.role === "user" ? "justify-end" : "justify-start",
|
|
)}
|
|
onMouseEnter={() =>
|
|
message.role === "assistant" &&
|
|
setHoveredMessageId(message.id)
|
|
}
|
|
onMouseLeave={() =>
|
|
message.role === "assistant" && setHoveredMessageId(null)
|
|
}
|
|
>
|
|
{message.role === "user" ? (
|
|
<UserMessage
|
|
message={message}
|
|
copiedMessageId={copiedMessageId}
|
|
onCopy={handleCopyMessage}
|
|
/>
|
|
) : (
|
|
<AgentMessage
|
|
message={message}
|
|
index={index}
|
|
messagesLength={messages.length}
|
|
hoveredMessageId={hoveredMessageId}
|
|
copiedMessageId={copiedMessageId}
|
|
messageFeedback={messageFeedback}
|
|
expandedMemories={expandedMemories}
|
|
followUpQuestions={followUpQuestions[message.id] || []}
|
|
isLoadingFollowUps={loadingFollowUps[message.id] || false}
|
|
onCopy={handleCopyMessage}
|
|
onLike={handleLikeMessage}
|
|
onDislike={handleDislikeMessage}
|
|
onToggleMemories={handleToggleMemories}
|
|
onQuestionClick={(question) => {
|
|
setInput(question)
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
{(status === "submitted" || status === "streaming") &&
|
|
messages[messages.length - 1]?.role === "user" && (
|
|
<div className="flex gap-2">
|
|
<SuperLoader label="Thinking..." />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<ChatInput
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onSend={handleSend}
|
|
onStop={stop}
|
|
onKeyDown={handleKeyDown}
|
|
isResponding={status === "submitted" || status === "streaming"}
|
|
activeStatus={
|
|
status === "submitted"
|
|
? "Thinking..."
|
|
: status === "streaming"
|
|
? "Structuring response..."
|
|
: "Waiting for input..."
|
|
}
|
|
chainOfThoughtComponent={(() => {
|
|
const lastUserMessage = [...messages]
|
|
.reverse()
|
|
.find((msg) => msg.role === "user")
|
|
const lastAgentMessage = [...messages]
|
|
.reverse()
|
|
.find((msg) => msg.role === "assistant")
|
|
const userMessageText =
|
|
lastUserMessage?.parts.find((part) => part.type === "text")
|
|
?.text ?? ""
|
|
|
|
return userMessageText ? (
|
|
<ChainOfThought
|
|
userMessage={userMessageText}
|
|
lastAgentMessage={lastAgentMessage}
|
|
/>
|
|
) : null
|
|
})()}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|
|
|
|
function ChatEmptyStatePlaceholder() {
|
|
const 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?",
|
|
]
|
|
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full">
|
|
<div className="relative">
|
|
<GradientLogo className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
|
<LogoBgGradient className="" />
|
|
</div>
|
|
<div className="gap-3 flex flex-col items-center justify-center">
|
|
<p>Ask me anything about your memories...</p>
|
|
<div
|
|
className={cn(
|
|
dmSansClassName(),
|
|
"flex flex-col gap-2 justify-center items-center",
|
|
)}
|
|
>
|
|
{suggestions.map((suggestion) => (
|
|
<Button
|
|
key={suggestion}
|
|
variant="default"
|
|
className="rounded-full text-base gap-1 !h-10 border-[#2261CA33] bg-[#0D121A] border w-fit"
|
|
>
|
|
<SearchIcon className="size-4 text-[#267BF1]" />
|
|
<span className="text-[#267BF1] text-[12px]">{suggestion}</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|