mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-22 06:53:57 +00:00
chore: delete document, document ui (#673)
This commit is contained in:
parent
59c294b299
commit
9fd49193ac
19 changed files with 1505 additions and 539 deletions
5
.github/workflows/claude-code-review.yml
vendored
5
.github/workflows/claude-code-review.yml
vendored
|
|
@ -26,6 +26,9 @@ jobs:
|
|||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
use_sticky_comment: true
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
You are reviewing a PR for supermemory - a Turbo monorepo with multiple apps and packages.
|
||||
|
||||
## Repository Structure Context
|
||||
|
|
@ -75,4 +78,4 @@ jobs:
|
|||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
claude_args: '--allowedTools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<script
|
||||
crossOrigin="anonymous"
|
||||
src="https://unpkg.com/react-scan/dist/auto.global.js"
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body
|
||||
className={`${font.variable} antialiased overflow-x-hidden`}
|
||||
suppressHydrationWarning
|
||||
|
|
|
|||
106
apps/web/app/new/onboarding/layout.tsx
Normal file
106
apps/web/app/new/onboarding/layout.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
export type MemoryFormData = {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
} | null
|
||||
|
||||
interface OnboardingContextValue {
|
||||
name: string
|
||||
setName: (name: string) => void
|
||||
memoryFormData: MemoryFormData
|
||||
setMemoryFormData: (data: MemoryFormData) => void
|
||||
resetOnboarding: () => void
|
||||
}
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextValue | null>(null)
|
||||
|
||||
export function useOnboardingContext() {
|
||||
const ctx = useContext(OnboardingContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useOnboardingContext must be used within OnboardingLayout")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function OnboardingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
|
||||
const [name, setNameState] = useState<string>("")
|
||||
const [memoryFormData, setMemoryFormDataState] =
|
||||
useState<MemoryFormData>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const storedName = localStorage.getItem("onboarding_name")
|
||||
const storedMemoryFormData = localStorage.getItem(
|
||||
"onboarding_memoryFormData",
|
||||
)
|
||||
|
||||
if (storedName) {
|
||||
setNameState(storedName)
|
||||
} else if (user?.name) {
|
||||
setNameState(user.name)
|
||||
localStorage.setItem("onboarding_name", user.name)
|
||||
}
|
||||
|
||||
if (storedMemoryFormData) {
|
||||
try {
|
||||
setMemoryFormDataState(JSON.parse(storedMemoryFormData))
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}, [user?.name])
|
||||
|
||||
const setName = useCallback((newName: string) => {
|
||||
setNameState(newName)
|
||||
localStorage.setItem("onboarding_name", newName)
|
||||
localStorage.setItem("username", newName)
|
||||
}, [])
|
||||
|
||||
const setMemoryFormData = useCallback((data: MemoryFormData) => {
|
||||
setMemoryFormDataState(data)
|
||||
if (data) {
|
||||
localStorage.setItem("onboarding_memoryFormData", JSON.stringify(data))
|
||||
} else {
|
||||
localStorage.removeItem("onboarding_memoryFormData")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetOnboarding = useCallback(() => {
|
||||
localStorage.removeItem("onboarding_name")
|
||||
localStorage.removeItem("onboarding_memoryFormData")
|
||||
setNameState("")
|
||||
setMemoryFormDataState(null)
|
||||
}, [])
|
||||
|
||||
const contextValue: OnboardingContextValue = {
|
||||
name,
|
||||
setName,
|
||||
memoryFormData,
|
||||
setMemoryFormData,
|
||||
resetOnboarding,
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</OnboardingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,267 +1,18 @@
|
|||
"use client"
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
import { InputStep } from "../../../components/new/onboarding/welcome/input-step"
|
||||
import { GreetingStep } from "../../../components/new/onboarding/welcome/greeting-step"
|
||||
import { WelcomeStep } from "../../../components/new/onboarding/welcome/welcome-step"
|
||||
import { ContinueStep } from "../../../components/new/onboarding/welcome/continue-step"
|
||||
import { FeaturesStep } from "../../../components/new/onboarding/welcome/features-step"
|
||||
import { ProfileStep } from "../../../components/new/onboarding/welcome/profile-step"
|
||||
import { RelatableQuestion } from "../../../components/new/onboarding/setup/relatable-question"
|
||||
import { IntegrationsStep } from "../../../components/new/onboarding/setup/integrations-step"
|
||||
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { SetupHeader } from "../../../components/new/onboarding/setup/header"
|
||||
import { ChatSidebar } from "../../../components/new/onboarding/setup/chat-sidebar"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
|
||||
|
||||
function UserSupermemory({ name }: { name: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-0 top-[-34px] flex items-center justify-center z-10"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
>
|
||||
<Logo className="h-14 text-white" />
|
||||
<div className="flex flex-col items-start justify-center ml-4">
|
||||
<p className="text-white text-[25px] font-medium leading-none">
|
||||
{name.split(" ")[0]}'s
|
||||
</p>
|
||||
<p className="text-white font-bold text-4xl leading-none -mt-2">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const { user } = useAuth()
|
||||
|
||||
const flow = searchParams.get("flow") as "welcome" | "setup" | null
|
||||
const step = searchParams.get("step") as string | null
|
||||
|
||||
const [name, setName] = useState(user?.name ?? "")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [memoryFormData, setMemoryFormData] = useState<{
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
} | null>(null)
|
||||
const [showWelcomeContent, setShowWelcomeContent] = useState(false)
|
||||
|
||||
const currentFlow = flow || "welcome"
|
||||
const currentStep = step || "input"
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.name) {
|
||||
setName(user.name)
|
||||
localStorage.setItem("username", user.name)
|
||||
}
|
||||
}, [user?.name])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFlow === "welcome" && currentStep === "input") {
|
||||
setShowWelcomeContent(false)
|
||||
const timer = setTimeout(() => {
|
||||
setShowWelcomeContent(true)
|
||||
}, 1250)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [currentFlow, currentStep])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFlow !== "welcome") return
|
||||
|
||||
const timers: NodeJS.Timeout[] = []
|
||||
|
||||
switch (currentStep) {
|
||||
case "greeting":
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
// Auto-advance to welcome step
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/new/onboarding?flow=welcome&step=welcome",
|
||||
)
|
||||
}, 2000),
|
||||
)
|
||||
break
|
||||
case "welcome":
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
// Auto-advance to username step
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/new/onboarding?flow=welcome&step=username",
|
||||
)
|
||||
}, 2000),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
return () => {
|
||||
timers.forEach(clearTimeout)
|
||||
}
|
||||
}, [currentStep, currentFlow])
|
||||
|
||||
const handleSubmit = () => {
|
||||
localStorage.setItem("username", name)
|
||||
if (name.trim()) {
|
||||
setIsSubmitting(true)
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/new/onboarding?flow=welcome&step=greeting",
|
||||
)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderWelcomeStep = () => {
|
||||
switch (currentStep) {
|
||||
case "input":
|
||||
return (
|
||||
<InputStep
|
||||
key="input"
|
||||
name={name}
|
||||
setName={setName}
|
||||
handleSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)
|
||||
case "greeting":
|
||||
return <GreetingStep key="greeting" name={name} />
|
||||
case "welcome":
|
||||
return <WelcomeStep key="welcome" />
|
||||
case "username":
|
||||
return <ContinueStep key="username" />
|
||||
case "features":
|
||||
return <FeaturesStep key="features" />
|
||||
case "memories":
|
||||
return <ProfileStep key="profile" onSubmit={setMemoryFormData} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const renderSetupStep = () => {
|
||||
switch (currentStep) {
|
||||
case "relatable":
|
||||
return <RelatableQuestion key="relatable" />
|
||||
case "integrations":
|
||||
return <IntegrationsStep key="integrations" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const isWelcomeFlow = currentFlow === "welcome"
|
||||
const isSetupFlow = currentFlow === "setup"
|
||||
|
||||
const minimizeNovaOrb =
|
||||
isWelcomeFlow && ["features", "memories"].includes(currentStep)
|
||||
const novaSize = currentStep === "memories" ? 150 : 300
|
||||
|
||||
const showUserSupermemory = isWelcomeFlow && currentStep === "username"
|
||||
router.replace("/new/onboarding/welcome?step=input")
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-black">
|
||||
{isWelcomeFlow && (
|
||||
<InitialHeader
|
||||
showUserSupermemory={
|
||||
currentStep === "features" || currentStep === "memories"
|
||||
}
|
||||
name={name}
|
||||
/>
|
||||
)}
|
||||
{isSetupFlow && <SetupHeader />}
|
||||
|
||||
{isSetupFlow && <AnimatedGradientBackground animateFromBottom={false} />}
|
||||
|
||||
{isWelcomeFlow && currentStep === "input" && (
|
||||
<AnimatedGradientBackground animateFromBottom={true} />
|
||||
)}
|
||||
|
||||
{isWelcomeFlow && showWelcomeContent && (
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center overflow-y-auto">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat pointer-events-none"
|
||||
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
|
||||
style={{
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex flex-col items-center justify-center",
|
||||
)}
|
||||
animate={{
|
||||
gap: minimizeNovaOrb ? 0 : 16,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale:
|
||||
currentStep === "features"
|
||||
? 0.7
|
||||
: currentStep === "memories"
|
||||
? 0.4
|
||||
: 1,
|
||||
padding: minimizeNovaOrb ? 0 : 48,
|
||||
paddingTop: 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: "easeOut",
|
||||
delay: 0.2,
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
<NovaOrb size={novaSize} />
|
||||
{showUserSupermemory && <UserSupermemory name={name} />}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">{renderWelcomeStep()}</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSetupFlow && (
|
||||
<main className="relative min-h-screen">
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-row h-[calc(100vh-90px)] relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-start p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
{renderSetupStep()}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ChatSidebar formData={memoryFormData} />
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
<div className="h-screen overflow-hidden bg-black flex items-center justify-center">
|
||||
<div className="text-white/50 text-sm">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
70
apps/web/app/new/onboarding/setup/layout.tsx
Normal file
70
apps/web/app/new/onboarding/setup/layout.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"use client"
|
||||
|
||||
import { createContext, useContext, useCallback, type ReactNode } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useOnboardingContext, type MemoryFormData } from "../layout"
|
||||
|
||||
export const SETUP_STEPS = ["relatable", "integrations"] as const
|
||||
export type SetupStep = (typeof SETUP_STEPS)[number]
|
||||
|
||||
interface SetupContextValue {
|
||||
memoryFormData: MemoryFormData
|
||||
currentStep: SetupStep
|
||||
goToStep: (step: SetupStep) => void
|
||||
goToWelcome: (step?: string) => void
|
||||
finishOnboarding: () => void
|
||||
}
|
||||
|
||||
const SetupContext = createContext<SetupContextValue | null>(null)
|
||||
|
||||
export function useSetupContext() {
|
||||
const ctx = useContext(SetupContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useSetupContext must be used within SetupLayout")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function SetupLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { memoryFormData, resetOnboarding } = useOnboardingContext()
|
||||
|
||||
const stepParam = searchParams.get("step")
|
||||
const currentStep: SetupStep = SETUP_STEPS.includes(stepParam as SetupStep)
|
||||
? (stepParam as SetupStep)
|
||||
: "relatable"
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: SetupStep) => {
|
||||
router.push(`/new/onboarding/setup?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const goToWelcome = useCallback(
|
||||
(step = "input") => {
|
||||
router.push(`/new/onboarding/welcome?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const finishOnboarding = useCallback(() => {
|
||||
resetOnboarding()
|
||||
router.push("/new")
|
||||
}, [router, resetOnboarding])
|
||||
|
||||
const contextValue: SetupContextValue = {
|
||||
memoryFormData,
|
||||
currentStep,
|
||||
goToStep,
|
||||
goToWelcome,
|
||||
finishOnboarding,
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SetupContext.Provider>
|
||||
)
|
||||
}
|
||||
68
apps/web/app/new/onboarding/setup/page.tsx
Normal file
68
apps/web/app/new/onboarding/setup/page.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"use client"
|
||||
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
|
||||
import { RelatableQuestion } from "@/components/new/onboarding/setup/relatable-question"
|
||||
import { IntegrationsStep } from "@/components/new/onboarding/setup/integrations-step"
|
||||
|
||||
import { SetupHeader } from "@/components/new/onboarding/setup/header"
|
||||
import { ChatSidebar } from "@/components/new/onboarding/setup/chat-sidebar"
|
||||
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
|
||||
|
||||
import { useSetupContext, type SetupStep } from "./layout"
|
||||
|
||||
function StepNotFound({ goToStep }: { goToStep: (step: SetupStep) => void }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<h2 className="text-white text-2xl mb-4">Unknown step</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep("relatable")}
|
||||
className="text-blue-400 underline"
|
||||
>
|
||||
Go to first step
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SetupPage() {
|
||||
const { memoryFormData, currentStep, goToStep } = useSetupContext()
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case "relatable":
|
||||
return <RelatableQuestion key="relatable" />
|
||||
case "integrations":
|
||||
return <IntegrationsStep key="integrations" />
|
||||
default:
|
||||
return <StepNotFound key="not-found" goToStep={goToStep} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-black">
|
||||
<SetupHeader />
|
||||
|
||||
<AnimatedGradientBackground animateFromBottom={false} />
|
||||
|
||||
<main className="relative min-h-screen">
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-row h-[calc(100vh-90px)] relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-start p-8">
|
||||
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ChatSidebar formData={memoryFormData} />
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
143
apps/web/app/new/onboarding/welcome/layout.tsx
Normal file
143
apps/web/app/new/onboarding/welcome/layout.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useOnboardingContext, type MemoryFormData } from "../layout"
|
||||
|
||||
export const WELCOME_STEPS = [
|
||||
"input",
|
||||
"greeting",
|
||||
"welcome",
|
||||
"username",
|
||||
"features",
|
||||
"memories",
|
||||
] as const
|
||||
export type WelcomeStep = (typeof WELCOME_STEPS)[number]
|
||||
|
||||
interface WelcomeContextValue {
|
||||
name: string
|
||||
setName: (name: string) => void
|
||||
isSubmitting: boolean
|
||||
setIsSubmitting: (value: boolean) => void
|
||||
showWelcomeContent: boolean
|
||||
memoryFormData: MemoryFormData
|
||||
setMemoryFormData: (data: MemoryFormData) => void
|
||||
currentStep: WelcomeStep
|
||||
goToStep: (step: WelcomeStep) => void
|
||||
goToSetup: (step?: string) => void
|
||||
}
|
||||
|
||||
const WelcomeContext = createContext<WelcomeContextValue | null>(null)
|
||||
|
||||
export function useWelcomeContext() {
|
||||
const ctx = useContext(WelcomeContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useWelcomeContext must be used within WelcomeLayout")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function WelcomeLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { name, setName, memoryFormData, setMemoryFormData } =
|
||||
useOnboardingContext()
|
||||
|
||||
const stepParam = searchParams.get("step")
|
||||
const currentStep: WelcomeStep = WELCOME_STEPS.includes(
|
||||
stepParam as WelcomeStep,
|
||||
)
|
||||
? (stepParam as WelcomeStep)
|
||||
: "input"
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showWelcomeContent, setShowWelcomeContent] = useState(false)
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === "input") {
|
||||
setShowWelcomeContent(false)
|
||||
const timer = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setShowWelcomeContent(true)
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
setShowWelcomeContent(true)
|
||||
}, [currentStep])
|
||||
|
||||
useEffect(() => {
|
||||
const timers: NodeJS.Timeout[] = []
|
||||
|
||||
if (currentStep === "greeting") {
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
router.replace("/new/onboarding/welcome?step=welcome")
|
||||
}
|
||||
}, 2000),
|
||||
)
|
||||
} else if (currentStep === "welcome") {
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
router.replace("/new/onboarding/welcome?step=username")
|
||||
}
|
||||
}, 2000),
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
timers.forEach(clearTimeout)
|
||||
}
|
||||
}, [currentStep, router])
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: WelcomeStep) => {
|
||||
router.push(`/new/onboarding/welcome?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const goToSetup = useCallback(
|
||||
(step = "relatable") => {
|
||||
router.push(`/new/onboarding/setup?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const contextValue: WelcomeContextValue = {
|
||||
name,
|
||||
setName,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
showWelcomeContent,
|
||||
memoryFormData,
|
||||
setMemoryFormData,
|
||||
currentStep,
|
||||
goToStep,
|
||||
goToSetup,
|
||||
}
|
||||
|
||||
return (
|
||||
<WelcomeContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</WelcomeContext.Provider>
|
||||
)
|
||||
}
|
||||
185
apps/web/app/new/onboarding/welcome/page.tsx
Normal file
185
apps/web/app/new/onboarding/welcome/page.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"use client"
|
||||
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
import { InputStep } from "@/components/new/onboarding/welcome/input-step"
|
||||
import { GreetingStep } from "@/components/new/onboarding/welcome/greeting-step"
|
||||
import { WelcomeStep } from "@/components/new/onboarding/welcome/welcome-step"
|
||||
import { OnboardingContentStep } from "@/components/new/onboarding/welcome/continue-step"
|
||||
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
|
||||
|
||||
import {
|
||||
useWelcomeContext,
|
||||
type WelcomeStep as WelcomeStepType,
|
||||
} from "./layout"
|
||||
import { gapVariants, orbVariants } from "@/lib/variants"
|
||||
|
||||
function UserSupermemory({ name }: { name: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-0 top-[-34px] flex items-center justify-center z-10"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
>
|
||||
<Logo className="h-14 text-white" />
|
||||
<div className="flex flex-col items-start justify-center ml-4">
|
||||
<p className="text-white text-[25px] font-medium leading-none">
|
||||
{name.split(" ")[0]}'s
|
||||
</p>
|
||||
<p className="text-white font-bold text-4xl leading-none -mt-2">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepNotFound({
|
||||
goToStep,
|
||||
}: {
|
||||
goToStep: (step: WelcomeStepType) => void
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<h2 className="text-white text-2xl mb-4">Unknown step</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep("input")}
|
||||
className="text-blue-400 underline"
|
||||
>
|
||||
Start from beginning
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WelcomePage() {
|
||||
const {
|
||||
name,
|
||||
setName,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
showWelcomeContent,
|
||||
setMemoryFormData,
|
||||
currentStep,
|
||||
goToStep,
|
||||
} = useWelcomeContext()
|
||||
|
||||
const handleSubmit = () => {
|
||||
localStorage.setItem("username", name)
|
||||
if (name.trim()) {
|
||||
setIsSubmitting(true)
|
||||
goToStep("greeting")
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case "input":
|
||||
return (
|
||||
<InputStep
|
||||
key="input"
|
||||
name={name}
|
||||
setName={setName}
|
||||
handleSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)
|
||||
case "greeting":
|
||||
return <GreetingStep key="greeting" name={name} />
|
||||
case "welcome":
|
||||
return <WelcomeStep key="welcome" />
|
||||
case "username":
|
||||
case "features":
|
||||
case "memories":
|
||||
return (
|
||||
<OnboardingContentStep
|
||||
key="onboarding-content"
|
||||
currentView={
|
||||
currentStep === "username"
|
||||
? "continue"
|
||||
: currentStep === "features"
|
||||
? "features"
|
||||
: "memories"
|
||||
}
|
||||
onSubmit={setMemoryFormData}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return <StepNotFound key="not-found" goToStep={goToStep} />
|
||||
}
|
||||
}
|
||||
|
||||
const minimizeNovaOrb = ["features", "memories"].includes(currentStep)
|
||||
const novaSize = currentStep === "memories" ? 150 : 300
|
||||
const showUserSupermemory = currentStep === "username"
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-black">
|
||||
<InitialHeader
|
||||
showUserSupermemory={
|
||||
currentStep === "features" || currentStep === "memories"
|
||||
}
|
||||
name={name}
|
||||
/>
|
||||
|
||||
{currentStep === "input" && (
|
||||
<AnimatedGradientBackground animateFromBottom={true} />
|
||||
)}
|
||||
|
||||
{showWelcomeContent && (
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center overflow-y-auto">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat pointer-events-none"
|
||||
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
|
||||
style={{
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex flex-col items-center justify-center",
|
||||
)}
|
||||
variants={gapVariants}
|
||||
animate={minimizeNovaOrb ? "minimized" : "default"}
|
||||
>
|
||||
<motion.div
|
||||
variants={orbVariants}
|
||||
animate={
|
||||
currentStep === "features"
|
||||
? "features"
|
||||
: currentStep === "memories"
|
||||
? "memories"
|
||||
: "default"
|
||||
}
|
||||
initial={{
|
||||
padding: 0,
|
||||
paddingTop: 0,
|
||||
y: 60,
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
<NovaOrb size={novaSize} />
|
||||
{showUserSupermemory && <UserSupermemory name={name} />}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { ArrowUpRightIcon, XIcon, Loader2 } from "lucide-react"
|
||||
import { ArrowUpRightIcon, XIcon, Loader2, Trash2Icon, CheckIcon } from "lucide-react"
|
||||
import type { z } from "zod"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
|
|
@ -20,6 +20,8 @@ import { useState, useEffect, useCallback, useMemo } from "react"
|
|||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import { useDocumentMutations } from "@/hooks/use-document-mutations"
|
||||
import type { UseMutationResult } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// Dynamically importing to prevent DOMMatrix error
|
||||
const PdfViewer = dynamic(
|
||||
|
|
@ -43,12 +45,102 @@ interface DocumentModalProps {
|
|||
onClose: () => void
|
||||
}
|
||||
|
||||
interface DeleteButtonProps {
|
||||
documentId: string | null | undefined
|
||||
customId: string | null | undefined
|
||||
deleteMutation: UseMutationResult<
|
||||
unknown,
|
||||
Error,
|
||||
{ documentId: string },
|
||||
unknown
|
||||
>
|
||||
}
|
||||
|
||||
function isTemporaryId(id: string | null | undefined): boolean {
|
||||
if (!id) return false
|
||||
return id.startsWith("temp-") || id.startsWith("temp-file-")
|
||||
}
|
||||
|
||||
function DeleteButton({ documentId, customId, deleteMutation }: DeleteButtonProps) {
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
const id = documentId ?? customId
|
||||
if (!id) return
|
||||
|
||||
// Check both IDs to ensure we catch temporary documents regardless of which ID is used
|
||||
if (isTemporaryId(documentId) || isTemporaryId(customId)) {
|
||||
// this is when user added document immediately and trying to delete
|
||||
toast.error("Cannot delete document", {
|
||||
description: "This document is still being processed. Please wait.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
deleteMutation.mutate({ documentId: id as string })
|
||||
}, [documentId, customId, deleteMutation])
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{!deleteConfirmOpen ? (
|
||||
<motion.button
|
||||
key="trash"
|
||||
type="button"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
tabIndex={-1}
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4 text-red-500" />
|
||||
<span className="sr-only">Delete document</span>
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.div
|
||||
key="confirm"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex items-center gap-1 px-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteConfirmOpen(false)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<XIcon className="w-4 h-4 text-[#737373]" />
|
||||
<span className="sr-only">Cancel delete</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 text-green-500 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
<span className="sr-only">Confirm delete</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocumentModal({
|
||||
document: _document,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: DocumentModalProps) {
|
||||
const { updateMutation } = useDocumentMutations()
|
||||
const { updateMutation, deleteMutation } = useDocumentMutations({ onClose })
|
||||
|
||||
const { initialEditorContent, initialEditorString } = useMemo(() => {
|
||||
const content = _document?.content as string | null | undefined
|
||||
|
|
@ -76,7 +168,9 @@ export function DocumentModal({
|
|||
}, [initialEditorString])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) resetEditor()
|
||||
if (!isOpen) {
|
||||
resetEditor()
|
||||
}
|
||||
}, [isOpen, resetEditor])
|
||||
|
||||
const hasUnsavedChanges =
|
||||
|
|
@ -107,13 +201,20 @@ export function DocumentModal({
|
|||
<DialogTitle className="sr-only">
|
||||
{_document?.title} - Document
|
||||
</DialogTitle>
|
||||
<div className="flex justify-between h-fit">
|
||||
<Title
|
||||
title={_document?.title}
|
||||
documentType={_document?.type ?? "text"}
|
||||
url={_document?.url}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-between h-fit gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Title
|
||||
title={_document?.title}
|
||||
documentType={_document?.type ?? "text"}
|
||||
url={_document?.url}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<DeleteButton
|
||||
documentId={_document?.id}
|
||||
customId={_document?.customId}
|
||||
deleteMutation={deleteMutation}
|
||||
/>
|
||||
{_document?.url && (
|
||||
<a
|
||||
href={_document.url}
|
||||
|
|
@ -126,8 +227,10 @@ export function DocumentModal({
|
|||
</a>
|
||||
)}
|
||||
<DialogPrimitive.Close
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
data-slot="dialog-close"
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<XIcon stroke="#737373" />
|
||||
<span className="sr-only">Close</span>
|
||||
|
|
|
|||
|
|
@ -30,10 +30,10 @@ export function Title({
|
|||
<div
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[16px] font-semibold text-[#FAFAFA] line-clamp-1 leading-[125%] flex items-center gap-3",
|
||||
"text-[16px] font-semibold text-[#FAFAFA] leading-[125%] flex items-center gap-3 min-w-0",
|
||||
)}
|
||||
>
|
||||
<div className="pl-1 flex items-center gap-1 w-5 h-5">
|
||||
<div className="pl-1 flex items-center gap-1 w-5 h-5 shrink-0">
|
||||
{getDocumentIcon(
|
||||
documentType as DocumentType,
|
||||
"w-5 h-5",
|
||||
|
|
@ -49,7 +49,7 @@ export function Title({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
{title || "Untitled Document"}
|
||||
<span className="truncate">{title || "Untitled Document"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ export function IntegrationsStep() {
|
|||
variant="link"
|
||||
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
|
||||
onClick={() =>
|
||||
router.push("/new/onboarding?flow=setup&step=relatable")
|
||||
router.push("/new/onboarding/setup?step=relatable")
|
||||
}
|
||||
>
|
||||
← Back
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export function RelatableQuestion() {
|
|||
const [selectedOptions, setSelectedOptions] = useState<number[]>([])
|
||||
|
||||
const handleContinueOrSkip = () => {
|
||||
router.push("/new/onboarding?flow=setup&step=integrations")
|
||||
router.push("/new/onboarding/setup?step=integrations")
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,45 +1,195 @@
|
|||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { motion } from "motion/react"
|
||||
import { motion, type Variants } from "motion/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ProfileStep } from "./profile-step"
|
||||
import { continueVariants, contentVariants } from "@/lib/variants"
|
||||
|
||||
export function ContinueStep() {
|
||||
type OnboardingView = "continue" | "features" | "memories"
|
||||
|
||||
interface OnboardingContentStepProps {
|
||||
currentView?: OnboardingView
|
||||
onSubmit?: (data: {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
}) => void
|
||||
}
|
||||
|
||||
const containerVariants: Variants = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function OnboardingContentStep({
|
||||
currentView = "continue",
|
||||
onSubmit,
|
||||
}: OnboardingContentStepProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleContinue = () => {
|
||||
router.push("/new/onboarding?flow=welcome&step=features")
|
||||
router.push("/new/onboarding/welcome?step=features")
|
||||
}
|
||||
|
||||
const handleAddMemories = () => {
|
||||
router.push("/new/onboarding/welcome?step=memories")
|
||||
}
|
||||
|
||||
const isContinue = currentView === "continue"
|
||||
const isFeatures = currentView === "features"
|
||||
const isMemories = currentView === "memories"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0, y: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 0, scale: 0.9 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
layout
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
className="text-center relative"
|
||||
>
|
||||
<p
|
||||
{/* Continue content */}
|
||||
<motion.div
|
||||
variants={continueVariants}
|
||||
animate={isContinue ? "visible" : "hidden"}
|
||||
initial="visible"
|
||||
className={cn(
|
||||
"text-[#8A8A8A] text-sm mb-6 max-w-sm",
|
||||
dmSansClassName(),
|
||||
"flex flex-col items-center justify-center max-w-88",
|
||||
!isContinue && "absolute inset-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
I'm built with Supermemory's super fast memory API,
|
||||
<br /> so you never have to worry about forgetting <br /> what matters
|
||||
across your AI apps.
|
||||
</p>
|
||||
<Button
|
||||
variant="onboarding"
|
||||
onClick={handleContinue}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
width: "147px",
|
||||
}}
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8A8A8A] text-sm mb-6 max-w-sm",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
I'm built with Supermemory's super fast memory API,
|
||||
<br /> so you never have to worry about forgetting <br /> what matters
|
||||
across your AI apps.
|
||||
</p>
|
||||
<Button
|
||||
variant="onboarding"
|
||||
onClick={handleContinue}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
width: "147px",
|
||||
}}
|
||||
>
|
||||
Continue →
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Features content */}
|
||||
<motion.div
|
||||
variants={contentVariants}
|
||||
animate={isFeatures ? "visible" : "hiddenDown"}
|
||||
initial="hiddenDown"
|
||||
className={cn(
|
||||
"space-y-6 max-w-88",
|
||||
!isFeatures && "absolute inset-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
Continue →
|
||||
</Button>
|
||||
<h2 className="text-white text-[32px] font-medium leading-[110%]">
|
||||
What I can do for you
|
||||
</h2>
|
||||
|
||||
<div className={cn("space-y-4 mb-[24px] mx-4", dmSansClassName())}>
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/human-brain.png"
|
||||
alt="Brain icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">Remember every context</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I keep track of what you've saved and shared with your
|
||||
supermemory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/search.png"
|
||||
alt="Search icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">Find when you need it</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I surface the right memories inside <br /> your supermemory,
|
||||
superfast.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/plant.png"
|
||||
alt="Growth icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">
|
||||
Grow with your supermemory
|
||||
</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I learn and personalize over time, so every interaction feels
|
||||
natural.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="onboarding"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
onClick={handleAddMemories}
|
||||
>
|
||||
Add memories →
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Memories/Profile content */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
!isMemories && "absolute inset-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{onSubmit && (
|
||||
<motion.div
|
||||
variants={contentVariants}
|
||||
animate={isMemories ? "visible" : "hiddenDown"}
|
||||
initial="hiddenDown"
|
||||
>
|
||||
<ProfileStep onSubmit={onSubmit} />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
import { motion } from "motion/react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
||||
export function FeaturesStep() {
|
||||
const router = useRouter()
|
||||
|
||||
const handleContinue = () => {
|
||||
router.push("/new/onboarding?flow=welcome&step=memories")
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.3 }}
|
||||
className="text-center max-w-88 space-y-6"
|
||||
layout
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium leading-[110%]">
|
||||
What I can do for you
|
||||
</h2>
|
||||
|
||||
<div className={cn("space-y-4 mb-[24px] mx-4", dmSansClassName())}>
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/human-brain.png"
|
||||
alt="Brain icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">Remember every context</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I keep track of what you've saved and shared with your
|
||||
supermemory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/search.png"
|
||||
alt="Search icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">Find when you need it</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I surface the right memories inside <br /> your supermemory,
|
||||
superfast.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/plant.png"
|
||||
alt="Growth icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">Grow with your supermemory</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I learn and personalize over time, so every interaction feels
|
||||
natural.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
transition={{ duration: 1, ease: "easeOut", delay: 1 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
>
|
||||
<Button
|
||||
variant="onboarding"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
onClick={handleContinue}
|
||||
>
|
||||
Add memories →
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -292,7 +292,7 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) {
|
|||
otherLinks: otherLinks.filter((l) => l.trim()),
|
||||
}
|
||||
onSubmit(formData)
|
||||
router.push("/new/onboarding?flow=setup&step=relatable")
|
||||
router.push("/new/onboarding/setup?step=relatable")
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Fetching..." : "Remember this →"}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ 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 { ModelIcon } from "@/lib/models"
|
||||
import { Spinner } from "../../spinner"
|
||||
import { areUIMessageArraysEqual } from "@/stores/chat"
|
||||
|
||||
|
|
@ -270,7 +270,6 @@ export function ChatMessages() {
|
|||
},
|
||||
},
|
||||
}),
|
||||
maxSteps: 10,
|
||||
onFinish: (result) => {
|
||||
const activeId = activeChatIdRef.current
|
||||
if (!activeId) return
|
||||
|
|
|
|||
|
|
@ -3,12 +3,28 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
|
||||
interface DocumentWithId {
|
||||
id?: string
|
||||
customId?: string | null
|
||||
}
|
||||
|
||||
interface DocumentsQueryData {
|
||||
documents: unknown[]
|
||||
documents: DocumentWithId[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
type InfiniteQueryData = {
|
||||
pages: DocumentsResponse[]
|
||||
pageParams: number[]
|
||||
}
|
||||
|
||||
type QueryData = DocumentsQueryData | InfiniteQueryData
|
||||
|
||||
interface UseDocumentMutationsOptions {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
|
@ -51,7 +67,7 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
|
|||
])
|
||||
|
||||
const optimisticMemory = {
|
||||
id: `temp-${Date.now()}`,
|
||||
id: `temp-${crypto.randomUUID()}`,
|
||||
content: content,
|
||||
url: null,
|
||||
title: content.substring(0, 100),
|
||||
|
|
@ -134,7 +150,7 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
|
|||
])
|
||||
|
||||
const optimisticMemory = {
|
||||
id: `temp-${Date.now()}`,
|
||||
id: `temp-${crypto.randomUUID()}`,
|
||||
content: "",
|
||||
url: url,
|
||||
title: "Processing...",
|
||||
|
|
@ -251,7 +267,7 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
|
|||
])
|
||||
|
||||
const optimisticMemory = {
|
||||
id: `temp-file-${Date.now()}`,
|
||||
id: `temp-file-${crypto.randomUUID()}`,
|
||||
content: "",
|
||||
url: null,
|
||||
title: title || file.name,
|
||||
|
|
@ -341,10 +357,100 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
|
|||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async ({ documentId }: { documentId: string }) => {
|
||||
const response = await $fetch("@delete/documents/:id", {
|
||||
params: { id: documentId },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to delete document")
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
onMutate: async ({ documentId }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["documents-with-memories"],
|
||||
})
|
||||
|
||||
const previousQueries = queryClient.getQueriesData({
|
||||
queryKey: ["documents-with-memories"],
|
||||
})
|
||||
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["documents-with-memories"] },
|
||||
(old: QueryData | undefined) => {
|
||||
if (!old) return old
|
||||
|
||||
if ("pages" in old) {
|
||||
const infiniteData = old as InfiniteQueryData
|
||||
return {
|
||||
...infiniteData,
|
||||
pages: infiniteData.pages.map((page) => {
|
||||
if (!page?.documents) return page
|
||||
return {
|
||||
...page,
|
||||
documents: page.documents.filter(
|
||||
(doc) => doc.id !== documentId && doc.customId !== documentId,
|
||||
),
|
||||
pagination: page.pagination
|
||||
? {
|
||||
...page.pagination,
|
||||
totalItems: Math.max(
|
||||
0,
|
||||
(page.pagination.totalItems ?? 0) - 1,
|
||||
),
|
||||
}
|
||||
: page.pagination,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if ("documents" in old) {
|
||||
const queryData = old as DocumentsQueryData
|
||||
return {
|
||||
...queryData,
|
||||
documents: queryData.documents.filter(
|
||||
(doc: DocumentWithId) => {
|
||||
return doc.id !== documentId && doc.customId !== documentId
|
||||
},
|
||||
),
|
||||
totalCount: Math.max(0, (queryData.totalCount ?? 0) - 1),
|
||||
}
|
||||
}
|
||||
|
||||
return old
|
||||
},
|
||||
)
|
||||
|
||||
return { previousQueries }
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previousQueries) {
|
||||
context.previousQueries.forEach(([queryKey, data]) => {
|
||||
queryClient.setQueryData(queryKey, data)
|
||||
})
|
||||
}
|
||||
toast.error("Failed to delete document", {
|
||||
description: _error instanceof Error ? _error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Document deleted successfully!")
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["documents-with-memories"],
|
||||
})
|
||||
onClose?.()
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
noteMutation,
|
||||
linkMutation,
|
||||
fileMutation,
|
||||
updateMutation,
|
||||
deleteMutation,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
apps/web/lib/variants.ts
Normal file
95
apps/web/lib/variants.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { Variants } from "motion/react"
|
||||
|
||||
export const contentVariants: Variants = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: "easeInOut",
|
||||
delay: 0.2,
|
||||
},
|
||||
},
|
||||
hiddenUp: {
|
||||
opacity: 0,
|
||||
y: -40,
|
||||
transition: {
|
||||
duration: 0,
|
||||
},
|
||||
},
|
||||
hiddenDown: {
|
||||
opacity: 0,
|
||||
y: 40,
|
||||
transition: {
|
||||
duration: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const continueVariants: Variants = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: -40,
|
||||
transition: {
|
||||
duration: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const gapVariants: Variants = {
|
||||
default: {
|
||||
gap: 16,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
minimized: {
|
||||
gap: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const orbVariants: Variants = {
|
||||
default: {
|
||||
scale: 1,
|
||||
padding: 48,
|
||||
paddingTop: 0,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
features: {
|
||||
scale: 0.7,
|
||||
padding: 0,
|
||||
paddingTop: 0,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
memories: {
|
||||
scale: 0.4,
|
||||
padding: 0,
|
||||
paddingTop: 0,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue