Self-hosted dashboard and admin auth (#4837)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
co-authored by
kartik-mem0
parent
15feaa8ac4
commit
db8ac61713
@@ -0,0 +1,44 @@
|
||||
import "@/styles/globals.css";
|
||||
import React from "react";
|
||||
import { Inter, InterDisplay, Roboto, Fustat, DMMono } from "../(root)/fonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
|
||||
export const metadata = {
|
||||
title: "Mem0 - Log in",
|
||||
description: "Log in to Mem0",
|
||||
};
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={cn(
|
||||
Fustat.variable,
|
||||
InterDisplay.variable,
|
||||
Inter.variable,
|
||||
Roboto.variable,
|
||||
DMMono.variable,
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="font-fustat" suppressHydrationWarning>
|
||||
<AuthProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { isValidEmail } from "@/lib/validators";
|
||||
|
||||
const RESET_COMMAND =
|
||||
"make reset-admin-password EMAIL=<your-email> PASSWORD=<new-password>";
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { user, isLoading, login } = useAuth();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && user) {
|
||||
router.push(searchParams.get("next") || "/dashboard/requests");
|
||||
}
|
||||
}, [user, isLoading, router, searchParams]);
|
||||
|
||||
const emailValid = isValidEmail(email);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (!emailValid) {
|
||||
setError("Enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
router.push(searchParams.get("next") || "/dashboard/requests");
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, "Login failed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<div className="flex-1 bg-surface-default-primary flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex justify-center mb-2">
|
||||
{mounted && (
|
||||
<Image
|
||||
src={
|
||||
resolvedTheme === "dark"
|
||||
? "/images/logos/logo-light.png"
|
||||
: "/images/logos/logo-dark.png"
|
||||
}
|
||||
alt="Mem0"
|
||||
width={41}
|
||||
height={41}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-onSurface-default-primary text-center mb-6 font-fustat">
|
||||
Sign in to Mem0
|
||||
</h1>
|
||||
<div className="flex flex-col gap-4 border p-8 border-memBorder-primary rounded-xl">
|
||||
{error && (
|
||||
<p className="text-sm text-onSurface-danger-primary bg-surface-danger-primary px-3 py-2 rounded">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-email">Email</Label>
|
||||
<Input
|
||||
id="login-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@company.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-password">Password</Label>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || !emailValid || !password}
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{submitting ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-onSurface-default-tertiary hover:text-onSurface-default-primary underline underline-offset-4 self-center"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset your admin password</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run this command on the server host. It overwrites the
|
||||
existing password; anyone already signed in stays signed in
|
||||
until their session expires.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={RESET_COMMAND}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<CopyToClipboard
|
||||
text={RESET_COMMAND}
|
||||
onCopy={() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="icon">
|
||||
{copied ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative hidden h-screen flex-1 items-center justify-center overflow-hidden bg-gradient-to-b from-[#31275A] to-[#5C49A3] px-10 lg:flex">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[url('/images/dither.svg')] bg-bottom bg-no-repeat bg-contain" />
|
||||
<div className="relative z-10 flex w-full max-w-[564px] flex-col items-center gap-20 text-center text-white">
|
||||
<div className="w-full space-y-5">
|
||||
<p className="typo-h3 text-white">
|
||||
"Mem0 allowed us to unlock true personalized tutoring for
|
||||
every student, and it took us just a weekend to integrate."
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-[7px]">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="typo-body-sm text-white">Michael Tong</p>
|
||||
<p className="typo-body-xs text-white">CTO, RevisionDojo</p>
|
||||
</div>
|
||||
<Image
|
||||
src="/images/micheal.png"
|
||||
alt="Michael Tong"
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<p className="typo-body text-white">Trusted by 100k+ Developers</p>
|
||||
<div className="flex items-center justify-center gap-8 text-white">
|
||||
<div className="h-6 shrink-0">
|
||||
<Image
|
||||
src="/images/logos/aws.svg"
|
||||
alt="AWS"
|
||||
width={41}
|
||||
height={24}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-5 shrink-0">
|
||||
<Image
|
||||
src="/images/logos/nvidia.svg"
|
||||
alt="NVIDIA"
|
||||
width={109}
|
||||
height={21}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[21px] shrink-0">
|
||||
<Image
|
||||
src="/images/vercel.png"
|
||||
alt="Vercel"
|
||||
width={66}
|
||||
height={21}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import LoginForm from "./login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import ThemeAwareLogo from "@/components/misc/theme-aware-logo";
|
||||
import { LinearProgress } from "@/components/ui/linearProgress";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export const SIDEBAR_WIDTH = 180;
|
||||
export const COLLAPSED_SIDEBAR_WIDTH = 64;
|
||||
export const COLLAPSED_SIDEBAR_PADDING = 16;
|
||||
export const COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING =
|
||||
COLLAPSED_SIDEBAR_WIDTH - COLLAPSED_SIDEBAR_PADDING;
|
||||
|
||||
function AuthLoadingState() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||
<ThemeAwareLogo />
|
||||
<LinearProgress value={66} className="mt-8 h-1 w-[180px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ClientLayout: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, isLoading } = useAuth();
|
||||
const isPublicPage =
|
||||
pathname.startsWith("/login") || pathname.startsWith("/setup");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPublicPage && !isLoading && !user) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [isLoading, isPublicPage, router, user]);
|
||||
|
||||
if (isLoading && !isPublicPage) {
|
||||
return <AuthLoadingState />;
|
||||
}
|
||||
|
||||
if (!isPublicPage && !user) {
|
||||
return <AuthLoadingState />;
|
||||
}
|
||||
|
||||
return <TooltipProvider delayDuration={0}>{children}</TooltipProvider>;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "@/styles/globals.css";
|
||||
import { ClientLayout } from "./clientLayout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Inter, InterDisplay, Roboto, Fustat, DMMono } from "./fonts";
|
||||
import { Provider } from "react-redux";
|
||||
import store from "@/store/store";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Toaster = dynamic(
|
||||
() =>
|
||||
import("@/components/ui/sonner").then((mod) => ({ default: mod.Toaster })),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
Inter.className,
|
||||
InterDisplay.variable,
|
||||
Roboto.variable,
|
||||
Fustat.variable,
|
||||
DMMono.variable,
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<Provider store={store}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ClientLayout>{children}</ClientLayout>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { LockedPage } from "@/components/self-hosted/locked-page";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
function AnalyticsMockup() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: "Total Operations", value: "12,847" },
|
||||
{ label: "Avg Latency", value: "142ms" },
|
||||
{ label: "Success Rate", value: "99.7%" },
|
||||
].map((stat) => (
|
||||
<Card key={stat.label} className="border-memBorder-primary">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold mt-1">{stat.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Operations over time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[200px] flex items-end gap-1">
|
||||
{[40, 65, 45, 80, 55, 90, 70, 85, 60, 95, 75, 50].map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-surface-default-brand rounded-t"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return (
|
||||
<LockedPage
|
||||
title="Analytics"
|
||||
description="Track memory operations, latency, and usage patterns over time."
|
||||
previewContent={<AnalyticsMockup />}
|
||||
utmMedium="dashboard-locked-analytics"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { TableSkeleton } from "@/components/shared/table-skeleton";
|
||||
import { EmptyState } from "@/components/self-hosted/empty-state";
|
||||
import DeleteConfirmationModal from "@/components/ui/delete-confirmation-modal";
|
||||
import { api } from "@/utils/api";
|
||||
import { API_KEY_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { UpgradeBanner } from "@/components/self-hosted/upgrade-banner";
|
||||
import { Plus, Copy, Check, Trash2 } from "lucide-react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { format } from "date-fns";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { ApiKey, ApiKeyCreateResponse } from "@/types/api";
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
|
||||
|
||||
const {
|
||||
data: keys = [],
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useApiQuery<ApiKey[]>(
|
||||
async () => {
|
||||
const res = await api.get<ApiKey[]>(API_KEY_ENDPOINTS.BASE);
|
||||
return res.data ?? [];
|
||||
},
|
||||
{ errorToast: "Failed to load API keys", initialData: [] },
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const res = await api.post<ApiKeyCreateResponse>(API_KEY_ENDPOINTS.BASE, {
|
||||
label: newLabel,
|
||||
});
|
||||
setNewKey(res.data.key);
|
||||
void refetch();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to create key",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!keyToRevoke) return;
|
||||
try {
|
||||
await api.delete(API_KEY_ENDPOINTS.BY_ID(keyToRevoke.id));
|
||||
toast({ title: "API key revoked", variant: "success" });
|
||||
setKeyToRevoke(null);
|
||||
void refetch();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to revoke key",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
setNewKey("");
|
||||
setNewLabel("");
|
||||
setCopied(false);
|
||||
}
|
||||
setCreateOpen(open);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "label" as keyof ApiKey, label: "Label", width: 150 },
|
||||
{
|
||||
key: "key_prefix" as keyof ApiKey,
|
||||
label: "Key",
|
||||
width: 120,
|
||||
render: (value: string) => (
|
||||
<code className="text-xs font-mono">{value}...</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created_at" as keyof ApiKey,
|
||||
label: "Created",
|
||||
width: 120,
|
||||
render: (value: string) => format(new Date(value), "MMM d, yyyy"),
|
||||
},
|
||||
{
|
||||
key: "last_used_at" as keyof ApiKey,
|
||||
label: "Last Used",
|
||||
width: 120,
|
||||
render: (value: string | null) =>
|
||||
value ? format(new Date(value), "MMM d, yyyy") : "Never",
|
||||
},
|
||||
{
|
||||
key: "id" as keyof ApiKey,
|
||||
label: "",
|
||||
width: 40,
|
||||
render: (_: string, row: ApiKey) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setKeyToRevoke(row)}
|
||||
className="size-7"
|
||||
>
|
||||
<Trash2 className="size-3.5 text-onSurface-danger-primary" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold font-fustat">API Keys</h1>
|
||||
<Dialog open={createOpen} onOpenChange={handleDialogClose}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="size-4 mr-1" /> Create Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!newKey ? (
|
||||
<div className="space-y-4 mt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key-label">Label</Label>
|
||||
<Input
|
||||
id="api-key-label"
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
placeholder="e.g. Production"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!newLabel}
|
||||
className="w-full"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 mt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key-new">Your API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="api-key-new"
|
||||
value={newKey}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<CopyToClipboard
|
||||
text={newKey}
|
||||
onCopy={() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="icon">
|
||||
{copied ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<p className="text-xs text-onSurface-danger-primary">
|
||||
Save this key -- you won't see it again.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleDialogClose(false)}
|
||||
className="w-full"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{keys.length >= 3 && (
|
||||
<UpgradeBanner
|
||||
id="api-keys-3"
|
||||
message="Managing multiple apps? Cloud offers project-based isolation."
|
||||
ctaLabel="Explore Cloud"
|
||||
ctaUrl="https://app.mem0.ai?utm_source=oss&utm_medium=dashboard-api-keys"
|
||||
variant="cloud"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={3} columns={4} />
|
||||
) : keys.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No API keys yet"
|
||||
description="Create your first API key to start using the Mem0 API."
|
||||
/>
|
||||
) : (
|
||||
<Card className="border-memBorder-primary overflow-hidden">
|
||||
<DataTable
|
||||
data={keys}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.id}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={!!keyToRevoke}
|
||||
onClose={() => setKeyToRevoke(null)}
|
||||
onConfirm={handleRevoke}
|
||||
title="Revoke API key"
|
||||
description="Applications using this key will immediately stop working. This cannot be undone."
|
||||
itemName={keyToRevoke?.label ?? ""}
|
||||
confirmButtonText="Revoke"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { LockedPage } from "@/components/self-hosted/locked-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
function CategoriesMockup() {
|
||||
const categories = ["Health", "Preferences", "Work"];
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{categories.map((cat) => (
|
||||
<Card key={cat} className="border-memBorder-primary">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium mb-3">{cat}</p>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-8 bg-surface-default-secondary rounded mb-2"
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CategoriesPage() {
|
||||
return (
|
||||
<LockedPage
|
||||
title="Custom Categories"
|
||||
description="Organize memories by domain -- health, preferences, work, and more."
|
||||
previewContent={<CategoriesMockup />}
|
||||
utmMedium="dashboard-locked-categories"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Activity,
|
||||
ChartLine,
|
||||
ChevronDown,
|
||||
FolderInput,
|
||||
GalleryVerticalEnd,
|
||||
KeyRound,
|
||||
Settings,
|
||||
Tags,
|
||||
Users,
|
||||
WebhookIcon,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarGroupLabel,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function MainNav({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLElement>) {
|
||||
const pathname = usePathname();
|
||||
const isSidebarCollapsed = useSelector(
|
||||
(state: RootState) => state.layout.isSidebarCollapsed,
|
||||
);
|
||||
const [isCloudOpen, setIsCloudOpen] = React.useState(true);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
collapsible={isSidebarCollapsed ? "icon" : undefined}
|
||||
className={cn(className, "border-r-0 w-full mb-0 bg-transparent")}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarMenu className="gap-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-0">
|
||||
{!isSidebarCollapsed && (
|
||||
<SidebarGroupLabel className="mb-0">
|
||||
ACTIVITY
|
||||
</SidebarGroupLabel>
|
||||
)}
|
||||
{[
|
||||
{
|
||||
title: "Requests",
|
||||
url: "/dashboard/requests",
|
||||
icon: Activity,
|
||||
active: pathname === "/dashboard/requests",
|
||||
},
|
||||
{
|
||||
title: "Memories",
|
||||
url: "/dashboard/memories",
|
||||
icon: GalleryVerticalEnd,
|
||||
active: pathname === "/dashboard/memories",
|
||||
},
|
||||
{
|
||||
title: "Entities",
|
||||
url: "/dashboard/entities",
|
||||
icon: Users,
|
||||
active: pathname === "/dashboard/entities",
|
||||
},
|
||||
].map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
collapsed={isSidebarCollapsed}
|
||||
active={item.active}
|
||||
tooltip={isSidebarCollapsed ? item.title : undefined}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
isSidebarCollapsed
|
||||
? "justify-center mx-auto"
|
||||
: "gap-1.5",
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" />
|
||||
{!isSidebarCollapsed && <span>{item.title}</span>}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isSidebarCollapsed && (
|
||||
<div className="h-[1px] w-full bg-memBorder-primary my-2" />
|
||||
)}
|
||||
|
||||
<Collapsible
|
||||
open={isCloudOpen}
|
||||
onOpenChange={setIsCloudOpen}
|
||||
className="flex flex-col gap-0"
|
||||
>
|
||||
{!isSidebarCollapsed && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarGroupLabel className="cursor-pointer mb-0">
|
||||
CLOUD FEATURES
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3 transition-transform duration-200",
|
||||
isCloudOpen ? "" : "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
<CollapsibleContent className="flex flex-col gap-0">
|
||||
{[
|
||||
{
|
||||
title: "Categories",
|
||||
url: "/dashboard/categories",
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
title: "Webhooks",
|
||||
url: "/dashboard/webhooks",
|
||||
icon: WebhookIcon,
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
url: "/dashboard/analytics",
|
||||
icon: ChartLine,
|
||||
},
|
||||
{
|
||||
title: "Export",
|
||||
url: "/dashboard/export",
|
||||
icon: FolderInput,
|
||||
},
|
||||
].map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
collapsed={isSidebarCollapsed}
|
||||
active={pathname === item.url}
|
||||
tooltip={isSidebarCollapsed ? item.title : undefined}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
isSidebarCollapsed
|
||||
? "justify-center mx-auto"
|
||||
: "gap-1.5",
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" />
|
||||
{!isSidebarCollapsed && (
|
||||
<>
|
||||
<span>{item.title}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto text-memGold-600 border-memGold-300 typo-caption-sm px-1.5 py-0"
|
||||
>
|
||||
PRO
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{isSidebarCollapsed && (
|
||||
<div className="h-[1px] w-full bg-memBorder-primary my-2" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0">
|
||||
{!isSidebarCollapsed && (
|
||||
<SidebarGroupLabel className="mb-0">
|
||||
ACCOUNT
|
||||
</SidebarGroupLabel>
|
||||
)}
|
||||
{[
|
||||
{
|
||||
title: "API Keys",
|
||||
url: "/dashboard/api-keys",
|
||||
icon: KeyRound,
|
||||
active: pathname === "/dashboard/api-keys",
|
||||
},
|
||||
{
|
||||
title: "Configuration",
|
||||
url: "/dashboard/configuration",
|
||||
icon: Wrench,
|
||||
active: pathname === "/dashboard/configuration",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/dashboard/settings",
|
||||
icon: Settings,
|
||||
active: pathname === "/dashboard/settings",
|
||||
},
|
||||
].map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
collapsed={isSidebarCollapsed}
|
||||
active={item.active}
|
||||
tooltip={isSidebarCollapsed ? item.title : undefined}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
isSidebarCollapsed
|
||||
? "justify-center mx-auto"
|
||||
: "gap-1.5",
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" />
|
||||
{!isSidebarCollapsed && <span>{item.title}</span>}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { MainNav } from "./main-nav";
|
||||
import { PanelRight, LogOut, Settings, HelpCircle } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
COLLAPSED_SIDEBAR_WIDTH,
|
||||
COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING,
|
||||
SIDEBAR_WIDTH,
|
||||
} from "../../clientLayout";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RootState } from "@/store/store";
|
||||
import { toggleSidebar } from "@/store/reducers/layoutReducer";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import Link from "next/link";
|
||||
import { Building2 } from "lucide-react";
|
||||
|
||||
export default function NavWrapper() {
|
||||
const dispatch = useDispatch();
|
||||
const isSidebarCollapsed = useSelector(
|
||||
(state: RootState) => state.layout.isSidebarCollapsed,
|
||||
);
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const instanceName = process.env.NEXT_PUBLIC_INSTANCE_NAME || "Mem0";
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
dispatch(toggleSidebar());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed top-0 left-0 h-full flex justify-between flex-col overflow-hidden transition-all duration-300 ease-in-out z-30 bg-transparent"
|
||||
style={{
|
||||
width: isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH : SIDEBAR_WIDTH,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-h-0 items-start gap-5 px-3 py-3 overflow-y-auto overflow-x-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full",
|
||||
isSidebarCollapsed ? "p-0 justify-center" : "",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
isSidebarCollapsed ? "justify-center" : "gap-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center size-7 rounded-md bg-surface-default-tertiary shrink-0">
|
||||
<Building2 className="size-4 text-onSurface-default-primary" />
|
||||
</div>
|
||||
{!isSidebarCollapsed && (
|
||||
<span className="typo-body-xs text-onSurface-default-primary truncate text-left flex-1 min-w-0">
|
||||
{instanceName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MainNav className="w-full" />
|
||||
</div>
|
||||
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="flex flex-col shrink-0">
|
||||
<div className="mx-3 px-0 py-3 border-t border-memBorder-primary">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-2 w-full text-left hover:bg-surface-default-secondary-hover rounded-md p-1.5 transition-colors">
|
||||
<div className="grid size-7 place-items-center rounded-md bg-surface-default-tertiary text-onSurface-default-secondary text-xs font-semibold shrink-0">
|
||||
{user?.name?.charAt(0).toUpperCase() || "?"}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="typo-body-xs text-onSurface-default-primary truncate">
|
||||
{user?.name}
|
||||
</span>
|
||||
<span className="typo-caption-sm text-onSurface-default-tertiary truncate">
|
||||
{user?.email}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side="top"
|
||||
className="w-56 font-fustat bg-surface-default-primary border-memBorder-secondary"
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="typo-body-sm text-onSurface-default-primary">
|
||||
{user?.name}
|
||||
</p>
|
||||
<p className="typo-body-xs text-onSurface-default-tertiary">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator className="bg-memBorder-primary" />
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="typo-body-sm text-onSurface-default-primary hover:bg-surface-default-tertiary-hover focus:bg-surface-default-tertiary-hover cursor-pointer"
|
||||
>
|
||||
<Link href="/dashboard/settings">
|
||||
<Settings className="size-4 mr-2" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-memBorder-primary" />
|
||||
<DropdownMenuItem
|
||||
onClick={logout}
|
||||
className="typo-body-sm text-onSurface-default-primary hover:bg-surface-default-tertiary-hover focus:bg-surface-default-tertiary-hover cursor-pointer"
|
||||
>
|
||||
<LogOut className="size-4 mr-2" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-transparent left-0 top-0 fixed flex justify-between items-center pr-4 h-12 font-fustat z-20"
|
||||
style={{
|
||||
width: `calc(100% - ${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING + 12 : SIDEBAR_WIDTH + 12}px)`,
|
||||
left: `${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING + 12 : SIDEBAR_WIDTH + 12}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="cursor-pointer text-onSurface-default-tertiary hover:text-onSurface-default-secondary"
|
||||
aria-label={
|
||||
isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
|
||||
}
|
||||
>
|
||||
<PanelRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://docs.mem0.ai/open-source/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center text-onSurface-default-tertiary hover:text-onSurface-default-secondary"
|
||||
>
|
||||
<HelpCircle className="size-4 shrink-0" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Documentation</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { UpgradeBanner } from "@/components/self-hosted/upgrade-banner";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { api } from "@/utils/api";
|
||||
import { MEMORY_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import {
|
||||
buildProviderConfig,
|
||||
getEffectiveConfig,
|
||||
} from "@/utils/self-hosted-config";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
|
||||
type BundledProviders = {
|
||||
llm: string[];
|
||||
embedder: string[];
|
||||
};
|
||||
|
||||
export default function ConfigurationPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [llmProvider, setLlmProvider] = useState("");
|
||||
const [llmModel, setLlmModel] = useState("");
|
||||
const [llmApiKey, setLlmApiKey] = useState("");
|
||||
const [embedderProvider, setEmbedderProvider] = useState("");
|
||||
const [embedderModel, setEmbedderModel] = useState("");
|
||||
|
||||
const { data: config, isLoading: isPrefilling } = useApiQuery(
|
||||
async () => {
|
||||
const res = await api.get(MEMORY_ENDPOINTS.CONFIGURE);
|
||||
return getEffectiveConfig(res.data);
|
||||
},
|
||||
{ errorToast: "Failed to load server configuration" },
|
||||
);
|
||||
|
||||
const { data: providers } = useApiQuery<BundledProviders>(
|
||||
async () => {
|
||||
const res = await api.get<BundledProviders>(
|
||||
MEMORY_ENDPOINTS.CONFIGURE_PROVIDERS,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
{ errorToast: "Failed to load bundled providers" },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
setLlmProvider((current) => current || config.llm?.provider || "");
|
||||
setLlmModel((current) => current || config.llm?.config?.model || "");
|
||||
setEmbedderProvider(
|
||||
(current) => current || config.embedder?.provider || "",
|
||||
);
|
||||
setEmbedderModel(
|
||||
(current) => current || config.embedder?.config?.model || "",
|
||||
);
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const llm = buildProviderConfig({
|
||||
provider: llmProvider,
|
||||
model: llmModel,
|
||||
apiKey: llmApiKey,
|
||||
});
|
||||
const embedder = buildProviderConfig({
|
||||
provider: embedderProvider,
|
||||
model: embedderModel,
|
||||
});
|
||||
|
||||
const newConfig: Record<string, unknown> = {
|
||||
version: "v1.1",
|
||||
};
|
||||
|
||||
if (llm) {
|
||||
newConfig.llm = llm;
|
||||
}
|
||||
|
||||
if (embedder) {
|
||||
newConfig.embedder = embedder;
|
||||
}
|
||||
|
||||
await api.post(MEMORY_ENDPOINTS.CONFIGURE, newConfig);
|
||||
toast({ title: "Configuration saved", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to save configuration",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold font-fustat">Configuration</h1>
|
||||
{isPrefilling && (
|
||||
<p className="text-sm text-onSurface-default-tertiary">
|
||||
Loading effective server configuration...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">LLM Provider</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Provider</Label>
|
||||
<Select
|
||||
value={llmProvider}
|
||||
onValueChange={setLlmProvider}
|
||||
disabled={!isAdmin || !providers}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.llm.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Model</Label>
|
||||
<Input
|
||||
placeholder="gpt-4.1-nano-2025-04-14"
|
||||
value={llmModel}
|
||||
onChange={(e) => setLlmModel(e.target.value)}
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={llmApiKey}
|
||||
onChange={(e) => setLlmApiKey(e.target.value)}
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Embedding Model</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Provider</Label>
|
||||
<Select
|
||||
value={embedderProvider}
|
||||
onValueChange={setEmbedderProvider}
|
||||
disabled={!isAdmin || !providers}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.embedder.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Model</Label>
|
||||
<Input
|
||||
placeholder="text-embedding-3-small"
|
||||
value={embedderModel}
|
||||
onChange={(e) => setEmbedderModel(e.target.value)}
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Need another provider? Install its Python package, rebuild the image,
|
||||
and extend the bundled list. See the{" "}
|
||||
<a
|
||||
href="https://docs.mem0.ai/open-source/setup#supported-providers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-4 hover:text-onSurface-default-primary"
|
||||
>
|
||||
setup guide
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
{isAdmin && (
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<UpgradeBanner
|
||||
id="config-sso"
|
||||
message="Looking for SSO / SAML? Available in Enterprise."
|
||||
ctaLabel="Contact sales"
|
||||
ctaUrl="https://app.mem0.ai/enterprise?utm_source=oss&utm_medium=dashboard-configuration-sso"
|
||||
variant="enterprise"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { TableSkeleton } from "@/components/shared/table-skeleton";
|
||||
import { EmptyState } from "@/components/self-hosted/empty-state";
|
||||
import DeleteConfirmationModal from "@/components/ui/delete-confirmation-modal";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { api } from "@/utils/api";
|
||||
import { ENTITY_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { Entity } from "@/types/api";
|
||||
|
||||
export default function EntitiesPage() {
|
||||
const [entityToDelete, setEntityToDelete] = useState<Entity | null>(null);
|
||||
|
||||
const {
|
||||
data: entities = [],
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useApiQuery<Entity[]>(
|
||||
async () => {
|
||||
const res = await api.get<Entity[]>(ENTITY_ENDPOINTS.BASE);
|
||||
return res.data ?? [];
|
||||
},
|
||||
{ errorToast: "Failed to load entities", initialData: [] },
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!entityToDelete) return;
|
||||
try {
|
||||
await api.delete(
|
||||
ENTITY_ENDPOINTS.BY_ID(entityToDelete.type, entityToDelete.id),
|
||||
);
|
||||
toast({ title: "Entity deleted", variant: "success" });
|
||||
setEntityToDelete(null);
|
||||
void refetch();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to delete entity",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "type" as keyof Entity,
|
||||
label: "Type",
|
||||
width: 100,
|
||||
render: (value: Entity["type"]) => (
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{value}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "id" as keyof Entity,
|
||||
label: "ID",
|
||||
width: 280,
|
||||
render: (value: string) => (
|
||||
<span className="font-mono text-sm truncate">{value}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "total_memories" as keyof Entity,
|
||||
label: "Memories",
|
||||
width: 100,
|
||||
align: "right" as const,
|
||||
},
|
||||
{
|
||||
key: "updated_at" as keyof Entity,
|
||||
label: "Last Active",
|
||||
width: 140,
|
||||
render: (value: string | null) =>
|
||||
value ? format(new Date(value), "MMM d, yyyy") : "--",
|
||||
},
|
||||
{
|
||||
key: "id" as keyof Entity,
|
||||
label: "",
|
||||
width: 40,
|
||||
render: (_: string, row: Entity) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEntityToDelete(row)}
|
||||
className="size-7"
|
||||
>
|
||||
<Trash2 className="size-3.5 text-onSurface-danger-primary" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold font-fustat">Entities</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={5} columns={5} />
|
||||
) : entities.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No entities yet"
|
||||
description="Entities appear once memories are stored with a user_id, agent_id, or run_id."
|
||||
/>
|
||||
) : (
|
||||
<Card className="border-memBorder-primary overflow-hidden">
|
||||
<DataTable
|
||||
data={entities}
|
||||
columns={columns}
|
||||
getRowKey={(row) => `${row.type}:${row.id}`}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={!!entityToDelete}
|
||||
onClose={() => setEntityToDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete entity"
|
||||
description="All memories associated with this entity will be permanently removed. This cannot be undone."
|
||||
itemName={entityToDelete?.id ?? ""}
|
||||
confirmButtonText="Delete"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { LockedPage } from "@/components/self-hosted/locked-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function ExportMockup() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Format</Label>
|
||||
<div className="flex gap-2">
|
||||
{["JSON", "CSV", "Pydantic Schema"].map((fmt) => (
|
||||
<Button
|
||||
key={fmt}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled
|
||||
className={fmt === "JSON" ? "border-memPurple-300" : ""}
|
||||
>
|
||||
{fmt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Sample output</Label>
|
||||
<Card className="border-memBorder-primary bg-surface-default-secondary">
|
||||
<CardContent className="p-3">
|
||||
<pre className="text-xs text-onSurface-default-secondary font-mono whitespace-pre-wrap">
|
||||
{`{
|
||||
"memories": [
|
||||
{
|
||||
"id": "mem_abc123",
|
||||
"content": "User prefers dark mode",
|
||||
"user_id": "user_1",
|
||||
"created_at": "2026-04-10T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1247
|
||||
}`}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Button disabled>Export Memories</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExportPage() {
|
||||
return (
|
||||
<LockedPage
|
||||
title="Export"
|
||||
description="Export your memories in JSON, CSV, or Pydantic schema format."
|
||||
previewContent={<ExportMockup />}
|
||||
utmMedium="dashboard-locked-export"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import NavWrapper from "./components/nav-wrapper";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SIDEBAR_WIDTH, COLLAPSED_SIDEBAR_WIDTH } from "../clientLayout";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const isSidebarCollapsed = useSelector(
|
||||
(state: RootState) => state.layout.isSidebarCollapsed,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavWrapper />
|
||||
<div
|
||||
className="mt-[48px] rounded-tl-lg relative h-[calc(100vh-56px)] bg-surface-default-primary border border-memBorder-primary overflow-hidden transition-all duration-300 ease-in-out font-fustat"
|
||||
style={{
|
||||
left: `${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH : SIDEBAR_WIDTH}px`,
|
||||
width: `calc(100vw - ${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH + 8 : SIDEBAR_WIDTH + 8}px)`,
|
||||
}}
|
||||
>
|
||||
<ScrollArea type="scroll" className="h-[calc(100vh-70px)]">
|
||||
<div className="mx-auto px-6 py-6 flex-1 flex-col space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { TableSkeleton } from "@/components/shared/table-skeleton";
|
||||
import { EmptyState } from "@/components/self-hosted/empty-state";
|
||||
import DeleteConfirmationModal from "@/components/ui/delete-confirmation-modal";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/ui/sheet";
|
||||
import { UpgradeBanner } from "@/components/self-hosted/upgrade-banner";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { api } from "@/utils/api";
|
||||
import { MEMORY_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { Memory } from "@/types/api";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function MemoriesPage() {
|
||||
const [userId, setUserId] = useState("");
|
||||
const [selectedMemory, setSelectedMemory] = useState<Memory | null>(null);
|
||||
const [memoryToDelete, setMemoryToDelete] = useState<Memory | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
const {
|
||||
data: memories = [],
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useApiQuery<Memory[]>(
|
||||
async () => {
|
||||
const params = userId.trim() ? { user_id: userId.trim() } : undefined;
|
||||
const res = await api.get(MEMORY_ENDPOINTS.BASE, { params });
|
||||
const raw = res.data?.results ?? res.data ?? [];
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
},
|
||||
{ errorToast: "Failed to load memories", initialData: [] },
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(memories.length / PAGE_SIZE);
|
||||
const paginatedMemories = memories.slice(
|
||||
page * PAGE_SIZE,
|
||||
(page + 1) * PAGE_SIZE,
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!memoryToDelete) return;
|
||||
try {
|
||||
await api.delete(MEMORY_ENDPOINTS.BY_ID(memoryToDelete.id));
|
||||
toast({ title: "Memory deleted", variant: "success" });
|
||||
if (selectedMemory?.id === memoryToDelete.id) setSelectedMemory(null);
|
||||
setMemoryToDelete(null);
|
||||
void refetch();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to delete memory",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "memory" as keyof Memory,
|
||||
label: "Content",
|
||||
width: 400,
|
||||
render: (value: string) => (
|
||||
<span className="line-clamp-2 text-sm">{value}</span>
|
||||
),
|
||||
},
|
||||
{ key: "user_id" as keyof Memory, label: "User", width: 100 },
|
||||
{ key: "agent_id" as keyof Memory, label: "Agent", width: 100 },
|
||||
{
|
||||
key: "created_at" as keyof Memory,
|
||||
label: "Created",
|
||||
width: 120,
|
||||
render: (value: string) =>
|
||||
value ? format(new Date(value), "MMM d, yyyy") : "--",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold font-fustat">Memories</h1>
|
||||
|
||||
{memories.length >= 1000 && (
|
||||
<UpgradeBanner
|
||||
id="memories-1k"
|
||||
message="1,000+ memories stored. Categories can help organize them."
|
||||
ctaLabel="Explore Cloud"
|
||||
ctaUrl="https://app.mem0.ai?utm_source=oss&utm_medium=dashboard-memories"
|
||||
variant="cloud"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
placeholder="Filter by User ID (optional)"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setPage(0);
|
||||
refetch();
|
||||
}
|
||||
}}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={5} columns={4} />
|
||||
) : memories.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No memories yet"
|
||||
description="Create your first memory by sending a POST /memories request."
|
||||
>
|
||||
<pre className="text-xs text-left bg-surface-default-secondary p-3 rounded font-mono overflow-x-auto mt-3 max-w-lg">
|
||||
{`curl -X POST ${apiUrl}/memories \\
|
||||
-H "X-API-Key: <your-key>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"messages": [{"role": "user", "content": "I like hiking"}], "user_id": "alice"}'`}
|
||||
</pre>
|
||||
<a
|
||||
href="https://docs.mem0.ai/open-source/features/rest-api#memory-operations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-onSurface-default-tertiary underline underline-offset-4 hover:text-onSurface-default-primary mt-2"
|
||||
>
|
||||
REST API reference
|
||||
</a>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<Card className="border-memBorder-primary overflow-hidden">
|
||||
<DataTable
|
||||
data={paginatedMemories}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.id}
|
||||
onRowClick={(row) => setSelectedMemory(row)}
|
||||
getRowClassName={(row) =>
|
||||
selectedMemory?.id === row.id
|
||||
? "bg-surface-default-tertiary"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-onSurface-default-tertiary">
|
||||
<span>
|
||||
{page * PAGE_SIZE + 1}–
|
||||
{Math.min((page + 1) * PAGE_SIZE, memories.length)} of{" "}
|
||||
{memories.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Sheet
|
||||
open={!!selectedMemory}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedMemory(null);
|
||||
}}
|
||||
>
|
||||
<SheetContent className="sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Memory Detail</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
View memory content and metadata
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedMemory && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-onSurface-default-tertiary">
|
||||
Content
|
||||
</Label>
|
||||
<p className="text-sm">{selectedMemory.memory}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-onSurface-default-tertiary">
|
||||
ID
|
||||
</Label>
|
||||
<p className="text-xs font-mono break-all">
|
||||
{selectedMemory.id}
|
||||
</p>
|
||||
</div>
|
||||
{selectedMemory.user_id && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-onSurface-default-tertiary">
|
||||
User
|
||||
</Label>
|
||||
<p className="text-sm">{selectedMemory.user_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedMemory.agent_id && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-onSurface-default-tertiary">
|
||||
Agent
|
||||
</Label>
|
||||
<p className="text-sm">{selectedMemory.agent_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedMemory.created_at && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-onSurface-default-tertiary">
|
||||
Created
|
||||
</Label>
|
||||
<p className="text-sm">
|
||||
{new Date(selectedMemory.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-onSurface-danger-primary"
|
||||
onClick={() => setMemoryToDelete(selectedMemory)}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
Delete memory
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={!!memoryToDelete}
|
||||
onClose={() => setMemoryToDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete memory"
|
||||
description="This memory will be permanently removed. This cannot be undone."
|
||||
itemName={memoryToDelete?.id ?? ""}
|
||||
confirmButtonText="Delete"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { TableSkeleton } from "@/components/shared/table-skeleton";
|
||||
import { EmptyState } from "@/components/self-hosted/empty-state";
|
||||
import { api } from "@/utils/api";
|
||||
import { REQUEST_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { ApiRequestLog } from "@/types/api";
|
||||
|
||||
type RequestLog = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
method: string;
|
||||
path: string;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
authType: string;
|
||||
};
|
||||
|
||||
const REQUEST_LOG_LIMIT = 200;
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const getStatusClassName = (statusCode: number) => {
|
||||
if (statusCode >= 500) {
|
||||
return "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/40 dark:bg-rose-950/40 dark:text-rose-300";
|
||||
}
|
||||
|
||||
if (statusCode >= 400) {
|
||||
return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-300";
|
||||
}
|
||||
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/40 dark:text-emerald-300";
|
||||
};
|
||||
|
||||
const getMethodClassName = (method: string) => {
|
||||
switch (method.toUpperCase()) {
|
||||
case "POST":
|
||||
return "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-950/40 dark:text-sky-300";
|
||||
case "PUT":
|
||||
case "PATCH":
|
||||
return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-300";
|
||||
case "DELETE":
|
||||
return "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/40 dark:bg-rose-950/40 dark:text-rose-300";
|
||||
default:
|
||||
return "border-memBorder-primary bg-surface-default-secondary text-onSurface-default-secondary";
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthLabel = (authType: string) => {
|
||||
switch (authType.toLowerCase()) {
|
||||
case "bearer":
|
||||
return "JWT";
|
||||
case "api_key":
|
||||
return "API Key";
|
||||
case "admin_api_key":
|
||||
return "Admin Key";
|
||||
case "disabled":
|
||||
return "Disabled";
|
||||
default:
|
||||
return "--";
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeLog = (entry: ApiRequestLog): RequestLog => {
|
||||
return {
|
||||
id: entry.id,
|
||||
createdAt: entry.created_at,
|
||||
method: entry.method,
|
||||
path: entry.path,
|
||||
statusCode: entry.status_code,
|
||||
latencyMs: entry.latency_ms,
|
||||
authType: entry.auth_type,
|
||||
};
|
||||
};
|
||||
|
||||
export default function RequestsPage() {
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const {
|
||||
data: logs = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useApiQuery<RequestLog[]>(
|
||||
async () => {
|
||||
const res = await api.get<ApiRequestLog[]>(REQUEST_ENDPOINTS.BASE, {
|
||||
params: { limit: REQUEST_LOG_LIMIT },
|
||||
});
|
||||
setLastUpdated(new Date().toISOString());
|
||||
return (res.data ?? []).map(normalizeLog);
|
||||
},
|
||||
{ errorToast: "Failed to load request logs", initialData: [] },
|
||||
);
|
||||
|
||||
const totalRequests = logs.length;
|
||||
const successfulRequests = logs.filter((log) => log.statusCode < 400).length;
|
||||
const successRate =
|
||||
totalRequests > 0
|
||||
? Math.round((successfulRequests / totalRequests) * 100)
|
||||
: 0;
|
||||
const averageLatency =
|
||||
totalRequests > 0
|
||||
? Math.round(
|
||||
logs.reduce((sum, log) => sum + log.latencyMs, 0) / totalRequests,
|
||||
)
|
||||
: 0;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "createdAt" as keyof RequestLog,
|
||||
label: "Time",
|
||||
width: 140,
|
||||
render: (value: string) => (
|
||||
<span title={format(new Date(value), "PPpp")}>
|
||||
{formatDistanceToNow(new Date(value), { addSuffix: true })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "method" as keyof RequestLog,
|
||||
label: "Method",
|
||||
width: 96,
|
||||
render: (value: string) => (
|
||||
<Badge variant="outline" className={getMethodClassName(value)}>
|
||||
{value.toUpperCase()}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "path" as keyof RequestLog,
|
||||
label: "Path",
|
||||
width: 360,
|
||||
render: (value: string) => (
|
||||
<span className="font-mono text-xs break-all text-onSurface-default-primary">
|
||||
{value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "statusCode" as keyof RequestLog,
|
||||
label: "Status",
|
||||
width: 120,
|
||||
render: (value: number) => (
|
||||
<Badge variant="outline" className={getStatusClassName(value)}>
|
||||
{value}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latencyMs" as keyof RequestLog,
|
||||
label: "Latency",
|
||||
width: 100,
|
||||
render: (value: number) => <span>{value} ms</span>,
|
||||
},
|
||||
{
|
||||
key: "authType" as keyof RequestLog,
|
||||
label: "Auth",
|
||||
width: 120,
|
||||
render: (value: string) => getAuthLabel(value),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold font-fustat">Requests</h1>
|
||||
<p className="text-sm text-onSurface-default-secondary">
|
||||
Recent request logs from your self-hosted instance.
|
||||
</p>
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Last updated{" "}
|
||||
{formatDistanceToNow(new Date(lastUpdated), { addSuffix: true })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPage(0);
|
||||
void refetch();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{ label: "Total Requests", value: totalRequests },
|
||||
{
|
||||
label: "Success Rate",
|
||||
value: totalRequests > 0 ? `${successRate}%` : "--",
|
||||
},
|
||||
{
|
||||
label: "Avg Latency",
|
||||
value: totalRequests > 0 ? `${averageLatency} ms` : "--",
|
||||
},
|
||||
].map((card) => (
|
||||
<Card key={card.label} className="border-memBorder-primary">
|
||||
<CardContent className="p-5">
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{card.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardContent className="p-4 text-sm text-onSurface-danger-primary">
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={6} columns={6} />
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No request logs yet"
|
||||
description="Requests will appear here once your instance receives traffic."
|
||||
image="requests"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Card className="border-memBorder-primary overflow-hidden">
|
||||
<DataTable
|
||||
data={logs.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.id}
|
||||
/>
|
||||
</Card>
|
||||
{logs.length > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between text-sm text-onSurface-default-tertiary">
|
||||
<span>
|
||||
{page * PAGE_SIZE + 1}–
|
||||
{Math.min((page + 1) * PAGE_SIZE, logs.length)} of {logs.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={(page + 1) * PAGE_SIZE >= logs.length}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { api } from "@/utils/api";
|
||||
import { AUTH_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.name);
|
||||
setEmail(user.email);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const profileDirty =
|
||||
user !== null && (name !== user.name || email !== user.email);
|
||||
const profileValid = name.trim().length > 0 && email.trim().length > 0;
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
await api.patch(AUTH_ENDPOINTS.ME, {
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
});
|
||||
await refreshUser();
|
||||
toast({ title: "Profile updated", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to update profile",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast({
|
||||
title: "Passwords don't match",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingPassword(true);
|
||||
try {
|
||||
await api.post(AUTH_ENDPOINTS.CHANGE_PASSWORD, {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
toast({ title: "Password updated", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to update password",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold font-fustat">Settings</h1>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Profile</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="settings-name" className="text-xs">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="settings-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="settings-email" className="text-xs">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="settings-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={!profileDirty || !profileValid || savingProfile}
|
||||
>
|
||||
{savingProfile ? "Saving..." : "Save profile"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Password</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="settings-current-password" className="text-xs">
|
||||
Current password
|
||||
</Label>
|
||||
<Input
|
||||
id="settings-current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="settings-new-password" className="text-xs">
|
||||
New password
|
||||
</Label>
|
||||
<Input
|
||||
id="settings-new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="settings-confirm-password" className="text-xs">
|
||||
Confirm new password
|
||||
</Label>
|
||||
<Input
|
||||
id="settings-confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleChangePassword}
|
||||
disabled={
|
||||
!currentPassword ||
|
||||
newPassword.length < 8 ||
|
||||
!confirmPassword ||
|
||||
savingPassword
|
||||
}
|
||||
>
|
||||
{savingPassword ? "Saving..." : "Update password"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Appearance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-onSurface-default-secondary">
|
||||
Theme
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setTheme("light")}
|
||||
className="p-2 rounded hover:bg-surface-default-secondary-hover"
|
||||
>
|
||||
<Sun className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme("dark")}
|
||||
className="p-2 rounded hover:bg-surface-default-secondary-hover"
|
||||
>
|
||||
<Moon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme("system")}
|
||||
className="p-2 rounded hover:bg-surface-default-secondary-hover"
|
||||
>
|
||||
<Monitor className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { LockedPage } from "@/components/self-hosted/locked-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function WebhooksMockup() {
|
||||
return (
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Endpoint URL</Label>
|
||||
<Input placeholder="https://your-app.com/webhooks/mem0" disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Events</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
"memory.created",
|
||||
"memory.updated",
|
||||
"memory.deleted",
|
||||
"search.performed",
|
||||
].map((event) => (
|
||||
<div key={event} className="flex items-center gap-2">
|
||||
<Checkbox disabled checked={event === "memory.created"} />
|
||||
<Label className="text-sm font-normal">{event}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Signing Secret</Label>
|
||||
<Input placeholder="whsec_..." disabled type="password" />
|
||||
</div>
|
||||
<Button disabled>Create Webhook</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebhooksPage() {
|
||||
return (
|
||||
<LockedPage
|
||||
title="Webhooks"
|
||||
description="Get notified when memories are created, updated, or deleted."
|
||||
previewContent={<WebhooksMockup />}
|
||||
utmMedium="dashboard-locked-webhooks"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import localFont from "next/font/local";
|
||||
|
||||
export const Inter = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../../public/fonts/InterVariable.woff2",
|
||||
weight: "100 900",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/InterVariable-Italic.woff2",
|
||||
weight: "100 900",
|
||||
style: "italic",
|
||||
},
|
||||
],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const InterDisplay = Inter;
|
||||
|
||||
export const Roboto = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../../public/fonts/RobotoMono-Regular.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/RobotoMono-Bold.ttf",
|
||||
weight: "700",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-roboto-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const Fustat = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../../public/fonts/Fustat-VariableFont_wght.ttf",
|
||||
weight: "200 800",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-fustat",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const DMMono = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-Light.ttf",
|
||||
weight: "300",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-LightItalic.ttf",
|
||||
weight: "300",
|
||||
style: "italic",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-Regular.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-Italic.ttf",
|
||||
weight: "400",
|
||||
style: "italic",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-Medium.ttf",
|
||||
weight: "500",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-MediumItalic.ttf",
|
||||
weight: "500",
|
||||
style: "italic",
|
||||
},
|
||||
],
|
||||
variable: "--font-dm-mono",
|
||||
display: "swap",
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Metadata } from "next";
|
||||
import { DashboardClientLayout } from "./dashboard-client-layout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard | Mem0",
|
||||
description: "Mem0 Dashboard",
|
||||
};
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return <DashboardClientLayout>{children}</DashboardClientLayout>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { AUTH_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import { getServerApiUrl } from "@/lib/server-api-url";
|
||||
|
||||
const COOKIE_NAME = "mem0_refresh_token";
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
};
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: "No refresh token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${getServerApiUrl()}${AUTH_ENDPOINTS.REFRESH}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Refresh token is invalid — clear cookie
|
||||
cookieStore.delete(COOKIE_NAME);
|
||||
return NextResponse.json({ error: "Refresh failed" }, { status: 401 });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
cookieStore.set(COOKIE_NAME, data.refresh_token, COOKIE_OPTIONS);
|
||||
|
||||
return NextResponse.json({ access_token: data.access_token });
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const cookieStore = await cookies();
|
||||
|
||||
if (!body.refresh_token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing refresh_token" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
cookieStore.set(COOKIE_NAME, body.refresh_token, COOKIE_OPTIONS);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(COOKIE_NAME);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const GET = async () => {
|
||||
return new Response(JSON.stringify({ status: "ok" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,42 @@
|
||||
import "@/styles/globals.css";
|
||||
import { Inter, Fustat, Roboto, DMMono, InterDisplay } from "../(root)/fonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
|
||||
export const metadata = {
|
||||
title: "Setup | Mem0",
|
||||
description: "Set up your Mem0 instance",
|
||||
};
|
||||
|
||||
export default function SetupLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={cn(
|
||||
Fustat.variable,
|
||||
InterDisplay.variable,
|
||||
Inter.variable,
|
||||
Roboto.variable,
|
||||
DMMono.variable,
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="font-fustat" suppressHydrationWarning>
|
||||
<AuthProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
API_KEY_ENDPOINTS,
|
||||
AUTH_ENDPOINTS,
|
||||
MEMORY_ENDPOINTS,
|
||||
} from "@/utils/api-endpoints";
|
||||
import {
|
||||
buildProviderConfig,
|
||||
getEffectiveConfig,
|
||||
} from "@/utils/self-hosted-config";
|
||||
import { isValidEmail } from "@/lib/validators";
|
||||
|
||||
type BundledProviders = {
|
||||
llm: string[];
|
||||
embedder: string[];
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
"Admin Account",
|
||||
"Providers",
|
||||
"API Key",
|
||||
"Use Case",
|
||||
"Quick Test",
|
||||
];
|
||||
const STEP_TITLES = [
|
||||
"Create your admin account",
|
||||
"Review provider configuration",
|
||||
"Your API key",
|
||||
"Tell us your use case",
|
||||
"Test your setup",
|
||||
];
|
||||
const SUPPORTED_PROVIDERS_URL =
|
||||
"https://docs.mem0.ai/open-source/setup#supported-providers";
|
||||
|
||||
const USE_CASE_PRESETS = [
|
||||
"Personal assistant",
|
||||
"Coding agent",
|
||||
"Customer support",
|
||||
"Research",
|
||||
"Therapy / journaling",
|
||||
];
|
||||
|
||||
const DEFAULT_TEST_MESSAGE = "I like to hike on weekends.";
|
||||
|
||||
export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const { register } = useAuth();
|
||||
const [step, setStep] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isPrefillingConfig, setIsPrefillingConfig] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const [llmProvider, setLlmProvider] = useState("");
|
||||
const [llmModel, setLlmModel] = useState("");
|
||||
const [llmApiKey, setLlmApiKey] = useState("");
|
||||
const [embedderProvider, setEmbedderProvider] = useState("");
|
||||
const [embedderModel, setEmbedderModel] = useState("");
|
||||
const [serverHasLlmKey, setServerHasLlmKey] = useState(false);
|
||||
const [providers, setProviders] = useState<BundledProviders | null>(null);
|
||||
const [initialLlmProvider, setInitialLlmProvider] = useState("");
|
||||
const [initialLlmModel, setInitialLlmModel] = useState("");
|
||||
const [initialEmbedderProvider, setInitialEmbedderProvider] = useState("");
|
||||
const [initialEmbedderModel, setInitialEmbedderModel] = useState("");
|
||||
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [keyLabel, setKeyLabel] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [testSuccess, setTestSuccess] = useState(false);
|
||||
|
||||
const [useCase, setUseCase] = useState("");
|
||||
const [customInstructions, setCustomInstructions] = useState("");
|
||||
const [testMessage, setTestMessage] = useState(DEFAULT_TEST_MESSAGE);
|
||||
const [isGeneratingInstructions, setIsGeneratingInstructions] =
|
||||
useState(false);
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 1) {
|
||||
setIsPrefillingConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setIsPrefillingConfig(true);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const [configRes, providersRes] = await Promise.all([
|
||||
api.get(MEMORY_ENDPOINTS.CONFIGURE),
|
||||
api.get<BundledProviders>(MEMORY_ENDPOINTS.CONFIGURE_PROVIDERS),
|
||||
]);
|
||||
const config = getEffectiveConfig(configRes.data);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const llmProv = config?.llm?.provider || "";
|
||||
const llmMod = config?.llm?.config?.model || "";
|
||||
const embProv = config?.embedder?.provider || "";
|
||||
const embMod = config?.embedder?.config?.model || "";
|
||||
|
||||
setLlmProvider(llmProv);
|
||||
setLlmModel(llmMod);
|
||||
setEmbedderProvider(embProv);
|
||||
setEmbedderModel(embMod);
|
||||
setInitialLlmProvider(llmProv);
|
||||
setInitialLlmModel(llmMod);
|
||||
setInitialEmbedderProvider(embProv);
|
||||
setInitialEmbedderModel(embMod);
|
||||
setServerHasLlmKey(!!config?.llm?.config?.api_key);
|
||||
setProviders(providersRes.data);
|
||||
} catch (err) {
|
||||
if (active) {
|
||||
setError(getErrorMessage(err, "Could not read server configuration"));
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsPrefillingConfig(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadConfig();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [step]);
|
||||
|
||||
const handleStep1 = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
setError("Enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords don't match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await register(name, email, password);
|
||||
setStep(1);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, "Registration failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStep2 = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
const dirty =
|
||||
!!llmApiKey ||
|
||||
llmProvider !== initialLlmProvider ||
|
||||
llmModel !== initialLlmModel ||
|
||||
embedderProvider !== initialEmbedderProvider ||
|
||||
embedderModel !== initialEmbedderModel;
|
||||
|
||||
if (!dirty) {
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const llm = buildProviderConfig({
|
||||
provider: llmProvider,
|
||||
model: llmModel,
|
||||
apiKey: llmApiKey,
|
||||
});
|
||||
const embedder = buildProviderConfig({
|
||||
provider: embedderProvider,
|
||||
model: embedderModel,
|
||||
apiKey:
|
||||
llmApiKey && embedderProvider === llmProvider ? llmApiKey : undefined,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = { version: "v1.1" };
|
||||
if (llm) payload.llm = llm;
|
||||
if (embedder) payload.embedder = embedder;
|
||||
|
||||
await api.post(MEMORY_ENDPOINTS.CONFIGURE, payload);
|
||||
if (llmApiKey) setServerHasLlmKey(true);
|
||||
setLlmApiKey("");
|
||||
setInitialLlmProvider(llmProvider);
|
||||
setInitialLlmModel(llmModel);
|
||||
setInitialEmbedderProvider(embedderProvider);
|
||||
setInitialEmbedderModel(embedderModel);
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, "Failed to save configuration"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStep3 = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.post(API_KEY_ENDPOINTS.BASE, {
|
||||
label: keyLabel.trim() || "My First Key",
|
||||
});
|
||||
setApiKey(res.data.key);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, "Failed to create API key"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueToUseCase = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStep(3);
|
||||
};
|
||||
|
||||
const handleContinueToQuickTest = () => {
|
||||
setError("");
|
||||
setStep(4);
|
||||
};
|
||||
|
||||
const handleGoToDashboard = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
router.push("/dashboard/requests");
|
||||
};
|
||||
|
||||
const handleTest = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/memories`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-API-Key": apiKey },
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", content: testMessage }],
|
||||
user_id: "setup-test",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
let detail = body;
|
||||
try {
|
||||
detail = JSON.parse(body).detail ?? body;
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Test failed (${res.status}): ${detail || res.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
setTestSuccess(true);
|
||||
void api
|
||||
.post(AUTH_ENDPOINTS.ONBOARDING_COMPLETE, { use_case: useCase })
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, "Test failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-default-primary p-4">
|
||||
<div className="w-full max-w-[560px] space-y-6">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{STEPS.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"size-7 rounded-full flex items-center justify-center text-xs font-medium",
|
||||
i <= step
|
||||
? "bg-memPurple-500 text-white"
|
||||
: "bg-memNeutral-200 text-onSurface-default-tertiary",
|
||||
)}
|
||||
>
|
||||
{i < step ? <Check className="size-3.5" /> : i + 1}
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-[2px]",
|
||||
i < step ? "bg-memPurple-500" : "bg-memNeutral-200",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-sm text-onSurface-default-tertiary">
|
||||
{STEPS[step]}
|
||||
</p>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold font-fustat">
|
||||
{STEP_TITLES[step]}
|
||||
</h2>
|
||||
{isPrefillingConfig && step === 1 && (
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Checking server configuration...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-onSurface-danger-primary">{error}</p>
|
||||
)}
|
||||
|
||||
{step === 0 && (
|
||||
<form onSubmit={handleStep1} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-name">Name</Label>
|
||||
<Input
|
||||
id="setup-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-email">Email</Label>
|
||||
<Input
|
||||
id="setup-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-password">Password</Label>
|
||||
<Input
|
||||
id="setup-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-confirm-password">
|
||||
Confirm Password
|
||||
</Label>
|
||||
<Input
|
||||
id="setup-confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !name || !email || !password}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? "Creating..." : "Create Admin Account"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<form onSubmit={handleStep2} className="space-y-4">
|
||||
{!serverHasLlmKey && (
|
||||
<div className="rounded-md border border-memBorder-primary bg-surface-default-secondary p-3">
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
No LLM provider API key is configured on the server. Paste
|
||||
one below to continue — it will be saved to the server and
|
||||
used for all memory operations.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-llm-provider">LLM Provider</Label>
|
||||
<Select
|
||||
value={llmProvider}
|
||||
onValueChange={setLlmProvider}
|
||||
disabled={!providers}
|
||||
>
|
||||
<SelectTrigger id="setup-llm-provider">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.llm.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-llm-model">Model</Label>
|
||||
<Input
|
||||
id="setup-llm-model"
|
||||
value={llmModel}
|
||||
onChange={(e) => setLlmModel(e.target.value)}
|
||||
placeholder="gpt-4.1-nano-2025-04-14"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-llm-api-key">LLM API Key</Label>
|
||||
<Input
|
||||
id="setup-llm-api-key"
|
||||
type="password"
|
||||
value={llmApiKey}
|
||||
onChange={(e) => setLlmApiKey(e.target.value)}
|
||||
placeholder={
|
||||
serverHasLlmKey
|
||||
? "Leave blank to keep existing key"
|
||||
: "sk-..."
|
||||
}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Also used for the embedder when it shares the same provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-embedder-provider">
|
||||
Embedder Provider
|
||||
</Label>
|
||||
<Select
|
||||
value={embedderProvider}
|
||||
onValueChange={setEmbedderProvider}
|
||||
disabled={!providers}
|
||||
>
|
||||
<SelectTrigger id="setup-embedder-provider">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.embedder.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-embedder-model">Model</Label>
|
||||
<Input
|
||||
id="setup-embedder-model"
|
||||
value={embedderModel}
|
||||
onChange={(e) => setEmbedderModel(e.target.value)}
|
||||
placeholder="text-embedding-3-small"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Need another provider? Install its Python package and rebuild
|
||||
the image. See{" "}
|
||||
<a
|
||||
href={SUPPORTED_PROVIDERS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-4 hover:text-onSurface-default-primary"
|
||||
>
|
||||
supported providers
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!llmProvider ||
|
||||
!embedderProvider ||
|
||||
(!serverHasLlmKey && !llmApiKey)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save & Continue"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 2 && !apiKey && (
|
||||
<form onSubmit={handleStep3} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-key-label">Label for this key</Label>
|
||||
<Input
|
||||
id="setup-key-label"
|
||||
value={keyLabel}
|
||||
onChange={(e) => setKeyLabel(e.target.value)}
|
||||
placeholder="My First Key"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isLoading} className="w-full">
|
||||
{isLoading ? "Generating..." : "Generate API Key"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 2 && apiKey && (
|
||||
<form onSubmit={handleContinueToUseCase} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-api-key">Your API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="setup-api-key"
|
||||
value={apiKey}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<CopyToClipboard
|
||||
text={apiKey}
|
||||
onCopy={() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="icon">
|
||||
{copied ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<p className="text-xs text-onSurface-danger-primary">
|
||||
Save this key. You will not see it again.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-use-case">Describe your use case</Label>
|
||||
<textarea
|
||||
id="setup-use-case"
|
||||
value={useCase}
|
||||
onChange={(e) => setUseCase(e.target.value)}
|
||||
placeholder="e.g. A personal assistant that remembers my preferences"
|
||||
className="flex w-full rounded-md border border-memBorder-primary bg-surface-default-primary px-3 py-2 text-sm placeholder:text-onSurface-default-tertiary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-memPurple-500 min-h-[80px] resize-y"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{USE_CASE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUseCase(preset)}
|
||||
className={cn(
|
||||
useCase === preset &&
|
||||
"border-memPurple-500 text-memPurple-500",
|
||||
)}
|
||||
>
|
||||
{preset}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
We'll generate custom instructions that tell the memory
|
||||
extractor which facts to prioritize for your use case.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleContinueToQuickTest}
|
||||
className="flex-1"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!useCase || isGeneratingInstructions}
|
||||
className="flex-1"
|
||||
onClick={async () => {
|
||||
setError("");
|
||||
setIsGeneratingInstructions(true);
|
||||
try {
|
||||
const res = await api.post(
|
||||
MEMORY_ENDPOINTS.GENERATE_INSTRUCTIONS,
|
||||
{
|
||||
use_case: useCase,
|
||||
},
|
||||
);
|
||||
setCustomInstructions(res.data.custom_instructions);
|
||||
if (res.data.test_message) {
|
||||
setTestMessage(res.data.test_message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
getErrorMessage(
|
||||
err,
|
||||
"Failed to generate instructions",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingInstructions(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isGeneratingInstructions
|
||||
? "Generating instructions..."
|
||||
: "Generate instructions"}
|
||||
</Button>
|
||||
</div>
|
||||
{customInstructions && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-instructions">
|
||||
Generated instructions
|
||||
</Label>
|
||||
<textarea
|
||||
id="setup-instructions"
|
||||
value={customInstructions}
|
||||
onChange={(e) => setCustomInstructions(e.target.value)}
|
||||
className="flex w-full rounded-md border border-memBorder-primary bg-surface-default-primary px-3 py-2 text-sm placeholder:text-onSurface-default-tertiary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-memPurple-500 min-h-[120px] resize-y"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await api.post(MEMORY_ENDPOINTS.CONFIGURE, {
|
||||
custom_instructions: customInstructions,
|
||||
});
|
||||
handleContinueToQuickTest();
|
||||
} catch (err) {
|
||||
setError(
|
||||
getErrorMessage(err, "Failed to save instructions"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save & Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<form
|
||||
onSubmit={testSuccess ? handleGoToDashboard : handleTest}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label>Test your setup</Label>
|
||||
{!apiUrl && (
|
||||
<p className="text-xs text-onSurface-danger-primary">
|
||||
NEXT_PUBLIC_API_URL is not set. Set it in .env and restart
|
||||
before running this test.
|
||||
</p>
|
||||
)}
|
||||
<pre className="text-xs bg-surface-default-secondary p-3 rounded font-mono overflow-x-auto">{`curl -X POST ${apiUrl}/memories \\
|
||||
-H "X-API-Key: ${apiKey}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"messages": [{"role": "user", "content": "${testMessage}"}], "user_id": "setup-test"}'`}</pre>
|
||||
</div>
|
||||
{!testSuccess ? (
|
||||
<>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? "Testing..." : "Run Test"}
|
||||
</Button>
|
||||
{error && (
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Provider credentials or model wrong? Fix them in{" "}
|
||||
<a
|
||||
href="/dashboard/configuration"
|
||||
className="underline underline-offset-4 hover:text-onSurface-default-primary"
|
||||
>
|
||||
Configuration
|
||||
</a>{" "}
|
||||
and run the test again.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-onSurface-positive-primary">
|
||||
<Check className="size-4" /> Memory created successfully
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user