mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-04-28 03:29:59 +00:00
870 lines
24 KiB
TypeScript
870 lines
24 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react"
|
|
import { motion, AnimatePresence } from "motion/react"
|
|
import { useAgent } from "agents/react"
|
|
import { useAgentChat } from "@cloudflare/ai-chat/react"
|
|
import NovaOrb from "@/components/nova/nova-orb"
|
|
import { Button } from "@ui/components/button"
|
|
import {
|
|
PanelRightCloseIcon,
|
|
SendIcon,
|
|
CheckIcon,
|
|
XIcon,
|
|
Loader2,
|
|
} from "lucide-react"
|
|
import { collectValidUrls } from "@/lib/url-helpers"
|
|
import { $fetch } from "@lib/api"
|
|
import { cn } from "@lib/utils"
|
|
import { dmSansClassName } from "@/lib/fonts"
|
|
import { useAuth } from "@lib/auth-context"
|
|
import { useProject } from "@/stores"
|
|
import { Streamdown } from "streamdown"
|
|
import { useIsMobile } from "@hooks/use-mobile"
|
|
|
|
interface ChatSidebarProps {
|
|
formData: {
|
|
twitter: string
|
|
linkedin: string
|
|
description: string
|
|
otherLinks: string[]
|
|
} | null
|
|
}
|
|
|
|
interface DraftDoc {
|
|
kind: "likes" | "link" | "x_research"
|
|
content: string
|
|
metadata: Record<string, string>
|
|
title?: string
|
|
url?: string
|
|
}
|
|
|
|
export function ChatSidebar({ formData }: ChatSidebarProps) {
|
|
const { user } = useAuth()
|
|
const { selectedProject } = useProject()
|
|
const isMobile = useIsMobile()
|
|
const [message, setMessage] = useState("")
|
|
const [isChatOpen, setIsChatOpen] = useState(!isMobile)
|
|
const [timelineMessages, setTimelineMessages] = useState<
|
|
{
|
|
message: string
|
|
type?: "formData" | "exa" | "memory" | "waiting"
|
|
memories?: {
|
|
url: string
|
|
title: string
|
|
description: string
|
|
fullContent: string
|
|
}[]
|
|
url?: string
|
|
title?: string
|
|
description?: string
|
|
}[]
|
|
>([])
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [isFetchingDrafts, setIsFetchingDrafts] = useState(false)
|
|
const [draftDocs, setDraftDocs] = useState<DraftDoc[]>([])
|
|
const [xResearchStatus, setXResearchStatus] = useState<
|
|
"correct" | "incorrect" | null
|
|
>(null)
|
|
const [isConfirmed, setIsConfirmed] = useState(false)
|
|
const [processingByUrl, setProcessingByUrl] = useState<
|
|
Record<string, boolean>
|
|
>({})
|
|
const displayedMemoriesRef = useRef<Set<string>>(new Set())
|
|
const contextInjectedRef = useRef(false)
|
|
const draftsBuiltRef = useRef(false)
|
|
const isProcessingRef = useRef(false)
|
|
const draftRequestIdRef = useRef(0)
|
|
|
|
const backendUrl = new URL(process.env.NEXT_PUBLIC_BACKEND_URL!)
|
|
const agent = useAgent({
|
|
agent: "chat-agent",
|
|
name: user?.id ?? "anonymous",
|
|
host: backendUrl.host,
|
|
})
|
|
|
|
useEffect(() => {
|
|
agent.setState({
|
|
model: "claude-sonnet-4.6" as const,
|
|
projectId: selectedProject,
|
|
})
|
|
}, [agent, selectedProject])
|
|
|
|
const {
|
|
messages: chatMessages,
|
|
sendMessage,
|
|
status,
|
|
} = useAgentChat({
|
|
agent,
|
|
getInitialMessages: null,
|
|
credentials: "include",
|
|
})
|
|
|
|
const buildOnboardingContext = useCallback(() => {
|
|
if (!formData) return ""
|
|
|
|
const contextParts: string[] = []
|
|
|
|
if (formData.description?.trim()) {
|
|
contextParts.push(`User's interests/likes: ${formData.description}`)
|
|
}
|
|
|
|
if (formData.twitter) {
|
|
contextParts.push(`X/Twitter profile: ${formData.twitter}`)
|
|
}
|
|
|
|
if (formData.linkedin) {
|
|
contextParts.push(`LinkedIn profile: ${formData.linkedin}`)
|
|
}
|
|
|
|
if (formData.otherLinks.length > 0) {
|
|
contextParts.push(`Other links: ${formData.otherLinks.join(", ")}`)
|
|
}
|
|
|
|
const memoryTexts = timelineMessages
|
|
.filter((msg) => msg.type === "memory" && msg.memories)
|
|
.flatMap(
|
|
(msg) => msg.memories?.map((m) => `${m.title}: ${m.description}`) || [],
|
|
)
|
|
|
|
if (memoryTexts.length > 0) {
|
|
contextParts.push(`Extracted memories:\n${memoryTexts.join("\n")}`)
|
|
}
|
|
|
|
return contextParts.join("\n\n")
|
|
}, [formData, timelineMessages])
|
|
|
|
const handleSend = () => {
|
|
if (!message.trim() || status === "submitted" || status === "streaming")
|
|
return
|
|
|
|
let messageToSend = message
|
|
|
|
const context = buildOnboardingContext()
|
|
|
|
if (context && !contextInjectedRef.current && chatMessages.length === 0) {
|
|
messageToSend = `${context}\n\nUser question: ${message}`
|
|
contextInjectedRef.current = true
|
|
}
|
|
|
|
sendMessage({ text: messageToSend })
|
|
setMessage("")
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}
|
|
|
|
const toggleChat = () => {
|
|
setIsChatOpen(!isChatOpen)
|
|
}
|
|
|
|
const pollForMemories = useCallback(
|
|
async (documentIds: string[]) => {
|
|
const maxAttempts = 30 // 30 attempts * 3 seconds = 90 seconds max
|
|
const pollInterval = 3000 // 3 seconds
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
try {
|
|
const response = await $fetch("@get/documents/:id", {
|
|
params: { id: documentIds[0] ?? "" },
|
|
disableValidation: true,
|
|
})
|
|
|
|
console.log("response", response)
|
|
|
|
if (response.data) {
|
|
const document = response.data
|
|
|
|
if (document.memories && document.memories.length > 0) {
|
|
const newMemories: {
|
|
url: string
|
|
title: string
|
|
description: string
|
|
fullContent: string
|
|
}[] = []
|
|
|
|
document.memories.forEach(
|
|
(memory: { memory: string; title?: string }) => {
|
|
if (!displayedMemoriesRef.current.has(memory.memory)) {
|
|
displayedMemoriesRef.current.add(memory.memory)
|
|
newMemories.push({
|
|
url: document.url || "",
|
|
title: memory.title || document.title || "Memory",
|
|
description: memory.memory || "",
|
|
fullContent: memory.memory || "",
|
|
})
|
|
}
|
|
},
|
|
)
|
|
|
|
if (newMemories.length > 0 && timelineMessages.length < 10) {
|
|
setTimelineMessages((prev) => [
|
|
...prev,
|
|
{
|
|
message: newMemories
|
|
.map((memory) => memory.description)
|
|
.join("\n"),
|
|
type: "memory" as const,
|
|
memories: newMemories,
|
|
},
|
|
])
|
|
}
|
|
}
|
|
|
|
if (document.memories && document.memories.length > 0) {
|
|
break
|
|
}
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
|
} catch (error) {
|
|
console.warn("Error polling for memories:", error)
|
|
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
|
}
|
|
}
|
|
},
|
|
[timelineMessages.length],
|
|
)
|
|
|
|
const buildDraftDocs = useCallback(async () => {
|
|
if (!formData || draftsBuiltRef.current) return
|
|
draftsBuiltRef.current = true
|
|
|
|
const hasContent =
|
|
formData.twitter ||
|
|
formData.linkedin ||
|
|
formData.otherLinks.length > 0 ||
|
|
formData.description?.trim()
|
|
|
|
if (!hasContent) return
|
|
|
|
const requestId = ++draftRequestIdRef.current
|
|
|
|
setIsFetchingDrafts(true)
|
|
const drafts: DraftDoc[] = []
|
|
|
|
const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
|
|
const allProcessingUrls: string[] = [...urls]
|
|
if (formData.twitter) {
|
|
allProcessingUrls.push(formData.twitter)
|
|
}
|
|
|
|
if (allProcessingUrls.length > 0) {
|
|
setProcessingByUrl((prev) => {
|
|
const next = { ...prev }
|
|
for (const url of allProcessingUrls) {
|
|
next[url] = true
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
try {
|
|
if (formData.description?.trim()) {
|
|
drafts.push({
|
|
kind: "likes",
|
|
content: formData.description,
|
|
metadata: {
|
|
sm_source: "consumer",
|
|
description_source: "user_input",
|
|
},
|
|
title: "Your Interests",
|
|
})
|
|
}
|
|
|
|
// Fetch each URL separately for per-link loading state
|
|
const linkPromises = urls.map(async (url) => {
|
|
try {
|
|
const response = await fetch("/api/onboarding/extract-content", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ urls: [url] }),
|
|
})
|
|
const data = await response.json()
|
|
return data.results?.[0] || null
|
|
} catch {
|
|
return null
|
|
} finally {
|
|
// Clear this URL's processing state
|
|
if (draftRequestIdRef.current === requestId) {
|
|
setProcessingByUrl((prev) => ({ ...prev, [url]: false }))
|
|
}
|
|
}
|
|
})
|
|
|
|
// Fetch X/Twitter research
|
|
const xResearchPromise = formData.twitter
|
|
? (async () => {
|
|
try {
|
|
const response = await fetch("/api/onboarding/research", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
xUrl: formData.twitter,
|
|
name: user?.name,
|
|
email: user?.email,
|
|
}),
|
|
})
|
|
if (!response.ok) return null
|
|
const data = await response.json()
|
|
return data?.text?.trim() || null
|
|
} catch {
|
|
return null
|
|
} finally {
|
|
// Clear twitter URL's processing state
|
|
if (draftRequestIdRef.current === requestId) {
|
|
setProcessingByUrl((prev) => ({
|
|
...prev,
|
|
[formData.twitter]: false,
|
|
}))
|
|
}
|
|
}
|
|
})()
|
|
: Promise.resolve(null)
|
|
|
|
const [exaResults, xResearchResult] = await Promise.all([
|
|
Promise.all(linkPromises),
|
|
xResearchPromise,
|
|
])
|
|
|
|
// Guard against stale request completing after a newer one
|
|
if (draftRequestIdRef.current !== requestId) return
|
|
|
|
for (const result of exaResults) {
|
|
if (result && (result.text || result.description)) {
|
|
drafts.push({
|
|
kind: "link",
|
|
content: result.text || result.description || "",
|
|
metadata: {
|
|
sm_source: "consumer",
|
|
exa_url: result.url,
|
|
exa_title: result.title,
|
|
},
|
|
title: result.title || "Extracted Content",
|
|
url: result.url,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (xResearchResult) {
|
|
drafts.push({
|
|
kind: "x_research",
|
|
content: xResearchResult,
|
|
metadata: {
|
|
sm_source: "consumer",
|
|
onboarding_source: "x_research",
|
|
x_url: formData.twitter,
|
|
},
|
|
title: "X/Twitter Profile Research",
|
|
url: formData.twitter,
|
|
})
|
|
}
|
|
|
|
setDraftDocs(drafts)
|
|
} catch (error) {
|
|
console.warn("Error building draft docs:", error)
|
|
} finally {
|
|
if (draftRequestIdRef.current === requestId) {
|
|
setIsFetchingDrafts(false)
|
|
}
|
|
}
|
|
}, [formData, user])
|
|
|
|
const handleConfirmDocs = useCallback(async () => {
|
|
if (isConfirmed || isProcessingRef.current) return
|
|
isProcessingRef.current = true
|
|
setIsConfirmed(true)
|
|
setIsLoading(true)
|
|
|
|
try {
|
|
const promises = draftDocs.map(async (draft) => {
|
|
if (draft.kind === "x_research" && xResearchStatus !== "correct") {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const docResponse = await $fetch("@post/documents", {
|
|
body: {
|
|
content: draft.content,
|
|
containerTags: ["sm_project_default"],
|
|
metadata: draft.metadata,
|
|
},
|
|
})
|
|
|
|
return docResponse.data?.id
|
|
} catch (error) {
|
|
console.warn("Error creating document:", error)
|
|
return null
|
|
}
|
|
})
|
|
|
|
const results = await Promise.all(promises)
|
|
const documentIds = results.filter(
|
|
(id): id is string => id !== null && id !== undefined,
|
|
)
|
|
|
|
if (documentIds.length > 0) {
|
|
await pollForMemories(documentIds)
|
|
}
|
|
} catch (error) {
|
|
console.warn("Error confirming documents:", error)
|
|
setIsConfirmed(false)
|
|
} finally {
|
|
setIsLoading(false)
|
|
isProcessingRef.current = false
|
|
}
|
|
}, [draftDocs, xResearchStatus, isConfirmed, pollForMemories])
|
|
|
|
useEffect(() => {
|
|
if (!formData) return
|
|
|
|
const formDataMessages: typeof timelineMessages = []
|
|
|
|
if (formData.twitter) {
|
|
formDataMessages.push({
|
|
message: formData.twitter,
|
|
url: formData.twitter,
|
|
title: "X/Twitter",
|
|
description: formData.twitter,
|
|
type: "formData" as const,
|
|
})
|
|
}
|
|
|
|
if (formData.linkedin) {
|
|
formDataMessages.push({
|
|
message: formData.linkedin,
|
|
url: formData.linkedin,
|
|
title: "LinkedIn",
|
|
description: formData.linkedin,
|
|
type: "formData" as const,
|
|
})
|
|
}
|
|
|
|
if (formData.otherLinks.length > 0) {
|
|
formData.otherLinks.forEach((link) => {
|
|
formDataMessages.push({
|
|
message: link,
|
|
url: link,
|
|
title: "Link",
|
|
description: link,
|
|
type: "formData" as const,
|
|
})
|
|
})
|
|
}
|
|
|
|
if (formData.description?.trim()) {
|
|
formDataMessages.push({
|
|
message: formData.description,
|
|
title: "Likes",
|
|
description: formData.description,
|
|
type: "formData" as const,
|
|
})
|
|
}
|
|
|
|
setTimelineMessages(formDataMessages)
|
|
buildDraftDocs()
|
|
}, [formData, buildDraftDocs])
|
|
|
|
return (
|
|
<AnimatePresence mode="wait">
|
|
{!isChatOpen ? (
|
|
<motion.div
|
|
key="closed"
|
|
className={cn(
|
|
"flex items-start justify-start",
|
|
isMobile
|
|
? "fixed bottom-4 right-4 z-50"
|
|
: "absolute top-0 right-0 m-4",
|
|
dmSansClassName(),
|
|
)}
|
|
layoutId="chat-toggle-button"
|
|
>
|
|
<motion.button
|
|
onClick={toggleChat}
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border border-[#17181A] text-white cursor-pointer shadow-lg",
|
|
isMobile && "px-4 py-2",
|
|
)}
|
|
style={{
|
|
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
|
}}
|
|
>
|
|
<NovaOrb size={24} className="blur-none! z-10" />
|
|
{!isMobile && "Chat with Nova"}
|
|
</motion.button>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="open"
|
|
className={cn(
|
|
"bg-[#0A0E14] backdrop-blur-md flex flex-col",
|
|
isMobile
|
|
? "fixed inset-0 z-50 w-full h-dvh rounded-none m-0"
|
|
: "w-[450px] h-[calc(100vh-110px)] rounded-2xl m-4",
|
|
dmSansClassName(),
|
|
)}
|
|
initial={
|
|
isMobile ? { y: "100%", opacity: 0 } : { x: "100px", opacity: 0 }
|
|
}
|
|
animate={{ x: 0, y: 0, opacity: 1 }}
|
|
exit={
|
|
isMobile ? { y: "100%", opacity: 0 } : { x: "100px", opacity: 0 }
|
|
}
|
|
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
|
|
>
|
|
<motion.button
|
|
onClick={toggleChat}
|
|
className={cn(
|
|
"absolute top-4 right-4 flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer",
|
|
isMobile && "bg-[#0D121A] border border-[#73737333]",
|
|
)}
|
|
style={
|
|
isMobile
|
|
? {
|
|
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
|
}
|
|
: {
|
|
background:
|
|
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
|
}
|
|
}
|
|
layoutId="chat-toggle-button"
|
|
>
|
|
{isMobile ? (
|
|
<XIcon className="size-4" />
|
|
) : (
|
|
<>
|
|
<PanelRightCloseIcon className="size-4" />
|
|
Close chat
|
|
</>
|
|
)}
|
|
</motion.button>
|
|
<div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end overflow-y-auto scrollbar-thin">
|
|
{timelineMessages.map((msg, i) => (
|
|
<div
|
|
key={`message-${i}-${msg.message}`}
|
|
className="flex items-start gap-2"
|
|
>
|
|
{msg.type === "waiting" ? (
|
|
<div className="flex items-center gap-2 text-white/50">
|
|
<NovaOrb size={30} className="blur-none!" />
|
|
<span className="text-sm">{msg.message}</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
"flex flex-col items-center justify-center w-[30px] h-full",
|
|
i !== 0 && "",
|
|
)}
|
|
>
|
|
{i === 0 && (
|
|
<div className="w-3 h-3 bg-[#293952]/40 rounded-full mb-1" />
|
|
)}
|
|
<div className="w-px flex-1 bg-[#293952]/40" />
|
|
</div>
|
|
{msg.type === "formData" && (
|
|
<div className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-1 flex-1">
|
|
{msg.title && (
|
|
<div className="flex items-center gap-2">
|
|
<h3
|
|
className="text-sm font-medium"
|
|
style={{
|
|
background:
|
|
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
backgroundClip: "text",
|
|
}}
|
|
>
|
|
{msg.title}
|
|
</h3>
|
|
{msg.url && processingByUrl[msg.url] && (
|
|
<Loader2 className="h-3 w-3 animate-spin text-blue-400" />
|
|
)}
|
|
</div>
|
|
)}
|
|
{msg.url && (
|
|
<a
|
|
href={msg.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-blue-400 hover:underline break-all block"
|
|
>
|
|
{msg.url}
|
|
</a>
|
|
)}
|
|
{msg.title === "Likes" && msg.description && (
|
|
<p className="text-xs text-white/70 mt-1">
|
|
{msg.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{msg.type === "memory" && (
|
|
<div className="space-y-2 w-full max-h-60 overflow-y-auto scrollbar-thin">
|
|
{msg.memories?.map((memory) => (
|
|
<div
|
|
key={memory.url + memory.title}
|
|
className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-2"
|
|
>
|
|
{memory.title && (
|
|
<h3
|
|
className="text-sm font-medium"
|
|
style={{
|
|
background:
|
|
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
backgroundClip: "text",
|
|
}}
|
|
>
|
|
{memory.title}
|
|
</h3>
|
|
)}
|
|
{memory.url && (
|
|
<a
|
|
href={memory.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-blue-400 hover:underline break-all"
|
|
>
|
|
{memory.url}
|
|
</a>
|
|
)}
|
|
{memory.description && (
|
|
<p className="text-xs text-white/50 mt-1">
|
|
{memory.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
{chatMessages.map((msg) => {
|
|
if (msg.role === "user") {
|
|
const text = msg.parts
|
|
.filter((part) => part.type === "text")
|
|
.map((part) => part.text)
|
|
.join(" ")
|
|
return (
|
|
<div
|
|
key={msg.id}
|
|
className="flex items-start gap-2 justify-end"
|
|
>
|
|
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
|
|
<p className="text-sm text-white">{text}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
if (msg.role === "assistant") {
|
|
return (
|
|
<div key={msg.id} className="flex items-start gap-2">
|
|
<NovaOrb size={30} className="blur-none!" />
|
|
<div className="flex-1">
|
|
{msg.parts.map((part, partIndex) => {
|
|
if (part.type === "text") {
|
|
return (
|
|
<div
|
|
key={`${msg.id}-${partIndex}`}
|
|
className="text-sm text-white/90 chat-markdown-content"
|
|
>
|
|
<Streamdown>{part.text}</Streamdown>
|
|
</div>
|
|
)
|
|
}
|
|
if (part.type === "tool-searchMemories") {
|
|
if (
|
|
part.state === "input-available" ||
|
|
part.state === "input-streaming"
|
|
) {
|
|
return (
|
|
<div
|
|
key={`${msg.id}-${partIndex}`}
|
|
className="text-xs text-white/50 italic"
|
|
>
|
|
Searching memories...
|
|
</div>
|
|
)
|
|
}
|
|
}
|
|
return null
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
return null
|
|
})}
|
|
{(status === "submitted" || status === "streaming") &&
|
|
chatMessages[chatMessages.length - 1]?.role === "user" && (
|
|
<div className="flex items-start gap-2">
|
|
<NovaOrb size={30} className="blur-none!" />
|
|
<span className="text-sm text-white/50">Thinking...</span>
|
|
</div>
|
|
)}
|
|
{timelineMessages.length === 0 &&
|
|
chatMessages.length === 0 &&
|
|
!isLoading &&
|
|
!formData && (
|
|
<div className="flex items-center gap-2 text-white/50">
|
|
<NovaOrb size={28} className="blur-none!" />
|
|
<span className="text-sm">Waiting for your input</span>
|
|
</div>
|
|
)}
|
|
{isLoading && (
|
|
<div className="flex items-center gap-2 text-foreground/50">
|
|
<NovaOrb size={28} className="blur-none!" />
|
|
<span className="text-sm">Extracting memories...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{draftDocs.some((d) => d.kind === "x_research") && !isConfirmed && (
|
|
<div className="px-4 pb-2 space-y-3">
|
|
<div className="bg-[#293952]/40 rounded-lg p-3 space-y-2">
|
|
<h3
|
|
className="text-sm font-medium"
|
|
style={{
|
|
background:
|
|
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
backgroundClip: "text",
|
|
}}
|
|
>
|
|
Your Profile Summary
|
|
</h3>
|
|
<div className="overflow-y-auto scrollbar-thin max-h-32">
|
|
<p className="text-xs text-white/70">
|
|
{draftDocs.find((d) => d.kind === "x_research")?.content}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 pt-2">
|
|
<span className="text-xs text-white/50">
|
|
Is this accurate?
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setXResearchStatus("correct")
|
|
handleConfirmDocs()
|
|
}}
|
|
disabled={isConfirmed || isLoading}
|
|
className={cn(
|
|
"flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors cursor-pointer",
|
|
xResearchStatus === "correct"
|
|
? "bg-green-500/20 text-green-400 border border-green-500/40"
|
|
: "bg-[#1B1F24] text-white/50 hover:text-white/70",
|
|
(isConfirmed || isLoading) &&
|
|
"opacity-50 cursor-not-allowed",
|
|
)}
|
|
>
|
|
<CheckIcon className="size-3" />
|
|
Correct
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setXResearchStatus("incorrect")}
|
|
className={cn(
|
|
"flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors cursor-pointer",
|
|
xResearchStatus === "incorrect"
|
|
? "bg-red-500/20 text-red-400 border border-red-500/40"
|
|
: "bg-[#1B1F24] text-white/50 hover:text-white/70",
|
|
)}
|
|
>
|
|
<XIcon className="size-3" />
|
|
Incorrect
|
|
</button>
|
|
</div>
|
|
{xResearchStatus === "incorrect" && (
|
|
<>
|
|
<p className="text-xs text-white/40 pt-1">
|
|
If incorrect, share your info in the input below, or you
|
|
can add memories later as well.
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
onClick={handleConfirmDocs}
|
|
disabled={isConfirmed || isLoading}
|
|
className="w-full bg-[#267BF1] hover:bg-[#1E6AD9] text-white rounded-lg py-2 text-sm cursor-pointer mt-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Continue
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!draftDocs.some((d) => d.kind === "x_research") &&
|
|
draftDocs.length > 0 &&
|
|
!isConfirmed && (
|
|
<div className="px-4 pb-2">
|
|
<Button
|
|
type="button"
|
|
onClick={handleConfirmDocs}
|
|
disabled={isConfirmed || isLoading}
|
|
className="w-full bg-[#267BF1] hover:bg-[#1E6AD9] text-white rounded-lg py-2 text-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Continue
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="p-4 space-y-2">
|
|
{isFetchingDrafts && (
|
|
<div className="flex items-center gap-2 text-white/50 px-2">
|
|
<NovaOrb size={20} className="blur-none!" />
|
|
<span className="text-sm">
|
|
Getting all relevant info about you...
|
|
</span>
|
|
</div>
|
|
)}
|
|
<form
|
|
className="flex flex-col gap-3 bg-[#0D121A] rounded-xl p-2 relative"
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
if (message.trim()) {
|
|
handleSend()
|
|
}
|
|
}}
|
|
>
|
|
<input
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Chat with your Supermemory"
|
|
className="w-full text-white placeholder:text-white/20 rounded-sm outline-none resize-none text-base leading-relaxed bg-transparent px-2 h-10"
|
|
disabled={status === "submitted" || status === "streaming"}
|
|
/>
|
|
<div className="flex justify-end absolute bottom-3 right-2">
|
|
<Button
|
|
type="submit"
|
|
disabled={
|
|
!message.trim() ||
|
|
status === "submitted" ||
|
|
status === "streaming"
|
|
}
|
|
className="text-white/20 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all"
|
|
size="icon"
|
|
>
|
|
<SendIcon className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|