aboutsummaryrefslogtreecommitdiff
path: root/apps/web/components/new/onboarding/setup/chat-sidebar.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components/new/onboarding/setup/chat-sidebar.tsx')
-rw-r--r--apps/web/components/new/onboarding/setup/chat-sidebar.tsx549
1 files changed, 413 insertions, 136 deletions
diff --git a/apps/web/components/new/onboarding/setup/chat-sidebar.tsx b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx
index 59742271..dd6eeb23 100644
--- a/apps/web/components/new/onboarding/setup/chat-sidebar.tsx
+++ b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx
@@ -2,14 +2,18 @@
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 { PanelRightCloseIcon, SendIcon } from "lucide-react"
+import { PanelRightCloseIcon, SendIcon, CheckIcon, XIcon } 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"
interface ChatSidebarProps {
formData: {
@@ -20,11 +24,20 @@ interface ChatSidebarProps {
} | 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 [message, setMessage] = useState("")
const [isChatOpen, setIsChatOpen] = useState(true)
- const [messages, setMessages] = useState<
+ const [timelineMessages, setTimelineMessages] = useState<
{
message: string
type?: "formData" | "exa" | "memory" | "waiting"
@@ -40,10 +53,82 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
}[]
>([])
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 displayedMemoriesRef = useRef<Set<string>>(new Set())
+ const contextInjectedRef = useRef(false)
+ const draftsBuiltRef = useRef(false)
+ const isProcessingRef = useRef(false)
+
+ const {
+ messages: chatMessages,
+ sendMessage,
+ status,
+ } = useChat({
+ transport: new DefaultChatTransport({
+ api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`,
+ credentials: "include",
+ body: {
+ metadata: {
+ projectId: selectedProject,
+ model: "gemini-2.5-pro",
+ },
+ },
+ }),
+ })
+
+ 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 = () => {
- console.log("Message:", message)
+ 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("")
}
@@ -97,8 +182,8 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
},
)
- if (newMemories.length > 0 && messages.length < 10) {
- setMessages((prev) => [
+ if (newMemories.length > 0 && timelineMessages.length < 10) {
+ setTimelineMessages((prev) => [
...prev,
{
message: newMemories
@@ -123,13 +208,151 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
}
}
},
- [messages.length],
+ [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
+
+ setIsFetchingDrafts(true)
+ const drafts: DraftDoc[] = []
+
+ try {
+ if (formData.description?.trim()) {
+ drafts.push({
+ kind: "likes",
+ content: formData.description,
+ metadata: {
+ sm_source: "consumer",
+ description_source: "user_input",
+ },
+ title: "Your Interests",
+ })
+ }
+
+ const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
+
+ const [exaResults, xResearchResult] = await Promise.all([
+ urls.length > 0
+ ? fetch("/api/onboarding/extract-content", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ urls }),
+ })
+ .then((r) => r.json())
+ .then((data) => data.results || [])
+ .catch(() => [])
+ : Promise.resolve([]),
+ formData.twitter
+ ? fetch("/api/onboarding/research", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ xUrl: formData.twitter,
+ name: user?.name,
+ email: user?.email,
+ }),
+ })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((data) => data?.text?.trim() || null)
+ .catch(() => null)
+ : Promise.resolve(null),
+ ])
+
+ for (const result of exaResults) {
+ if (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 {
+ setIsFetchingDrafts(false)
+ }
+ }, [formData, user])
+
+ const handleConfirmDocs = useCallback(async () => {
+ if (isConfirmed || isProcessingRef.current) return
+ isProcessingRef.current = true
+ setIsConfirmed(true)
+ setIsLoading(true)
+
+ try {
+ const documentIds: string[] = []
+
+ for (const draft of draftDocs) {
+ if (draft.kind === "x_research" && xResearchStatus !== "correct") {
+ continue
+ }
+
+ try {
+ const docResponse = await $fetch("@post/documents", {
+ body: {
+ content: draft.content,
+ containerTags: ["sm_project_default"],
+ metadata: draft.metadata,
+ },
+ })
+
+ if (docResponse.data?.id) {
+ documentIds.push(docResponse.data.id)
+ }
+ } catch (error) {
+ console.warn("Error creating document:", error)
+ }
+ }
+
+ 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 messages = []
+ const formDataMessages: typeof timelineMessages = []
if (formData.twitter) {
formDataMessages.push({
@@ -172,125 +395,9 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
})
}
- setMessages(formDataMessages)
-
- const hasContent =
- formData.twitter ||
- formData.linkedin ||
- formData.otherLinks.length > 0 ||
- formData.description?.trim()
-
- if (!hasContent) return
-
- const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
-
- const processContent = async () => {
- setIsLoading(true)
-
- try {
- const documentIds: string[] = []
-
- if (formData.description?.trim()) {
- try {
- const descDocResponse = await $fetch("@post/documents", {
- body: {
- content: formData.description,
- containerTags: ["sm_project_default"],
- metadata: {
- sm_source: "consumer",
- description_source: "user_input",
- },
- },
- })
-
- if (descDocResponse.data?.id) {
- documentIds.push(descDocResponse.data.id)
- }
- } catch (error) {
- console.warn("Error creating description document:", error)
- }
- }
-
- if (formData.twitter) {
- try {
- const researchResponse = 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 (researchResponse.ok) {
- const { text } = await researchResponse.json()
-
- if (text?.trim()) {
- const xDocResponse = await $fetch("@post/documents", {
- body: {
- content: text,
- containerTags: ["sm_project_default"],
- metadata: {
- sm_source: "consumer",
- onboarding_source: "x_research",
- x_url: formData.twitter,
- },
- },
- })
-
- if (xDocResponse.data?.id) {
- documentIds.push(xDocResponse.data.id)
- }
- }
- }
- } catch (error) {
- console.warn("Error fetching X research:", error)
- }
- }
-
- if (urls.length > 0) {
- const response = await fetch("/api/onboarding/extract-content", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ urls }),
- })
- const { results } = await response.json()
-
- for (const result of results) {
- try {
- const docResponse = await $fetch("@post/documents", {
- body: {
- content: result.text || result.description || "",
- containerTags: ["sm_project_default"],
- metadata: {
- sm_source: "consumer",
- exa_url: result.url,
- exa_title: result.title,
- },
- },
- })
-
- if (docResponse.data?.id) {
- documentIds.push(docResponse.data.id)
- }
- } catch (error) {
- console.warn("Error creating document:", error)
- }
- }
- }
-
- if (documentIds.length > 0) {
- await pollForMemories(documentIds)
- }
- } catch (error) {
- console.warn("Error processing content:", error)
- }
- setIsLoading(false)
- }
-
- processContent()
- }, [formData, pollForMemories, user])
+ setTimelineMessages(formDataMessages)
+ buildDraftDocs()
+ }, [formData, buildDraftDocs])
return (
<AnimatePresence mode="wait">
@@ -337,8 +444,8 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
<PanelRightCloseIcon className="size-4" />
Close chat
</motion.button>
- <div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end">
- {messages.map((msg, i) => (
+ <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"
@@ -438,12 +545,78 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
)}
</div>
))}
- {messages.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>
- )}
+ {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!" />
@@ -452,7 +625,106 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
)}
</div>
- <div className="p-4">
+ {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) => {
@@ -468,11 +740,16 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
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()}
+ 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"
>