formatting

This commit is contained in:
Dhravya Shah 2025-09-18 20:34:18 -07:00
parent ea17bc4451
commit 1fcb569089
71 changed files with 2657 additions and 2831 deletions

View file

@ -5,9 +5,9 @@ export async function GET() {
return new ImageResponse(
<div tw="w-full h-full flex flex-col justify-center items-center">
<img
src="https://pub-1be2b1df2c7e456f8e21149e972f4caf.r2.dev/bust.png"
alt="Google Logo"
height={367}
src="https://pub-1be2b1df2c7e456f8e21149e972f4caf.r2.dev/bust.png"
width={369}
/>
</div>,

View file

@ -4,20 +4,24 @@ import * as Sentry from "@sentry/nextjs";
import NextError from "next/error";
import { useEffect } from "react";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
{/* `NextError` is the default Next.js error page component. Its type
return (
<html>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
);
}
<NextError statusCode={0} />
</body>
</html>
);
}

View file

@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google";
import "../globals.css";
import "@ui/globals.css";
import { AuthProvider } from "@lib/auth-context";
@ -24,6 +24,11 @@ const mono = JetBrains_Mono({
variable: "--font-mono",
});
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-space-grotesk",
});
export const metadata: Metadata = {
metadataBase: new URL("https://app.supermemory.ai"),
description: "Your memories, wherever you are",
@ -38,7 +43,7 @@ export default function RootLayout({
return (
<html className="dark bg-sm-black" lang="en">
<body
className={`${sans.variable} ${mono.variable} antialiased bg-[#0f1419]`}
className={`${sans.variable} ${mono.variable} ${spaceGrotesk.variable} antialiased bg-[#0f1419]`}
>
<AutumnProvider
backendUrl={

View file

@ -1,14 +1,14 @@
"use client"
"use client";
import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@repo/lib/api"
import { MemoryGraph } from "@repo/ui/memory-graph"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { Logo, LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { GlassMenuEffect } from "@ui/other/glass-effect"
import { useIsMobile } from "@hooks/use-mobile";
import { useAuth } from "@lib/auth-context";
import { $fetch } from "@repo/lib/api";
import { MemoryGraph } from "@repo/ui/memory-graph";
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Logo, LogoFull } from "@ui/assets/Logo";
import { Button } from "@ui/components/button";
import { GlassMenuEffect } from "@ui/other/glass-effect";
import {
HelpCircle,
LayoutGrid,
@ -16,58 +16,59 @@ import {
LoaderIcon,
MessageSquare,
Unplug,
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { z } from "zod"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { InstallPrompt } from "@/components/install-prompt"
import { MemoryListView } from "@/components/memory-list-view"
import Menu from "@/components/menu"
import { ProjectSelector } from "@/components/project-selector"
import { ReferralUpgradeModal } from "@/components/referral-upgrade-modal"
import type { TourStep } from "@/components/tour"
import { TourAlertDialog, useTour } from "@/components/tour"
import { AddMemoryView } from "@/components/views/add-memory"
import { ChatRewrite } from "@/components/views/chat"
import { TOUR_STEP_IDS, TOUR_STORAGE_KEY } from "@/lib/tour-constants"
import { useViewMode } from "@/lib/view-mode-context"
import { useChatOpen, useProject } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { z } from "zod";
import { ConnectAIModal } from "@/components/connect-ai-modal";
import { GetStarted } from "@/components/get-started";
import { InstallPrompt } from "@/components/install-prompt";
import { MemoryListView } from "@/components/memory-list-view";
import Menu from "@/components/menu";
import { ProjectSelector } from "@/components/project-selector";
import { ReferralUpgradeModal } from "@/components/referral-upgrade-modal";
import type { TourStep } from "@/components/tour";
import { TourAlertDialog, useTour } from "@/components/tour";
import { AddMemoryView } from "@/components/views/add-memory";
import { ChatRewrite } from "@/components/views/chat";
import { TOUR_STEP_IDS, TOUR_STORAGE_KEY } from "@/lib/tour-constants";
import { useViewMode } from "@/lib/view-mode-context";
import { useChatOpen, useProject } from "@/stores";
import { useGraphHighlights } from "@/stores/highlights";
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>;
type DocumentWithMemories = DocumentsResponse["documents"][0];
const MemoryGraphPage = () => {
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const isMobile = useIsMobile()
const { viewMode, setViewMode, isInitialized } = useViewMode()
const { selectedProject } = useProject()
const { setSteps, isTourCompleted } = useTour()
const { isOpen, setIsOpen } = useChatOpen()
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([])
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showReferralModal, setShowReferralModal] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const [isHelpHovered, setIsHelpHovered] = useState(false)
const { documentIds: allHighlightDocumentIds } = useGraphHighlights();
const isMobile = useIsMobile();
const { viewMode, setViewMode, isInitialized } = useViewMode();
const { selectedProject } = useProject();
const { setSteps, isTourCompleted } = useTour();
const { isOpen, setIsOpen } = useChatOpen();
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([]);
const [showAddMemoryView, setShowAddMemoryView] = useState(false);
const [showReferralModal, setShowReferralModal] = useState(false);
const [showConnectAIModal, setShowConnectAIModal] = useState(false);
const [isHelpHovered, setIsHelpHovered] = useState(false);
// Fetch projects meta to detect experimental flag
const { data: projectsMeta = [] } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects")
return response.data?.projects ?? []
const response = await $fetch("@get/projects");
return response.data?.projects ?? [];
},
staleTime: 5 * 60 * 1000,
})
});
const isCurrentProjectExperimental = !!projectsMeta.find(
(p: any) => p.containerTag === selectedProject,
)?.isExperimental
)?.isExperimental;
// Tour state
const [showTourDialog, setShowTourDialog] = useState(false)
const [showTourDialog, setShowTourDialog] = useState(false);
// Define tour steps with useMemo to prevent recreation
const tourSteps: TourStep[] = useMemo(() => {
@ -200,37 +201,37 @@ const MemoryGraphPage = () => {
selectorId: TOUR_STEP_IDS.FLOATING_CHAT,
position: "left",
},
]
}, [])
];
}, []);
// Check if tour has been completed before
useEffect(() => {
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true"
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true";
if (!hasCompletedTour && !isTourCompleted) {
const timer = setTimeout(() => {
setShowTourDialog(true)
setShowConnectAIModal(false)
}, 1000) // Show after 1 second
return () => clearTimeout(timer)
setShowTourDialog(true);
setShowConnectAIModal(false);
}, 1000); // Show after 1 second
return () => clearTimeout(timer);
}
}, [isTourCompleted])
}, [isTourCompleted]);
// Set up tour steps
useEffect(() => {
setSteps(tourSteps)
}, [setSteps, tourSteps])
setSteps(tourSteps);
}, [setSteps, tourSteps]);
// Save tour completion to localStorage
useEffect(() => {
if (isTourCompleted) {
localStorage.setItem(TOUR_STORAGE_KEY, "true")
localStorage.setItem(TOUR_STORAGE_KEY, "true");
}
}, [isTourCompleted])
}, [isTourCompleted]);
// Progressive loading via useInfiniteQuery
const IS_DEV = process.env.NODE_ENV === "development"
const PAGE_SIZE = IS_DEV ? 100 : 100
const MAX_TOTAL = 1000
const IS_DEV = process.env.NODE_ENV === "development";
const PAGE_SIZE = IS_DEV ? 100 : 100;
const MAX_TOTAL = 1000;
const {
data,
@ -252,75 +253,76 @@ const MemoryGraphPage = () => {
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
})
});
if (response.error) {
throw new Error(response.error?.message || "Failed to fetch documents")
throw new Error(response.error?.message || "Failed to fetch documents");
}
return response.data
return response.data;
},
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce(
(acc, p) => acc + (p.documents?.length ?? 0),
0,
)
if (loaded >= MAX_TOTAL) return undefined
);
if (loaded >= MAX_TOTAL) return undefined;
const { currentPage, totalPages } = lastPage.pagination
const { currentPage, totalPages } = lastPage.pagination;
if (currentPage < totalPages) {
return currentPage + 1
return currentPage + 1;
}
return undefined
return undefined;
},
staleTime: 5 * 60 * 1000,
})
});
const baseDocuments = useMemo(() => {
return (
data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
)
}, [data])
);
}, [data]);
const allDocuments = useMemo(() => {
if (injectedDocs.length === 0) return baseDocuments
const byId = new Map<string, DocumentWithMemories>()
for (const d of injectedDocs) byId.set(d.id, d)
for (const d of baseDocuments) if (!byId.has(d.id)) byId.set(d.id, d)
return Array.from(byId.values())
}, [baseDocuments, injectedDocs])
if (injectedDocs.length === 0) return baseDocuments;
const byId = new Map<string, DocumentWithMemories>();
for (const d of injectedDocs) byId.set(d.id, d);
for (const d of baseDocuments) if (!byId.has(d.id)) byId.set(d.id, d);
return Array.from(byId.values());
}, [baseDocuments, injectedDocs]);
const totalLoaded = allDocuments.length
const hasMore = hasNextPage
const isLoadingMore = isFetchingNextPage
const totalLoaded = allDocuments.length;
const hasMore = hasNextPage;
const isLoadingMore = isFetchingNextPage;
const loadMoreDocuments = useCallback(async (): Promise<void> => {
if (hasNextPage && !isFetchingNextPage) {
await fetchNextPage()
return
await fetchNextPage();
return;
}
return
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
return;
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Reset injected docs when project changes
useEffect(() => {
setInjectedDocs([])
}, [selectedProject])
setInjectedDocs([]);
}, [selectedProject]);
// Surgical fetch of missing highlighted documents (customId-based IDs from search)
useEffect(() => {
if (!isOpen) return
if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0) return
const present = new Set<string>()
if (!isOpen) return;
if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0)
return;
const present = new Set<string>();
for (const d of [...baseDocuments, ...injectedDocs]) {
if (d.id) present.add(d.id)
if ((d as any).customId) present.add((d as any).customId as string)
if (d.id) present.add(d.id);
if ((d as any).customId) present.add((d as any).customId as string);
}
const missing = allHighlightDocumentIds.filter(
(id: string) => !present.has(id),
)
if (missing.length === 0) return
let cancelled = false
);
if (missing.length === 0) return;
let cancelled = false;
const run = async () => {
try {
const resp = await $fetch("@post/memories/documents/by-ids", {
@ -330,32 +332,32 @@ const MemoryGraphPage = () => {
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
})
if (cancelled || (resp as any)?.error) return
});
if (cancelled || (resp as any)?.error) return;
const extraDocs = (resp as any)?.data?.documents as
| DocumentWithMemories[]
| undefined
if (!extraDocs || extraDocs.length === 0) return
| undefined;
if (!extraDocs || extraDocs.length === 0) return;
setInjectedDocs((prev) => {
const seen = new Set<string>([
...prev.map((d) => d.id),
...baseDocuments.map((d) => d.id),
])
const merged = [...prev]
]);
const merged = [...prev];
for (const doc of extraDocs) {
if (!seen.has(doc.id)) {
merged.push(doc)
seen.add(doc.id)
merged.push(doc);
seen.add(doc.id);
}
}
return merged
})
return merged;
});
} catch {}
}
void run()
};
void run();
return () => {
cancelled = true
}
cancelled = true;
};
}, [
isOpen,
allHighlightDocumentIds.join("|"),
@ -363,39 +365,39 @@ const MemoryGraphPage = () => {
injectedDocs,
selectedProject,
$fetch,
])
]);
// Handle view mode change
const handleViewModeChange = useCallback(
(mode: "graph" | "list") => {
setViewMode(mode)
setViewMode(mode);
},
[setViewMode],
)
);
useEffect(() => {
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true"
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true";
if (hasCompletedTour && allDocuments.length === 0 && !showTourDialog) {
setShowConnectAIModal(true)
setShowConnectAIModal(true);
} else if (showTourDialog) {
setShowConnectAIModal(false)
setShowConnectAIModal(false);
}
}, [allDocuments.length, showTourDialog])
}, [allDocuments.length, showTourDialog]);
// Prevent body scrolling
useEffect(() => {
document.body.style.overflow = "hidden"
document.body.style.height = "100vh"
document.documentElement.style.overflow = "hidden"
document.documentElement.style.height = "100vh"
document.body.style.overflow = "hidden";
document.body.style.height = "100vh";
document.documentElement.style.overflow = "hidden";
document.documentElement.style.height = "100vh";
return () => {
document.body.style.overflow = ""
document.body.style.height = ""
document.documentElement.style.overflow = ""
document.documentElement.style.height = ""
}
}, [])
document.body.style.overflow = "";
document.body.style.height = "";
document.documentElement.style.overflow = "";
document.documentElement.style.height = "";
};
}, []);
return (
<div className="relative h-screen bg-[#0f1419] overflow-hidden touch-none">
@ -526,9 +528,9 @@ const MemoryGraphPage = () => {
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
onClick={(e) => {
e.stopPropagation()
setShowAddMemoryView(true)
setShowConnectAIModal(false)
e.stopPropagation();
setShowAddMemoryView(true);
setShowConnectAIModal(false);
}}
type="button"
>
@ -564,37 +566,7 @@ const MemoryGraphPage = () => {
loadMoreDocuments={loadMoreDocuments}
totalLoaded={totalLoaded}
>
<div className="absolute inset-0 flex items-center justify-center">
<ConnectAIModal
onOpenChange={setShowConnectAIModal}
open={showConnectAIModal}
>
<div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6">
<div className="relative z-10 text-slate-200 text-center">
<p className="text-lg font-medium mb-4">
Get Started with supermemory
</p>
<div className="flex flex-col gap-3">
<p className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
Click here to set up your AI connection
</p>
<p className="text-xs text-white/60">or</p>
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
onClick={(e) => {
e.stopPropagation()
setShowAddMemoryView(true)
setShowConnectAIModal(false)
}}
type="button"
>
Add your first memory
</button>
</div>
</div>
</div>
</ConnectAIModal>
</div>
<GetStarted />
</MemoryListView>
</motion.div>
)}
@ -735,35 +707,35 @@ const MemoryGraphPage = () => {
onClose={() => setShowReferralModal(false)}
/>
</div>
)
}
);
};
// Wrapper component to handle auth and waitlist checks
export default function Page() {
const { user, session } = useAuth()
const { user, session } = useAuth();
useEffect(() => {
const url = new URL(window.location.href)
const url = new URL(window.location.href);
const authenticateChromeExtension = url.searchParams.get(
"extension-auth-success",
)
);
if (authenticateChromeExtension) {
const sessionToken = session?.token
const sessionToken = session?.token;
const userData = {
email: user?.email,
name: user?.name,
userId: user?.id,
}
};
if (sessionToken && userData?.email) {
const encodedToken = encodeURIComponent(sessionToken)
window.postMessage({ token: encodedToken, userData }, "*")
url.searchParams.delete("extension-auth-success")
window.history.replaceState({}, "", url.toString())
const encodedToken = encodeURIComponent(sessionToken);
window.postMessage({ token: encodedToken, userData }, "*");
url.searchParams.delete("extension-auth-success");
window.history.replaceState({}, "", url.toString());
}
}
}, [user, session])
}, [user, session]);
// Show loading state while checking authentication and waitlist status
if (!user) {
@ -774,7 +746,7 @@ export default function Page() {
<p className="text-white/60">Loading...</p>
</div>
</div>
)
);
}
// If we have a user and they have access, show the main component
@ -783,5 +755,5 @@ export default function Page() {
<MemoryGraphPage />
<InstallPrompt />
</>
)
);
}

View file

@ -55,8 +55,6 @@ export default function ReferralPage() {
checkReferral();
}, [referralCode]);
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(referralLink);
@ -146,11 +144,10 @@ export default function ReferralPage() {
</p>
</div>
<div className="text-center">
<Link
href="https://supermemory.ai"
className="text-orange-500 hover:text-orange-400 text-sm underline"
href="https://supermemory.ai"
>
Learn more about supermemory
</Link>
@ -178,10 +175,10 @@ export default function ReferralPage() {
</p>
</div>
<Button
className="shrink-0 border-white/10 hover:bg-white/5"
onClick={handleCopyLink}
size="sm"
variant="outline"
className="shrink-0 border-white/10 hover:bg-white/5"
>
{copiedLink ? (
<CheckIcon className="w-4 h-4" />
@ -192,9 +189,9 @@ export default function ReferralPage() {
</div>
<Button
className="w-full border-white/10 text-white hover:bg-white/5"
onClick={handleShare}
variant="outline"
className="w-full border-white/10 text-white hover:bg-white/5"
>
<ShareIcon className="w-4 h-4" />
Share this link

View file

@ -1,107 +1,107 @@
"use client"
"use client";
import { $fetch } from "@lib/api"
import { useSession } from "@lib/auth"
import { useMutation } from "@tanstack/react-query"
import { Logo, LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { GlassMenuEffect } from "@ui/other/glass-effect"
import { ArrowRight, CheckCircle, Upload, Zap } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { Spinner } from "@/components/spinner"
import { $fetch } from "@lib/api";
import { useSession } from "@lib/auth";
import { useMutation } from "@tanstack/react-query";
import { Logo, LogoFull } from "@ui/assets/Logo";
import { Button } from "@ui/components/button";
import { Input } from "@ui/components/input";
import { GlassMenuEffect } from "@ui/other/glass-effect";
import { ArrowRight, CheckCircle, Upload, Zap } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Spinner } from "@/components/spinner";
interface MigrateMCPRequest {
userId: string
projectId: string
userId: string;
projectId: string;
}
interface MigrateMCPResponse {
success: boolean
migratedCount: number
message: string
documentIds?: string[]
success: boolean;
migratedCount: number;
message: string;
documentIds?: string[];
}
export default function MigrateMCPPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [mcpUrl, setMcpUrl] = useState("")
const [projectId, setProjectId] = useState("default")
const router = useRouter();
const searchParams = useSearchParams();
const [mcpUrl, setMcpUrl] = useState("");
const [projectId, setProjectId] = useState("default");
const session = useSession()
const session = useSession();
// Extract MCP URL from query parameter
useEffect(() => {
const urlParam = searchParams.get("url")
const urlParam = searchParams.get("url");
if (urlParam) {
setMcpUrl(urlParam)
setMcpUrl(urlParam);
}
}, [searchParams])
}, [searchParams]);
useEffect(() => {
console.log("session", session)
console.log("session", session);
if (!session.isPending && !session.data) {
const redirectUrl = new URL("/login", window.location.href)
redirectUrl.searchParams.set("redirect", window.location.href)
router.push(redirectUrl.toString())
return
const redirectUrl = new URL("/login", window.location.href);
redirectUrl.searchParams.set("redirect", window.location.href);
router.push(redirectUrl.toString());
return;
}
}, [session, router])
}, [session, router]);
// Extract userId from MCP URL
const getUserIdFromUrl = (url: string) => {
return url.split("/").at(-2) || ""
}
return url.split("/").at(-2) || "";
};
const migrateMutation = useMutation({
mutationFn: async (data: MigrateMCPRequest) => {
const response = await $fetch("@post/documents/migrate-mcp", {
body: data,
})
});
if (response.error) {
throw new Error(
response.error?.message || "Failed to migrate documents",
)
);
}
return response.data
return response.data;
},
onSuccess: (data: MigrateMCPResponse) => {
toast.success("Migration completed successfully", {
description: data.message,
})
});
// Redirect to home page after successful migration
setTimeout(() => {
router.push("/?open=mcp")
}, 2000) // Wait 2 seconds to show the success message
router.push("/?open=mcp");
}, 2000); // Wait 2 seconds to show the success message
},
onError: (error: Error) => {
toast.error("Migration failed", {
description: error.message || "An unexpected error occurred",
})
});
},
})
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
const userId = getUserIdFromUrl(mcpUrl)
const userId = getUserIdFromUrl(mcpUrl);
if (!userId) {
toast.error("Please enter a valid MCP URL")
return
toast.error("Please enter a valid MCP URL");
return;
}
migrateMutation.mutate({
userId,
projectId: projectId.trim() || "default",
})
}
});
};
return (
<div className="min-h-screen bg-[#0f1419] overflow-hidden relative">
@ -320,5 +320,5 @@ export default function MigrateMCPPage() {
</motion.div>
</div>
</div>
)
);
}

View file

@ -9,4 +9,4 @@
}
}
}
}
}

View file

@ -235,7 +235,11 @@ export function ConnectAIModal({
parent.appendChild(fallback);
}
}}
src={key === "mcp-url" ? "/mcp-icon.svg" : `/mcp-supported-tools/${key === "claude-code" ? "claude" : key}.png`}
src={
key === "mcp-url"
? "/mcp-icon.svg"
: `/mcp-supported-tools/${key === "claude-code" ? "claude" : key}.png`
}
width={20}
/>
</div>

View file

@ -46,12 +46,12 @@ export function CreateProjectDialog({
return (
<AnimatePresence>
{open && (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="sm:max-w-2xl bg-[#0f1419] backdrop-blur-xl border-white/10 text-white">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
>
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
@ -61,23 +61,23 @@ export function CreateProjectDialog({
</DialogHeader>
<div className="grid gap-4 py-4">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="flex flex-col gap-2"
initial={{ opacity: 0, y: 10 }}
transition={{ delay: 0.1 }}
>
<Label htmlFor="projectName">Project Name</Label>
<Input
id="projectName"
className="bg-white/5 border-white/10 text-white"
placeholder="My Awesome Project"
value={projectName}
id="projectName"
onChange={(e) => setProjectName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && projectName.trim()) {
handleCreate();
}
}}
placeholder="My Awesome Project"
value={projectName}
/>
<p className="text-xs text-white/50">
This will help you organize your memories
@ -90,10 +90,10 @@ export function CreateProjectDialog({
whileTap={{ scale: 0.95 }}
>
<Button
type="button"
variant="outline"
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
onClick={handleClose}
type="button"
variant="outline"
>
Cancel
</Button>
@ -103,12 +103,12 @@ export function CreateProjectDialog({
whileTap={{ scale: 0.95 }}
>
<Button
type="button"
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
disabled={
createProjectMutation.isPending || !projectName.trim()
}
onClick={handleCreate}
type="button"
>
{createProjectMutation.isPending ? (
<>

View file

@ -1,26 +1,26 @@
import { AnimatePresence, motion } from "framer-motion"
import { Brain, Chrome, ExternalLink, Plus } from "lucide-react"
import { useEffect, useState } from "react"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { analytics } from "@/lib/analytics"
import { AnimatePresence, motion } from "framer-motion";
import { Brain, Chrome, ExternalLink, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { ConnectAIModal } from "@/components/connect-ai-modal";
import { analytics } from "@/lib/analytics";
interface GetStartedProps {
setShowAddMemoryView?: (show: boolean) => void
setShowAddMemoryView?: (show: boolean) => void;
}
export const GetStarted = ({ setShowAddMemoryView }: GetStartedProps) => {
const [imageLoaded, setImageLoaded] = useState(false)
const [showContent, setShowContent] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false);
const [showContent, setShowContent] = useState(false);
const [showConnectAIModal, setShowConnectAIModal] = useState(false);
useEffect(() => {
const img = new Image()
const img = new Image();
img.onload = () => {
setImageLoaded(true)
setTimeout(() => setShowContent(true), 100)
}
img.src = "/images/onboarding.png"
}, [])
setImageLoaded(true);
setTimeout(() => setShowContent(true), 100);
};
img.src = "/images/onboarding.png";
}, []);
const containerVariants = {
hidden: { opacity: 0 },
@ -33,7 +33,7 @@ export const GetStarted = ({ setShowAddMemoryView }: GetStartedProps) => {
delayChildren: 0.2,
},
},
}
};
const leftColumnVariants = {
hidden: {
@ -49,7 +49,7 @@ export const GetStarted = ({ setShowAddMemoryView }: GetStartedProps) => {
staggerChildren: 0.1,
},
},
}
};
const rightColumnVariants = {
hidden: {
@ -66,7 +66,7 @@ export const GetStarted = ({ setShowAddMemoryView }: GetStartedProps) => {
staggerChildren: 0.1,
},
},
}
};
const textLineVariants = {
hidden: {
@ -81,7 +81,7 @@ export const GetStarted = ({ setShowAddMemoryView }: GetStartedProps) => {
ease: [0.25, 0.1, 0.25, 1] as const,
},
},
}
};
const cardVariants = {
hidden: {
@ -98,7 +98,7 @@ export const GetStarted = ({ setShowAddMemoryView }: GetStartedProps) => {
ease: [0.25, 0.1, 0.25, 1] as const,
},
},
}
};
const backgroundVariants = {
hidden: {
@ -113,22 +113,22 @@ export const GetStarted = ({ setShowAddMemoryView }: GetStartedProps) => {
ease: [0.25, 0.1, 0.25, 1] as const,
},
},
}
};
const handleChromeExtension = () => {
analytics.extensionInstallClicked()
analytics.extensionInstallClicked();
window.open(
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
"_blank",
)
}
);
};
const handleAddMemory = () => {
setShowConnectAIModal(false)
setShowConnectAIModal(false);
if (setShowAddMemoryView) {
setShowAddMemoryView(true)
setShowAddMemoryView(true);
}
}
};
return (
<div className="fixed inset-0 overflow-hidden bg-black">
@ -358,5 +358,5 @@ export const GetStarted = ({ setShowAddMemoryView }: GetStartedProps) => {
</>
)}
</div>
)
}
);
};

View file

@ -70,9 +70,9 @@ export function InstallPrompt() {
<AnimatePresence>
<motion.div
animate={{ y: 0, opacity: 1 }}
className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-sm md:hidden"
exit={{ y: 100, opacity: 0 }}
initial={{ y: 100, opacity: 0 }}
className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-sm md:hidden"
>
<div className="bg-black/90 backdrop-blur-md text-white rounded-2xl p-4 shadow-2xl border border-white/10">
<div className="flex items-start justify-between mb-3">
@ -83,10 +83,10 @@ export function InstallPrompt() {
<h3 className="font-semibold text-sm">Install Supermemory</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleDismiss}
className="text-white/60 hover:text-white h-6 w-6 p-0"
onClick={handleDismiss}
size="sm"
variant="ghost"
>
<X className="w-4 h-4" />
</Button>
@ -107,19 +107,19 @@ export function InstallPrompt() {
2. Select "Add to Home Screen"
</p>
<Button
variant="secondary"
size="sm"
onClick={handleDismiss}
className="w-full text-xs"
onClick={handleDismiss}
size="sm"
variant="secondary"
>
Got it
</Button>
</div>
) : (
<Button
className="w-full bg-[#0f1419] hover:bg-[#1a1f2a] text-white text-xs"
onClick={handleInstall}
size="sm"
className="w-full bg-[#0f1419] hover:bg-[#1a1f2a] text-white text-xs"
>
<Download className="w-3 h-3 mr-1" />
Add to Home Screen

View file

@ -50,4 +50,4 @@ export const getSourceUrl = (document: DocumentWithMemories) => {
}
// Fallback to existing URL for all other document types
return document.url;
};
};

View file

@ -1,415 +1,422 @@
import { getDocumentIcon } from '@/lib/document-icon';
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@repo/ui/components/drawer';
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@repo/ui/components/drawer";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@repo/ui/components/sheet';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@repo/ui/components/sheet";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from '@repo/ui/components/tabs';
import { colors } from '@repo/ui/memory-graph/constants';
import type { DocumentsWithMemoriesResponseSchema } from '@repo/validation/api';
import { Badge } from '@ui/components/badge';
import { Brain, Calendar, CircleUserRound, ExternalLink, List, Sparkles } from 'lucide-react';
import { memo } from 'react';
import type { z } from 'zod';
import { formatDate, getSourceUrl } from '.';
import { Label1Regular } from '@ui/text/label/label-1-regular';
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@repo/ui/components/tabs";
import { colors } from "@repo/ui/memory-graph/constants";
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api";
import { Badge } from "@ui/components/badge";
import { Label1Regular } from "@ui/text/label/label-1-regular";
import {
Brain,
Calendar,
CircleUserRound,
ExternalLink,
List,
Sparkles,
} from "lucide-react";
import { memo } from "react";
import type { z } from "zod";
import { getDocumentIcon } from "@/lib/document-icon";
import { formatDate, getSourceUrl } from ".";
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>;
type DocumentWithMemories = DocumentsResponse['documents'][0];
type MemoryEntry = DocumentWithMemories['memoryEntries'][0];
type DocumentWithMemories = DocumentsResponse["documents"][0];
type MemoryEntry = DocumentWithMemories["memoryEntries"][0];
const formatDocumentType = (type: string) => {
// Special case for PDF
if (type.toLowerCase() === 'pdf') return 'PDF';
// Special case for PDF
if (type.toLowerCase() === "pdf") return "PDF";
// Replace underscores with spaces and capitalize each word
return type
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
// Replace underscores with spaces and capitalize each word
return type
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
};
const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => {
return (
<button
className="p-4 rounded-lg transition-all relative overflow-hidden cursor-pointer"
style={{
backgroundColor: memory.isLatest
? colors.memory.primary
: 'rgba(255, 255, 255, 0.02)',
}}
tabIndex={0}
type="button"
>
<div className="flex items-start gap-2 relative z-10">
<div
className="p-1 rounded"
style={{
backgroundColor: memory.isLatest
? colors.memory.secondary
: 'transparent',
}}
>
<Brain
className={`w-4 h-4 flex-shrink-0 transition-all ${
memory.isLatest ? 'text-blue-400' : 'text-blue-400/50'
}`}
/>
</div>
<div className="flex-1 space-y-2">
<Label1Regular
className="text-sm leading-relaxed text-left"
style={{ color: colors.text.primary }}
>
{memory.memory}
</Label1Regular>
<div className="flex gap-2 justify-between">
<div
className="flex items-center gap-4 text-xs"
style={{ color: colors.text.muted }}
>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(memory.createdAt)}
</span>
<span className="font-mono">v{memory.version}</span>
{memory.sourceRelevanceScore && (
<span
className="flex items-center gap-1"
style={{
color:
memory.sourceRelevanceScore > 70
? colors.accent.emerald
: colors.text.muted,
}}
>
<Sparkles className="w-3 h-3" />
{memory.sourceRelevanceScore}%
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{memory.isForgotten && (
<Badge
className="text-xs border-red-500/30 backdrop-blur-sm"
style={{
backgroundColor: colors.status.forgotten,
color: '#dc2626',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="destructive"
>
Forgotten
</Badge>
)}
{memory.isLatest && (
<Badge
className="text-xs"
style={{
backgroundColor: colors.memory.secondary,
color: colors.text.primary,
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="default"
>
Latest
</Badge>
)}
{memory.forgetAfter && (
<Badge
className="text-xs backdrop-blur-sm"
style={{
color: colors.status.expiring,
backgroundColor: 'rgba(251, 165, 36, 0.1)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="outline"
>
Expires: {formatDate(memory.forgetAfter)}
</Badge>
)}
</div>
</div>
</div>
</div>
</button>
);
return (
<button
className="p-4 rounded-lg transition-all relative overflow-hidden cursor-pointer"
style={{
backgroundColor: memory.isLatest
? colors.memory.primary
: "rgba(255, 255, 255, 0.02)",
}}
tabIndex={0}
type="button"
>
<div className="flex items-start gap-2 relative z-10">
<div
className="p-1 rounded"
style={{
backgroundColor: memory.isLatest
? colors.memory.secondary
: "transparent",
}}
>
<Brain
className={`w-4 h-4 flex-shrink-0 transition-all ${
memory.isLatest ? "text-blue-400" : "text-blue-400/50"
}`}
/>
</div>
<div className="flex-1 space-y-2">
<Label1Regular
className="text-sm leading-relaxed text-left"
style={{ color: colors.text.primary }}
>
{memory.memory}
</Label1Regular>
<div className="flex gap-2 justify-between">
<div
className="flex items-center gap-4 text-xs"
style={{ color: colors.text.muted }}
>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(memory.createdAt)}
</span>
<span className="font-mono">v{memory.version}</span>
{memory.sourceRelevanceScore && (
<span
className="flex items-center gap-1"
style={{
color:
memory.sourceRelevanceScore > 70
? colors.accent.emerald
: colors.text.muted,
}}
>
<Sparkles className="w-3 h-3" />
{memory.sourceRelevanceScore}%
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{memory.isForgotten && (
<Badge
className="text-xs border-red-500/30 backdrop-blur-sm"
style={{
backgroundColor: colors.status.forgotten,
color: "#dc2626",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
}}
variant="destructive"
>
Forgotten
</Badge>
)}
{memory.isLatest && (
<Badge
className="text-xs"
style={{
backgroundColor: colors.memory.secondary,
color: colors.text.primary,
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
}}
variant="default"
>
Latest
</Badge>
)}
{memory.forgetAfter && (
<Badge
className="text-xs backdrop-blur-sm"
style={{
color: colors.status.expiring,
backgroundColor: "rgba(251, 165, 36, 0.1)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
}}
variant="outline"
>
Expires: {formatDate(memory.forgetAfter)}
</Badge>
)}
</div>
</div>
</div>
</div>
</button>
);
});
export const MemoryDetail = memo(
({
document,
isOpen,
onClose,
isMobile,
}: {
document: DocumentWithMemories | null;
isOpen: boolean;
onClose: () => void;
isMobile: boolean;
}) => {
if (!document) return null;
({
document,
isOpen,
onClose,
isMobile,
}: {
document: DocumentWithMemories | null;
isOpen: boolean;
onClose: () => void;
isMobile: boolean;
}) => {
if (!document) return null;
const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten);
const forgottenMemories = document.memoryEntries.filter(
(m) => m.isForgotten
);
const activeMemories = (document.memoryEntries || []).filter(
(m) => !m.isForgotten,
);
const forgottenMemories = (document.memoryEntries || []).filter(
(m) => m.isForgotten,
);
const HeaderContent = ({
TitleComponent,
}: {
TitleComponent: typeof SheetTitle | typeof DrawerTitle;
}) => (
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 flex-1">
<div
className="p-2 rounded-lg"
style={{
backgroundColor: colors.background.secondary,
}}
>
{getDocumentIcon(document.type, 'w-5 h-5')}
</div>
<div className="flex-1">
<TitleComponent style={{ color: colors.text.primary }}>
{document.title || 'Untitled Document'}
</TitleComponent>
<div
className="flex items-center gap-2 mt-1 text-xs"
style={{ color: colors.text.muted }}
>
<span>{formatDocumentType(document.type)}</span>
<span></span>
<span>{formatDate(document.createdAt)}</span>
{document.url && (
<>
<span></span>
<button
className="flex items-center gap-1 transition-all hover:gap-2"
onClick={() => {
const sourceUrl = getSourceUrl(document);
window.open(sourceUrl ?? undefined, '_blank');
}}
style={{ color: colors.accent.primary }}
type="button"
>
View source
<ExternalLink className="w-3 h-3" />
</button>
</>
)}
</div>
</div>
</div>
</div>
);
const HeaderContent = ({
TitleComponent,
}: {
TitleComponent: typeof SheetTitle | typeof DrawerTitle;
}) => (
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 flex-1">
<div
className="p-2 rounded-lg"
style={{
backgroundColor: colors.background.secondary,
}}
>
{getDocumentIcon(document.type, "w-5 h-5")}
</div>
<div className="flex-1">
<TitleComponent style={{ color: colors.text.primary }}>
{document.title || "Untitled Document"}
</TitleComponent>
<div
className="flex items-center gap-2 mt-1 text-xs"
style={{ color: colors.text.muted }}
>
<span>{formatDocumentType(document.type)}</span>
<span></span>
<span>{formatDate(document.createdAt)}</span>
{document.url && (
<>
<span></span>
<button
className="flex items-center gap-1 transition-all hover:gap-2"
onClick={() => {
const sourceUrl = getSourceUrl(document);
window.open(sourceUrl ?? undefined, "_blank");
}}
style={{ color: colors.accent.primary }}
type="button"
>
View source
<ExternalLink className="w-3 h-3" />
</button>
</>
)}
</div>
</div>
</div>
</div>
);
const ContentAndSummarySection = () => {
const hasContent = document.content && document.content.trim().length > 0;
const hasSummary = document.summary && document.summary.trim().length > 0;
if (!hasContent && !hasSummary) return null;
const ContentAndSummarySection = () => {
const hasContent = document.content && document.content.trim().length > 0;
const hasSummary = document.summary && document.summary.trim().length > 0;
const defaultTab = hasContent ? 'content' : 'summary';
if (!hasContent && !hasSummary) return null;
return (
<div className="mt-4">
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList
className={`grid w-full bg-white/5 border border-white/10 h-11 ${
hasContent && hasSummary ? 'grid-cols-2' : 'grid-cols-1'
}`}
>
{hasContent && (
<TabsTrigger
value="content"
className="text-xs bg-transparent h-8"
style={{ color: colors.text.secondary }}
>
<CircleUserRound className="w-3 h-3" />
Original Content
</TabsTrigger>
)}
{hasSummary && (
<TabsTrigger
value="summary"
className="text-xs flex items-center gap-1 bg-transparent h-8"
style={{ color: colors.text.secondary }}
>
<List className="w-3 h-3" />
Summary
</TabsTrigger>
)}
</TabsList>
const defaultTab = hasContent ? "content" : "summary";
{hasContent && (
<TabsContent value="content" className="mt-3">
<div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-white/[0.03] border border-white/[0.08]">
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
style={{ color: colors.text.primary }}
>
{document.content}
</p>
</div>
</TabsContent>
)}
return (
<div className="mt-4">
<Tabs className="w-full" defaultValue={defaultTab}>
<TabsList
className={`grid w-full bg-white/5 border border-white/10 h-11 ${
hasContent && hasSummary ? "grid-cols-2" : "grid-cols-1"
}`}
>
{hasContent && (
<TabsTrigger
className="text-xs bg-transparent h-8"
style={{ color: colors.text.secondary }}
value="content"
>
<CircleUserRound className="w-3 h-3" />
Original Content
</TabsTrigger>
)}
{hasSummary && (
<TabsTrigger
className="text-xs flex items-center gap-1 bg-transparent h-8"
style={{ color: colors.text.secondary }}
value="summary"
>
<List className="w-3 h-3" />
Summary
</TabsTrigger>
)}
</TabsList>
{hasSummary && (
<TabsContent value="summary" className="mt-3">
<div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-indigo-500/5 border border-indigo-500/15">
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
style={{ color: colors.text.muted }}
>
{document.summary}
</p>
</div>
</TabsContent>
)}
</Tabs>
</div>
);
};
{hasContent && (
<TabsContent className="mt-3" value="content">
<div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-white/[0.03] border border-white/[0.08]">
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
style={{ color: colors.text.primary }}
>
{document.content}
</p>
</div>
</TabsContent>
)}
const MemoryContent = () => (
<div className="space-y-6 px-6">
{activeMemories.length > 0 && (
<div>
<div
className="text-sm font-medium mb-2 flex items-start gap-2 py-2"
style={{
color: colors.text.secondary,
}}
>
Active Memories ({activeMemories.length})
</div>
<div className="space-y-3">
{activeMemories.map((memory) => (
<div
key={memory.id}
>
<MemoryDetailItem memory={memory} />
</div>
))}
</div>
</div>
)}
{hasSummary && (
<TabsContent className="mt-3" value="summary">
<div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-indigo-500/5 border border-indigo-500/15">
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
style={{ color: colors.text.muted }}
>
{document.summary}
</p>
</div>
</TabsContent>
)}
</Tabs>
</div>
);
};
{forgottenMemories.length > 0 && (
<div>
<div
className="text-sm font-medium mb-4 px-3 py-2 rounded-lg opacity-60"
style={{
color: colors.text.muted,
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
Forgotten Memories ({forgottenMemories.length})
</div>
<div className="space-y-3 opacity-40">
{forgottenMemories.map((memory) => (
<MemoryDetailItem key={memory.id} memory={memory} />
))}
</div>
</div>
)}
const MemoryContent = () => (
<div className="space-y-6 px-6">
{activeMemories.length > 0 && (
<div>
<div
className="text-sm font-medium mb-2 flex items-start gap-2 py-2"
style={{
color: colors.text.secondary,
}}
>
Active Memories ({activeMemories.length})
</div>
<div className="space-y-3">
{activeMemories.map((memory) => (
<div key={memory.id}>
<MemoryDetailItem memory={memory} />
</div>
))}
</div>
</div>
)}
{activeMemories.length === 0 && forgottenMemories.length === 0 && (
<div
className="text-center py-12 rounded-lg"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<Brain
className="w-12 h-12 mx-auto mb-4 opacity-30"
style={{ color: colors.text.muted }}
/>
<p style={{ color: colors.text.muted }}>
No memories found for this document
</p>
</div>
)}
</div>
);
{forgottenMemories.length > 0 && (
<div>
<div
className="text-sm font-medium mb-4 px-3 py-2 rounded-lg opacity-60"
style={{
color: colors.text.muted,
backgroundColor: "rgba(255, 255, 255, 0.02)",
}}
>
Forgotten Memories ({forgottenMemories.length})
</div>
<div className="space-y-3 opacity-40">
{forgottenMemories.map((memory) => (
<MemoryDetailItem key={memory.id} memory={memory} />
))}
</div>
</div>
)}
if (isMobile) {
return (
<Drawer onOpenChange={onClose} open={isOpen}>
<DrawerContent
className="border-0 p-0 overflow-hidden max-h-[90vh]"
style={{
backgroundColor: colors.background.secondary,
borderTop: `1px solid ${colors.document.border}`,
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
{/* Header section with glass effect */}
<div
className="p-4 relative border-b"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
borderBottom: `1px solid ${colors.document.border}`,
}}
>
<DrawerHeader className="pb-0 px-0 text-left">
<HeaderContent TitleComponent={DrawerTitle} />
</DrawerHeader>
{activeMemories.length === 0 && forgottenMemories.length === 0 && (
<div
className="text-center py-12 rounded-lg"
style={{
backgroundColor: "rgba(255, 255, 255, 0.02)",
}}
>
<Brain
className="w-12 h-12 mx-auto mb-4 opacity-30"
style={{ color: colors.text.muted }}
/>
<p style={{ color: colors.text.muted }}>
No memories found for this document
</p>
</div>
)}
</div>
);
<ContentAndSummarySection />
</div>
if (isMobile) {
return (
<Drawer onOpenChange={onClose} open={isOpen}>
<DrawerContent
className="border-0 p-0 overflow-hidden max-h-[90vh]"
style={{
backgroundColor: colors.background.secondary,
borderTop: `1px solid ${colors.document.border}`,
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
}}
>
{/* Header section with glass effect */}
<div
className="p-4 relative border-b"
style={{
backgroundColor: "rgba(255, 255, 255, 0.02)",
borderBottom: `1px solid ${colors.document.border}`,
}}
>
<DrawerHeader className="pb-0 px-0 text-left">
<HeaderContent TitleComponent={DrawerTitle} />
</DrawerHeader>
<div className="flex-1 memory-drawer-scroll overflow-y-auto">
<MemoryContent />
</div>
</DrawerContent>
</Drawer>
);
}
<ContentAndSummarySection />
</div>
return (
<Sheet onOpenChange={onClose} open={isOpen}>
<SheetContent
className="w-full sm:max-w-2xl border-0 p-0 overflow-hidden"
style={{
backgroundColor: colors.background.secondary,
}}
>
<div
className="p-6 relative"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<SheetHeader className="pb-0">
<HeaderContent TitleComponent={SheetTitle} />
</SheetHeader>
<div className="flex-1 memory-drawer-scroll overflow-y-auto">
<MemoryContent />
</div>
</DrawerContent>
</Drawer>
);
}
<ContentAndSummarySection />
</div>
return (
<Sheet onOpenChange={onClose} open={isOpen}>
<SheetContent
className="w-full sm:max-w-2xl border-0 p-0 overflow-hidden"
style={{
backgroundColor: colors.background.secondary,
}}
>
<div
className="p-6 relative"
style={{
backgroundColor: "rgba(255, 255, 255, 0.02)",
}}
>
<SheetHeader className="pb-0">
<HeaderContent TitleComponent={SheetTitle} />
</SheetHeader>
<div className="h-[calc(100vh-200px)] memory-sheet-scroll overflow-y-auto">
<MemoryContent />
</div>
</SheetContent>
</Sheet>
);
}
<ContentAndSummarySection />
</div>
<div className="h-[calc(100vh-200px)] memory-sheet-scroll overflow-y-auto">
<MemoryContent />
</div>
</SheetContent>
</Sheet>
);
},
);

View file

@ -1,9 +1,8 @@
"use client"
"use client";
import { useIsMobile } from "@hooks/use-mobile"
import { cn } from "@lib/utils"
import { Badge } from "@repo/ui/components/badge"
import { Card, CardContent, CardHeader } from "@repo/ui/components/card"
import { useIsMobile } from "@hooks/use-mobile";
import { useDeleteDocument } from "@lib/queries";
import { cn } from "@lib/utils";
import {
AlertDialog,
AlertDialogAction,
@ -14,43 +13,43 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@repo/ui/components/alert-dialog"
import { colors } from "@repo/ui/memory-graph/constants"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useVirtualizer } from "@tanstack/react-virtual"
import { Brain, ExternalLink, Sparkles, Trash2 } from "lucide-react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { z } from "zod"
import useResizeObserver from "@/hooks/use-resize-observer"
import { analytics } from "@/lib/analytics"
import { useDeleteDocument } from "@lib/queries"
import { useProject } from "@/stores"
} from "@repo/ui/components/alert-dialog";
import { Badge } from "@repo/ui/components/badge";
import { Card, CardContent, CardHeader } from "@repo/ui/components/card";
import { colors } from "@repo/ui/memory-graph/constants";
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Brain, ExternalLink, Sparkles, Trash2 } from "lucide-react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { z } from "zod";
import useResizeObserver from "@/hooks/use-resize-observer";
import { analytics } from "@/lib/analytics";
import { getDocumentIcon } from "@/lib/document-icon";
import { useProject } from "@/stores";
import { formatDate, getSourceUrl } from "./memories";
import { MemoryDetail } from "./memories/memory-detail";
import { MemoryDetail } from "./memories/memory-detail"
import { getDocumentIcon } from "@/lib/document-icon"
import { formatDate, getSourceUrl } from "./memories"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>;
type DocumentWithMemories = DocumentsResponse["documents"][0];
interface MemoryListViewProps {
children?: React.ReactNode
documents: DocumentWithMemories[]
isLoading: boolean
isLoadingMore: boolean
error: Error | null
totalLoaded: number
hasMore: boolean
loadMoreDocuments: () => Promise<void>
children?: React.ReactNode;
documents: DocumentWithMemories[];
isLoading: boolean;
isLoadingMore: boolean;
error: Error | null;
totalLoaded: number;
hasMore: boolean;
loadMoreDocuments: () => Promise<void>;
}
const GreetingMessage = memo(() => {
const getGreeting = () => {
const hour = new Date().getHours()
if (hour < 12) return "Good morning"
if (hour < 17) return "Good afternoon"
return "Good evening"
}
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
return (
<div className="flex items-center gap-3 mb-3 px-4 md:mb-6 md:mt-3">
@ -66,8 +65,8 @@ const GreetingMessage = memo(() => {
</p>
</div>
</div>
)
})
);
});
const DocumentCard = memo(
({
@ -75,21 +74,23 @@ const DocumentCard = memo(
onOpenDetails,
onDelete,
}: {
document: DocumentWithMemories
onOpenDetails: (document: DocumentWithMemories) => void
onDelete: (document: DocumentWithMemories) => void
document: DocumentWithMemories;
onOpenDetails: (document: DocumentWithMemories) => void;
onDelete: (document: DocumentWithMemories) => void;
}) => {
const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten)
const forgottenMemories = document.memoryEntries.filter(
const activeMemories = (document.memoryEntries || []).filter(
(m) => !m.isForgotten,
);
const forgottenMemories = (document.memoryEntries || []).filter(
(m) => m.isForgotten,
)
);
return (
<Card
className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden border-0 gap-2 md:w-full"
onClick={() => {
analytics.documentCardClicked()
onOpenDetails(document)
analytics.documentCardClicked();
onOpenDetails(document);
}}
style={{
backgroundColor: colors.document.primary,
@ -112,9 +113,9 @@ const DocumentCard = memo(
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded"
onClick={(e) => {
e.stopPropagation()
const sourceUrl = getSourceUrl(document)
window.open(sourceUrl ?? undefined, "_blank")
e.stopPropagation();
const sourceUrl = getSourceUrl(document);
window.open(sourceUrl ?? undefined, "_blank");
}}
style={{
backgroundColor: "rgba(255, 255, 255, 0.05)",
@ -141,7 +142,20 @@ const DocumentCard = memo(
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-wrap">
{activeMemories.length > 0 && (
{(document as any).isOptimistic && (
<Badge
className="text-xs text-blue-200 animate-pulse"
style={{
backgroundColor: "rgba(59, 130, 246, 0.2)",
borderColor: "rgba(59, 130, 246, 0.3)",
}}
variant="outline"
>
<Sparkles className="w-3 h-3 mr-1" />
Supermemory updating...
</Badge>
)}
{!(document as any).isOptimistic && activeMemories.length > 0 && (
<Badge
className="text-xs text-accent-foreground"
style={{
@ -173,7 +187,7 @@ const DocumentCard = memo(
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation()
e.stopPropagation();
}}
style={{
color: colors.text.muted,
@ -194,7 +208,7 @@ const DocumentCard = memo(
<AlertDialogFooter>
<AlertDialogCancel
onClick={(e) => {
e.stopPropagation()
e.stopPropagation();
}}
>
Cancel
@ -202,8 +216,8 @@ const DocumentCard = memo(
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 text-white"
onClick={(e) => {
e.stopPropagation()
onDelete(document)
e.stopPropagation();
onDelete(document);
}}
>
Delete
@ -214,9 +228,9 @@ const DocumentCard = memo(
</div>
</CardContent>
</Card>
)
);
},
)
);
export const MemoryListView = ({
children,
@ -227,86 +241,86 @@ export const MemoryListView = ({
hasMore,
loadMoreDocuments,
}: MemoryListViewProps) => {
const [selectedSpace, _] = useState<string>("all")
const [selectedSpace, _] = useState<string>("all");
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null)
const [isDetailOpen, setIsDetailOpen] = useState(false)
const parentRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const isMobile = useIsMobile()
const { selectedProject } = useProject()
const deleteDocumentMutation = useDeleteDocument(selectedProject)
useState<DocumentWithMemories | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
const { selectedProject } = useProject();
const deleteDocumentMutation = useDeleteDocument(selectedProject);
const gap = 14
const gap = 14;
const handleDeleteDocument = useCallback(
(document: DocumentWithMemories) => {
deleteDocumentMutation.mutate(document.id)
deleteDocumentMutation.mutate(document.id);
},
[deleteDocumentMutation],
)
);
const { width: containerWidth } = useResizeObserver(containerRef)
const columnWidth = isMobile ? containerWidth : 320
const { width: containerWidth } = useResizeObserver(containerRef);
const columnWidth = isMobile ? containerWidth : 320;
const columns = Math.max(
1,
Math.floor((containerWidth + gap) / (columnWidth + gap)),
)
);
// Filter documents based on selected space
const filteredDocuments = useMemo(() => {
if (!documents) return []
if (!documents) return [];
if (selectedSpace === "all") {
return documents
return documents;
}
return documents
.map((doc) => ({
...doc,
memoryEntries: doc.memoryEntries.filter(
memoryEntries: (doc.memoryEntries || []).filter(
(memory) =>
(memory.spaceContainerTag ?? memory.spaceId) === selectedSpace,
),
}))
.filter((doc) => doc.memoryEntries.length > 0)
}, [documents, selectedSpace])
.filter((doc) => (doc.memoryEntries || []).length > 0);
}, [documents, selectedSpace]);
const handleOpenDetails = useCallback((document: DocumentWithMemories) => {
analytics.memoryDetailOpened()
setSelectedDocument(document)
setIsDetailOpen(true)
}, [])
analytics.memoryDetailOpened();
setSelectedDocument(document);
setIsDetailOpen(true);
}, []);
const handleCloseDetails = useCallback(() => {
setIsDetailOpen(false)
setTimeout(() => setSelectedDocument(null), 300)
}, [])
setIsDetailOpen(false);
setTimeout(() => setSelectedDocument(null), 300);
}, []);
const virtualItems = useMemo(() => {
const items = []
const items = [];
for (let i = 0; i < filteredDocuments.length; i += columns) {
items.push(filteredDocuments.slice(i, i + columns))
items.push(filteredDocuments.slice(i, i + columns));
}
return items
}, [filteredDocuments, columns])
return items;
}, [filteredDocuments, columns]);
const virtualizer = useVirtualizer({
count: virtualItems.length,
getScrollElement: () => parentRef.current,
overscan: 5,
estimateSize: () => 200,
})
});
useEffect(() => {
const [lastItem] = [...virtualizer.getVirtualItems()].reverse()
const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
if (!lastItem || !hasMore || isLoadingMore) {
return
return;
}
if (lastItem.index >= virtualItems.length - 1) {
loadMoreDocuments()
loadMoreDocuments();
}
}, [
hasMore,
@ -314,7 +328,7 @@ export const MemoryListView = ({
loadMoreDocuments,
virtualizer.getVirtualItems(),
virtualItems.length,
])
]);
// Always render with consistent structure
return (
@ -355,8 +369,8 @@ export const MemoryListView = ({
</div>
) : (
<div
ref={parentRef}
className="h-full overflow-auto mt-20 custom-scrollbar"
ref={parentRef}
>
<GreetingMessage />
@ -367,15 +381,15 @@ export const MemoryListView = ({
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const rowItems = virtualItems[virtualRow.index]
if (!rowItems) return null
const rowItems = virtualItems[virtualRow.index];
if (!rowItems) return null;
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
data-index={virtualRow.index}
key={virtualRow.key}
ref={virtualizer.measureElement}
style={{
transform: `translateY(${virtualRow.start + virtualRow.index * gap}px)`,
}}
@ -389,15 +403,15 @@ export const MemoryListView = ({
>
{rowItems.map((document, columnIndex) => (
<DocumentCard
key={`${document.id}-${virtualRow.index}-${columnIndex}`}
document={document}
onOpenDetails={handleOpenDetails}
key={`${document.id}-${virtualRow.index}-${columnIndex}`}
onDelete={handleDeleteDocument}
onOpenDetails={handleOpenDetails}
/>
))}
</div>
</div>
)
);
})}
</div>
@ -417,10 +431,10 @@ export const MemoryListView = ({
<MemoryDetail
document={selectedDocument}
isMobile={isMobile}
isOpen={isDetailOpen}
onClose={handleCloseDetails}
isMobile={isMobile}
/>
</>
)
}
);
};

View file

@ -1,28 +1,28 @@
"use client"
"use client";
import { useIsMobile } from "@hooks/use-mobile"
import { useIsMobile } from "@hooks/use-mobile";
import {
fetchConsumerProProduct,
fetchMemoriesFeature,
} from "@repo/lib/queries"
import { Button } from "@repo/ui/components/button"
import { ConnectAIModal } from "./connect-ai-modal"
import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold"
import { GlassMenuEffect } from "@ui/other/glass-effect"
import { useCustomer } from "autumn-js/react"
import { MessageSquareMore, Plus, Puzzle, User, X } from "lucide-react"
import { AnimatePresence, LayoutGroup, motion } from "motion/react"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { Drawer } from "vaul"
import { useMobilePanel } from "@/lib/mobile-panel-context"
import { TOUR_STEP_IDS } from "@/lib/tour-constants"
import { useChatOpen } from "@/stores"
import { ProjectSelector } from "./project-selector"
import { useTour } from "./tour"
import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory"
import { IntegrationsView } from "./views/integrations"
import { ProfileView } from "./views/profile"
} from "@repo/lib/queries";
import { Button } from "@repo/ui/components/button";
import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold";
import { GlassMenuEffect } from "@ui/other/glass-effect";
import { useCustomer } from "autumn-js/react";
import { MessageSquareMore, Plus, Puzzle, User, X } from "lucide-react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { Drawer } from "vaul";
import { useMobilePanel } from "@/lib/mobile-panel-context";
import { TOUR_STEP_IDS } from "@/lib/tour-constants";
import { useChatOpen } from "@/stores";
import { ConnectAIModal } from "./connect-ai-modal";
import { ProjectSelector } from "./project-selector";
import { useTour } from "./tour";
import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory";
import { IntegrationsView } from "./views/integrations";
import { ProfileView } from "./views/profile";
const MCPIcon = ({ className }: { className?: string }) => {
return (
@ -37,13 +37,13 @@ const MCPIcon = ({ className }: { className?: string }) => {
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
</svg>
)
}
);
};
function Menu({ id }: { id?: string }) {
const router = useRouter()
const searchParams = useSearchParams()
const openParam = searchParams.get("open")
const router = useRouter();
const searchParams = useSearchParams();
const openParam = searchParams.get("open");
// Valid view names that can be opened via URL parameter
const validViews = [
@ -52,52 +52,52 @@ function Menu({ id }: { id?: string }) {
"projects",
"profile",
"integrations",
] as const
type ValidView = (typeof validViews)[number]
] as const;
type ValidView = (typeof validViews)[number];
const [isHovered, setIsHovered] = useState(false)
const [isHovered, setIsHovered] = useState(false);
const [expandedView, setExpandedView] = useState<
"addUrl" | "mcp" | "projects" | "profile" | "integrations" | null
>(null)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isCollapsing, setIsCollapsing] = useState(false)
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const isMobile = useIsMobile()
const { activePanel, setActivePanel } = useMobilePanel()
const { setMenuExpanded } = useTour()
const autumn = useCustomer()
const { setIsOpen } = useChatOpen()
>(null);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isCollapsing, setIsCollapsing] = useState(false);
const [showAddMemoryView, setShowAddMemoryView] = useState(false);
const [showConnectAIModal, setShowConnectAIModal] = useState(false);
const isMobile = useIsMobile();
const { activePanel, setActivePanel } = useMobilePanel();
const { setMenuExpanded } = useTour();
const autumn = useCustomer();
const { setIsOpen } = useChatOpen();
const { data: memoriesCheck } = fetchMemoriesFeature(autumn)
const { data: memoriesCheck } = fetchMemoriesFeature(autumn);
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 0
const memoriesUsed = memoriesCheck?.usage ?? 0;
const memoriesLimit = memoriesCheck?.included_usage ?? 0;
const { data: proCheck } = fetchConsumerProProduct(autumn)
const { data: proCheck } = fetchConsumerProProduct(autumn);
useEffect(() => {
if (memoriesCheck) {
console.log({ memoriesCheck })
console.log({ memoriesCheck });
}
if (proCheck) {
console.log({ proCheck })
console.log({ proCheck });
}
}, [memoriesCheck, proCheck])
}, [memoriesCheck, proCheck]);
// Function to clear the 'open' parameter from URL
const clearOpenParam = useCallback(() => {
const newSearchParams = new URLSearchParams(searchParams.toString())
newSearchParams.delete("open")
const newUrl = `${window.location.pathname}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`
router.replace(newUrl)
}, [searchParams, router])
const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.delete("open");
const newUrl = `${window.location.pathname}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`;
router.replace(newUrl);
}, [searchParams, router]);
const isProUser = proCheck?.allowed ?? false
const isProUser = proCheck?.allowed ?? false;
const shouldShowLimitWarning =
!isProUser && memoriesUsed >= memoriesLimit * 0.8
!isProUser && memoriesUsed >= memoriesLimit * 0.8;
// Map menu item keys to tour IDs
const menuItemTourIds: Record<string, string> = {
@ -105,7 +105,7 @@ function Menu({ id }: { id?: string }) {
projects: TOUR_STEP_IDS.MENU_PROJECTS,
mcp: TOUR_STEP_IDS.MENU_MCP,
integrations: "", // No tour ID for integrations yet
}
};
const menuItems = [
{
@ -138,70 +138,70 @@ function Menu({ id }: { id?: string }) {
key: "profile" as const,
disabled: false,
},
]
];
const handleMenuItemClick = (
key: "chat" | "addUrl" | "mcp" | "projects" | "profile" | "integrations",
) => {
if (key === "chat") {
setIsOpen(true)
setIsMobileMenuOpen(false)
setIsOpen(true);
setIsMobileMenuOpen(false);
if (isMobile) {
setActivePanel("chat")
setActivePanel("chat");
}
} else if (key === "mcp") {
// Open ConnectAIModal directly for MCP
setIsMobileMenuOpen(false)
setExpandedView(null)
setShowConnectAIModal(true)
setIsMobileMenuOpen(false);
setExpandedView(null);
setShowConnectAIModal(true);
} else {
if (expandedView === key) {
setIsCollapsing(true)
setExpandedView(null)
setIsCollapsing(true);
setExpandedView(null);
} else if (key === "addUrl") {
setShowAddMemoryView(true)
setExpandedView(null)
setShowAddMemoryView(true);
setExpandedView(null);
} else {
setExpandedView(key)
setExpandedView(key);
}
if (isMobile) {
setActivePanel("menu")
setActivePanel("menu");
}
}
}
};
// Handle initial view opening based on URL parameter
useEffect(() => {
if (openParam) {
if (openParam === "chat") {
setIsOpen(true)
setIsMobileMenuOpen(false)
setIsOpen(true);
setIsMobileMenuOpen(false);
if (isMobile) {
setActivePanel("chat")
setActivePanel("chat");
}
} else if (openParam === "mcp") {
// Open ConnectAIModal directly for MCP
setIsMobileMenuOpen(false)
setExpandedView(null)
setShowConnectAIModal(true)
setIsMobileMenuOpen(false);
setExpandedView(null);
setShowConnectAIModal(true);
} else if (openParam === "addUrl") {
setShowAddMemoryView(true)
setExpandedView(null)
setShowAddMemoryView(true);
setExpandedView(null);
if (isMobile) {
setIsMobileMenuOpen(true)
setActivePanel("menu")
setIsMobileMenuOpen(true);
setActivePanel("menu");
}
} else if (validViews.includes(openParam as ValidView)) {
// For other valid views like "profile", "integrations"
setExpandedView(openParam as ValidView)
setExpandedView(openParam as ValidView);
if (isMobile) {
setIsMobileMenuOpen(true)
setActivePanel("menu")
setIsMobileMenuOpen(true);
setActivePanel("menu");
}
}
// Clear the parameter from URL after performing any action
clearOpenParam()
clearOpenParam();
}
}, [
openParam,
@ -210,30 +210,31 @@ function Menu({ id }: { id?: string }) {
setActivePanel,
validViews,
clearOpenParam,
])
]);
// Watch for active panel changes on mobile
useEffect(() => {
if (isMobile && activePanel !== "menu" && activePanel !== null) {
// Another panel became active, close the menu
setIsMobileMenuOpen(false)
setExpandedView(null)
setIsMobileMenuOpen(false);
setExpandedView(null);
}
}, [isMobile, activePanel])
}, [isMobile, activePanel]);
// Notify tour provider about expansion state changes
useEffect(() => {
const isExpanded = isMobile
? isMobileMenuOpen || !!expandedView
: isHovered || !!expandedView
setMenuExpanded(isExpanded)
}, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded])
: isHovered || !!expandedView;
setMenuExpanded(isExpanded);
}, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded]);
// Calculate width based on state
const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56
const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56;
// Dynamic z-index for mobile based on active panel
const mobileZIndex = isMobile && activePanel === "menu" ? "z-[70]" : "z-[100]"
const mobileZIndex =
isMobile && activePanel === "menu" ? "z-[70]" : "z-[100]";
return (
<>
@ -442,8 +443,8 @@ function Menu({ id }: { id?: string }) {
<Button
className="text-white/70 hover:text-white transition-colors duration-200"
onClick={() => {
setIsCollapsing(true)
setExpandedView(null)
setIsCollapsing(true);
setExpandedView(null);
}}
size="icon"
variant="ghost"
@ -479,14 +480,14 @@ function Menu({ id }: { id?: string }) {
{/* Mobile Menu with Vaul Drawer */}
{isMobile && (
<Drawer.Root
open={isMobileMenuOpen || !!expandedView}
onOpenChange={(open) => {
if (!open) {
setIsMobileMenuOpen(false)
setExpandedView(null)
setActivePanel(null)
setIsMobileMenuOpen(false);
setExpandedView(null);
setActivePanel(null);
}
}}
open={isMobileMenuOpen || !!expandedView}
>
{/* Menu Trigger Button */}
{!isMobileMenuOpen && !expandedView && (
@ -497,8 +498,8 @@ function Menu({ id }: { id?: string }) {
className="w-14 h-14 flex items-center justify-center text-white rounded-full shadow-2xl"
initial={{ scale: 0.8, opacity: 0 }}
onClick={() => {
setIsMobileMenuOpen(true)
setActivePanel("menu")
setIsMobileMenuOpen(true);
setActivePanel("menu");
}}
transition={{
duration: 0.3,
@ -594,13 +595,13 @@ function Menu({ id }: { id?: string }) {
initial={{ opacity: 0, y: 10 }}
layout
onClick={() => {
handleMenuItemClick(item.key)
handleMenuItemClick(item.key);
if (
item.key !== "mcp" &&
item.key !== "profile" &&
item.key !== "integrations"
) {
setIsMobileMenuOpen(false)
setIsMobileMenuOpen(false);
}
}}
type="button"
@ -664,8 +665,8 @@ function Menu({ id }: { id?: string }) {
<Button
className="text-white/70 hover:text-white transition-colors duration-200"
onClick={() => {
setIsCollapsing(true)
setExpandedView(null)
setIsCollapsing(true);
setExpandedView(null);
}}
size="icon"
variant="ghost"
@ -707,7 +708,7 @@ function Menu({ id }: { id?: string }) {
<Button className="hidden">Connect AI Assistant</Button>
</ConnectAIModal>
</>
)
);
}
export default Menu
export default Menu;

View file

@ -150,18 +150,18 @@ export function ProjectSelector() {
{isOpen && (
<>
<motion.div
className="fixed inset-0 z-40"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 z-40"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
onClick={() => setIsOpen(false)}
/>
<motion.div
className="absolute top-full left-0 mt-1 w-56 bg-[#0f1419] backdrop-blur-xl border border-white/10 rounded-md shadow-xl z-50 overflow-hidden"
initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
className="absolute top-full left-0 mt-1 w-56 bg-[#0f1419] backdrop-blur-xl border border-white/10 rounded-md shadow-xl z-50 overflow-hidden"
exit={{ opacity: 0, y: -5, scale: 0.98 }}
initial={{ opacity: 0, y: -5, scale: 0.98 }}
transition={{ duration: 0.15 }}
>
<div className="p-1.5 max-h-64 overflow-y-auto">
@ -187,14 +187,14 @@ export function ProjectSelector() {
.filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID)
.map((project: Project, index: number) => (
<motion.div
key={project.id}
animate={{ opacity: 1, x: 0 }}
className={`flex items-center justify-between p-2 rounded-md transition-colors group ${
selectedProject === project.containerTag
? "bg-white/15"
: "hover:bg-white/8"
}`}
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
key={project.id}
transition={{ delay: index * 0.03 }}
>
<div
@ -278,12 +278,12 @@ export function ProjectSelector() {
))}
<motion.div
className="flex items-center gap-2 p-2 rounded-md hover:bg-white/8 transition-colors cursor-pointer border-t border-white/10 mt-1"
onClick={handleCreateNewProject}
whileHover={{ x: 1 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-2 p-2 rounded-md hover:bg-white/8 transition-colors cursor-pointer border-t border-white/10 mt-1"
initial={{ opacity: 0 }}
onClick={handleCreateNewProject}
transition={{ delay: (projects.length + 1) * 0.03 }}
whileHover={{ x: 1 }}
>
<Plus className="h-3.5 w-3.5 text-white/70" />
<span className="text-xs font-medium text-white/80">
@ -297,8 +297,8 @@ export function ProjectSelector() {
</AnimatePresence>
<CreateProjectDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
open={showCreateDialog}
/>
{/* Delete Project Dialog */}

View file

@ -107,7 +107,7 @@ export function ReferralUpgradeModal({
if (user?.isAnonymous) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog onOpenChange={onClose} open={isOpen}>
<DialogContent className="sm:max-w-md bg-[#0f1419] backdrop-blur-xl border-white/10 text-white">
<motion.div
animate={{ opacity: 1, scale: 1 }}
@ -136,7 +136,7 @@ export function ReferralUpgradeModal({
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog onOpenChange={onClose} open={isOpen}>
<DialogContent className="sm:max-w-lg bg-[#0f1419] backdrop-blur-xl border-white/10 text-white">
<motion.div
animate={{ opacity: 1, scale: 1 }}
@ -177,16 +177,16 @@ export function ReferralUpgradeModal({
</div>
{/* Tabs */}
<Tabs defaultValue="refer" className="w-full">
<Tabs className="w-full" defaultValue="refer">
<TabsList className="grid w-full grid-cols-2 bg-white/5">
<TabsTrigger value="refer" className="flex items-center gap-2">
<TabsTrigger className="flex items-center gap-2" value="refer">
<Users className="w-4 h-4" />
Refer Friends
</TabsTrigger>
{!isPro && (
<TabsTrigger
value="upgrade"
className="flex items-center gap-2"
value="upgrade"
>
<CreditCard className="w-4 h-4" />
Upgrade Plan
@ -194,7 +194,7 @@ export function ReferralUpgradeModal({
)}
</TabsList>
<TabsContent value="refer" className="space-y-4 mt-6">
<TabsContent className="space-y-4 mt-6" value="refer">
<div className="text-center">
<Gift className="w-12 h-12 text-blue-400 mx-auto mb-3" />
<HeadingH3Bold className="text-white mb-2">
@ -241,7 +241,7 @@ export function ReferralUpgradeModal({
</TabsContent>
{!isPro && (
<TabsContent value="upgrade" className="space-y-4 mt-6">
<TabsContent className="space-y-4 mt-6" value="upgrade">
<div className="text-center">
<CreditCard className="w-12 h-12 text-purple-400 mx-auto mb-3" />
<HeadingH3Bold className="text-white mb-2">

View file

@ -28,6 +28,7 @@ function TextShimmerComponent({
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text",
"text-transparent [--base-color:#a1a1aa] [--base-gradient-color:#000]",
@ -36,18 +37,18 @@ function TextShimmerComponent({
className,
)}
initial={{ backgroundPosition: "100% center" }}
animate={{ backgroundPosition: "0% center" }}
transition={{
repeat: Infinity,
duration,
ease: "linear",
}}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`,
backgroundImage:
"var(--bg), linear-gradient(var(--base-color), var(--base-color))",
} as React.CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
>
{children}
</MotionComponent>

View file

@ -1,67 +1,67 @@
import { Button } from '@repo/ui/components/button';
import { Loader2, type LucideIcon } from 'lucide-react';
import { motion } from 'motion/react';
import { Button } from "@repo/ui/components/button";
import { Loader2, type LucideIcon } from "lucide-react";
import { motion } from "motion/react";
interface ActionButtonsProps {
onCancel: () => void;
onSubmit?: () => void;
submitText: string;
submitIcon?: LucideIcon;
isSubmitting?: boolean;
isSubmitDisabled?: boolean;
submitType?: 'button' | 'submit';
className?: string;
onCancel: () => void;
onSubmit?: () => void;
submitText: string;
submitIcon?: LucideIcon;
isSubmitting?: boolean;
isSubmitDisabled?: boolean;
submitType?: "button" | "submit";
className?: string;
}
export function ActionButtons({
onCancel,
onSubmit,
submitText,
submitIcon: SubmitIcon,
isSubmitting = false,
isSubmitDisabled = false,
submitType = 'submit',
className = '',
onCancel,
onSubmit,
submitText,
submitIcon: SubmitIcon,
isSubmitting = false,
isSubmitDisabled = false,
submitType = "submit",
className = "",
}: ActionButtonsProps) {
return (
<div className={`flex gap-3 order-1 sm:order-2 justify-end ${className}`}>
<Button
className="hover:bg-white/10 text-white border-none flex-1 sm:flex-initial"
onClick={onCancel}
type="button"
variant="ghost"
>
Cancel
</Button>
return (
<div className={`flex gap-3 order-1 sm:order-2 justify-end ${className}`}>
<Button
className="hover:bg-white/10 text-white border-none flex-1 sm:flex-initial"
onClick={onCancel}
type="button"
variant="ghost"
>
Cancel
</Button>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="flex-1 sm:flex-initial"
>
<Button
className="bg-white/10 hover:bg-white/20 text-white border-white/20 w-full"
disabled={isSubmitting || isSubmitDisabled}
onClick={submitType === 'button' ? onSubmit : undefined}
type={submitType}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
{submitText.includes('Add')
? 'Adding...'
: submitText.includes('Upload')
? 'Uploading...'
: 'Processing...'}
</>
) : (
<>
{SubmitIcon && <SubmitIcon className="h-4 w-4 mr-2" />}
{submitText}
</>
)}
</Button>
</motion.div>
</div>
);
<motion.div
className="flex-1 sm:flex-initial"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Button
className="bg-white/10 hover:bg-white/20 text-white border-white/20 w-full"
disabled={isSubmitting || isSubmitDisabled}
onClick={submitType === "button" ? onSubmit : undefined}
type={submitType}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
{submitText.includes("Add")
? "Adding..."
: submitText.includes("Upload")
? "Uploading..."
: "Processing..."}
</>
) : (
<>
{SubmitIcon && <SubmitIcon className="h-4 w-4 mr-2" />}
{submitText}
</>
)}
</Button>
</motion.div>
</div>
);
}

View file

@ -1,244 +0,0 @@
import { $fetch } from "@lib/api"
import { useMutation } from "@tanstack/react-query"
import { toast } from "sonner"
// Simplified mutation that doesn't block UI with polling
export const createSimpleAddContentMutation = (
queryClient: any,
onClose?: () => void,
) => {
return useMutation({
mutationFn: async ({
content,
project,
contentType,
}: {
content: string
project: string
contentType: "note" | "link"
}) => {
// Just create the memory, don't wait for processing
const response = await $fetch("@post/documents", {
body: {
content: content,
containerTags: [project],
metadata: {
sm_source: "consumer",
},
},
})
if (response.error) {
throw new Error(
response.error?.message || `Failed to add ${contentType}`,
)
}
return { id: response.data.id, contentType }
},
onMutate: async ({ content, project, contentType }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({
queryKey: ["documents-with-memories", project],
})
// Snapshot the previous value
const previousMemories = queryClient.getQueryData([
"documents-with-memories",
project,
])
// Create optimistic memory
const tempId = `temp-${Date.now()}`
const optimisticMemory = {
id: tempId,
content: contentType === "link" ? "" : content,
url: contentType === "link" ? content : null,
title:
contentType === "link" ? "Processing..." : content.substring(0, 100),
description:
contentType === "link"
? "Extracting content..."
: "Processing content...",
containerTags: [project],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: "processing",
type: contentType,
metadata: {
processingStage: "queued",
processingMessage: "Added to processing queue",
},
memoryEntries: [],
isOptimistic: true,
}
// Optimistically update to include the new memory
queryClient.setQueryData(
["documents-with-memories", project],
(old: any) => {
// Handle infinite query structure
if (old?.pages) {
const newPages = [...old.pages]
if (newPages.length > 0) {
// Add to the first page
const firstPage = { ...newPages[0] }
firstPage.documents = [
optimisticMemory,
...(firstPage.documents || []),
]
newPages[0] = firstPage
} else {
// No pages yet, create the first page
newPages.push({
documents: [optimisticMemory],
pagination: { currentPage: 1, totalPages: 1, totalCount: 1 },
totalCount: 1,
})
}
return {
...old,
pages: newPages,
}
}
// Fallback for regular query structure
const newData = old
? {
...old,
documents: [optimisticMemory, ...(old.documents || [])],
totalCount: (old.totalCount || 0) + 1,
}
: { documents: [optimisticMemory], totalCount: 1 }
return newData
},
)
return { previousMemories, optimisticId: tempId }
},
onError: (error, variables, context) => {
// Roll back on error
if (context?.previousMemories) {
queryClient.setQueryData(
["documents-with-memories", variables.project],
context.previousMemories,
)
}
toast.error(`Failed to add ${variables.contentType}`, {
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (data, variables, context) => {
// Show success message
toast.success(
`${variables.contentType === "link" ? "Link" : "Note"} created successfully!`,
)
// Close modal
onClose?.()
// Start polling for this specific memory ID
// The polling will happen in the background and update the optimistic memory when done
startMemoryPolling(
data.id,
variables.project,
context?.optimisticId,
queryClient,
)
},
})
}
// Background polling function
const startMemoryPolling = (
memoryId: string,
project: string,
optimisticId: string | undefined,
queryClient: any,
) => {
const pollMemory = async () => {
try {
const memory = await $fetch(`@get/documents/${memoryId}`)
if (memory.error) {
console.error("Failed to fetch memory status:", memory.error)
return false
}
const isComplete =
memory.data?.status === "done" ||
memory.data?.content ||
memory.data?.memoryEntries?.length > 0
if (isComplete) {
// Replace optimistic memory with real data
queryClient.setQueryData(
["documents-with-memories", project],
(old: any) => {
if (old?.pages) {
// Handle infinite query structure
const newPages = old.pages.map((page: any) => ({
...page,
documents: page.documents.map((doc: any) => {
if (doc.isOptimistic || doc.id === optimisticId) {
// Replace with real memory
return {
...memory.data,
isOptimistic: false,
}
}
return doc
}),
}))
return {
...old,
pages: newPages,
}
}
// Handle regular query structure
return {
...old,
documents: old.documents.map((doc: any) => {
if (doc.isOptimistic || doc.id === optimisticId) {
return {
...memory.data,
isOptimistic: false,
}
}
return doc
}),
}
},
)
return true // Stop polling
}
return false // Continue polling
} catch (error) {
console.error("Error polling memory:", error)
return false // Continue polling
}
}
// Poll every 3 seconds, max 60 attempts (3 minutes)
let attempts = 0
const maxAttempts = 60
const poll = async () => {
if (attempts >= maxAttempts) {
console.log("Memory polling timed out")
return
}
const isComplete = await pollMemory()
attempts++
if (!isComplete && attempts < maxAttempts) {
setTimeout(poll, 3000) // Poll again in 3 seconds
}
}
// Start polling after a short delay
setTimeout(poll, 2000)
}

View file

@ -1,9 +1,9 @@
import { $fetch } from "@lib/api";
import { $fetch } from "@lib/api"
import {
fetchConsumerProProduct,
fetchMemoriesFeature,
} from "@repo/lib/queries";
import { Button } from "@repo/ui/components/button";
} from "@repo/lib/queries"
import { Button } from "@repo/ui/components/button"
import {
Dialog,
DialogContent,
@ -11,18 +11,19 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@repo/ui/components/dialog";
import { Input } from "@repo/ui/components/input";
import { Label } from "@repo/ui/components/label";
import { Textarea } from "@repo/ui/components/textarea";
import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
} from "@repo/ui/components/dialog"
import { Input } from "@repo/ui/components/input"
import { Label } from "@repo/ui/components/label"
import { Textarea } from "@repo/ui/components/textarea"
import type { GetMemoryResponseSchema } from "@repo/validation/api"
import { useForm } from "@tanstack/react-form"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
Dropzone,
DropzoneContent,
DropzoneEmptyState,
} from "@ui/components/shadcn-io/dropzone";
import { useCustomer } from "autumn-js/react";
} from "@ui/components/shadcn-io/dropzone"
import { useCustomer } from "autumn-js/react"
import {
Brain,
FileIcon,
@ -31,19 +32,19 @@ import {
PlugIcon,
Plus,
UploadIcon,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { analytics } from "@/lib/analytics";
import { useProject } from "@/stores";
import { ConnectionsTabContent } from "../connections-tab-content";
import { ActionButtons } from "./action-buttons";
import { MemoryUsageRing } from "./memory-usage-ring";
import { ProjectSelection } from "./project-selection";
import { TabButton } from "./tab-button";
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import dynamic from "next/dynamic"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { z } from "zod"
import { analytics } from "@/lib/analytics"
import { useProject } from "@/stores"
import { ConnectionsTabContent } from "../connections-tab-content"
import { ActionButtons } from "./action-buttons"
import { MemoryUsageRing } from "./memory-usage-ring"
import { ProjectSelection } from "./project-selection"
import { TabButton } from "./tab-button"
const TextEditor = dynamic(
() => import("./text-editor").then((mod) => ({ default: mod.TextEditor })),
@ -64,91 +65,100 @@ const TextEditor = dynamic(
),
ssr: false,
},
);
)
// Simple function to extract plain text title from HTML content
const getPlainTextTitle = (htmlContent: string) => {
const temp = document.createElement("div");
temp.innerHTML = htmlContent;
const plainText = temp.textContent || temp.innerText || htmlContent;
const firstLine = plainText.split("\n")[0].trim();
const temp = document.createElement("div")
temp.innerHTML = htmlContent
const plainText = temp.textContent || temp.innerText || htmlContent
if (!plainText) {
return "Untitled"
}
const firstLine = plainText.split("\n")[0]?.trim()
if (!firstLine) {
return plainText.substring(0, 100)
}
return firstLine.length > 0
? firstLine.substring(0, 100)
: plainText.substring(0, 100);
};
: plainText.substring(0, 100)
}
export function AddMemoryView({
onClose,
initialTab = "note",
}: {
onClose?: () => void;
initialTab?: "note" | "link" | "file" | "connect";
onClose?: () => void
initialTab?: "note" | "link" | "file" | "connect"
}) {
const queryClient = useQueryClient();
const { selectedProject, setSelectedProject } = useProject();
const [showAddDialog, setShowAddDialog] = useState(true);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const queryClient = useQueryClient()
const { selectedProject, setSelectedProject } = useProject()
const [showAddDialog, setShowAddDialog] = useState(true)
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [activeTab, setActiveTab] = useState<
"note" | "link" | "file" | "connect"
>(initialTab);
const autumn = useCustomer();
const [showCreateProjectDialog, setShowCreateProjectDialog] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
>(initialTab)
const autumn = useCustomer()
const [showCreateProjectDialog, setShowCreateProjectDialog] = useState(false)
const [newProjectName, setNewProjectName] = useState("")
// Check memory limits
const { data: memoriesCheck } = fetchMemoriesFeature(autumn);
const { data: memoriesCheck } = fetchMemoriesFeature(autumn)
const memoriesUsed = memoriesCheck?.usage ?? 0;
const memoriesLimit = memoriesCheck?.included_usage ?? 0;
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 0
// Fetch projects for the dropdown
const { data: projects = [], isLoading: isLoadingProjects } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects");
const response = await $fetch("@get/projects")
if (response.error) {
throw new Error(response.error?.message || "Failed to load projects");
throw new Error(response.error?.message || "Failed to load projects")
}
return response.data?.projects || [];
return response.data?.projects || []
},
staleTime: 30 * 1000,
});
})
// Create project mutation
const createProjectMutation = useMutation({
mutationFn: async (name: string) => {
const response = await $fetch("@post/projects", {
body: { name },
});
})
if (response.error) {
throw new Error(response.error?.message || "Failed to create project");
throw new Error(response.error?.message || "Failed to create project")
}
return response.data;
return response.data
},
onSuccess: (data) => {
analytics.projectCreated();
toast.success("Project created successfully!");
setShowCreateProjectDialog(false);
setNewProjectName("");
queryClient.invalidateQueries({ queryKey: ["projects"] });
analytics.projectCreated()
toast.success("Project created successfully!")
setShowCreateProjectDialog(false)
setNewProjectName("")
queryClient.invalidateQueries({ queryKey: ["projects"] })
// Set the newly created project as selected
if (data?.containerTag) {
setSelectedProject(data.containerTag);
setSelectedProject(data.containerTag)
// Update form values
addContentForm.setFieldValue("project", data.containerTag);
fileUploadForm.setFieldValue("project", data.containerTag);
addContentForm.setFieldValue("project", data.containerTag)
fileUploadForm.setFieldValue("project", data.containerTag)
}
},
onError: (error) => {
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
})
},
});
})
const addContentForm = useForm({
defaultValues: {
@ -160,8 +170,8 @@ export function AddMemoryView({
content: value.content,
project: value.project,
contentType: activeTab as "note" | "link",
});
formApi.reset();
})
formApi.reset()
},
validators: {
onChange: z.object({
@ -169,19 +179,19 @@ export function AddMemoryView({
project: z.string(),
}),
},
});
})
// Re-validate content field when tab changes between note/link
// biome-ignore lint/correctness/useExhaustiveDependencies: It is what it is
useEffect(() => {
// Trigger validation of the content field when switching between note/link
if (activeTab === "note" || activeTab === "link") {
const currentValue = addContentForm.getFieldValue("content");
const currentValue = addContentForm.getFieldValue("content")
if (currentValue) {
addContentForm.validateField("content", "change");
addContentForm.validateField("content", "change")
}
}
}, [activeTab]);
}, [activeTab])
// Form for file upload metadata
const fileUploadForm = useForm({
@ -192,8 +202,8 @@ export function AddMemoryView({
},
onSubmit: async ({ value, formApi }) => {
if (selectedFiles.length === 0) {
toast.error("Please select a file to upload");
return;
toast.error("Please select a file to upload")
return
}
for (const file of selectedFiles) {
@ -202,13 +212,13 @@ export function AddMemoryView({
title: value.title || undefined,
description: value.description || undefined,
project: value.project,
});
})
}
formApi.reset();
setSelectedFiles([]);
formApi.reset()
setSelectedFiles([])
},
});
})
const addContentMutation = useMutation({
mutationFn: async ({
@ -216,11 +226,11 @@ export function AddMemoryView({
project,
contentType,
}: {
content: string;
project: string;
contentType: "note" | "link";
content: string
project: string
contentType: "note" | "link"
}) => {
console.log("📤 Creating memory...");
console.log("📤 Creating memory...")
const response = await $fetch("@post/documents", {
body: {
@ -230,34 +240,34 @@ export function AddMemoryView({
sm_source: "consumer",
},
},
});
})
if (response.error) {
throw new Error(
response.error?.message || `Failed to add ${contentType}`,
);
)
}
console.log("✅ Memory created:", response.data);
return response.data;
console.log("✅ Memory created:", response.data)
return response.data
},
onMutate: async ({ content, project, contentType }) => {
console.log("🚀 OPTIMISTIC UPDATE: Starting for", contentType);
console.log("🚀 OPTIMISTIC UPDATE: Starting for", contentType)
// Cancel queries to prevent conflicts
await queryClient.cancelQueries({
queryKey: ["documents-with-memories", project],
});
console.log("📞 QUERIES CANCELLED for project:", project);
})
console.log("📞 QUERIES CANCELLED for project:", project)
// Get previous data for rollback
const previousMemories = queryClient.getQueryData([
"documents-with-memories",
project,
]);
])
// Create optimistic memory with proper title
const tempId = `temp-${Date.now()}`;
const tempId = `temp-${Date.now()}`
const optimisticMemory = {
id: tempId,
content: contentType === "link" ? "" : content,
@ -270,9 +280,9 @@ export function AddMemoryView({
updatedAt: new Date().toISOString(),
memoryEntries: [],
isOptimistic: true,
};
}
console.log("🎯 Adding optimistic memory:", optimisticMemory);
console.log("🎯 Adding optimistic memory:", optimisticMemory)
// Add to cache optimistically
queryClient.setQueryData(
@ -280,19 +290,19 @@ export function AddMemoryView({
(old: any) => {
if (old?.pages) {
// Infinite query structure
const newPages = [...old.pages];
const newPages = [...old.pages]
if (newPages.length > 0) {
newPages[0] = {
...newPages[0],
documents: [optimisticMemory, ...(newPages[0].documents || [])],
};
}
} else {
newPages.push({
documents: [optimisticMemory],
pagination: { currentPage: 1, totalPages: 1, totalCount: 1 },
});
})
}
return { ...old, pages: newPages };
return { ...old, pages: newPages }
}
// Regular query structure
@ -302,44 +312,46 @@ export function AddMemoryView({
documents: [optimisticMemory, ...(old.documents || [])],
totalCount: (old.totalCount || 0) + 1,
}
: { documents: [optimisticMemory], totalCount: 1 };
: { documents: [optimisticMemory], totalCount: 1 }
},
);
)
return { previousMemories, optimisticId: tempId };
return { previousMemories, optimisticId: tempId }
},
onError: (error, variables, context) => {
console.log("❌ Mutation failed, rolling back");
console.log("❌ Mutation failed, rolling back")
if (context?.previousMemories) {
queryClient.setQueryData(
["documents-with-memories", variables.project],
context.previousMemories,
);
)
}
toast.error(`Failed to add ${variables.contentType}`);
toast.error(`Failed to add ${variables.contentType}`)
},
onSuccess: (data, variables, context) => {
console.log(
"✅ Mutation succeeded, starting simple polling for memory:",
data.id,
);
)
analytics.memoryAdded({
type: variables.contentType === "link" ? "link" : "note",
project_id: variables.project,
content_length: variables.content.length,
});
})
toast.success(
`${variables.contentType === "link" ? "Link" : "Note"} added successfully!`,
);
setShowAddDialog(false);
onClose?.();
)
setShowAddDialog(false)
onClose?.()
// Simple polling to replace optimistic update when ready
const pollMemory = async () => {
try {
const memory = await $fetch(`@get/documents/${data.id}`);
console.log("🔍 Polling memory:", memory.data);
const memory = await $fetch<z.infer<typeof GetMemoryResponseSchema>>(
`@get/documents/${data.id}`,
)
console.log("🔍 Polling memory:", memory.data)
if (memory.data && !memory.error) {
// Check if memory has been processed (has memory entries, substantial content, and NOT untitled/processing)
@ -347,17 +359,16 @@ export function AddMemoryView({
memory.data.title &&
!memory.data.title.toLowerCase().includes("untitled") &&
!memory.data.title.toLowerCase().includes("processing") &&
memory.data.title.length > 3;
memory.data.title.length > 3
const isReady =
memory.data.memoryEntries?.length > 0 ||
(memory.data.content &&
memory.data.content.length > 10 &&
hasRealTitle);
memory.data.content &&
memory.data.content.length > 10 &&
hasRealTitle
console.log("📊 Memory ready check:", {
isReady,
hasMemoryEntries: memory.data.memoryEntries?.length,
hasMemoryEntries: 0,
hasContent: memory.data.content?.length,
title: memory.data.title,
hasRealTitle,
@ -367,10 +378,10 @@ export function AddMemoryView({
titleNotProcessing:
memory.data.title &&
!memory.data.title.toLowerCase().includes("processing"),
});
})
if (isReady) {
console.log("✅ Memory ready, replacing optimistic update");
console.log("✅ Memory ready, replacing optimistic update")
// Replace optimistic memory with real data
queryClient.setQueryData(
["documents-with-memories", variables.project],
@ -380,48 +391,48 @@ export function AddMemoryView({
...page,
documents: page.documents.map((doc: any) => {
if (doc.isOptimistic && doc.id.startsWith("temp-")) {
return { ...memory.data, isOptimistic: false };
return { ...memory.data, isOptimistic: false }
}
return doc;
return doc
}),
}));
return { ...old, pages: newPages };
}))
return { ...old, pages: newPages }
}
return old;
return old
},
);
return true; // Stop polling
)
return true // Stop polling
}
}
return false; // Continue polling
return false // Continue polling
} catch (error) {
console.error("❌ Error polling memory:", error);
return false;
console.error("❌ Error polling memory:", error)
return false
}
};
}
// Poll every 3 seconds for up to 30 attempts (90 seconds)
let attempts = 0;
const maxAttempts = 30;
let attempts = 0
const maxAttempts = 30
const poll = async () => {
if (attempts >= maxAttempts) {
console.log("⚠️ Polling stopped after max attempts");
return;
console.log("⚠️ Polling stopped after max attempts")
return
}
const isDone = await pollMemory();
attempts++;
const isDone = await pollMemory()
attempts++
if (!isDone && attempts < maxAttempts) {
setTimeout(poll, 3000);
setTimeout(poll, 3000)
}
};
}
// Start polling after 2 seconds
setTimeout(poll, 2000);
setTimeout(poll, 2000)
},
});
})
const fileUploadMutation = useMutation({
mutationFn: async ({
@ -430,10 +441,10 @@ export function AddMemoryView({
description,
project,
}: {
file: File;
title?: string;
description?: string;
project: string;
file: File
title?: string
description?: string
project: string
}) => {
// TEMPORARILY DISABLED: Limit check disabled
// Check if user can add more memories
@ -443,9 +454,9 @@ export function AddMemoryView({
// );
// }
const formData = new FormData();
formData.append("file", file);
formData.append("containerTags", JSON.stringify([project]));
const formData = new FormData()
formData.append("file", file)
formData.append("containerTags", JSON.stringify([project]))
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
@ -454,14 +465,14 @@ export function AddMemoryView({
body: formData,
credentials: "include",
},
);
)
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to upload file");
const error = await response.json()
throw new Error(error.error || "Failed to upload file")
}
const data = await response.json();
const data = await response.json()
// If we have metadata, we can update the document after creation
if (title || description) {
@ -473,23 +484,23 @@ export function AddMemoryView({
sm_source: "consumer", // Use "consumer" source to bypass limits
},
},
});
})
}
return data;
return data
},
// Optimistic update
onMutate: async ({ file, title, description, project }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({
queryKey: ["documents-with-memories", project],
});
})
// Snapshot the previous value
const previousMemories = queryClient.getQueryData([
"documents-with-memories",
project,
]);
])
// Create optimistic memory for the file
const optimisticMemory = {
@ -509,7 +520,7 @@ export function AddMemoryView({
mimeType: file.type,
},
memoryEntries: [],
};
}
// Optimistically update to include the new memory
queryClient.setQueryData(
@ -518,41 +529,41 @@ export function AddMemoryView({
// Handle infinite query structure
if (old?.pages) {
// This is an infinite query - add to the first page
const newPages = [...old.pages];
const newPages = [...old.pages]
if (newPages.length > 0) {
// Add to the first page
const firstPage = { ...newPages[0] };
const firstPage = { ...newPages[0] }
firstPage.documents = [
optimisticMemory,
...(firstPage.documents || []),
];
newPages[0] = firstPage;
]
newPages[0] = firstPage
} else {
// No pages yet, create the first page
newPages.push({
documents: [optimisticMemory],
pagination: { currentPage: 1, totalPages: 1, totalCount: 1 },
totalCount: 1,
});
})
}
return {
...old,
pages: newPages,
};
}
}
// Fallback for regular query structure
if (!old) return { documents: [optimisticMemory], totalCount: 1 };
if (!old) return { documents: [optimisticMemory], totalCount: 1 }
return {
...old,
documents: [optimisticMemory, ...(old.documents || [])],
totalCount: (old.totalCount || 0) + 1,
};
}
},
);
)
// Return a context object with the snapshotted value
return { previousMemories };
return { previousMemories }
},
// If the mutation fails, roll back to the previous value
onError: (error, variables, context) => {
@ -560,11 +571,11 @@ export function AddMemoryView({
queryClient.setQueryData(
["documents-with-memories", variables.project],
context.previousMemories,
);
)
}
toast.error("Failed to upload file", {
description: error instanceof Error ? error.message : "Unknown error",
});
})
},
onSuccess: (_data, variables) => {
analytics.memoryAdded({
@ -572,18 +583,18 @@ export function AddMemoryView({
project_id: variables.project,
file_size: variables.file.size,
file_type: variables.file.type,
});
})
toast.success("File uploaded successfully!", {
description: "Your file is being processed",
});
setShowAddDialog(false);
onClose?.();
})
setShowAddDialog(false)
onClose?.()
},
// Don't invalidate queries immediately - let optimistic updates work
// onSettled: () => {
// queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
// },
});
})
return (
<AnimatePresence mode="wait">
@ -591,8 +602,8 @@ export function AddMemoryView({
<Dialog
key="add-memory-dialog"
onOpenChange={(open) => {
setShowAddDialog(open);
if (!open) onClose?.();
setShowAddDialog(open)
if (!open) onClose?.()
}}
open={showAddDialog}
>
@ -651,9 +662,9 @@ export function AddMemoryView({
<div className="space-y-4">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
addContentForm.handleSubmit();
e.preventDefault()
e.stopPropagation()
addContentForm.handleSubmit()
}}
>
<div className="grid gap-4">
@ -669,9 +680,9 @@ export function AddMemoryView({
validators={{
onChange: ({ value }) => {
if (!value || value.trim() === "") {
return "Note is required";
return "Note is required"
}
return undefined;
return undefined
},
}}
>
@ -753,9 +764,9 @@ export function AddMemoryView({
isSubmitDisabled={!addContentForm.state.canSubmit}
isSubmitting={addContentMutation.isPending}
onCancel={() => {
setShowAddDialog(false);
onClose?.();
addContentForm.reset();
setShowAddDialog(false)
onClose?.()
addContentForm.reset()
}}
submitIcon={Plus}
submitText="Add Note"
@ -769,9 +780,9 @@ export function AddMemoryView({
<div className="space-y-4">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
addContentForm.handleSubmit();
e.preventDefault()
e.stopPropagation()
addContentForm.handleSubmit()
}}
>
<div className="grid gap-4">
@ -793,13 +804,13 @@ export function AddMemoryView({
validators={{
onChange: ({ value }) => {
if (!value || value.trim() === "") {
return "Link is required";
return "Link is required"
}
try {
new URL(value);
return undefined;
new URL(value)
return undefined
} catch {
return "Please enter a valid link";
return "Please enter a valid link"
}
},
}}
@ -879,9 +890,9 @@ export function AddMemoryView({
isSubmitDisabled={!addContentForm.state.canSubmit}
isSubmitting={addContentMutation.isPending}
onCancel={() => {
setShowAddDialog(false);
onClose?.();
addContentForm.reset();
setShowAddDialog(false)
onClose?.()
addContentForm.reset()
}}
submitIcon={Plus}
submitText="Add Link"
@ -895,9 +906,9 @@ export function AddMemoryView({
<div className="space-y-4">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
fileUploadForm.handleSubmit();
e.preventDefault()
e.stopPropagation()
fileUploadForm.handleSubmit()
}}
>
<div className="grid gap-4">
@ -1031,10 +1042,10 @@ export function AddMemoryView({
isSubmitDisabled={selectedFiles.length === 0}
isSubmitting={fileUploadMutation.isPending}
onCancel={() => {
setShowAddDialog(false);
onClose?.();
fileUploadForm.reset();
setSelectedFiles([]);
setShowAddDialog(false)
onClose?.()
fileUploadForm.reset()
setSelectedFiles([])
}}
submitIcon={UploadIcon}
submitText="Upload File"
@ -1102,8 +1113,8 @@ export function AddMemoryView({
<Button
className="bg-white/5 hover:bg-white/10 border-white/10 text-white w-full sm:w-auto"
onClick={() => {
setShowCreateProjectDialog(false);
setNewProjectName("");
setShowCreateProjectDialog(false)
setNewProjectName("")
}}
type="button"
variant="outline"
@ -1140,19 +1151,19 @@ export function AddMemoryView({
</Dialog>
)}
</AnimatePresence>
);
)
}
export function AddMemoryExpandedView() {
const [showDialog, setShowDialog] = useState(false);
const [showDialog, setShowDialog] = useState(false)
const [selectedTab, setSelectedTab] = useState<
"note" | "link" | "file" | "connect"
>("note");
>("note")
const handleOpenDialog = (tab: "note" | "link" | "file" | "connect") => {
setSelectedTab(tab);
setShowDialog(true);
};
setSelectedTab(tab)
setShowDialog(true)
}
return (
<>
@ -1223,5 +1234,5 @@ export function AddMemoryExpandedView() {
/>
)}
</>
);
)
}

View file

@ -1,7 +1,7 @@
interface MemoryUsageRingProps {
memoriesUsed: number;
memoriesLimit: number;
className?: string;
memoriesUsed: number
memoriesLimit: number
className?: string
}
export function MemoryUsageRing({
@ -9,10 +9,10 @@ export function MemoryUsageRing({
memoriesLimit,
className = "",
}: MemoryUsageRingProps) {
const usagePercentage = memoriesUsed / memoriesLimit;
const usagePercentage = memoriesUsed / memoriesLimit
const strokeColor =
memoriesUsed >= memoriesLimit * 0.8 ? "rgb(251 191 36)" : "rgb(34 197 94)";
const circumference = 2 * Math.PI * 10;
memoriesUsed >= memoriesLimit * 0.8 ? "rgb(251 191 36)" : "rgb(34 197 94)"
const circumference = 2 * Math.PI * 10
return (
<div
@ -20,27 +20,28 @@ export function MemoryUsageRing({
title={`${memoriesUsed} of ${memoriesLimit} memories used`}
>
<svg className="w-6 h-6 transform -rotate-90" viewBox="0 0 24 24">
<title>Memory Usage</title>
{/* Background circle */}
<circle
cx="12"
cy="12"
fill="none"
r="10"
stroke="rgb(255 255 255 / 0.1)"
strokeWidth="2"
fill="none"
/>
{/* Progress circle */}
<circle
className="transition-all duration-300"
cx="12"
cy="12"
fill="none"
r="10"
stroke={strokeColor}
strokeWidth="2"
fill="none"
strokeDasharray={`${circumference}`}
strokeDashoffset={`${circumference * (1 - usagePercentage)}`}
className="transition-all duration-300"
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
@ -49,5 +50,5 @@ export function MemoryUsageRing({
{memoriesUsed} / {memoriesLimit}
</div>
</div>
);
)
}

View file

@ -1,94 +1,94 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/ui/components/select';
import { Plus } from 'lucide-react';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/ui/components/select";
import { Plus } from "lucide-react";
interface Project {
id?: string;
containerTag: string;
name: string;
id?: string;
containerTag: string;
name: string;
}
interface ProjectSelectionProps {
projects: Project[];
selectedProject: string;
onProjectChange: (value: string) => void;
onCreateProject: () => void;
disabled?: boolean;
isLoading?: boolean;
className?: string;
id?: string;
projects: Project[];
selectedProject: string;
onProjectChange: (value: string) => void;
onCreateProject: () => void;
disabled?: boolean;
isLoading?: boolean;
className?: string;
id?: string;
}
export function ProjectSelection({
projects,
selectedProject,
onProjectChange,
onCreateProject,
disabled = false,
isLoading = false,
className = '',
id = 'project-select',
projects,
selectedProject,
onProjectChange,
onCreateProject,
disabled = false,
isLoading = false,
className = "",
id = "project-select",
}: ProjectSelectionProps) {
const handleValueChange = (value: string) => {
if (value === 'create-new-project') {
onCreateProject();
} else {
onProjectChange(value);
}
};
const handleValueChange = (value: string) => {
if (value === "create-new-project") {
onCreateProject();
} else {
onProjectChange(value);
}
};
return (
<Select
key={`${id}-${selectedProject}`}
disabled={isLoading || disabled}
onValueChange={handleValueChange}
value={selectedProject}
>
<SelectTrigger
className={`bg-white/5 border-white/10 text-white ${className}`}
id={id}
>
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent
className="bg-black/90 backdrop-blur-xl border-white/10 z-[90]"
position="popper"
sideOffset={5}
>
<SelectItem
className="text-white hover:bg-white/10"
key="default"
value="sm_project_default"
>
Default Project
</SelectItem>
{projects
.filter((p) => p.containerTag !== 'sm_project_default' && p.id)
.map((project) => (
<SelectItem
className="text-white hover:bg-white/10"
key={project.id || project.containerTag}
value={project.containerTag}
>
{project.name}
</SelectItem>
))}
<SelectItem
className="text-white hover:bg-white/10 border-t border-white/10 mt-1"
key="create-new"
value="create-new-project"
>
<div className="flex items-center gap-2">
<Plus className="h-4 w-4" />
<span>Create new project</span>
</div>
</SelectItem>
</SelectContent>
</Select>
);
return (
<Select
disabled={isLoading || disabled}
key={`${id}-${selectedProject}`}
onValueChange={handleValueChange}
value={selectedProject}
>
<SelectTrigger
className={`bg-white/5 border-white/10 text-white ${className}`}
id={id}
>
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent
className="bg-black/90 backdrop-blur-xl border-white/10 z-[90]"
position="popper"
sideOffset={5}
>
<SelectItem
className="text-white hover:bg-white/10"
key="default"
value="sm_project_default"
>
Default Project
</SelectItem>
{projects
.filter((p) => p.containerTag !== "sm_project_default" && p.id)
.map((project) => (
<SelectItem
className="text-white hover:bg-white/10"
key={project.id || project.containerTag}
value={project.containerTag}
>
{project.name}
</SelectItem>
))}
<SelectItem
className="text-white hover:bg-white/10 border-t border-white/10 mt-1"
key="create-new"
value="create-new-project"
>
<div className="flex items-center gap-2">
<Plus className="h-4 w-4" />
<span>Create new project</span>
</div>
</SelectItem>
</SelectContent>
</Select>
);
}

View file

@ -1,28 +1,28 @@
import type { LucideIcon } from 'lucide-react';
import type { LucideIcon } from "lucide-react";
interface TabButtonProps {
icon: LucideIcon;
label: string;
isActive: boolean;
onClick: () => void;
icon: LucideIcon;
label: string;
isActive: boolean;
onClick: () => void;
}
export function TabButton({
icon: Icon,
label,
isActive,
onClick,
icon: Icon,
label,
isActive,
onClick,
}: TabButtonProps) {
return (
<button
className={`flex items-center gap-1.5 text-xs sm:text-xs px-4 sm:px-3 py-2 sm:py-1 h-8 sm:h-6 rounded-sm transition-colors whitespace-nowrap min-w-0 ${
isActive ? 'bg-white/10' : 'hover:bg-white/5'
}`}
onClick={onClick}
type="button"
>
<Icon className="h-4 w-4 sm:h-3 sm:w-3" />
{label}
</button>
);
return (
<button
className={`flex items-center gap-1.5 text-xs sm:text-xs px-4 sm:px-3 py-2 sm:py-1 h-8 sm:h-6 rounded-sm transition-colors whitespace-nowrap min-w-0 ${
isActive ? "bg-white/10" : "hover:bg-white/5"
}`}
onClick={onClick}
type="button"
>
<Icon className="h-4 w-4 sm:h-3 sm:w-3" />
{label}
</button>
);
}

View file

@ -399,8 +399,6 @@ export function TextEditor({
title: string;
}) => (
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 !p-0 text-white/70 transition-all duration-200 rounded-sm",
"hover:bg-white/15 hover:text-white hover:scale-105",
@ -408,8 +406,10 @@ export function TextEditor({
isActive && "bg-white/20 text-white",
)}
onMouseDown={onMouseDown}
size="sm"
title={title}
type="button"
variant="ghost"
>
<Icon
className={cn(
@ -426,13 +426,20 @@ export function TextEditor({
<Slate
editor={editor}
initialValue={editorValue}
onValueChange={handleSlateChange}
onSelectionChange={() => setSelection(editor.selection)}
onValueChange={handleSlateChange}
>
<Editable
className={cn(
"outline-none w-full h-full text-white placeholder:text-white/50",
disabled && "opacity-50 cursor-not-allowed",
)}
onBlur={onBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
readOnly={disabled}
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={placeholder}
renderPlaceholder={({ children, attributes }) => {
return (
<div {...attributes} className="mt-2">
@ -440,13 +447,6 @@ export function TextEditor({
</div>
);
}}
onKeyDown={handleKeyDown}
onBlur={onBlur}
readOnly={disabled}
className={cn(
"outline-none w-full h-full text-white placeholder:text-white/50",
disabled && "opacity-50 cursor-not-allowed",
)}
style={{
minHeight: "11rem",
maxHeight: "15rem",

View file

@ -50,12 +50,12 @@ export function ChatRewrite() {
{getCurrentChat()?.title ?? "New Chat"}
</h3>
<div className="flex items-center gap-2">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<Dialog onOpenChange={setIsDialogOpen} open={isDialogOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => analytics.chatHistoryViewed()}
size="icon"
variant="outline"
>
<HistoryIcon className="size-4 text-muted-foreground" />
</Button>
@ -77,19 +77,19 @@ export function ChatRewrite() {
const isActive = c.id === currentChatId;
return (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => {
setCurrentChatId(c.id);
setIsDialogOpen(false);
}}
aria-current={isActive ? "true" : undefined}
className={cn(
"flex items-center justify-between rounded-md px-3 py-2 outline-none",
"transition-colors",
isActive ? "bg-primary/10" : "hover:bg-muted",
)}
aria-current={isActive ? "true" : undefined}
key={c.id}
onClick={() => {
setCurrentChatId(c.id);
setIsDialogOpen(false);
}}
role="button"
tabIndex={0}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
@ -107,14 +107,14 @@ export function ChatRewrite() {
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Delete conversation"
onClick={(e) => {
e.stopPropagation();
analytics.chatDeleted();
}}
aria-label="Delete conversation"
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="size-4 text-muted-foreground" />
</Button>
@ -129,22 +129,22 @@ export function ChatRewrite() {
</div>
</ScrollArea>
<Button
variant="outline"
size="lg"
className="w-full border-dashed"
onClick={handleNewChat}
size="lg"
variant="outline"
>
<Plus className="size-4 mr-1" /> New Conversation
</Button>
</DialogContent>
</Dialog>
<Button variant="outline" size="icon" onClick={handleNewChat}>
<Button onClick={handleNewChat} size="icon" variant="outline">
<Plus className="size-4 text-muted-foreground" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setIsOpen(false)}
size="icon"
variant="outline"
>
<X className="size-4 text-muted-foreground" />
</Button>

View file

@ -1,22 +1,22 @@
"use client"
"use client";
import { $fetch } from "@lib/api"
import { Button } from "@repo/ui/components/button"
import { Skeleton } from "@repo/ui/components/skeleton"
import type { ConnectionResponseSchema } from "@repo/validation/api"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { useCustomer } from "autumn-js/react"
import { Trash2 } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import type { z } from "zod"
import { analytics } from "@/lib/analytics"
import { useProject } from "@/stores"
import { $fetch } from "@lib/api";
import { Button } from "@repo/ui/components/button";
import { Skeleton } from "@repo/ui/components/skeleton";
import type { ConnectionResponseSchema } from "@repo/validation/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons";
import { useCustomer } from "autumn-js/react";
import { Trash2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import type { z } from "zod";
import { analytics } from "@/lib/analytics";
import { useProject } from "@/stores";
// Define types
type Connection = z.infer<typeof ConnectionResponseSchema>
type Connection = z.infer<typeof ConnectionResponseSchema>;
// Connector configurations
const CONNECTORS = {
@ -35,27 +35,27 @@ const CONNECTORS = {
description: "Access your Microsoft Office documents",
icon: OneDrive,
},
} as const
} as const;
type ConnectorProvider = keyof typeof CONNECTORS
type ConnectorProvider = keyof typeof CONNECTORS;
export function ConnectionsTabContent() {
const queryClient = useQueryClient()
const { selectedProject } = useProject()
const autumn = useCustomer()
const [isProUser, setIsProUser] = useState(false)
const queryClient = useQueryClient();
const { selectedProject } = useProject();
const autumn = useCustomer();
const [isProUser, setIsProUser] = useState(false);
const handleUpgrade = async () => {
try {
await autumn.attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/",
})
window.location.reload()
});
window.location.reload();
} catch (error) {
console.error(error)
console.error(error);
}
}
};
// Set pro user status when autumn data loads
useEffect(() => {
@ -64,16 +64,16 @@ export function ConnectionsTabContent() {
autumn.customer?.products.some(
(product) => product.id === "consumer_pro",
) ?? false,
)
);
}
}, [autumn.isLoading, autumn.customer])
}, [autumn.isLoading, autumn.customer]);
// Get connections data directly from autumn customer
const connectionsFeature = autumn.customer?.features?.connections
const connectionsUsed = connectionsFeature?.usage ?? 0
const connectionsLimit = connectionsFeature?.included_usage ?? 0
const connectionsFeature = autumn.customer?.features?.connections;
const connectionsUsed = connectionsFeature?.usage ?? 0;
const connectionsLimit = connectionsFeature?.included_usage ?? 0;
const canAddConnection = connectionsUsed < connectionsLimit
const canAddConnection = connectionsUsed < connectionsLimit;
// Fetch connections
const {
@ -87,26 +87,28 @@ export function ConnectionsTabContent() {
body: {
containerTags: [],
},
})
});
if (response.error) {
throw new Error(response.error?.message || "Failed to load connections")
throw new Error(
response.error?.message || "Failed to load connections",
);
}
return response.data as Connection[]
return response.data as Connection[];
},
staleTime: 30 * 1000,
refetchInterval: 60 * 1000,
})
});
// Show error toast if connections fail to load
useEffect(() => {
if (error) {
toast.error("Failed to load connections", {
description: error instanceof Error ? error.message : "Unknown error",
})
});
}
}, [error])
}, [error]);
// Add connection mutation
const addConnectionMutation = useMutation({
@ -115,7 +117,7 @@ export function ConnectionsTabContent() {
if (!canAddConnection && !isProUser) {
throw new Error(
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
)
);
}
const response = await $fetch("@post/connections/:provider", {
@ -124,61 +126,61 @@ export function ConnectionsTabContent() {
redirectUrl: window.location.href,
containerTags: [selectedProject],
},
})
});
// biome-ignore lint/style/noNonNullAssertion: its fine
if ("data" in response && !("error" in response.data!)) {
return response.data
return response.data;
}
throw new Error(response.error?.message || "Failed to connect")
throw new Error(response.error?.message || "Failed to connect");
},
onSuccess: (data, provider) => {
analytics.connectionAdded(provider)
analytics.connectionAuthStarted()
analytics.connectionAdded(provider);
analytics.connectionAuthStarted();
autumn.track({
featureId: "connections",
value: 1,
})
});
if (data?.authLink) {
window.location.href = data.authLink
window.location.href = data.authLink;
}
},
onError: (error, provider) => {
analytics.connectionAuthFailed()
analytics.connectionAuthFailed();
toast.error(`Failed to connect ${provider}`, {
description: error instanceof Error ? error.message : "Unknown error",
})
});
},
})
});
// Delete connection mutation
const deleteConnectionMutation = useMutation({
mutationFn: async (connectionId: string) => {
await $fetch(`@delete/connections/${connectionId}`)
await $fetch(`@delete/connections/${connectionId}`);
},
onSuccess: () => {
analytics.connectionDeleted()
analytics.connectionDeleted();
toast.success(
"Connection removal has started. supermemory will permanently delete the documents in the next few minutes.",
)
queryClient.invalidateQueries({ queryKey: ["connections"] })
);
queryClient.invalidateQueries({ queryKey: ["connections"] });
},
onError: (error) => {
toast.error("Failed to remove connection", {
description: error instanceof Error ? error.message : "Unknown error",
})
});
},
})
});
const getProviderIcon = (provider: string) => {
const connector = CONNECTORS[provider as ConnectorProvider]
const connector = CONNECTORS[provider as ConnectorProvider];
if (connector) {
const Icon = connector.icon
return <Icon className="h-10 w-10" />
const Icon = connector.icon;
return <Icon className="h-10 w-10" />;
}
return <span className="text-2xl">📎</span>
}
return <span className="text-2xl">📎</span>;
};
return (
<div className="space-y-4">
@ -310,7 +312,7 @@ export function ConnectionsTabContent() {
</h3>
<div className="grid gap-3">
{Object.entries(CONNECTORS).map(([provider, config], index) => {
const Icon = config.icon
const Icon = config.icon;
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
@ -324,7 +326,7 @@ export function ConnectionsTabContent() {
className="justify-start h-auto p-4 bg-white/5 hover:bg-white/10 border-white/10 text-white w-full"
disabled={addConnectionMutation.isPending}
onClick={() => {
addConnectionMutation.mutate(provider as ConnectorProvider)
addConnectionMutation.mutate(provider as ConnectorProvider);
}}
variant="outline"
>
@ -337,10 +339,10 @@ export function ConnectionsTabContent() {
</div>
</Button>
</motion.div>
)
);
})}
</div>
</div>
</div>
)
);
}

View file

@ -1,35 +1,35 @@
import { $fetch } from "@lib/api"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { generateId } from "@lib/generate-id"
import { $fetch } from "@lib/api";
import { authClient } from "@lib/auth";
import { useAuth } from "@lib/auth-context";
import { generateId } from "@lib/generate-id";
import {
ADD_MEMORY_SHORTCUT_URL,
SEARCH_MEMORY_SHORTCUT_URL,
} from "@repo/lib/constants"
import { fetchConnectionsFeature } from "@repo/lib/queries"
import { Button } from "@repo/ui/components/button"
} from "@repo/lib/constants";
import { fetchConnectionsFeature } from "@repo/lib/queries";
import { Button } from "@repo/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogPortal,
DialogTitle,
} from "@repo/ui/components/dialog"
import { Skeleton } from "@repo/ui/components/skeleton"
import type { ConnectionResponseSchema } from "@repo/validation/api"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { useCustomer } from "autumn-js/react"
import { Check, Copy, Smartphone, Trash2 } from "lucide-react"
import { motion } from "motion/react"
import Image from "next/image"
import { useEffect, useId, useState } from "react"
import { toast } from "sonner"
import type { z } from "zod"
import { analytics } from "@/lib/analytics"
import { useProject } from "@/stores"
} from "@repo/ui/components/dialog";
import { Skeleton } from "@repo/ui/components/skeleton";
import type { ConnectionResponseSchema } from "@repo/validation/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons";
import { useCustomer } from "autumn-js/react";
import { Check, Copy, Smartphone, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import Image from "next/image";
import { useEffect, useId, useState } from "react";
import { toast } from "sonner";
import type { z } from "zod";
import { analytics } from "@/lib/analytics";
import { useProject } from "@/stores";
type Connection = z.infer<typeof ConnectionResponseSchema>
type Connection = z.infer<typeof ConnectionResponseSchema>;
const CONNECTORS = {
"google-drive": {
@ -47,66 +47,66 @@ const CONNECTORS = {
description: "Access your Microsoft Office documents",
icon: OneDrive,
},
} as const
} as const;
type ConnectorProvider = keyof typeof CONNECTORS
type ConnectorProvider = keyof typeof CONNECTORS;
const ChromeIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
preserveAspectRatio="xMidYMid"
viewBox="0 0 190.5 190.5"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<title>Google Chrome Icon</title>
<path
fill="#fff"
d="M95.252 142.873c26.304 0 47.627-21.324 47.627-47.628s-21.323-47.628-47.627-47.628-47.627 21.324-47.627 47.628 21.323 47.628 47.627 47.628z"
fill="#fff"
/>
<path
fill="#229342"
d="m54.005 119.07-41.24-71.43a95.227 95.227 0 0 0-.003 95.25 95.234 95.234 0 0 0 82.496 47.61l41.24-71.43v-.011a47.613 47.613 0 0 1-17.428 17.443 47.62 47.62 0 0 1-47.632.007 47.62 47.62 0 0 1-17.433-17.437z"
fill="#229342"
/>
<path
fill="#fbc116"
d="m136.495 119.067-41.239 71.43a95.229 95.229 0 0 0 82.489-47.622A95.24 95.24 0 0 0 190.5 95.248a95.237 95.237 0 0 0-12.772-47.623H95.249l-.01.007a47.62 47.62 0 0 1 23.819 6.372 47.618 47.618 0 0 1 17.439 17.431 47.62 47.62 0 0 1-.001 47.633z"
fill="#fbc116"
/>
<path
fill="#1a73e8"
d="M95.252 132.961c20.824 0 37.705-16.881 37.705-37.706S116.076 57.55 95.252 57.55 57.547 74.431 57.547 95.255s16.881 37.706 37.705 37.706z"
fill="#1a73e8"
/>
<path
fill="#e33b2e"
d="M95.252 47.628h82.479A95.237 95.237 0 0 0 142.87 12.76 95.23 95.23 0 0 0 95.245 0a95.222 95.222 0 0 0-47.623 12.767 95.23 95.23 0 0 0-34.856 34.872l41.24 71.43.011.006a47.62 47.62 0 0 1-.015-47.633 47.61 47.61 0 0 1 41.252-23.815z"
fill="#e33b2e"
/>
</svg>
)
);
export function IntegrationsView() {
const { org } = useAuth()
const queryClient = useQueryClient()
const { selectedProject } = useProject()
const autumn = useCustomer()
const [showApiKeyModal, setShowApiKeyModal] = useState(false)
const [apiKey, setApiKey] = useState<string>("")
const [copied, setCopied] = useState(false)
const [isProUser, setIsProUser] = useState(false)
const { org } = useAuth();
const queryClient = useQueryClient();
const { selectedProject } = useProject();
const autumn = useCustomer();
const [showApiKeyModal, setShowApiKeyModal] = useState(false);
const [apiKey, setApiKey] = useState<string>("");
const [copied, setCopied] = useState(false);
const [isProUser, setIsProUser] = useState(false);
const [selectedShortcutType, setSelectedShortcutType] = useState<
"add" | "search" | null
>(null)
const apiKeyId = useId()
>(null);
const apiKeyId = useId();
const handleUpgrade = async () => {
try {
await autumn.attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/",
})
window.location.reload()
});
window.location.reload();
} catch (error) {
console.error(error)
console.error(error);
}
}
};
useEffect(() => {
if (!autumn.isLoading) {
@ -114,15 +114,15 @@ export function IntegrationsView() {
autumn.customer?.products.some(
(product) => product.id === "consumer_pro",
) ?? false,
)
);
}
}, [autumn.isLoading, autumn.customer])
}, [autumn.isLoading, autumn.customer]);
const { data: connectionsCheck } = fetchConnectionsFeature(autumn)
const connectionsUsed = connectionsCheck?.balance ?? 0
const connectionsLimit = connectionsCheck?.included_usage ?? 0
const { data: connectionsCheck } = fetchConnectionsFeature(autumn);
const connectionsUsed = connectionsCheck?.balance ?? 0;
const connectionsLimit = connectionsCheck?.included_usage ?? 0;
const canAddConnection = connectionsUsed < connectionsLimit
const canAddConnection = connectionsUsed < connectionsLimit;
const {
data: connections = [],
@ -135,17 +135,19 @@ export function IntegrationsView() {
body: {
containerTags: [],
},
})
});
if (response.error) {
throw new Error(response.error?.message || "Failed to load connections")
throw new Error(
response.error?.message || "Failed to load connections",
);
}
return response.data as Connection[]
return response.data as Connection[];
},
staleTime: 30 * 1000,
refetchInterval: 60 * 1000,
})
});
useEffect(() => {
if (connectionsError) {
@ -154,16 +156,16 @@ export function IntegrationsView() {
connectionsError instanceof Error
? connectionsError.message
: "Unknown error",
})
});
}
}, [connectionsError])
}, [connectionsError]);
const addConnectionMutation = useMutation({
mutationFn: async (provider: ConnectorProvider) => {
if (!canAddConnection && !isProUser) {
throw new Error(
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
)
);
}
const response = await $fetch("@post/connections/:provider", {
@ -172,47 +174,47 @@ export function IntegrationsView() {
redirectUrl: window.location.href,
containerTags: [selectedProject],
},
})
});
// biome-ignore lint/style/noNonNullAssertion: its fine
if ("data" in response && !("error" in response.data!)) {
return response.data
return response.data;
}
throw new Error(response.error?.message || "Failed to connect")
throw new Error(response.error?.message || "Failed to connect");
},
onSuccess: (data, provider) => {
analytics.connectionAdded(provider)
analytics.connectionAuthStarted()
analytics.connectionAdded(provider);
analytics.connectionAuthStarted();
if (data?.authLink) {
window.location.href = data.authLink
window.location.href = data.authLink;
}
},
onError: (error, provider) => {
analytics.connectionAuthFailed()
analytics.connectionAuthFailed();
toast.error(`Failed to connect ${provider}`, {
description: error instanceof Error ? error.message : "Unknown error",
})
});
},
})
});
const deleteConnectionMutation = useMutation({
mutationFn: async (connectionId: string) => {
await $fetch(`@delete/connections/${connectionId}`)
await $fetch(`@delete/connections/${connectionId}`);
},
onSuccess: () => {
analytics.connectionDeleted()
analytics.connectionDeleted();
toast.success(
"Connection removal has started. supermemory will permanently delete all documents related to the connection in the next few minutes.",
)
queryClient.invalidateQueries({ queryKey: ["connections"] })
);
queryClient.invalidateQueries({ queryKey: ["connections"] });
},
onError: (error) => {
toast.error("Failed to remove connection", {
description: error instanceof Error ? error.message : "Unknown error",
})
});
},
})
});
const createApiKeyMutation = useMutation({
mutationFn: async () => {
@ -223,60 +225,60 @@ export function IntegrationsView() {
},
name: `ios-${generateId().slice(0, 8)}`,
prefix: `sm_${org?.id}_`,
})
return res.key
});
return res.key;
},
onSuccess: (apiKey) => {
setApiKey(apiKey)
setShowApiKeyModal(true)
setCopied(false)
handleCopyApiKey()
setApiKey(apiKey);
setShowApiKeyModal(true);
setCopied(false);
handleCopyApiKey();
},
onError: (error) => {
toast.error("Failed to create API key", {
description: error instanceof Error ? error.message : "Unknown error",
})
});
},
})
});
const handleShortcutClick = (shortcutType: "add" | "search") => {
setSelectedShortcutType(shortcutType)
createApiKeyMutation.mutate()
}
setSelectedShortcutType(shortcutType);
createApiKeyMutation.mutate();
};
const handleCopyApiKey = async () => {
try {
await navigator.clipboard.writeText(apiKey)
setCopied(true)
toast.success("API key copied to clipboard!")
setTimeout(() => setCopied(false), 2000)
await navigator.clipboard.writeText(apiKey);
setCopied(true);
toast.success("API key copied to clipboard!");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy API key")
toast.error("Failed to copy API key");
}
}
};
const handleOpenShortcut = () => {
if (!selectedShortcutType) {
toast.error("No shortcut type selected")
return
toast.error("No shortcut type selected");
return;
}
if (selectedShortcutType === "add") {
window.open(ADD_MEMORY_SHORTCUT_URL, "_blank")
window.open(ADD_MEMORY_SHORTCUT_URL, "_blank");
} else if (selectedShortcutType === "search") {
window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank")
window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank");
}
}
};
const handleDialogClose = (open: boolean) => {
setShowApiKeyModal(open)
setShowApiKeyModal(open);
if (!open) {
// Reset state when dialog closes
setSelectedShortcutType(null)
setApiKey("")
setCopied(false)
setSelectedShortcutType(null);
setApiKey("");
setCopied(false);
}
}
};
return (
<div className="space-y-4 sm:space-y-4 custom-scrollbar">
@ -298,32 +300,32 @@ export function IntegrationsView() {
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<Button
variant="ghost"
className="flex-1 text-white hover:bg-blue-500/10 bg-[#171F59]/75 "
onClick={() => handleShortcutClick("add")}
disabled={createApiKeyMutation.isPending}
onClick={() => handleShortcutClick("add")}
variant="ghost"
>
<Image
src="/images/ios-shortcuts.png"
alt="iOS Shortcuts"
width={20}
height={20}
src="/images/ios-shortcuts.png"
width={20}
/>
{createApiKeyMutation.isPending
? "Creating..."
: "Add Memory Shortcut"}
</Button>
<Button
variant="ghost"
className="flex-1 text-white hover:bg-blue-500/10 bg-[#171F59]/75"
onClick={() => handleShortcutClick("search")}
disabled={createApiKeyMutation.isPending}
onClick={() => handleShortcutClick("search")}
variant="ghost"
>
<Image
src="/images/ios-shortcuts.png"
alt="iOS Shortcuts"
width={20}
height={20}
src="/images/ios-shortcuts.png"
width={20}
/>
{createApiKeyMutation.isPending
? "Creating..."
@ -352,8 +354,8 @@ export function IntegrationsView() {
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
"_blank",
"noopener,noreferrer",
)
analytics.extensionInstallClicked()
);
analytics.extensionInstallClicked();
}}
size="sm"
variant="ghost"
@ -465,11 +467,11 @@ export function IntegrationsView() {
) : (
<div className="space-y-2">
{Object.entries(CONNECTORS).map(([provider, config], index) => {
const Icon = config.icon
const Icon = config.icon;
const connection = connections.find(
(conn) => conn.provider === provider,
)
const isConnected = !!connection
);
const isConnected = !!connection;
return (
<motion.div
@ -548,9 +550,9 @@ export function IntegrationsView() {
</span>
</div>
<motion.div
className="flex-shrink-0"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="flex-shrink-0"
>
<Button
className="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border-blue-600/30 min-w-[80px] disabled:cursor-not-allowed"
@ -560,7 +562,7 @@ export function IntegrationsView() {
onClick={() => {
addConnectionMutation.mutate(
provider as ConnectorProvider,
)
);
}}
size="sm"
variant="outline"
@ -575,7 +577,7 @@ export function IntegrationsView() {
)}
</div>
</motion.div>
)
);
})}
</div>
)}
@ -587,10 +589,10 @@ export function IntegrationsView() {
More integrations are coming soon! Have a suggestion? Share it with us
on{" "}
<a
href="https://x.com/supermemoryai"
target="_blank"
rel="noopener noreferrer"
className="text-orange-500 hover:text-orange-400 underline"
href="https://x.com/supermemoryai"
rel="noopener noreferrer"
target="_blank"
>
X
</a>
@ -599,7 +601,7 @@ export function IntegrationsView() {
</div>
{/* API Key Modal */}
<Dialog open={showApiKeyModal} onOpenChange={handleDialogClose}>
<Dialog onOpenChange={handleDialogClose} open={showApiKeyModal}>
<DialogPortal>
<DialogContent className="bg-[#0f1419] border-white/10 text-white md:max-w-md z-[100]">
<DialogHeader>
@ -618,24 +620,24 @@ export function IntegrationsView() {
{/* API Key Section */}
<div className="space-y-2">
<label
htmlFor={apiKeyId}
className="text-sm font-medium text-white/80"
htmlFor={apiKeyId}
>
Your API Key
</label>
<div className="flex items-center gap-2">
<input
className="flex-1 bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-sm text-white font-mono"
id={apiKeyId}
readOnly
type="text"
value={apiKey}
readOnly
className="flex-1 bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-sm text-white font-mono"
/>
<Button
className="text-white/70 hover:text-white hover:bg-white/10"
onClick={handleCopyApiKey}
size="sm"
variant="ghost"
onClick={handleCopyApiKey}
className="text-white/70 hover:text-white hover:bg-white/10"
>
{copied ? (
<Check className="h-4 w-4 text-green-400" />
@ -681,16 +683,16 @@ export function IntegrationsView() {
<div className="flex gap-2 pt-2">
<Button
onClick={handleOpenShortcut}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
disabled={!selectedShortcutType}
onClick={handleOpenShortcut}
>
<Image
src="/images/ios-shortcuts.png"
alt="iOS Shortcuts"
width={16}
height={16}
className="mr-2"
height={16}
src="/images/ios-shortcuts.png"
width={16}
/>
Add to Shortcuts
</Button>
@ -700,5 +702,5 @@ export function IntegrationsView() {
</DialogPortal>
</Dialog>
</div>
)
);
}

View file

@ -1,9 +1,9 @@
import { $fetch } from "@lib/api"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { useForm } from "@tanstack/react-form"
import { useMutation } from "@tanstack/react-query"
import { Button } from "@ui/components/button"
import { $fetch } from "@lib/api";
import { authClient } from "@lib/auth";
import { useAuth } from "@lib/auth-context";
import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import {
Dialog,
DialogContent,
@ -11,18 +11,18 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/components/dialog"
import { Input } from "@ui/components/input"
import { CopyableCell } from "@ui/copyable-cell"
import { Loader2 } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import Image from "next/image"
import { generateSlug } from "random-word-slugs"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { z } from "zod/v4"
import { analytics } from "@/lib/analytics"
import { InstallationDialogContent } from "./installation-dialog-content"
} from "@ui/components/dialog";
import { Input } from "@ui/components/input";
import { CopyableCell } from "@ui/copyable-cell";
import { Loader2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { generateSlug } from "random-word-slugs";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { z } from "zod/v4";
import { analytics } from "@/lib/analytics";
import { InstallationDialogContent } from "./installation-dialog-content";
// Validation schemas
const mcpMigrationSchema = z.object({
@ -33,56 +33,56 @@ const mcpMigrationSchema = z.object({
/^https:\/\/mcp\.supermemory\.ai\/[^/]+\/sse$/,
"Link must be in format: https://mcp.supermemory.ai/userId/sse",
),
})
});
export function MCPView() {
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false)
const projectId = localStorage.getItem("selectedProject") ?? "default"
const { org } = useAuth()
const [apiKey, setApiKey] = useState<string>()
const [isInstallDialogOpen, setIsInstallDialogOpen] = useState(false)
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false);
const projectId = localStorage.getItem("selectedProject") ?? "default";
const { org } = useAuth();
const [apiKey, setApiKey] = useState<string>();
const [isInstallDialogOpen, setIsInstallDialogOpen] = useState(false);
useEffect(() => {
analytics.mcpViewOpened()
}, [])
analytics.mcpViewOpened();
}, []);
const apiKeyMutation = useMutation({
mutationFn: async () => {
if (apiKey) return apiKey
if (apiKey) return apiKey;
const res = await authClient.apiKey.create({
metadata: {
organizationId: org?.id,
},
name: generateSlug(),
prefix: `sm_${org?.id}_`,
})
return res.key
});
return res.key;
},
onSuccess: (data) => {
setApiKey(data)
setIsInstallDialogOpen(true)
setApiKey(data);
setIsInstallDialogOpen(true);
},
})
});
// Form for MCP migration
const mcpMigrationForm = useForm({
defaultValues: { url: "" },
onSubmit: async ({ value, formApi }) => {
const userId = extractUserIdFromMCPUrl(value.url)
const userId = extractUserIdFromMCPUrl(value.url);
if (userId) {
migrateMCPMutation.mutate({ userId, projectId })
formApi.reset()
migrateMCPMutation.mutate({ userId, projectId });
formApi.reset();
}
},
validators: {
onChange: mcpMigrationSchema,
},
})
});
const extractUserIdFromMCPUrl = (url: string): string | null => {
const regex = /^https:\/\/mcp\.supermemory\.ai\/([^/]+)\/sse$/
const match = url.trim().match(regex)
return match?.[1] || null
}
const regex = /^https:\/\/mcp\.supermemory\.ai\/([^/]+)\/sse$/;
const match = url.trim().match(regex);
return match?.[1] || null;
};
// Migrate MCP mutation
const migrateMCPMutation = useMutation({
@ -90,33 +90,33 @@ export function MCPView() {
userId,
projectId,
}: {
userId: string
projectId: string
userId: string;
projectId: string;
}) => {
const response = await $fetch("@post/documents/migrate-mcp", {
body: { userId, projectId },
})
});
if (response.error) {
throw new Error(
response.error?.message || "Failed to migrate documents",
)
);
}
return response.data
return response.data;
},
onSuccess: (data) => {
toast.success("Migration completed!", {
description: `Successfully migrated ${data?.migratedCount} documents`,
})
setIsMigrateDialogOpen(false)
});
setIsMigrateDialogOpen(false);
},
onError: (error) => {
toast.error("Migration failed", {
description: error instanceof Error ? error.message : "Unknown error",
})
});
},
})
});
return (
<div className="space-y-6">
@ -155,9 +155,9 @@ export function MCPView() {
<Button
disabled={apiKeyMutation.isPending}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
apiKeyMutation.mutate()
e.preventDefault();
e.stopPropagation();
apiKeyMutation.mutate();
}}
>
Install Now
@ -213,9 +213,9 @@ export function MCPView() {
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
mcpMigrationForm.handleSubmit()
e.preventDefault();
e.stopPropagation();
mcpMigrationForm.handleSubmit();
}}
>
<div className="grid gap-4">
@ -268,8 +268,8 @@ export function MCPView() {
<Button
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
onClick={() => {
setIsMigrateDialogOpen(false)
mcpMigrationForm.reset()
setIsMigrateDialogOpen(false);
mcpMigrationForm.reset();
}}
type="button"
variant="outline"
@ -307,5 +307,5 @@ export function MCPView() {
)}
</AnimatePresence>
</div>
)
);
}

View file

@ -1,3 +1,5 @@
import { $fetch } from "@repo/lib/api";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import {
DialogContent,
@ -6,6 +8,7 @@ import {
DialogTitle,
} from "@ui/components/dialog";
import { Input } from "@ui/components/input";
import { Label } from "@ui/components/label";
import {
Select,
SelectContent,
@ -13,13 +16,10 @@ import {
SelectTrigger,
SelectValue,
} from "@ui/components/select";
import { Label } from "@ui/components/label";
import { CopyIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { analytics } from "@/lib/analytics";
import { $fetch } from "@repo/lib/api";
import { useQuery } from "@tanstack/react-query";
const clients = {
cursor: "Cursor",
@ -67,7 +67,7 @@ export function InstallationDialogContent() {
if (selectedProject && selectedProject !== "none") {
// Remove the "sm_project_" prefix from the containerTag
const projectId = selectedProject.replace(/^sm_project_/, '');
const projectId = selectedProject.replace(/^sm_project_/, "");
command += ` --project ${projectId}`;
}
@ -79,8 +79,8 @@ export function InstallationDialogContent() {
<DialogHeader>
<DialogTitle>Install the supermemory MCP Server</DialogTitle>
<DialogDescription>
Select the app and project you want to install supermemory MCP to, then run the
following command:
Select the app and project you want to install supermemory MCP to,
then run the following command:
</DialogDescription>
</DialogHeader>
@ -91,7 +91,7 @@ export function InstallationDialogContent() {
onValueChange={(value) => setClient(value as keyof typeof clients)}
value={client}
>
<SelectTrigger id="client-select" className="w-full">
<SelectTrigger className="w-full" id="client-select">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
@ -107,27 +107,30 @@ export function InstallationDialogContent() {
<div className="space-y-2">
<Label htmlFor="project-select">Target Project (Optional)</Label>
<Select
disabled={isLoadingProjects}
onValueChange={setSelectedProject}
value={selectedProject || "none"}
disabled={isLoadingProjects}
>
<SelectTrigger id="project-select" className="w-full">
<SelectTrigger className="w-full" id="project-select">
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
<SelectItem value="none" className="text-white hover:bg-white/10">
<SelectItem className="text-white hover:bg-white/10" value="none">
Auto-select project
</SelectItem>
<SelectItem value="sm_project_default" className="text-white hover:bg-white/10">
<SelectItem
className="text-white hover:bg-white/10"
value="sm_project_default"
>
Default Project
</SelectItem>
{projects
.filter((p: Project) => p.containerTag !== "sm_project_default")
.map((project: Project) => (
<SelectItem
className="text-white hover:bg-white/10"
key={project.id}
value={project.containerTag}
className="text-white hover:bg-white/10"
>
{project.name}
</SelectItem>
@ -139,8 +142,8 @@ export function InstallationDialogContent() {
<div className="space-y-2">
<Label htmlFor="command-input">Installation Command</Label>
<Input
id="command-input"
className="font-mono text-xs!"
id="command-input"
readOnly
value={generateInstallCommand()}
/>

View file

@ -1,10 +1,11 @@
"use client"
"use client";
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { Button } from "@repo/ui/components/button"
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"
import { useCustomer } from "autumn-js/react"
import { $fetch } from "@lib/api";
import { authClient } from "@lib/auth";
import { useAuth } from "@lib/auth-context";
import { Button } from "@repo/ui/components/button";
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold";
import { useCustomer } from "autumn-js/react";
import {
CheckCircle,
CreditCard,
@ -12,48 +13,47 @@ import {
LogOut,
User,
X,
} from "lucide-react"
import { motion } from "motion/react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import { analytics } from "@/lib/analytics"
import { $fetch } from "@lib/api"
} from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { analytics } from "@/lib/analytics";
export function ProfileView() {
const router = useRouter()
const { user: session } = useAuth()
const router = useRouter();
const { user: session } = useAuth();
const {
customer,
isLoading: isCustomerLoading,
openBillingPortal,
attach,
} = useCustomer()
const [isLoading, setIsLoading] = useState(false)
} = useCustomer();
const [isLoading, setIsLoading] = useState(false);
const [billingData, setBillingData] = useState<{
isPro: boolean
memoriesUsed: number
memoriesLimit: number
connectionsUsed: number
connectionsLimit: number
isPro: boolean;
memoriesUsed: number;
memoriesLimit: number;
connectionsUsed: number;
connectionsLimit: number;
}>({
isPro: false,
memoriesUsed: 0,
memoriesLimit: 0,
connectionsUsed: 0,
connectionsLimit: 0,
})
});
useEffect(() => {
if (!isCustomerLoading) {
const memoriesFeature = customer?.features?.memories ?? {
usage: 0,
included_usage: 0,
}
};
const connectionsFeature = customer?.features?.connections ?? {
usage: 0,
included_usage: 0,
}
};
setBillingData({
isPro:
@ -64,30 +64,30 @@ export function ProfileView() {
memoriesLimit: memoriesFeature?.included_usage ?? 0,
connectionsUsed: connectionsFeature?.usage ?? 0,
connectionsLimit: connectionsFeature?.included_usage ?? 0,
})
});
}
}, [isCustomerLoading, customer])
}, [isCustomerLoading, customer]);
const handleLogout = () => {
analytics.userSignedOut()
authClient.signOut()
router.push("/login")
}
analytics.userSignedOut();
authClient.signOut();
router.push("/login");
};
const handleUpgrade = async () => {
setIsLoading(true)
setIsLoading(true);
try {
const upgradeResult = await attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/",
})
});
if (
upgradeResult.statusCode === 200 &&
upgradeResult.data &&
"code" in upgradeResult.data
) {
const isProPlanActivated =
upgradeResult.data.code === "new_product_attached"
upgradeResult.data.code === "new_product_attached";
if (isProPlanActivated && session?.name && session?.email) {
try {
await $fetch("@post/emails/welcome/pro", {
@ -95,24 +95,24 @@ export function ProfileView() {
email: session?.email,
firstName: session?.name,
},
})
});
} catch (error) {
console.error(error)
console.error(error);
}
}
}
} catch (error) {
console.error(error)
setIsLoading(false)
console.error(error);
setIsLoading(false);
}
}
};
// Handle manage billing
const handleManageBilling = async () => {
await openBillingPortal({
returnUrl: "https://app.supermemory.ai",
})
}
});
};
if (session?.isAnonymous) {
return (
@ -137,7 +137,7 @@ export function ProfileView() {
</motion.div>
</motion.div>
</div>
)
);
}
return (
@ -337,5 +337,5 @@ export function ProfileView() {
Sign Out
</Button>
</div>
)
);
}

View file

@ -2,29 +2,29 @@
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: 'https://2451ebfd1a7490f05fa7776482df81b6@o4508385422802944.ingest.us.sentry.io/4509872269819904',
dsn: "https://2451ebfd1a7490f05fa7776482df81b6@o4508385422802944.ingest.us.sentry.io/4509872269819904",
// Add optional integrations for additional features
integrations: [Sentry.replayIntegration()],
// Add optional integrations for additional features
integrations: [Sentry.replayIntegration()],
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// Define how likely Replay events are sampled when an error occurs.
replaysOnErrorSampleRate: 1.0,
// Define how likely Replay events are sampled when an error occurs.
replaysOnErrorSampleRate: 1.0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

View file

@ -1,4 +1,4 @@
import posthog from "posthog-js"
import posthog from "posthog-js";
export const analytics = {
userSignedOut: () => posthog.capture("user_signed_out"),
@ -7,11 +7,11 @@ export const analytics = {
tourSkipped: () => posthog.capture("tour_skipped"),
memoryAdded: (props: {
type: "note" | "link" | "file"
project_id?: string
content_length?: number
file_size?: number
file_type?: string
type: "note" | "link" | "file";
project_id?: string;
content_length?: number;
file_size?: number;
file_type?: string;
}) => posthog.capture("memory_added", props),
memoryDetailOpened: () => posthog.capture("memory_detail_opened"),
@ -43,4 +43,4 @@ export const analytics = {
mcpInstallCmdCopied: () => posthog.capture("mcp_install_cmd_copied"),
extensionInstallClicked: () => posthog.capture("extension_install_clicked"),
}
};

View file

@ -1,54 +1,54 @@
import { colors } from '@repo/ui/memory-graph/constants';
import { colors } from "@repo/ui/memory-graph/constants";
import {
GoogleDocs,
MicrosoftWord,
NotionDoc,
GoogleDrive,
GoogleSheets,
GoogleSlides,
PDF,
OneDrive,
MicrosoftOneNote,
MicrosoftPowerpoint,
MicrosoftExcel,
} from '@ui/assets/icons';
import { FileText } from 'lucide-react';
GoogleDocs,
GoogleDrive,
GoogleSheets,
GoogleSlides,
MicrosoftExcel,
MicrosoftOneNote,
MicrosoftPowerpoint,
MicrosoftWord,
NotionDoc,
OneDrive,
PDF,
} from "@ui/assets/icons";
import { FileText } from "lucide-react";
export const getDocumentIcon = (type: string, className: string) => {
const iconProps = {
className,
style: { color: colors.text.muted },
};
const iconProps = {
className,
style: { color: colors.text.muted },
};
switch (type) {
case 'google_doc':
return <GoogleDocs {...iconProps} />;
case 'google_sheet':
return <GoogleSheets {...iconProps} />;
case 'google_slide':
return <GoogleSlides {...iconProps} />;
case 'google_drive':
return <GoogleDrive {...iconProps} />;
case 'notion':
case 'notion_doc':
return <NotionDoc {...iconProps} />;
case 'word':
case 'microsoft_word':
return <MicrosoftWord {...iconProps} />;
case 'excel':
case 'microsoft_excel':
return <MicrosoftExcel {...iconProps} />;
case 'powerpoint':
case 'microsoft_powerpoint':
return <MicrosoftPowerpoint {...iconProps} />;
case 'onenote':
case 'microsoft_onenote':
return <MicrosoftOneNote {...iconProps} />;
case 'onedrive':
return <OneDrive {...iconProps} />;
case 'pdf':
return <PDF {...iconProps} />;
default:
return <FileText {...iconProps} />;
}
switch (type) {
case "google_doc":
return <GoogleDocs {...iconProps} />;
case "google_sheet":
return <GoogleSheets {...iconProps} />;
case "google_slide":
return <GoogleSlides {...iconProps} />;
case "google_drive":
return <GoogleDrive {...iconProps} />;
case "notion":
case "notion_doc":
return <NotionDoc {...iconProps} />;
case "word":
case "microsoft_word":
return <MicrosoftWord {...iconProps} />;
case "excel":
case "microsoft_excel":
return <MicrosoftExcel {...iconProps} />;
case "powerpoint":
case "microsoft_powerpoint":
return <MicrosoftPowerpoint {...iconProps} />;
case "onenote":
case "microsoft_onenote":
return <MicrosoftOneNote {...iconProps} />;
case "onedrive":
return <OneDrive {...iconProps} />;
case "pdf":
return <PDF {...iconProps} />;
default:
return <FileText {...iconProps} />;
}
};

View file

@ -1,30 +1,30 @@
import { getSessionCookie } from "better-auth/cookies"
import { NextResponse } from "next/server"
import { getSessionCookie } from "better-auth/cookies";
import { NextResponse } from "next/server";
export default async function middleware(request: Request) {
console.debug("[MIDDLEWARE] === MIDDLEWARE START ===")
const url = new URL(request.url)
console.debug("[MIDDLEWARE] Path:", url.pathname)
console.debug("[MIDDLEWARE] Method:", request.method)
console.debug("[MIDDLEWARE] === MIDDLEWARE START ===");
const url = new URL(request.url);
console.debug("[MIDDLEWARE] Path:", url.pathname);
console.debug("[MIDDLEWARE] Method:", request.method);
const sessionCookie = getSessionCookie(request)
console.debug("[MIDDLEWARE] Session cookie exists:", !!sessionCookie)
const sessionCookie = getSessionCookie(request);
console.debug("[MIDDLEWARE] Session cookie exists:", !!sessionCookie);
// Always allow access to login and waitlist pages
const publicPaths = ["/login"]
const publicPaths = ["/login"];
if (publicPaths.includes(url.pathname)) {
console.debug("[MIDDLEWARE] Public path, allowing access")
return NextResponse.next()
console.debug("[MIDDLEWARE] Public path, allowing access");
return NextResponse.next();
}
// If no session cookie and not on a public path, redirect to login
if (!sessionCookie) {
console.debug(
"[MIDDLEWARE] No session cookie and not on public path, redirecting to /login",
)
const url = new URL("/login", request.url)
url.searchParams.set("redirect", request.url)
return NextResponse.redirect(url)
);
const url = new URL("/login", request.url);
url.searchParams.set("redirect", request.url);
return NextResponse.redirect(url);
}
// TEMPORARILY DISABLED: Waitlist check
@ -40,19 +40,19 @@ export default async function middleware(request: Request) {
// }
// }
console.debug("[MIDDLEWARE] Passing through to next handler")
console.debug("[MIDDLEWARE] === MIDDLEWARE END ===")
const response = NextResponse.next()
console.debug("[MIDDLEWARE] Passing through to next handler");
console.debug("[MIDDLEWARE] === MIDDLEWARE END ===");
const response = NextResponse.next();
response.cookies.set({
name: "last-site-visited",
value: "https://app.supermemory.ai",
domain: "supermemory.ai",
})
return response
});
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|ingest|api|login|api/emails).*)",
],
}
};

View file

@ -2,56 +2,56 @@ import { withSentryConfig } from "@sentry/nextjs";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
reactCompiler: true,
viewTransition: true,
},
eslint: {
ignoreDuringBuilds: true,
},
poweredByHeader: false,
async rewrites() {
return [
{
source: "/ingest/static/:path*",
destination: "https://us-assets.i.posthog.com/static/:path*",
},
{
source: "/ingest/:path*",
destination: "https://us.i.posthog.com/:path*",
},
];
},
skipTrailingSlashRedirect: true,
experimental: {
reactCompiler: true,
viewTransition: true,
},
eslint: {
ignoreDuringBuilds: true,
},
poweredByHeader: false,
async rewrites() {
return [
{
source: "/ingest/static/:path*",
destination: "https://us-assets.i.posthog.com/static/:path*",
},
{
source: "/ingest/:path*",
destination: "https://us.i.posthog.com/:path*",
},
];
},
skipTrailingSlashRedirect: true,
};
export default withSentryConfig(nextConfig, {
// For all available options, see:
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: "supermemory",
project: "consumer-app",
project: "consumer-app",
// Only print logs for uploading source maps in CI
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: "/monitoring",
// Automatically tree-shake Sentry logger statements to reduce bundle size
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
@ -60,4 +60,4 @@ export default withSentryConfig(nextConfig, {
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
initOpenNextCloudflareForDev();
initOpenNextCloudflareForDev();

View file

@ -1,20 +1,20 @@
{
"compilerOptions": {
"incremental": true,
"jsx": "preserve",
"paths": {
"@/*": ["./*"],
"@ui/*": ["../../packages/ui/*"],
"@lib/*": ["../../packages/lib/*"],
"@hooks/*": ["../../packages/hooks/*"]
},
"plugins": [
{
"name": "next"
}
]
},
"exclude": ["node_modules"],
"extends": "@total-typescript/tsconfig/bundler/dom/app",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
"compilerOptions": {
"incremental": true,
"jsx": "preserve",
"paths": {
"@/*": ["./*"],
"@ui/*": ["../../packages/ui/*"],
"@lib/*": ["../../packages/lib/*"],
"@hooks/*": ["../../packages/hooks/*"]
},
"plugins": [
{
"name": "next"
}
]
},
"exclude": ["node_modules"],
"extends": "@total-typescript/tsconfig/bundler/dom/app",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
}

View file

@ -1,31 +1,31 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "supermemory-app",
"compatibility_date": "2024-12-30",
"compatibility_flags": [
// Enable Node.js API
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#nodejs-compatibility-flag
"nodejs_compat",
// Allow to fetch URLs in your app
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
"global_fetch_strictly_public",
],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS",
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
// The service should match the "name" of your worker
"service": "supermemory-app",
},
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "supermemory-console-cache",
},
],
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "supermemory-app",
"compatibility_date": "2024-12-30",
"compatibility_flags": [
// Enable Node.js API
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#nodejs-compatibility-flag
"nodejs_compat",
// Allow to fetch URLs in your app
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
"global_fetch_strictly_public"
],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
// The service should match the "name" of your worker
"service": "supermemory-app"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "supermemory-console-cache"
}
]
}

View file

@ -26,6 +26,7 @@
"drizzle-zod": "~0.7.1",
"file-type": "^21.0.0",
"hono-openapi": "^0.4.8",
"iconsax-reactjs": "^0.0.8",
"nanoid": "^5.1.5",
"neverthrow": "^8.2.0",
"pg": "^8.16.3",
@ -2488,6 +2489,8 @@
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"iconsax-reactjs": ["iconsax-reactjs@0.0.8", "", { "peerDependencies": { "react": "*" } }, "sha512-cb+uTMxbkSFNbu8ZclX7BWQVfOWQt8+m/PsDjnsm/H+mcYrnfTYMjHxiof1FB43k7UAgt1ds+0oFeMVKdqyslw=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],

View file

@ -40,7 +40,7 @@
"drizzle-zod": "~0.7.1",
"file-type": "^21.0.0",
"hono-openapi": "^0.4.8",
"iconsax-reactjs": "^0.0.8",
"nanoid": "^5.1.5",
"neverthrow": "^8.2.0",
"pg": "^8.16.3",

View file

@ -1,27 +1,29 @@
import { createOpenAI } from "@ai-sdk/openai"
import { generateText } from "ai"
import { describe, expect, it } from "vitest"
import { type SupermemoryToolsConfig, supermemoryTools } from "./tools"
import { createOpenAI } from "@ai-sdk/openai";
import { generateText } from "ai";
import { describe, expect, it } from "vitest";
import { type SupermemoryToolsConfig, supermemoryTools } from "./tools";
import "dotenv/config"
import "dotenv/config";
describe("supermemoryTools", () => {
// Required API keys - tests will fail if not provided
const testApiKey = process.env.SUPERMEMORY_API_KEY
const testOpenAIKey = process.env.OPENAI_API_KEY
const testApiKey = process.env.SUPERMEMORY_API_KEY;
const testOpenAIKey = process.env.OPENAI_API_KEY;
if (!testApiKey) {
throw new Error(
"SUPERMEMORY_API_KEY environment variable is required for tests",
)
);
}
if (!testOpenAIKey) {
throw new Error("OPENAI_API_KEY environment variable is required for tests")
throw new Error(
"OPENAI_API_KEY environment variable is required for tests",
);
}
// Optional configuration with defaults
const testBaseUrl = process.env.SUPERMEMORY_BASE_URL ?? undefined
const testModelName = process.env.MODEL_NAME || "gpt-5-mini"
const testBaseUrl = process.env.SUPERMEMORY_BASE_URL ?? undefined;
const testModelName = process.env.MODEL_NAME || "gpt-5-mini";
const testPrompts = [
"What do you remember about my preferences?",
@ -29,57 +31,57 @@ describe("supermemoryTools", () => {
"What are my current projects?",
"Remind me of my interests and hobbies",
"What should I focus on today?",
]
];
describe("client initialization", () => {
it("should create tools with default configuration", () => {
const config: SupermemoryToolsConfig = {}
const tools = supermemoryTools(testApiKey, config)
const config: SupermemoryToolsConfig = {};
const tools = supermemoryTools(testApiKey, config);
expect(tools).toBeDefined()
expect(tools.searchMemories).toBeDefined()
expect(tools.addMemory).toBeDefined()
})
expect(tools).toBeDefined();
expect(tools.searchMemories).toBeDefined();
expect(tools.addMemory).toBeDefined();
});
it("should create tools with custom baseUrl", () => {
const config: SupermemoryToolsConfig = {
baseUrl: testBaseUrl,
}
const tools = supermemoryTools(testApiKey, config)
};
const tools = supermemoryTools(testApiKey, config);
expect(tools).toBeDefined()
expect(tools.searchMemories).toBeDefined()
expect(tools.addMemory).toBeDefined()
})
expect(tools).toBeDefined();
expect(tools.searchMemories).toBeDefined();
expect(tools.addMemory).toBeDefined();
});
it("should create tools with projectId configuration", () => {
const config: SupermemoryToolsConfig = {
projectId: "test-project-123",
}
const tools = supermemoryTools(testApiKey, config)
};
const tools = supermemoryTools(testApiKey, config);
expect(tools).toBeDefined()
expect(tools.searchMemories).toBeDefined()
expect(tools.addMemory).toBeDefined()
})
expect(tools).toBeDefined();
expect(tools.searchMemories).toBeDefined();
expect(tools.addMemory).toBeDefined();
});
it("should create tools with custom container tags", () => {
const config: SupermemoryToolsConfig = {
containerTags: ["custom-tag-1", "custom-tag-2"],
}
const tools = supermemoryTools(testApiKey, config)
};
const tools = supermemoryTools(testApiKey, config);
expect(tools).toBeDefined()
expect(tools.searchMemories).toBeDefined()
expect(tools.addMemory).toBeDefined()
})
})
expect(tools).toBeDefined();
expect(tools.searchMemories).toBeDefined();
expect(tools.addMemory).toBeDefined();
});
});
describe("AI SDK integration", () => {
it("should work with AI SDK generateText", async () => {
const openai = createOpenAI({
apiKey: testOpenAIKey,
})
});
const result = await generateText({
model: openai(testModelName),
@ -100,22 +102,22 @@ describe("supermemoryTools", () => {
baseUrl: testBaseUrl,
}),
},
})
});
expect(result).toBeDefined()
expect(result.text).toBeDefined()
expect(typeof result.text).toBe("string")
})
expect(result).toBeDefined();
expect(result.text).toBeDefined();
expect(typeof result.text).toBe("string");
});
it("should use tools when prompted", async () => {
const openai = createOpenAI({
apiKey: testOpenAIKey,
})
});
const tools = supermemoryTools(testApiKey, {
projectId: "test-tool-usage",
baseUrl: testBaseUrl,
})
});
const result = await generateText({
model: openai(testModelName),
@ -133,24 +135,24 @@ describe("supermemoryTools", () => {
tools: {
addMemory: tools.addMemory,
},
})
});
expect(result).toBeDefined()
expect(result.text).toBeDefined()
expect(result.toolCalls).toBeDefined()
expect(result).toBeDefined();
expect(result.text).toBeDefined();
expect(result.toolCalls).toBeDefined();
if (result.toolCalls && result.toolCalls.length > 0) {
const addMemoryCall = result.toolCalls.find(
(call) => call.toolName === "addMemory",
)
expect(addMemoryCall).toBeDefined()
);
expect(addMemoryCall).toBeDefined();
}
})
});
it("should handle multiple tool types", async () => {
const openai = createOpenAI({
apiKey: testOpenAIKey,
})
});
const result = await generateText({
model: openai(testModelName),
@ -171,11 +173,11 @@ describe("supermemoryTools", () => {
containerTags: ["test-multi-tools"],
}),
},
})
});
expect(result).toBeDefined()
expect(result.text).toBeDefined()
expect(typeof result.text).toBe("string")
})
})
})
expect(result).toBeDefined();
expect(result.text).toBeDefined();
expect(typeof result.text).toBe("string");
});
});
});

View file

@ -1,15 +1,15 @@
import { tool } from "ai"
import Supermemory from "supermemory"
import { z } from "zod"
import { tool } from "ai";
import Supermemory from "supermemory";
import { z } from "zod";
/**
* Supermemory configuration
* Only one of `projectId` or `containerTags` can be provided.
*/
export interface SupermemoryToolsConfig {
baseUrl?: string
containerTags?: string[]
projectId?: string
baseUrl?: string;
containerTags?: string[];
projectId?: string;
}
/**
@ -22,11 +22,11 @@ export function supermemoryTools(
const client = new Supermemory({
apiKey,
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
})
});
const containerTags = config?.projectId
? [`sm_project_${config?.projectId}`]
: (config?.containerTags ?? ["sm_project_default"])
: (config?.containerTags ?? ["sm_project_default"]);
const searchMemories = tool({
description:
@ -60,21 +60,21 @@ export function supermemoryTools(
limit,
chunkThreshold: 0.6,
includeFullDocs,
})
});
return {
success: true,
results: response.results,
count: response.results?.length || 0,
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
};
}
},
})
});
const addMemory = tool({
description:
@ -88,31 +88,31 @@ export function supermemoryTools(
}),
execute: async ({ memory }) => {
try {
const metadata: Record<string, string | number | boolean> = {}
const metadata: Record<string, string | number | boolean> = {};
const response = await client.memories.add({
content: memory,
containerTags,
...(Object.keys(metadata).length > 0 && { metadata }),
})
});
return {
success: true,
memory: response,
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
};
}
},
})
});
return {
searchMemories,
addMemory,
}
};
}
// Export individual tool creators for more flexibility
@ -120,14 +120,14 @@ export const searchMemoriesTool = (
apiKey: string,
config?: SupermemoryToolsConfig,
) => {
const { searchMemories } = supermemoryTools(apiKey, config)
return searchMemories
}
const { searchMemories } = supermemoryTools(apiKey, config);
return searchMemories;
};
export const addMemoryTool = (
apiKey: string,
config?: SupermemoryToolsConfig,
) => {
const { addMemory } = supermemoryTools(apiKey, config)
return addMemory
}
const { addMemory } = supermemoryTools(apiKey, config);
return addMemory;
};

View file

@ -1,4 +1,4 @@
import { defineConfig } from "tsdown"
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
@ -12,4 +12,4 @@ export default defineConfig({
sourcemap: true,
},
exports: true,
})
});

View file

@ -1,15 +1,15 @@
import { useEffect } from "react"
import { useEffect } from "react";
export const useKeyPress = (key: string, callback: () => void) => {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === key && e.altKey) {
callback()
callback();
}
}
window.addEventListener("keydown", handler)
};
window.addEventListener("keydown", handler);
return () => {
window.removeEventListener("keydown", handler)
}
}, [key, callback])
}
window.removeEventListener("keydown", handler);
};
}, [key, callback]);
};

View file

@ -1,19 +1,21 @@
import * as React from "react"
import * as React from "react";
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile
return !!isMobile;
}

View file

@ -10,6 +10,7 @@ import {
DeleteProjectSchema,
DocumentsWithMemoriesQuerySchema,
DocumentsWithMemoriesResponseSchema,
GetMemoryResponseSchema,
ListMemoriesResponseSchema,
ListProjectsResponseSchema,
MemoryAddSchema,
@ -126,7 +127,24 @@ export const apiSchema = createSchema({
input: SettingsRequestSchema,
output: SettingsResponseSchema,
},
"@get/documents/:id": {
output: GetMemoryResponseSchema,
params: z.object({ id: z.string() }),
},
// Memory operations
"@get/documents": {
output: ListMemoriesResponseSchema,
query: z
.object({
limit: z.number().optional(),
page: z.number().optional(),
status: z.string().optional(),
containerTags: z.array(z.string()).optional(),
})
.optional(),
},
"@post/documents": {
input: MemoryAddSchema,
output: MemoryResponseSchema,

View file

@ -1,4 +1,4 @@
"use client"
"use client";
import {
createContext,
@ -6,75 +6,74 @@ import {
useContext,
useEffect,
useState,
} from "react"
import { authClient, useSession } from "./auth"
} from "react";
import { authClient, useSession } from "./auth";
type Organization = typeof authClient.$Infer.ActiveOrganization
type SessionData = NonNullable<ReturnType<typeof useSession>["data"]>
type Organization = typeof authClient.$Infer.ActiveOrganization;
type SessionData = NonNullable<ReturnType<typeof useSession>["data"]>;
interface AuthContextType {
session: SessionData["session"] | null
user: SessionData["user"] | null
org: Organization | null
setActiveOrg: (orgSlug: string) => Promise<void>
session: SessionData["session"] | null;
user: SessionData["user"] | null;
org: Organization | null;
setActiveOrg: (orgSlug: string) => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const { data: session } = useSession()
const [org, setOrg] = useState<Organization | null>(null)
const { data: session } = useSession();
const [org, setOrg] = useState<Organization | null>(null);
useEffect(() => {
if (session?.session.activeOrganizationId) {
authClient.organization.getFullOrganization().then((org) => {
setOrg(org)
})
setOrg(org);
});
}
}, [session?.session.activeOrganizationId])
}, [session?.session.activeOrganizationId]);
// When a session exists and there is a pending login method recorded,
// promote it to the last-used method (successful login) and clear pending.
useEffect(() => {
if (typeof window === "undefined") return
if (!session?.session) return
if (typeof window === "undefined") return;
if (!session?.session) return;
try {
const pendingMethod = localStorage.getItem(
"supermemory-pending-login-method",
)
);
const pendingTsRaw = localStorage.getItem(
"supermemory-pending-login-timestamp",
)
);
if (pendingMethod) {
const now = Date.now()
const ts = pendingTsRaw ? Number.parseInt(pendingTsRaw, 10) : NaN
const isFresh = Number.isFinite(ts) && now - ts < 10 * 60 * 1000 // 10 minutes TTL
const now = Date.now();
const ts = pendingTsRaw
? Number.parseInt(pendingTsRaw, 10)
: Number.NaN;
const isFresh = Number.isFinite(ts) && now - ts < 10 * 60 * 1000; // 10 minutes TTL
if (isFresh) {
localStorage.setItem(
"supermemory-last-login-method",
pendingMethod,
)
localStorage.setItem("supermemory-last-login-method", pendingMethod);
}
}
} catch { }
} catch {}
// Always clear pending markers once a session is present
try {
localStorage.removeItem("supermemory-pending-login-method")
localStorage.removeItem("supermemory-pending-login-timestamp")
} catch { }
}, [session?.session])
localStorage.removeItem("supermemory-pending-login-method");
localStorage.removeItem("supermemory-pending-login-timestamp");
} catch {}
}, [session?.session]);
const setActiveOrg = async (slug: string) => {
if (!slug) return
if (!slug) return;
const activeOrg = await authClient.organization.setActive({
organizationSlug: slug,
})
setOrg(activeOrg)
}
});
setOrg(activeOrg);
};
return (
<AuthContext.Provider
@ -87,13 +86,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
>
{children}
</AuthContext.Provider>
)
);
}
export function useAuth() {
const context = useContext(AuthContext)
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider")
throw new Error("useAuth must be used within an AuthProvider");
}
return context
return context;
}

View file

@ -1,4 +1,4 @@
import { createAuthClient } from "better-auth/client"
import { createAuthClient } from "better-auth/client";
import {
adminClient,
anonymousClient,
@ -7,7 +7,7 @@ import {
magicLinkClient,
organizationClient,
usernameClient,
} from "better-auth/client/plugins"
} from "better-auth/client/plugins";
export const middlewareAuthClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai",
@ -23,4 +23,4 @@ export const middlewareAuthClient = createAuthClient({
organizationClient(),
anonymousClient(),
],
})
});

View file

@ -6,8 +6,8 @@ import {
magicLinkClient,
organizationClient,
usernameClient,
} from "better-auth/client/plugins"
import { createAuthClient } from "better-auth/react"
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai",
@ -24,9 +24,9 @@ export const authClient = createAuthClient({
organizationClient(),
anonymousClient(),
],
})
});
export const signIn = authClient.signIn
export const signOut = authClient.signOut
export const useSession = authClient.useSession
export const getSession = authClient.getSession
export const signIn = authClient.signIn;
export const signOut = authClient.signOut;
export const useSession = authClient.useSession;
export const getSession = authClient.getSession;

View file

@ -1,13 +1,13 @@
const BIG_DIMENSIONS_NEW = 1536
const DEFAULT_PROJECT_ID = "sm_project_default"
const BIG_DIMENSIONS_NEW = 1536;
const DEFAULT_PROJECT_ID = "sm_project_default";
const SEARCH_MEMORY_SHORTCUT_URL =
"https://www.icloud.com/shortcuts/f2b5c544372844a38ab4c6900e2a88de"
"https://www.icloud.com/shortcuts/f2b5c544372844a38ab4c6900e2a88de";
const ADD_MEMORY_SHORTCUT_URL =
"https://www.icloud.com/shortcuts/ec33b029b2c7481d89eda7640dbb7688"
"https://www.icloud.com/shortcuts/ec33b029b2c7481d89eda7640dbb7688";
export {
BIG_DIMENSIONS_NEW,
DEFAULT_PROJECT_ID,
SEARCH_MEMORY_SHORTCUT_URL,
ADD_MEMORY_SHORTCUT_URL,
}
};

View file

@ -1,14 +1,14 @@
"use client"
"use client";
import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { useSession } from "./auth"
import { usePostHog } from "./posthog"
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { useSession } from "./auth";
import { usePostHog } from "./posthog";
export function useErrorTracking() {
const posthog = usePostHog()
const { data: session } = useSession()
const pathname = usePathname()
const posthog = usePostHog();
const { data: session } = useSession();
const pathname = usePathname();
const trackError = (
error: Error | unknown,
@ -23,10 +23,10 @@ export function useErrorTracking() {
user_email: session?.user?.email,
timestamp: new Date().toISOString(),
...context,
}
};
posthog.capture("error_occurred", errorDetails)
}
posthog.capture("error_occurred", errorDetails);
};
const trackApiError = (
error: Error | unknown,
@ -37,8 +37,8 @@ export function useErrorTracking() {
error_type: "api_error",
api_endpoint: endpoint,
api_method: method,
})
}
});
};
const trackComponentError = (
error: Error | unknown,
@ -47,8 +47,8 @@ export function useErrorTracking() {
trackError(error, {
error_type: "component_error",
component_name: componentName,
})
}
});
};
const trackValidationError = (
error: Error | unknown,
@ -59,24 +59,24 @@ export function useErrorTracking() {
error_type: "validation_error",
form_name: formName,
field_name: field,
})
}
});
};
return {
trackError,
trackApiError,
trackComponentError,
trackValidationError,
}
};
}
// Global error boundary component
export function ErrorTrackingProvider({
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
const { trackError } = useErrorTracking()
const { trackError } = useErrorTracking();
useEffect(() => {
// Global error handler for unhandled errors
@ -87,34 +87,37 @@ export function ErrorTrackingProvider({
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
})
}
});
};
// Global handler for unhandled promise rejections
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
trackError(event.reason, {
error_type: "unhandled_promise_rejection",
source: "promise_rejection",
})
}
});
};
window.addEventListener("error", handleError)
window.addEventListener("unhandledrejection", handleUnhandledRejection)
window.addEventListener("error", handleError);
window.addEventListener("unhandledrejection", handleUnhandledRejection);
return () => {
window.removeEventListener("error", handleError)
window.removeEventListener("unhandledrejection", handleUnhandledRejection)
}
}, [trackError])
window.removeEventListener("error", handleError);
window.removeEventListener(
"unhandledrejection",
handleUnhandledRejection,
);
};
}, [trackError]);
return <>{children}</>
return <>{children}</>;
}
// Hook for tracking user interactions
export function useInteractionTracking() {
const posthog = usePostHog()
const { data: session } = useSession()
const pathname = usePathname()
const posthog = usePostHog();
const { data: session } = useSession();
const pathname = usePathname();
const trackInteraction = (action: string, details?: Record<string, any>) => {
posthog.capture("user_interaction", {
@ -123,8 +126,8 @@ export function useInteractionTracking() {
user_id: session?.user?.id,
timestamp: new Date().toISOString(),
...details,
})
}
});
};
const trackFormSubmission = (
formName: string,
@ -138,15 +141,15 @@ export function useInteractionTracking() {
user_id: session?.user?.id,
timestamp: new Date().toISOString(),
...details,
})
}
});
};
const trackButtonClick = (buttonName: string, context?: string) => {
trackInteraction("button_click", {
button_name: buttonName,
context,
})
}
});
};
const trackLinkClick = (
url: string,
@ -157,27 +160,27 @@ export function useInteractionTracking() {
url,
link_text: linkText,
external,
})
}
});
};
const trackModalOpen = (modalName: string) => {
trackInteraction("modal_open", {
modal_name: modalName,
})
}
});
};
const trackModalClose = (modalName: string) => {
trackInteraction("modal_close", {
modal_name: modalName,
})
}
});
};
const trackTabChange = (fromTab: string, toTab: string) => {
trackInteraction("tab_change", {
from_tab: fromTab,
to_tab: toTab,
})
}
});
};
return {
trackInteraction,
@ -187,5 +190,5 @@ export function useInteractionTracking() {
trackModalOpen,
trackModalClose,
trackTabChange,
}
};
}

View file

@ -1,6 +1,6 @@
import { customAlphabet } from "nanoid"
import { customAlphabet } from "nanoid";
export const generateId = () =>
customAlphabet("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")(
22,
)
);

View file

@ -1,56 +1,56 @@
// Singleton WebGL context manager for glass effects
class GlassEffectManager {
private static instance: GlassEffectManager | null = null
private canvas: HTMLCanvasElement | null = null
private gl: WebGLRenderingContext | null = null
private program: WebGLProgram | null = null
private uniforms: Record<string, WebGLUniformLocation | null> = {}
private effects: Map<string, EffectInstance> = new Map()
private animationFrame: number | null = null
private startTime: number = performance.now()
private mousePositions: Map<string, { x: number; y: number }> = new Map()
private static instance: GlassEffectManager | null = null;
private canvas: HTMLCanvasElement | null = null;
private gl: WebGLRenderingContext | null = null;
private program: WebGLProgram | null = null;
private uniforms: Record<string, WebGLUniformLocation | null> = {};
private effects: Map<string, EffectInstance> = new Map();
private animationFrame: number | null = null;
private startTime: number = performance.now();
private mousePositions: Map<string, { x: number; y: number }> = new Map();
static getInstance(): GlassEffectManager {
if (!GlassEffectManager.instance) {
GlassEffectManager.instance = new GlassEffectManager()
GlassEffectManager.instance = new GlassEffectManager();
}
return GlassEffectManager.instance
return GlassEffectManager.instance;
}
private constructor() {
this.initializeContext()
this.initializeContext();
}
private initializeContext() {
// Create offscreen canvas
this.canvas = document.createElement("canvas")
this.canvas.width = 1024 // Default size, will be adjusted
this.canvas.height = 1024
this.canvas = document.createElement("canvas");
this.canvas.width = 1024; // Default size, will be adjusted
this.canvas.height = 1024;
this.gl = this.canvas.getContext("webgl", {
alpha: true,
premultipliedAlpha: false,
preserveDrawingBuffer: true,
})
});
if (!this.gl) {
console.error("WebGL not supported")
return
console.error("WebGL not supported");
return;
}
this.setupShaders()
this.startRenderLoop()
this.setupShaders();
this.startRenderLoop();
}
private setupShaders() {
if (!this.gl) return
if (!this.gl) return;
const vsSource = `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`
`;
const fsSource = `
precision mediump float;
@ -106,48 +106,48 @@ class GlassEffectManager {
gl_FragColor = vec4(glassColor, alpha);
}
`
`;
const createShader = (type: number, source: string) => {
const shader = this.gl!.createShader(type)
if (!shader) return null
const shader = this.gl!.createShader(type);
if (!shader) return null;
this.gl!.shaderSource(shader, source)
this.gl!.compileShader(shader)
this.gl!.shaderSource(shader, source);
this.gl!.compileShader(shader);
if (!this.gl!.getShaderParameter(shader, this.gl!.COMPILE_STATUS)) {
console.error("Shader error:", this.gl!.getShaderInfoLog(shader))
this.gl!.deleteShader(shader)
return null
console.error("Shader error:", this.gl!.getShaderInfoLog(shader));
this.gl!.deleteShader(shader);
return null;
}
return shader
}
return shader;
};
const vs = createShader(this.gl.VERTEX_SHADER, vsSource)
const fs = createShader(this.gl.FRAGMENT_SHADER, fsSource)
if (!vs || !fs) return
const vs = createShader(this.gl.VERTEX_SHADER, vsSource);
const fs = createShader(this.gl.FRAGMENT_SHADER, fsSource);
if (!vs || !fs) return;
this.program = this.gl.createProgram()
if (!this.program) return
this.program = this.gl.createProgram();
if (!this.program) return;
this.gl.attachShader(this.program, vs)
this.gl.attachShader(this.program, fs)
this.gl.linkProgram(this.program)
this.gl.attachShader(this.program, vs);
this.gl.attachShader(this.program, fs);
this.gl.linkProgram(this.program);
// biome-ignore lint/correctness/useHookAtTopLevel: Well, not a hook
this.gl.useProgram(this.program)
this.gl.useProgram(this.program);
// Buffer setup
const buffer = this.gl.createBuffer()
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer)
const buffer = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
this.gl.bufferData(
this.gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
this.gl.STATIC_DRAW,
)
);
const position = this.gl.getAttribLocation(this.program, "position")
this.gl.enableVertexAttribArray(position)
this.gl.vertexAttribPointer(position, 2, this.gl.FLOAT, false, 0, 0)
const position = this.gl.getAttribLocation(this.program, "position");
this.gl.enableVertexAttribArray(position);
this.gl.vertexAttribPointer(position, 2, this.gl.FLOAT, false, 0, 0);
// Store uniform locations
this.uniforms = {
@ -155,11 +155,11 @@ class GlassEffectManager {
time: this.gl.getUniformLocation(this.program, "iTime"),
mouse: this.gl.getUniformLocation(this.program, "iMouse"),
expanded: this.gl.getUniformLocation(this.program, "iExpanded"),
}
};
// Enable blending
this.gl.enable(this.gl.BLEND)
this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA)
this.gl.enable(this.gl.BLEND);
this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);
}
registerEffect(
@ -168,8 +168,8 @@ class GlassEffectManager {
isExpanded: boolean,
): () => void {
// Ensure minimum dimensions
const width = Math.max(1, targetCanvas.width)
const height = Math.max(1, targetCanvas.height)
const width = Math.max(1, targetCanvas.width);
const height = Math.max(1, targetCanvas.height);
const effect: EffectInstance = {
id,
@ -177,58 +177,58 @@ class GlassEffectManager {
isExpanded,
width,
height,
}
};
this.effects.set(id, effect)
this.mousePositions.set(id, { x: 0, y: 0 })
this.effects.set(id, effect);
this.mousePositions.set(id, { x: 0, y: 0 });
// Return cleanup function
return () => {
this.effects.delete(id)
this.mousePositions.delete(id)
this.effects.delete(id);
this.mousePositions.delete(id);
if (this.effects.size === 0 && this.animationFrame) {
cancelAnimationFrame(this.animationFrame)
this.animationFrame = null
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
}
};
}
updateMousePosition(id: string, x: number, y: number) {
this.mousePositions.set(id, { x, y })
this.mousePositions.set(id, { x, y });
}
updateExpanded(id: string, isExpanded: boolean) {
const effect = this.effects.get(id)
const effect = this.effects.get(id);
if (effect) {
effect.isExpanded = isExpanded
effect.isExpanded = isExpanded;
}
}
updateSize(id: string, width: number, height: number) {
const effect = this.effects.get(id)
const effect = this.effects.get(id);
if (effect) {
// Ensure minimum dimensions
effect.width = Math.max(1, width)
effect.height = Math.max(1, height)
effect.width = Math.max(1, width);
effect.height = Math.max(1, height);
}
}
private startRenderLoop() {
const render = () => {
if (!this.gl || !this.program || this.effects.size === 0) {
this.animationFrame = requestAnimationFrame(render)
return
this.animationFrame = requestAnimationFrame(render);
return;
}
const currentTime = (performance.now() - this.startTime) / 1000
const currentTime = (performance.now() - this.startTime) / 1000;
// Render each effect
for (const [id, effect] of Array.from(this.effects)) {
const mousePos = this.mousePositions.get(id) || { x: 0, y: 0 }
const mousePos = this.mousePositions.get(id) || { x: 0, y: 0 };
// Skip rendering if dimensions are invalid
if (effect.width <= 0 || effect.height <= 0) {
continue
continue;
}
// Set canvas size if needed
@ -236,14 +236,14 @@ class GlassEffectManager {
this.canvas!.width !== effect.width ||
this.canvas!.height !== effect.height
) {
this.canvas!.width = effect.width
this.canvas!.height = effect.height
this.gl.viewport(0, 0, effect.width, effect.height)
this.canvas!.width = effect.width;
this.canvas!.height = effect.height;
this.gl.viewport(0, 0, effect.width, effect.height);
}
// Clear and render
this.gl.clearColor(0, 0, 0, 0)
this.gl.clear(this.gl.COLOR_BUFFER_BIT)
this.gl.clearColor(0, 0, 0, 0);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
// Set uniforms
if (this.uniforms.resolution) {
@ -251,58 +251,58 @@ class GlassEffectManager {
this.uniforms.resolution,
effect.width,
effect.height,
)
);
}
if (this.uniforms.time) {
this.gl.uniform1f(this.uniforms.time, currentTime)
this.gl.uniform1f(this.uniforms.time, currentTime);
}
if (this.uniforms.mouse) {
this.gl.uniform2f(this.uniforms.mouse, mousePos.x, mousePos.y)
this.gl.uniform2f(this.uniforms.mouse, mousePos.x, mousePos.y);
}
if (this.uniforms.expanded) {
this.gl.uniform1f(
this.uniforms.expanded,
effect.isExpanded ? 1.0 : 0.0,
)
);
}
// Draw
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4)
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
// Copy to target canvas
const targetCtx = effect.targetCanvas.getContext("2d")
const targetCtx = effect.targetCanvas.getContext("2d");
if (targetCtx) {
targetCtx.clearRect(0, 0, effect.width, effect.height)
targetCtx.drawImage(this.canvas!, 0, 0)
targetCtx.clearRect(0, 0, effect.width, effect.height);
targetCtx.drawImage(this.canvas!, 0, 0);
}
}
this.animationFrame = requestAnimationFrame(render)
}
this.animationFrame = requestAnimationFrame(render);
};
render()
render();
}
// Clean up method (optional, for when the app unmounts)
destroy() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame)
cancelAnimationFrame(this.animationFrame);
}
if (this.gl && this.program) {
this.gl.deleteProgram(this.program)
this.gl.deleteProgram(this.program);
}
this.effects.clear()
this.mousePositions.clear()
GlassEffectManager.instance = null
this.effects.clear();
this.mousePositions.clear();
GlassEffectManager.instance = null;
}
}
interface EffectInstance {
id: string
targetCanvas: HTMLCanvasElement
isExpanded: boolean
width: number
height: number
id: string;
targetCanvas: HTMLCanvasElement;
isExpanded: boolean;
width: number;
height: number;
}
export default GlassEffectManager
export default GlassEffectManager;

View file

@ -1,20 +1,20 @@
"use client"
"use client";
import { usePathname, useSearchParams } from "next/navigation"
import posthog from "posthog-js"
import { Suspense, useEffect } from "react"
import { useSession } from "./auth"
import { usePathname, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { Suspense, useEffect } from "react";
import { useSession } from "./auth";
function PostHogPageTracking() {
const pathname = usePathname()
const searchParams = useSearchParams()
const pathname = usePathname();
const searchParams = useSearchParams();
// Page tracking
useEffect(() => {
if (pathname) {
let url = window.origin + pathname
let url = window.origin + pathname;
if (searchParams.toString()) {
url = `${url}?${searchParams.toString()}`
url = `${url}?${searchParams.toString()}`;
}
// Extract page context for better tracking
@ -24,17 +24,17 @@ function PostHogPageTracking() {
search_params: searchParams.toString(),
page_type: getPageType(pathname),
org_slug: getOrgSlug(pathname),
}
};
posthog.capture("$pageview", pageContext)
posthog.capture("$pageview", pageContext);
}
}, [pathname, searchParams])
}, [pathname, searchParams]);
return null
return null;
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
const { data: session } = useSession()
const { data: session } = useSession();
useEffect(() => {
if (typeof window !== "undefined") {
@ -44,9 +44,9 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
person_profiles: "identified_only",
capture_pageview: false,
capture_pageleave: true,
})
});
}
}, [])
}, []);
// User identification
useEffect(() => {
@ -56,9 +56,9 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
name: session.user.name,
userId: session.user.id,
createdAt: session.user.createdAt,
})
});
}
}, [session?.user])
}, [session?.user]);
return (
<>
@ -67,18 +67,18 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
</Suspense>
{children}
</>
)
);
}
function getPageType(pathname: string): string {
return "other"
return "other";
}
function getOrgSlug(pathname: string): string | null {
const match = pathname.match(/^\/([^/]+)\//)
return match ? (match[1] ?? null) : null
const match = pathname.match(/^\/([^/]+)\//);
return match ? (match[1] ?? null) : null;
}
export function usePostHog() {
return posthog
return posthog;
}

View file

@ -1,12 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { useCustomer } from "autumn-js/react"
import { toast } from "sonner"
import type { z } from "zod"
import type { DocumentsWithMemoriesResponseSchema } from "../validation/api"
import { $fetch } from "./api"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { useCustomer } from "autumn-js/react";
import { toast } from "sonner";
import type { z } from "zod";
import type { DocumentsWithMemoriesResponseSchema } from "../validation/api";
import { $fetch } from "./api";
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>;
type DocumentWithMemories = DocumentsResponse["documents"][0];
export const fetchSubscriptionStatus = (
autumn: ReturnType<typeof useCustomer>,
@ -18,54 +18,54 @@ export const fetchSubscriptionStatus = (
"memory_starter",
"memory_growth",
"consumer_pro",
]
const statusMap: Record<string, boolean | null> = {}
];
const statusMap: Record<string, boolean | null> = {};
await Promise.all(
allPlans.map(async (plan) => {
try {
const res = await autumn.check({
productId: plan,
})
statusMap[plan] = res.data?.allowed ?? false
});
statusMap[plan] = res.data?.allowed ?? false;
} catch (error) {
console.error(`Error checking status for ${plan}:`, error)
statusMap[plan] = false
console.error(`Error checking status for ${plan}:`, error);
statusMap[plan] = false;
}
}),
)
);
return statusMap
return statusMap;
},
queryKey: ["subscription-status"],
refetchInterval: 5000, // Refetch every 5 seconds
staleTime: 4000, // Consider data stale after 4 seconds
})
});
// Feature checks
export const fetchMemoriesFeature = (autumn: ReturnType<typeof useCustomer>) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ featureId: "memories" })
return res.data
const res = await autumn.check({ featureId: "memories" });
return res.data;
},
queryKey: ["autumn-feature", "memories"],
staleTime: 30 * 1000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
})
});
export const fetchConnectionsFeature = (
autumn: ReturnType<typeof useCustomer>,
) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ featureId: "connections" })
return res.data
const res = await autumn.check({ featureId: "connections" });
return res.data;
},
queryKey: ["autumn-feature", "connections"],
staleTime: 30 * 1000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
})
});
// Product checks
export const fetchConsumerProProduct = (
@ -73,54 +73,54 @@ export const fetchConsumerProProduct = (
) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ productId: "consumer_pro" })
return res.data
const res = await autumn.check({ productId: "consumer_pro" });
return res.data;
},
queryKey: ["autumn-product", "consumer_pro"],
staleTime: 30 * 1000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
})
});
export const fetchProProduct = (autumn: ReturnType<typeof useCustomer>) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ productId: "pro" })
return res.data
const res = await autumn.check({ productId: "pro" });
return res.data;
},
queryKey: ["autumn-product", "pro"],
staleTime: 30 * 1000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
})
});
export const useDeleteDocument = (selectedProject: string) => {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (documentId: string) => {
// context for LLM: delete/memories/:documentId is documents delete endpoint not memories delete endpoint
const response = await $fetch(`@delete/documents/${documentId}`)
const response = await $fetch(`@delete/documents/${documentId}`);
if (response.error) {
throw new Error(response.error?.message || "Failed to delete document")
throw new Error(response.error?.message || "Failed to delete document");
}
return response.data
return response.data;
},
onMutate: async (documentId: string) => {
await queryClient.cancelQueries({
queryKey: ["documents-with-memories", selectedProject],
})
});
const previousData = queryClient.getQueryData([
"documents-with-memories",
selectedProject,
])
]);
queryClient.setQueryData(
["documents-with-memories", selectedProject],
(old: unknown) => {
if (!old || typeof old !== "object") return old
if (!old || typeof old !== "object") return old;
const typedOld = old as {
pages?: Array<{ documents?: DocumentWithMemories[] }>
}
pages?: Array<{ documents?: DocumentWithMemories[] }>;
};
return {
...typedOld,
pages: typedOld.pages?.map((page) => ({
@ -129,30 +129,30 @@ export const useDeleteDocument = (selectedProject: string) => {
(doc: DocumentWithMemories) => doc.id !== documentId,
),
})),
}
};
},
)
);
return { previousData }
return { previousData };
},
onSuccess: () => {
toast.success("Memory deleted successfully")
toast.success("Memory deleted successfully");
},
onError: (error, _documentId, context) => {
if (context?.previousData) {
queryClient.setQueryData(
["documents-with-memories", selectedProject],
context.previousData,
)
);
}
toast.error("Failed to delete memory", {
description: error instanceof Error ? error.message : "Unknown error",
})
});
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["documents-with-memories", selectedProject],
})
});
},
})
}
});
};

View file

@ -1,8 +1,8 @@
"use client"
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { useState } from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export const QueryProvider = ({ children }: { children: React.ReactNode }) => {
const [queryClient] = useState(
@ -16,12 +16,12 @@ export const QueryProvider = ({ children }: { children: React.ReactNode }) => {
},
},
}),
)
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
);
};

View file

@ -10,27 +10,27 @@ export const cosineSimilarity = (
vectorB: number[],
): number => {
if (vectorA.length !== vectorB.length) {
throw new Error("Vectors must have the same length")
throw new Error("Vectors must have the same length");
}
let dotProduct = 0
let dotProduct = 0;
for (let i = 0; i < vectorA.length; i++) {
const vectorAi = vectorA[i]
const vectorBi = vectorB[i]
const vectorAi = vectorA[i];
const vectorBi = vectorB[i];
if (
typeof vectorAi !== "number" ||
typeof vectorBi !== "number" ||
isNaN(vectorAi) ||
isNaN(vectorBi)
) {
throw new Error("Vectors must contain only numbers")
throw new Error("Vectors must contain only numbers");
}
dotProduct += vectorAi * vectorBi
dotProduct += vectorAi * vectorBi;
}
return dotProduct
}
return dotProduct;
};
/**
* Calculate semantic similarity between two documents
@ -47,13 +47,13 @@ export const calculateSemanticSimilarity = (
document1Embedding.length > 0 &&
document2Embedding.length > 0
) {
const similarity = cosineSimilarity(document1Embedding, document2Embedding)
const similarity = cosineSimilarity(document1Embedding, document2Embedding);
// Convert from [-1, 1] to [0, 1] range
return similarity >= 0 ? similarity : 0
return similarity >= 0 ? similarity : 0;
}
return 0
}
return 0;
};
/**
* Calculate semantic similarity between a document and memory entry
@ -71,34 +71,34 @@ export const calculateDocumentMemorySimilarity = (
documentEmbedding.length > 0 &&
memoryEmbedding.length > 0
) {
const similarity = cosineSimilarity(documentEmbedding, memoryEmbedding)
const similarity = cosineSimilarity(documentEmbedding, memoryEmbedding);
// Convert from [-1, 1] to [0, 1] range
return similarity >= 0 ? similarity : 0
return similarity >= 0 ? similarity : 0;
}
// Fall back to relevance score from database (0-100 scale)
if (relevanceScore !== null && relevanceScore !== undefined) {
return Math.max(0, Math.min(1, relevanceScore / 100))
return Math.max(0, Math.min(1, relevanceScore / 100));
}
// Default similarity for connections without embeddings or relevance scores
return 0.5
}
return 0.5;
};
/**
* Get visual properties for connection based on similarity
*/
export const getConnectionVisualProps = (similarity: number) => {
// Ensure similarity is between 0 and 1
const normalizedSimilarity = Math.max(0, Math.min(1, similarity))
const normalizedSimilarity = Math.max(0, Math.min(1, similarity));
return {
opacity: Math.max(0, normalizedSimilarity), // 0 to 1 range
thickness: Math.max(1, normalizedSimilarity * 4), // 1 to 4 pixels
glow: normalizedSimilarity * 0.6, // Glow intensity
pulseDuration: 2000 + (1 - normalizedSimilarity) * 3000, // Faster pulse for higher similarity
}
}
};
};
/**
* Generate magical color based on similarity and connection type
@ -107,9 +107,9 @@ export const getMagicalConnectionColor = (
similarity: number,
hue = 220,
): string => {
const normalizedSimilarity = Math.max(0, Math.min(1, similarity))
const saturation = 60 + normalizedSimilarity * 40 // 60% to 100%
const lightness = 40 + normalizedSimilarity * 30 // 40% to 70%
const normalizedSimilarity = Math.max(0, Math.min(1, similarity));
const saturation = 60 + normalizedSimilarity * 40; // 60% to 100%
const lightness = 40 + normalizedSimilarity * 30; // 40% to 70%
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
}
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};

View file

@ -1,8 +1,8 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
export const isSelfHosted = process.env.NEXT_PUBLIC_HOST_ID !== "supermemory"
export const isSelfHosted = process.env.NEXT_PUBLIC_HOST_ID !== "supermemory";

View file

@ -1,13 +1,13 @@
import Supermemory from "supermemory"
import { tool } from "ai"
import { z } from "zod"
import { tool } from "ai";
import Supermemory from "supermemory";
import { z } from "zod";
import {
DEFAULT_VALUES,
getContainerTags,
PARAMETER_DESCRIPTIONS,
TOOL_DESCRIPTIONS,
getContainerTags,
} from "./shared"
import type { SupermemoryToolsConfig } from "./types"
} from "./shared";
import type { SupermemoryToolsConfig } from "./types";
// Export individual tool creators
export const searchMemoriesTool = (
@ -17,9 +17,9 @@ export const searchMemoriesTool = (
const client = new Supermemory({
apiKey,
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
})
});
const containerTags = getContainerTags(config)
const containerTags = getContainerTags(config);
return tool({
description: TOOL_DESCRIPTIONS.searchMemories,
@ -50,22 +50,22 @@ export const searchMemoriesTool = (
limit,
chunkThreshold: DEFAULT_VALUES.chunkThreshold,
includeFullDocs,
})
});
return {
success: true,
results: response.results,
count: response.results?.length || 0,
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
};
}
},
})
}
});
};
export const addMemoryTool = (
apiKey: string,
@ -74,9 +74,9 @@ export const addMemoryTool = (
const client = new Supermemory({
apiKey,
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
})
});
const containerTags = getContainerTags(config)
const containerTags = getContainerTags(config);
return tool({
description: TOOL_DESCRIPTIONS.addMemory,
@ -85,27 +85,27 @@ export const addMemoryTool = (
}),
execute: async ({ memory }) => {
try {
const metadata: Record<string, string | number | boolean> = {}
const metadata: Record<string, string | number | boolean> = {};
const response = await client.memories.add({
content: memory,
containerTags,
...(Object.keys(metadata).length > 0 && { metadata }),
})
});
return {
success: true,
memory: response,
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
};
}
},
})
}
});
};
/**
* Create Supermemory tools for AI SDK
@ -117,5 +117,5 @@ export function supermemoryTools(
return {
searchMemories: searchMemoriesTool(apiKey, config),
addMemory: addMemoryTool(apiKey, config),
}
};
}

View file

@ -1,2 +1,2 @@
// Export shared types and utilities
export type { SupermemoryToolsConfig } from "./types"
export type { SupermemoryToolsConfig } from "./types";

View file

@ -1,27 +1,27 @@
import type OpenAI from "openai"
import Supermemory from "supermemory"
import type OpenAI from "openai";
import Supermemory from "supermemory";
import {
DEFAULT_VALUES,
getContainerTags,
PARAMETER_DESCRIPTIONS,
TOOL_DESCRIPTIONS,
getContainerTags,
} from "./shared"
import type { SupermemoryToolsConfig } from "./types"
} from "./shared";
import type { SupermemoryToolsConfig } from "./types";
/**
* Result types for memory operations
*/
export interface MemorySearchResult {
success: boolean
results?: Awaited<ReturnType<Supermemory["search"]["execute"]>>["results"]
count?: number
error?: string
success: boolean;
results?: Awaited<ReturnType<Supermemory["search"]["execute"]>>["results"];
count?: number;
error?: string;
}
export interface MemoryAddResult {
success: boolean
memory?: Awaited<ReturnType<Supermemory["memories"]["add"]>>
error?: string
success: boolean;
memory?: Awaited<ReturnType<Supermemory["memories"]["add"]>>;
error?: string;
}
/**
@ -67,7 +67,7 @@ export const memoryToolSchemas = {
required: ["memory"],
},
} satisfies OpenAI.FunctionDefinition,
} as const
} as const;
/**
* Create a Supermemory client with configuration
@ -76,11 +76,11 @@ function createClient(apiKey: string, config?: SupermemoryToolsConfig) {
const client = new Supermemory({
apiKey,
...(config?.baseUrl && { baseURL: config.baseUrl }),
})
});
const containerTags = getContainerTags(config)
const containerTags = getContainerTags(config);
return { client, containerTags }
return { client, containerTags };
}
/**
@ -90,16 +90,16 @@ export function createSearchMemoriesFunction(
apiKey: string,
config?: SupermemoryToolsConfig,
) {
const { client, containerTags } = createClient(apiKey, config)
const { client, containerTags } = createClient(apiKey, config);
return async function searchMemories({
informationToGet,
includeFullDocs = DEFAULT_VALUES.includeFullDocs,
limit = DEFAULT_VALUES.limit,
}: {
informationToGet: string
includeFullDocs?: boolean
limit?: number
informationToGet: string;
includeFullDocs?: boolean;
limit?: number;
}): Promise<MemorySearchResult> {
try {
const response = await client.search.execute({
@ -108,20 +108,20 @@ export function createSearchMemoriesFunction(
limit,
chunkThreshold: DEFAULT_VALUES.chunkThreshold,
includeFullDocs,
})
});
return {
success: true,
results: response.results,
count: response.results?.length || 0,
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
};
}
}
};
}
/**
@ -131,33 +131,33 @@ export function createAddMemoryFunction(
apiKey: string,
config?: SupermemoryToolsConfig,
) {
const { client, containerTags } = createClient(apiKey, config)
const { client, containerTags } = createClient(apiKey, config);
return async function addMemory({
memory,
}: {
memory: string
memory: string;
}): Promise<MemoryAddResult> {
try {
const metadata: Record<string, string | number | boolean> = {}
const metadata: Record<string, string | number | boolean> = {};
const response = await client.memories.add({
content: memory,
containerTags,
...(Object.keys(metadata).length > 0 && { metadata }),
})
});
return {
success: true,
memory: response,
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
};
}
}
};
}
/**
@ -167,13 +167,13 @@ export function supermemoryTools(
apiKey: string,
config?: SupermemoryToolsConfig,
) {
const searchMemories = createSearchMemoriesFunction(apiKey, config)
const addMemory = createAddMemoryFunction(apiKey, config)
const searchMemories = createSearchMemoriesFunction(apiKey, config);
const addMemory = createAddMemoryFunction(apiKey, config);
return {
searchMemories,
addMemory,
}
};
}
/**
@ -183,7 +183,7 @@ export function getToolDefinitions(): OpenAI.Chat.Completions.ChatCompletionTool
return [
{ type: "function", function: memoryToolSchemas.searchMemories },
{ type: "function", function: memoryToolSchemas.addMemory },
]
];
}
/**
@ -193,26 +193,26 @@ export function createToolCallExecutor(
apiKey: string,
config?: SupermemoryToolsConfig,
) {
const tools = supermemoryTools(apiKey, config)
const tools = supermemoryTools(apiKey, config);
return async function executeToolCall(
toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall,
): Promise<string> {
const functionName = toolCall.function.name
const args = JSON.parse(toolCall.function.arguments)
const functionName = toolCall.function.name;
const args = JSON.parse(toolCall.function.arguments);
switch (functionName) {
case "searchMemories":
return JSON.stringify(await tools.searchMemories(args))
return JSON.stringify(await tools.searchMemories(args));
case "addMemory":
return JSON.stringify(await tools.addMemory(args))
return JSON.stringify(await tools.addMemory(args));
default:
return JSON.stringify({
success: false,
error: `Unknown function: ${functionName}`,
})
});
}
}
};
}
/**
@ -222,24 +222,24 @@ export function createToolCallsExecutor(
apiKey: string,
config?: SupermemoryToolsConfig,
) {
const executeToolCall = createToolCallExecutor(apiKey, config)
const executeToolCall = createToolCallExecutor(apiKey, config);
return async function executeToolCalls(
toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[],
): Promise<OpenAI.Chat.Completions.ChatCompletionToolMessageParam[]> {
const results = await Promise.all(
toolCalls.map(async (toolCall) => {
const result = await executeToolCall(toolCall)
const result = await executeToolCall(toolCall);
return {
tool_call_id: toolCall.id,
role: "tool" as const,
content: result,
}
};
}),
)
);
return results
}
return results;
};
}
/**
@ -249,7 +249,7 @@ export function createSearchMemoriesTool(
apiKey: string,
config?: SupermemoryToolsConfig,
) {
const searchMemories = createSearchMemoriesFunction(apiKey, config)
const searchMemories = createSearchMemoriesFunction(apiKey, config);
return {
definition: {
@ -257,14 +257,14 @@ export function createSearchMemoriesTool(
function: memoryToolSchemas.searchMemories,
},
execute: searchMemories,
}
};
}
export function createAddMemoryTool(
apiKey: string,
config?: SupermemoryToolsConfig,
) {
const addMemory = createAddMemoryFunction(apiKey, config)
const addMemory = createAddMemoryFunction(apiKey, config);
return {
definition: {
@ -272,5 +272,5 @@ export function createAddMemoryTool(
function: memoryToolSchemas.addMemory,
},
execute: addMemory,
}
};
}

View file

@ -8,7 +8,7 @@ export const TOOL_DESCRIPTIONS = {
"Search (recall) memories/details/information about the user or other facts or entities. Run when explicitly asked or when context about user's past choices would be helpful.",
addMemory:
"Add (remember) memories/details/information about the user or other facts or entities. Run when explicitly asked or when the user mentions any information generalizable beyond the context of the current conversation.",
} as const
} as const;
// Parameter descriptions
export const PARAMETER_DESCRIPTIONS = {
@ -18,30 +18,30 @@ export const PARAMETER_DESCRIPTIONS = {
limit: "Maximum number of results to return",
memory:
"The text content of the memory to add. This should be a single sentence or a short paragraph.",
} as const
} as const;
// Default values
export const DEFAULT_VALUES = {
includeFullDocs: true,
limit: 10,
chunkThreshold: 0.6,
} as const
} as const;
// Container tag constants
export const CONTAINER_TAG_CONSTANTS = {
projectPrefix: "sm_project_",
defaultTags: ["sm_project_default"] as string[],
} as const
} as const;
/**
* Helper function to generate container tags based on config
*/
export function getContainerTags(config?: {
projectId?: string
containerTags?: string[]
projectId?: string;
containerTags?: string[];
}): string[] {
if (config?.projectId) {
return [`${CONTAINER_TAG_CONSTANTS.projectPrefix}${config.projectId}`]
return [`${CONTAINER_TAG_CONSTANTS.projectPrefix}${config.projectId}`];
}
return config?.containerTags ?? CONTAINER_TAG_CONSTANTS.defaultTags
return config?.containerTags ?? CONTAINER_TAG_CONSTANTS.defaultTags;
}

View file

@ -1,70 +1,72 @@
import { createOpenAI } from "@ai-sdk/openai"
import { generateText } from "ai"
import { describe, expect, it } from "vitest"
import * as aiSdk from "./ai-sdk"
import * as openAi from "./openai"
import type { SupermemoryToolsConfig } from "./types"
import { createOpenAI } from "@ai-sdk/openai";
import { generateText } from "ai";
import { describe, expect, it } from "vitest";
import * as aiSdk from "./ai-sdk";
import * as openAi from "./openai";
import type { SupermemoryToolsConfig } from "./types";
import "dotenv/config"
import "dotenv/config";
describe("@supermemory/tools", () => {
// Required API keys - tests will fail if not provided
const testApiKey = process.env.SUPERMEMORY_API_KEY
const testOpenAIKey = process.env.OPENAI_API_KEY
const testApiKey = process.env.SUPERMEMORY_API_KEY;
const testOpenAIKey = process.env.OPENAI_API_KEY;
if (!testApiKey) {
throw new Error(
"SUPERMEMORY_API_KEY environment variable is required for tests",
)
);
}
if (!testOpenAIKey) {
throw new Error("OPENAI_API_KEY environment variable is required for tests")
throw new Error(
"OPENAI_API_KEY environment variable is required for tests",
);
}
// Optional configuration with defaults
const testBaseUrl = process.env.SUPERMEMORY_BASE_URL ?? undefined
const testModelName = process.env.MODEL_NAME || "gpt-4o-mini"
const testBaseUrl = process.env.SUPERMEMORY_BASE_URL ?? undefined;
const testModelName = process.env.MODEL_NAME || "gpt-4o-mini";
describe("aiSdk module", () => {
describe("client initialization", () => {
it("should create tools with default configuration", () => {
const config: SupermemoryToolsConfig = {}
const tools = aiSdk.supermemoryTools(testApiKey, config)
const config: SupermemoryToolsConfig = {};
const tools = aiSdk.supermemoryTools(testApiKey, config);
expect(tools).toBeDefined()
expect(tools.searchMemories).toBeDefined()
expect(tools.addMemory).toBeDefined()
})
expect(tools).toBeDefined();
expect(tools.searchMemories).toBeDefined();
expect(tools.addMemory).toBeDefined();
});
it("should create tools with custom baseUrl", () => {
const config: SupermemoryToolsConfig = {
baseUrl: testBaseUrl,
}
const tools = aiSdk.supermemoryTools(testApiKey, config)
};
const tools = aiSdk.supermemoryTools(testApiKey, config);
expect(tools).toBeDefined()
expect(tools.searchMemories).toBeDefined()
expect(tools.addMemory).toBeDefined()
})
expect(tools).toBeDefined();
expect(tools.searchMemories).toBeDefined();
expect(tools.addMemory).toBeDefined();
});
it("should create individual tools", () => {
const searchTool = aiSdk.searchMemoriesTool(testApiKey, {
projectId: "test-project-123",
})
});
const addTool = aiSdk.addMemoryTool(testApiKey, {
projectId: "test-project-123",
})
});
expect(searchTool).toBeDefined()
expect(addTool).toBeDefined()
})
})
expect(searchTool).toBeDefined();
expect(addTool).toBeDefined();
});
});
describe("AI SDK integration", () => {
it("should work with AI SDK generateText", async () => {
const openai = createOpenAI({
apiKey: testOpenAIKey,
})
});
const result = await generateText({
model: openai(testModelName),
@ -85,22 +87,22 @@ describe("@supermemory/tools", () => {
baseUrl: testBaseUrl,
}),
},
})
});
expect(result).toBeDefined()
expect(result.text).toBeDefined()
expect(typeof result.text).toBe("string")
})
expect(result).toBeDefined();
expect(result.text).toBeDefined();
expect(typeof result.text).toBe("string");
});
it("should use tools when prompted", async () => {
const openai = createOpenAI({
apiKey: testOpenAIKey,
})
});
const tools = aiSdk.supermemoryTools(testApiKey, {
projectId: "test-tool-usage",
baseUrl: testBaseUrl,
})
});
const result = await generateText({
model: openai(testModelName),
@ -118,157 +120,159 @@ describe("@supermemory/tools", () => {
tools: {
addMemory: tools.addMemory,
},
})
});
expect(result).toBeDefined()
expect(result.text).toBeDefined()
})
})
})
expect(result).toBeDefined();
expect(result.text).toBeDefined();
});
});
});
describe("openAi module", () => {
describe("function-based tools", () => {
it("should create function-based tools", () => {
const tools = openAi.supermemoryTools(testApiKey, {
projectId: "test-openai-functions",
})
});
expect(tools).toBeDefined()
expect(tools.searchMemories).toBeDefined()
expect(tools.addMemory).toBeDefined()
})
expect(tools).toBeDefined();
expect(tools.searchMemories).toBeDefined();
expect(tools.addMemory).toBeDefined();
});
it("should create individual tool functions", () => {
const searchFunction = openAi.createSearchMemoriesFunction(testApiKey, {
projectId: "test-individual",
})
});
const addFunction = openAi.createAddMemoryFunction(testApiKey, {
projectId: "test-individual",
})
});
expect(searchFunction).toBeDefined()
expect(addFunction).toBeDefined()
expect(typeof searchFunction).toBe("function")
expect(typeof addFunction).toBe("function")
})
})
expect(searchFunction).toBeDefined();
expect(addFunction).toBeDefined();
expect(typeof searchFunction).toBe("function");
expect(typeof addFunction).toBe("function");
});
});
describe("tool definitions", () => {
it("should return proper OpenAI function definitions", () => {
const definitions = openAi.getToolDefinitions()
const definitions = openAi.getToolDefinitions();
expect(definitions).toBeDefined()
expect(definitions.length).toBe(2)
expect(definitions).toBeDefined();
expect(definitions.length).toBe(2);
// Check searchMemories
const searchTool = definitions.find(
(d) => d.function.name === "searchMemories",
)
expect(searchTool).toBeDefined()
expect(searchTool!.type).toBe("function")
);
expect(searchTool).toBeDefined();
expect(searchTool!.type).toBe("function");
expect(searchTool!.function.parameters?.required).toContain(
"informationToGet",
)
);
// Check addMemory
const addTool = definitions.find((d) => d.function.name === "addMemory")
expect(addTool).toBeDefined()
expect(addTool!.type).toBe("function")
expect(addTool!.function.parameters?.required).toContain("memory")
})
})
const addTool = definitions.find(
(d) => d.function.name === "addMemory",
);
expect(addTool).toBeDefined();
expect(addTool!.type).toBe("function");
expect(addTool!.function.parameters?.required).toContain("memory");
});
});
describe("tool execution", () => {
it("should create tool call executor", () => {
const executor = openAi.createToolCallExecutor(testApiKey, {
containerTags: ["test-executor"],
baseUrl: testBaseUrl,
})
});
expect(executor).toBeDefined()
expect(typeof executor).toBe("function")
})
expect(executor).toBeDefined();
expect(typeof executor).toBe("function");
});
it("should create tool calls executor", () => {
const executor = openAi.createToolCallsExecutor(testApiKey, {
containerTags: ["test-executors"],
baseUrl: testBaseUrl,
})
});
expect(executor).toBeDefined()
expect(typeof executor).toBe("function")
})
})
expect(executor).toBeDefined();
expect(typeof executor).toBe("function");
});
});
describe("individual tool creators", () => {
it("should create individual search tool", () => {
const searchTool = openAi.createSearchMemoriesTool(testApiKey, {
projectId: "test-individual",
})
});
expect(searchTool).toBeDefined()
expect(searchTool.definition).toBeDefined()
expect(searchTool.execute).toBeDefined()
expect(searchTool.definition.function.name).toBe("searchMemories")
})
expect(searchTool).toBeDefined();
expect(searchTool.definition).toBeDefined();
expect(searchTool.execute).toBeDefined();
expect(searchTool.definition.function.name).toBe("searchMemories");
});
it("should create individual add tool", () => {
const addTool = openAi.createAddMemoryTool(testApiKey, {
projectId: "test-individual",
})
});
expect(addTool).toBeDefined()
expect(addTool.definition).toBeDefined()
expect(addTool.execute).toBeDefined()
expect(addTool.definition.function.name).toBe("addMemory")
})
})
expect(addTool).toBeDefined();
expect(addTool.definition).toBeDefined();
expect(addTool.execute).toBeDefined();
expect(addTool.definition.function.name).toBe("addMemory");
});
});
describe("memory operations", () => {
it("should search memories", async () => {
const searchFunction = openAi.createSearchMemoriesFunction(testApiKey, {
projectId: "test-search",
baseUrl: testBaseUrl,
})
});
const result = await searchFunction({
informationToGet: "test preferences",
limit: 5,
})
});
expect(result).toBeDefined()
expect(result.success).toBeDefined()
expect(typeof result.success).toBe("boolean")
expect(result).toBeDefined();
expect(result.success).toBeDefined();
expect(typeof result.success).toBe("boolean");
if (result.success) {
expect(result.results).toBeDefined()
expect(result.count).toBeDefined()
expect(typeof result.count).toBe("number")
expect(result.results).toBeDefined();
expect(result.count).toBeDefined();
expect(typeof result.count).toBe("number");
} else {
expect(result.error).toBeDefined()
expect(result.error).toBeDefined();
}
})
});
it("should add memory", async () => {
const addFunction = openAi.createAddMemoryFunction(testApiKey, {
containerTags: ["test-add-memory"],
baseUrl: testBaseUrl,
})
});
const result = await addFunction({
memory: "User prefers dark roast coffee in the morning - test memory",
})
});
expect(result).toBeDefined()
expect(result.success).toBeDefined()
expect(typeof result.success).toBe("boolean")
expect(result).toBeDefined();
expect(result.success).toBeDefined();
expect(typeof result.success).toBe("boolean");
if (result.success) {
expect(result.memory).toBeDefined()
expect(result.memory).toBeDefined();
} else {
expect(result.error).toBeDefined()
expect(result.error).toBeDefined();
}
})
})
})
})
});
});
});
});

View file

@ -3,7 +3,7 @@
* Only one of `projectId` or `containerTags` can be provided.
*/
export interface SupermemoryToolsConfig {
baseUrl?: string
containerTags?: string[]
projectId?: string
baseUrl?: string;
containerTags?: string[];
projectId?: string;
}

View file

@ -1,4 +1,4 @@
import { defineConfig } from "tsdown"
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts", "src/ai-sdk.ts", "src/openai.ts"],
@ -12,4 +12,4 @@ export default defineConfig({
sourcemap: false,
},
exports: true,
})
});

View file

@ -29,26 +29,26 @@ export function useGraphData(
const allEdges: GraphEdge[] = [];
// Filter documents that have memories in selected space
const filteredDocuments = data.documents
const filteredDocuments = (data.documents || [])
.map((doc) => ({
...doc,
memoryEntries:
selectedSpace === "all"
? doc.memoryEntries
: doc.memoryEntries.filter(
? doc.memoryEntries || []
: (doc.memoryEntries || []).filter(
(memory) =>
(memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
selectedSpace,
),
}))
.filter((doc) => doc.memoryEntries.length > 0);
.filter((doc) => (doc.memoryEntries || []).length > 0);
// Group documents by space for better clustering
const documentsBySpace = new Map<string, typeof filteredDocuments>();
filteredDocuments.forEach((doc) => {
const docSpace =
doc.memoryEntries[0]?.spaceContainerTag ??
doc.memoryEntries[0]?.spaceId ??
(doc.memoryEntries || [])[0]?.spaceContainerTag ??
(doc.memoryEntries || [])[0]?.spaceId ??
"default";
if (!documentsBySpace.has(docSpace)) {
documentsBySpace.set(docSpace, []);
@ -171,7 +171,7 @@ export function useGraphData(
const memoryNodeMap = new Map<string, GraphNode>();
const doc = docNode.data as DocumentWithMemories;
doc.memoryEntries.forEach((memory, memIndex) => {
(doc.memoryEntries || []).forEach((memory, memIndex) => {
const memoryId = `${memory.id}`;
const customMemPos = nodePositions.get(memoryId);
@ -231,8 +231,8 @@ export function useGraphData(
});
// Add version-chain edges (old -> new)
data.documents.forEach((doc) => {
doc.memoryEntries.forEach((mem: MemoryEntry) => {
(data.documents || []).forEach((doc) => {
(doc.memoryEntries || []).forEach((mem: MemoryEntry) => {
// Support both new object structure and legacy array/single parent fields
let parentRelations: Record<string, MemoryRelation> = {};

View file

@ -157,8 +157,8 @@ export const MemoryGraph = ({
const spaceSet = new Set<string>();
const counts: Record<string, number> = {};
data.documents.forEach((doc) => {
doc.memoryEntries.forEach((memory) => {
(data.documents || []).forEach((doc) => {
(doc.memoryEntries || []).forEach((memory) => {
const spaceId = memory.spaceContainerTag || memory.spaceId || "default";
spaceSet.add(spaceId);
counts[spaceId] = (counts[spaceId] || 0) + 1;
@ -199,32 +199,47 @@ export const MemoryGraph = ({
const handleCenter = useCallback(() => {
if (nodes.length > 0) {
// Calculate center of all nodes
let sumX = 0
let sumY = 0
let count = 0
let sumX = 0;
let sumY = 0;
let count = 0;
nodes.forEach((node) => {
sumX += node.x
sumY += node.y
count++
})
sumX += node.x;
sumY += node.y;
count++;
});
if (count > 0) {
const centerX = sumX / count
const centerY = sumY / count
centerViewportOn(centerX, centerY, containerSize.width, containerSize.height)
const centerX = sumX / count;
const centerY = sumY / count;
centerViewportOn(
centerX,
centerY,
containerSize.width,
containerSize.height,
);
}
}
}, [nodes, centerViewportOn, containerSize.width, containerSize.height])
}, [nodes, centerViewportOn, containerSize.width, containerSize.height]);
const handleAutoFit = useCallback(() => {
if (nodes.length > 0 && containerSize.width > 0 && containerSize.height > 0) {
if (
nodes.length > 0 &&
containerSize.width > 0 &&
containerSize.height > 0
) {
autoFitToViewport(nodes, containerSize.width, containerSize.height, {
occludedRightPx,
animate: true,
})
});
}
}, [nodes, containerSize.width, containerSize.height, occludedRightPx, autoFitToViewport])
}, [
nodes,
containerSize.width,
containerSize.height,
occludedRightPx,
autoFitToViewport,
]);
// Get selected node data
const selectedNodeData = useMemo(() => {
@ -251,7 +266,7 @@ export const MemoryGraph = ({
};
// Count visible documents
const visibleDocuments = data.documents.filter((doc) => {
const visibleDocuments = (data.documents || []).filter((doc) => {
const docNodes = nodes.filter(
(node) => node.type === "document" && node.data.id === doc.id,
);
@ -265,7 +280,8 @@ export const MemoryGraph = ({
});
// If 80% or more of documents are visible, load more
const visibilityRatio = visibleDocuments.length / data.documents.length;
const visibilityRatio =
visibleDocuments.length / (data.documents || []).length;
if (visibilityRatio >= 0.8) {
loadMoreDocuments();
}
@ -421,8 +437,12 @@ export const MemoryGraph = ({
{containerSize.width > 0 && (
<NavigationControls
onCenter={handleCenter}
onZoomIn={() => zoomIn(containerSize.width / 2, containerSize.height / 2)}
onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)}
onZoomIn={() =>
zoomIn(containerSize.width / 2, containerSize.height / 2)
}
onZoomOut={() =>
zoomOut(containerSize.width / 2, containerSize.height / 2)
}
onAutoFit={handleAutoFit}
nodes={nodes}
className="absolute bottom-4 left-4"

View file

@ -1,67 +1,62 @@
"use client"
"use client";
import { memo } from "react"
import type { GraphNode } from "./types"
import { memo } from "react";
import type { GraphNode } from "./types";
interface NavigationControlsProps {
onCenter: () => void
onZoomIn: () => void
onZoomOut: () => void
onAutoFit: () => void
nodes: GraphNode[]
className?: string
onCenter: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onAutoFit: () => void;
nodes: GraphNode[];
className?: string;
}
export const NavigationControls = memo<NavigationControlsProps>(({
onCenter,
onZoomIn,
onZoomOut,
onAutoFit,
nodes,
className = "",
}) => {
if (nodes.length === 0) {
return null
}
export const NavigationControls = memo<NavigationControlsProps>(
({ onCenter, onZoomIn, onZoomOut, onAutoFit, nodes, className = "" }) => {
if (nodes.length === 0) {
return null;
}
return (
<div className={`flex flex-col gap-1 ${className}`}>
<button
type="button"
onClick={onAutoFit}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
title="Auto-fit graph to viewport"
>
Fit
</button>
<button
type="button"
onClick={onCenter}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
title="Center view on graph"
>
Center
</button>
<div className="flex flex-col">
return (
<div className={`flex flex-col gap-1 ${className}`}>
<button
type="button"
onClick={onZoomIn}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-t-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16 border-b-0"
title="Zoom in"
onClick={onAutoFit}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
title="Auto-fit graph to viewport"
>
+
Fit
</button>
<button
type="button"
onClick={onZoomOut}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-b-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
title="Zoom out"
onClick={onCenter}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
title="Center view on graph"
>
Center
</button>
<div className="flex flex-col">
<button
type="button"
onClick={onZoomIn}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-t-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16 border-b-0"
title="Zoom in"
>
+
</button>
<button
type="button"
onClick={onZoomOut}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-b-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
title="Zoom out"
>
</button>
</div>
</div>
</div>
)
})
);
},
);
NavigationControls.displayName = "NavigationControls"
NavigationControls.displayName = "NavigationControls";

View file

@ -5,8 +5,8 @@ import { usePostHog } from "@lib/posthog";
import { LogoFull } from "@repo/ui/assets/Logo";
import { TextSeparator } from "@repo/ui/components/text-separator";
import { ExternalAuthButton } from "@ui/button/external-auth";
import { Button } from "@ui/components/button";
import { Badge } from "@ui/components/badge";
import { Button } from "@ui/components/button";
import {
Carousel,
CarouselContent,
@ -20,7 +20,7 @@ import { Title1Bold } from "@ui/text/title/title-1-bold";
import Autoplay from "embla-carousel-autoplay";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
export function LoginPage({
heroText = "The unified memory API for the AI era.",

View file

@ -1,21 +1,21 @@
import { z } from "zod"
import "zod-openapi/extend"
import { z } from "zod";
import "zod-openapi/extend";
import {
MetadataSchema as BaseMetadataSchema,
DocumentSchema,
MemoryEntrySchema,
OrganizationSettingsSchema,
RequestTypeEnum,
} from "./schemas"
} from "./schemas";
export const MetadataSchema = BaseMetadataSchema
export const MetadataSchema = BaseMetadataSchema;
export const SearchFiltersSchema = z
.object({
AND: z.array(z.unknown()).optional(),
OR: z.array(z.unknown()).optional(),
})
.or(z.record(z.unknown()))
.or(z.record(z.unknown()));
const exampleMetadata: Record<string, string | number | boolean> = {
category: "technology",
@ -24,7 +24,7 @@ const exampleMetadata: Record<string, string | number | boolean> = {
source: "web",
tag_1: "ai",
tag_2: "machine-learning",
} as const
} as const;
const exampleMemory = {
connectionId: "conn_123",
@ -45,7 +45,7 @@ const exampleMemory = {
type: "text",
updatedAt: new Date().toISOString(),
url: "https://example.com/article",
} as const
} as const;
export const MemorySchema = z
.object({
@ -136,7 +136,7 @@ export const MemorySchema = z
.openapi({
description: "Memory object",
example: exampleMemory,
})
});
export const MemoryUpdateSchema = z.object({
containerTags: z
@ -162,9 +162,9 @@ export const MemoryUpdateSchema = z.object({
"Optional metadata for the memory. This is used to store additional information about the memory. You can use this to store any additional information you need about the memory. Metadata can be filtered through. Keys must be strings and are case sensitive. Values can be strings, numbers, or booleans. You cannot nest objects.",
example: exampleMetadata,
}),
})
});
export const MemoryAddSchema = MemoryUpdateSchema
export const MemoryAddSchema = MemoryUpdateSchema;
export const PaginationSchema = z
.object({
@ -181,9 +181,9 @@ export const PaginationSchema = z
totalItems: 100,
totalPages: 10,
},
})
});
export const GetMemoryResponseSchema = MemorySchema
export const GetMemoryResponseSchema = MemorySchema;
export const ListMemoriesResponseSchema = z
.object({
@ -229,7 +229,7 @@ export const ListMemoriesResponseSchema = z
totalPages: 10,
},
},
})
});
export const ListMemoriesQuerySchema = z
.object({
@ -317,12 +317,12 @@ export const ListMemoriesQuerySchema = z
page: 1,
sort: "createdAt",
},
})
});
export const MemoryResponseSchema = z.object({
id: z.string(),
status: z.string(),
})
});
export const SearchRequestSchema = z.object({
categoriesFilter: z
@ -455,7 +455,7 @@ export const SearchRequestSchema = z.object({
"If true, rewrites the query to make it easier to find documents. This increases the latency by about 400ms",
example: false,
}),
})
});
export const Searchv4RequestSchema = z.object({
containerTag: z.string().optional().openapi({
@ -546,7 +546,7 @@ export const Searchv4RequestSchema = z.object({
"If true, rewrites the query to make it easier to find documents. This increases the latency by about 400ms",
example: false,
}),
})
});
export const SearchResultSchema = z.object({
chunks: z
@ -632,13 +632,13 @@ export const SearchResultSchema = z.object({
description: "Document type",
example: "web",
}),
})
});
export const SearchResponseSchema = z.object({
results: z.array(SearchResultSchema),
timing: z.number(),
total: z.number(),
})
});
// V4 Memory Search Schemas
export const MemorySearchDocumentSchema = z.object({
@ -666,7 +666,7 @@ export const MemorySearchDocumentSchema = z.object({
description: "Document last update date",
format: "date-time",
}),
})
});
export const MemorySearchResult = z.object({
id: z.string().openapi({
@ -765,7 +765,7 @@ export const MemorySearchResult = z.object({
documents: z.array(MemorySearchDocumentSchema).optional().openapi({
description: "Associated documents for this memory entry",
}),
})
});
export const MemorySearchResponseSchema = z.object({
results: z.array(MemorySearchResult).openapi({
@ -779,7 +779,7 @@ export const MemorySearchResponseSchema = z.object({
description: "Total number of results returned",
example: 5,
}),
})
});
export const ErrorResponseSchema = z.object({
details: z.string().optional().openapi({
@ -790,15 +790,15 @@ export const ErrorResponseSchema = z.object({
description: "Error message",
example: "Invalid request parameters",
}),
})
});
export type SearchResult = z.infer<typeof SearchResultSchema>
export type SearchResult = z.infer<typeof SearchResultSchema>;
export const SettingsRequestSchema = OrganizationSettingsSchema.omit({
id: true,
orgId: true,
updatedAt: true,
})
});
export const ConnectionResponseSchema = z.object({
createdAt: z.string().datetime(),
@ -808,21 +808,21 @@ export const ConnectionResponseSchema = z.object({
id: z.string(),
metadata: z.record(z.any()).optional(),
provider: z.string(),
})
});
export const RequestTypeSchema = RequestTypeEnum
export const RequestTypeSchema = RequestTypeEnum;
export const HourlyAnalyticsSchema = z.object({
count: z.number(),
hour: z.union([z.date(), z.string()]),
})
});
export const ApiKeyAnalyticsBaseSchema = z.object({
count: z.number(),
keyId: z.string(),
keyName: z.string().nullable(),
lastUsed: z.union([z.date(), z.string()]).nullable(),
})
});
export const AnalyticsUsageResponseSchema = z.object({
byKey: z.array(
@ -845,7 +845,7 @@ export const AnalyticsUsageResponseSchema = z.object({
type: RequestTypeSchema,
}),
),
})
});
export const AnalyticsErrorResponseSchema = z.object({
byKey: z.array(
@ -878,7 +878,7 @@ export const AnalyticsErrorResponseSchema = z.object({
type: RequestTypeSchema,
}),
),
})
});
export const AnalyticsLogSchema = z.object({
createdAt: z.date(),
@ -917,12 +917,12 @@ export const AnalyticsLogSchema = z.object({
]),
statusCode: z.number(),
type: RequestTypeSchema,
})
});
export const AnalyticsLogsResponseSchema = z.object({
logs: z.array(z.unknown()),
pagination: PaginationSchema,
})
});
export const AnalyticsChatResponseSchema = z.object({
analytics: z.object({
@ -1022,7 +1022,7 @@ export const AnalyticsChatResponseSchema = z.object({
}),
}),
}),
})
});
export const AnalyticsMemoryResponseSchema = z.object({
connectionsGrowth: z.number(),
@ -1033,7 +1033,7 @@ export const AnalyticsMemoryResponseSchema = z.object({
tokensProcessed: z.number(),
totalConnections: z.number(),
totalMemories: z.number(),
})
});
export const MemoryEntryAPISchema = MemoryEntrySchema.extend({
sourceAddedAt: z.date().nullable(), // From join relationship
@ -1042,7 +1042,7 @@ export const MemoryEntryAPISchema = MemoryEntrySchema.extend({
spaceContainerTag: z.string().nullable(), // From join relationship
}).openapi({
description: "Memory entry with source relationship data",
})
});
// Extended document schema with memory entries
export const DocumentWithMemoriesSchema = z
@ -1075,7 +1075,7 @@ export const DocumentWithMemoriesSchema = z
})
.openapi({
description: "Document with associated memory entries",
})
});
export const DocumentsWithMemoriesResponseSchema = z
.object({
@ -1084,7 +1084,7 @@ export const DocumentsWithMemoriesResponseSchema = z
})
.openapi({
description: "List of documents with their memory entries",
})
});
export const DocumentsWithMemoriesQuerySchema = z
.object({
@ -1114,7 +1114,7 @@ export const DocumentsWithMemoriesQuerySchema = z
})
.openapi({
description: "Query parameters for listing documents with memory entries",
})
});
export const MigrateMCPRequestSchema = z
.object({
@ -1129,7 +1129,7 @@ export const MigrateMCPRequestSchema = z
})
.openapi({
description: "Request body for migrating MCP documents",
})
});
export const MigrateMCPResponseSchema = z
.object({
@ -1155,7 +1155,7 @@ export const MigrateMCPResponseSchema = z
})
.openapi({
description: "Response for MCP document migration",
})
});
// Processing documents schema
export const ProcessingDocumentsResponseSchema = z
@ -1196,7 +1196,7 @@ export const ProcessingDocumentsResponseSchema = z
],
totalCount: 5,
},
})
});
// Project schemas
export const ProjectSchema = z
@ -1235,7 +1235,7 @@ export const ProjectSchema = z
})
.openapi({
description: "Project object for organizing memories",
})
});
export const CreateProjectSchema = z
.object({
@ -1248,7 +1248,7 @@ export const CreateProjectSchema = z
})
.openapi({
description: "Request body for creating a new project",
})
});
export const ListProjectsResponseSchema = z
.object({
@ -1258,7 +1258,7 @@ export const ListProjectsResponseSchema = z
})
.openapi({
description: "Response containing list of projects",
})
});
export const DeleteProjectSchema = z
.object({
@ -1275,9 +1275,9 @@ export const DeleteProjectSchema = z
(data) => {
// If action is "move", targetProjectId is required
if (data.action === "move") {
return !!data.targetProjectId
return !!data.targetProjectId;
}
return true
return true;
},
{
message: "targetProjectId is required when action is 'move'",
@ -1286,7 +1286,7 @@ export const DeleteProjectSchema = z
)
.openapi({
description: "Request body for deleting a project",
})
});
export const DeleteProjectResponseSchema = z
.object({
@ -1309,7 +1309,7 @@ export const DeleteProjectResponseSchema = z
})
.openapi({
description: "Response for project deletion",
})
});
// Bulk delete schema - supports both IDs and container tags
export const BulkDeleteMemoriesSchema = z
@ -1336,7 +1336,7 @@ export const BulkDeleteMemoriesSchema = z
.refine(
(data) => {
// At least one of ids or containerTags must be provided
return !!data.ids?.length || !!data.containerTags?.length
return !!data.ids?.length || !!data.containerTags?.length;
},
{
message: "Either 'ids' or 'containerTags' must be provided",
@ -1348,7 +1348,7 @@ export const BulkDeleteMemoriesSchema = z
example: {
ids: ["acxV5LHMEsG2hMSNb4umbn", "bxcV5LHMEsG2hMSNb4umbn"],
},
})
});
export const BulkDeleteMemoriesResponseSchema = z
.object({
@ -1383,4 +1383,4 @@ export const BulkDeleteMemoriesResponseSchema = z
})
.openapi({
description: "Response for bulk memory deletion",
})
});

View file

@ -1,13 +1,13 @@
import { z } from "zod"
import { ConnectionProviderEnum } from "./schemas"
import { z } from "zod";
import { ConnectionProviderEnum } from "./schemas";
export const providers = ConnectionProviderEnum
export type Provider = z.infer<typeof providers>
export const providers = ConnectionProviderEnum;
export type Provider = z.infer<typeof providers>;
const BaseMetadataSchema = <T extends z.ZodTypeAny>(provider: T) =>
z.object({
provider,
})
});
export const NotionMetadataSchema = BaseMetadataSchema(
z.literal("notion"),
@ -17,8 +17,8 @@ export const NotionMetadataSchema = BaseMetadataSchema(
workspaceIcon: z.string().optional(),
workspaceId: z.string(),
workspaceName: z.string(),
})
export type NotionMetadata = z.infer<typeof NotionMetadataSchema>
});
export type NotionMetadata = z.infer<typeof NotionMetadataSchema>;
export const GoogleDriveMetadataSchema = BaseMetadataSchema(
z.literal("google-drive"),
@ -27,8 +27,8 @@ export const GoogleDriveMetadataSchema = BaseMetadataSchema(
webhookChannelId: z.string().optional(),
webhookExpiration: z.number().optional(),
webhookResourceId: z.string().optional(),
})
export type GoogleDriveMetadata = z.infer<typeof GoogleDriveMetadataSchema>
});
export type GoogleDriveMetadata = z.infer<typeof GoogleDriveMetadataSchema>;
export const OneDriveMetadataSchema = BaseMetadataSchema(
z.literal("onedrive"),
@ -38,38 +38,38 @@ export const OneDriveMetadataSchema = BaseMetadataSchema(
webhookClientState: z.string().optional(),
webhookExpiration: z.number().optional(),
webhookSubscriptionId: z.string().optional(),
})
export type OneDriveMetadata = z.infer<typeof OneDriveMetadataSchema>
});
export type OneDriveMetadata = z.infer<typeof OneDriveMetadataSchema>;
export const ConnectionMetadataSchema = z.discriminatedUnion("provider", [
NotionMetadataSchema,
GoogleDriveMetadataSchema,
OneDriveMetadataSchema,
])
]);
export type ConnectionMetadata<T extends Provider> = T extends "notion"
? NotionMetadata
: T extends "google-drive"
? GoogleDriveMetadata
: T extends "onedrive"
? OneDriveMetadata
: never
: never;
export function isNotionMetadata(
metadata: unknown,
): metadata is NotionMetadata {
return NotionMetadataSchema.safeParse(metadata).success
return NotionMetadataSchema.safeParse(metadata).success;
}
export function isGoogleDriveMetadata(
metadata: unknown,
): metadata is GoogleDriveMetadata {
return GoogleDriveMetadataSchema.safeParse(metadata).success
return GoogleDriveMetadataSchema.safeParse(metadata).success;
}
export function isOneDriveMetadata(
metadata: unknown,
): metadata is OneDriveMetadata {
return OneDriveMetadataSchema.safeParse(metadata).success
return OneDriveMetadataSchema.safeParse(metadata).success;
}
export const ConnectionStateSchema = z.object({
@ -77,8 +77,8 @@ export const ConnectionStateSchema = z.object({
org: z.string(),
provider: providers,
userId: z.string(),
})
export type ConnectionState = z.infer<typeof ConnectionStateSchema>
});
export type ConnectionState = z.infer<typeof ConnectionStateSchema>;
export const TokenDataSchema = z.object({
// Only used for Notion connections since they don't support refresh tokens
@ -90,8 +90,8 @@ export const TokenDataSchema = z.object({
metadata: ConnectionMetadataSchema.optional(),
refreshToken: z.string().optional(),
userId: z.string().optional(),
})
export type TokenData = z.infer<typeof TokenDataSchema>
});
export type TokenData = z.infer<typeof TokenDataSchema>;
export const NotionTokenResponseSchema = z.object({
access_token: z.string(),
@ -123,8 +123,8 @@ export const NotionTokenResponseSchema = z.object({
workspace_icon: z.string().optional(),
workspace_id: z.string(),
workspace_name: z.string(),
})
export type NotionTokenResponse = z.infer<typeof NotionTokenResponseSchema>
});
export type NotionTokenResponse = z.infer<typeof NotionTokenResponseSchema>;
export const GoogleDriveTokenResponseSchema = z.object({
access_token: z.string(),
@ -132,10 +132,10 @@ export const GoogleDriveTokenResponseSchema = z.object({
refresh_token: z.string().optional(),
scope: z.string(),
token_type: z.literal("Bearer"),
})
});
export type GoogleDriveTokenResponse = z.infer<
typeof GoogleDriveTokenResponseSchema
>
>;
export const OneDriveTokenResponseSchema = z.object({
access_token: z.string(),
@ -143,8 +143,8 @@ export const OneDriveTokenResponseSchema = z.object({
refresh_token: z.string().optional(),
scope: z.string(),
token_type: z.literal("Bearer"),
})
export type OneDriveTokenResponse = z.infer<typeof OneDriveTokenResponseSchema>
});
export type OneDriveTokenResponse = z.infer<typeof OneDriveTokenResponseSchema>;
export const NotionConfigSchema = z.object({
clientId: z.string(),
@ -154,23 +154,23 @@ export const NotionConfigSchema = z.object({
token: z.string().url(),
}),
scopes: z.array(z.string()),
})
export type NotionConfig = z.infer<typeof NotionConfigSchema>
});
export type NotionConfig = z.infer<typeof NotionConfigSchema>;
export const ConnectionQuerySchema = z.object({
id: z.string(),
redirectUrl: z.string().optional(),
})
});
export const GoogleDrivePageTokenResponseSchema = z.object({
startPageToken: z.union([z.string(), z.number()]),
})
});
export const GoogleDriveWatchResponseSchema = z.object({
expiration: z.string(),
id: z.string(),
resourceId: z.string(),
})
});
export const OneDriveSubscriptionResponseSchema = z.object({
changeType: z.string(),
@ -179,13 +179,13 @@ export const OneDriveSubscriptionResponseSchema = z.object({
id: z.string(),
notificationUrl: z.string(),
resource: z.string(),
})
});
export const GoogleUserInfoResponseSchema = z.object({
email: z.string().email(),
})
});
export const MicrosoftUserInfoResponseSchema = z.object({
mail: z.string().optional(),
userPrincipalName: z.string().optional(),
})
});