mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
add org level context and space level profile (#1002)
This commit is contained in:
parent
55445bff1d
commit
9229c60a61
8 changed files with 866 additions and 70 deletions
|
|
@ -6,6 +6,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
|||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
|
||||
import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import type { z } from "zod"
|
||||
import { Masonry, useInfiniteLoader } from "masonic"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
|
@ -54,11 +55,21 @@ import {
|
|||
CheckIcon,
|
||||
LayoutGrid,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Trash2Icon,
|
||||
UserRound,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { useProcessingDocuments } from "@/hooks/use-processing-documents"
|
||||
import { TimelineView } from "./timeline-view"
|
||||
import { SpaceProfilePanel } from "@/components/space-profile-panel"
|
||||
import { SpaceProfileModal } from "@/components/space-profile-modal"
|
||||
|
||||
// Document category type
|
||||
type DocumentCategory =
|
||||
|
|
@ -249,6 +260,7 @@ export function MemoriesGrid({
|
|||
emptyStateProps,
|
||||
}: MemoriesGridProps) {
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
const [localViewMode, setLocalViewMode] = useState<"grid" | "timeline">(
|
||||
() => {
|
||||
if (typeof window === "undefined") return "grid"
|
||||
|
|
@ -259,7 +271,7 @@ export function MemoriesGrid({
|
|||
},
|
||||
)
|
||||
const { user, isSessionPending } = useAuth()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const { effectiveContainerTags, selectedProject } = useProject()
|
||||
const processingStatusMap = useProcessingDocuments()
|
||||
const isMobile = useIsMobile()
|
||||
const [selectedCategories, setSelectedCategories] = useQueryState(
|
||||
|
|
@ -347,6 +359,14 @@ export function MemoriesGrid({
|
|||
localStorage.setItem("memories-view-mode", mode)
|
||||
}, [])
|
||||
|
||||
const handleToggleProfile = useCallback(() => {
|
||||
if (isMobile) {
|
||||
setProfileOpen(true)
|
||||
return
|
||||
}
|
||||
setProfileOpen((open) => !open)
|
||||
}, [isMobile])
|
||||
|
||||
const handleCategoryToggle = useCallback(
|
||||
(category: DocumentCategory) => {
|
||||
setSelectedCategories((prev) => {
|
||||
|
|
@ -541,7 +561,7 @@ export function MemoriesGrid({
|
|||
documents.every((d) => d.id && selectedDocumentIds.has(d.id))
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative flex h-full min-h-0 flex-col">
|
||||
{!isEmpty && !isSelectionMode && (
|
||||
<div
|
||||
id="filter-pills"
|
||||
|
|
@ -621,17 +641,52 @@ export function MemoriesGrid({
|
|||
Timeline
|
||||
</button>
|
||||
</div>
|
||||
{onEnterSelectionMode && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select documents"
|
||||
title="Select documents"
|
||||
className="size-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] hover:border-[#2261CA33] transition-colors cursor-pointer"
|
||||
onClick={onEnterSelectionMode}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More memory actions"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 text-xs font-medium transition-colors cursor-pointer",
|
||||
profileOpen
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "text-[#737373] hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
More
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn(
|
||||
"min-w-[180px] rounded-xl border border-[#2E3033] p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<BoxSelect className="size-4 text-[#737373]" />
|
||||
</button>
|
||||
)}
|
||||
{onEnterSelectionMode && (
|
||||
<DropdownMenuItem
|
||||
onSelect={onEnterSelectionMode}
|
||||
className="gap-2 rounded-md px-3 py-2.5 text-sm font-medium text-white cursor-pointer hover:bg-[#293952]/40"
|
||||
>
|
||||
<BoxSelect className="size-4 text-[#737373]" />
|
||||
Select memories
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={handleToggleProfile}
|
||||
className="gap-2 rounded-md px-3 py-2.5 text-sm font-medium text-white cursor-pointer hover:bg-[#293952]/40"
|
||||
>
|
||||
<UserRound className="size-4 text-[#737373]" />
|
||||
Space profile
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -749,64 +804,82 @@ export function MemoriesGrid({
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
Error loading documents: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
) : isPending ? (
|
||||
<MemoriesGridLoading />
|
||||
) : showNovaEmptyState ? (
|
||||
<NovaEmptyState
|
||||
onAddMemory={emptyStateProps.onAddMemory}
|
||||
onOpenIntegrations={emptyStateProps.onOpenIntegrations}
|
||||
isAllSpaces={emptyStateProps.isAllSpaces}
|
||||
spaceName={emptyStateProps.spaceName}
|
||||
onSwitchToAllSpaces={emptyStateProps.onSwitchToAllSpaces}
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
No memories found
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto scrollbar-thin">
|
||||
{localViewMode === "timeline" ? (
|
||||
<TimelineView
|
||||
documents={documents}
|
||||
onOpenDocument={onOpenDocument}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={loadMoreDocuments}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onToggleSelection={onToggleSelection}
|
||||
/>
|
||||
) : (
|
||||
<Masonry
|
||||
key={masonryKey}
|
||||
items={masonryItems}
|
||||
render={renderMasonryItem}
|
||||
itemKey={getMasonryItemKey}
|
||||
columnGutter={0}
|
||||
rowGutter={0}
|
||||
columnWidth={260}
|
||||
maxColumnCount={isMobile ? 1 : undefined}
|
||||
itemHeightEstimate={200}
|
||||
overscanBy={3}
|
||||
onRender={maybeLoadMore}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingMore && localViewMode === "grid" && (
|
||||
<div className="py-10 flex items-center justify-center">
|
||||
<Loader className="size-10 animate-spin text-sky-400" />
|
||||
<div className="min-h-0 flex-1">
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
Error loading documents: {error.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isPending ? (
|
||||
<MemoriesGridLoading />
|
||||
) : showNovaEmptyState ? (
|
||||
<NovaEmptyState
|
||||
onAddMemory={emptyStateProps.onAddMemory}
|
||||
onOpenIntegrations={emptyStateProps.onOpenIntegrations}
|
||||
isAllSpaces={emptyStateProps.isAllSpaces}
|
||||
spaceName={emptyStateProps.spaceName}
|
||||
onSwitchToAllSpaces={emptyStateProps.onSwitchToAllSpaces}
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
No memories found
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 gap-4">
|
||||
<div className="min-w-0 flex-1 overflow-auto scrollbar-thin">
|
||||
{localViewMode === "timeline" ? (
|
||||
<TimelineView
|
||||
documents={documents}
|
||||
onOpenDocument={onOpenDocument}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={loadMoreDocuments}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onToggleSelection={onToggleSelection}
|
||||
/>
|
||||
) : (
|
||||
<Masonry
|
||||
key={masonryKey}
|
||||
items={masonryItems}
|
||||
render={renderMasonryItem}
|
||||
itemKey={getMasonryItemKey}
|
||||
columnGutter={0}
|
||||
rowGutter={0}
|
||||
columnWidth={260}
|
||||
maxColumnCount={isMobile ? 1 : undefined}
|
||||
itemHeightEstimate={200}
|
||||
overscanBy={3}
|
||||
onRender={maybeLoadMore}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingMore && localViewMode === "grid" && (
|
||||
<div className="py-10 flex items-center justify-center">
|
||||
<Loader className="size-10 animate-spin text-sky-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{profileOpen && !isMobile && (
|
||||
<SpaceProfilePanel
|
||||
containerTag={selectedProject}
|
||||
isOpen
|
||||
onClose={() => setProfileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SpaceProfileModal
|
||||
containerTag={selectedProject}
|
||||
open={profileOpen && isMobile}
|
||||
onOpenChange={setProfileOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { useMemo, useRef, useState } from "react"
|
|||
import { toast } from "sonner"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { PopoverAnchor } from "@ui/components/popover"
|
||||
import { OrgContext } from "@/components/settings/org-context"
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
|
@ -541,6 +542,8 @@ export default function Account() {
|
|||
</SettingsCard>
|
||||
</section>
|
||||
|
||||
<OrgContext />
|
||||
|
||||
<section id="team-members" className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
|
|
|||
385
apps/web/components/settings/org-context.tsx
Normal file
385
apps/web/components/settings/org-context.tsx
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { LoaderIcon, Settings, X } from "lucide-react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
||||
|
||||
type ContextTemplate = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
const SURFACE_SHADOW =
|
||||
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset"
|
||||
|
||||
const CONTEXT_TEMPLATES: ContextTemplate[] = [
|
||||
{
|
||||
id: "personal-general",
|
||||
label: "General Personal Assistant",
|
||||
description:
|
||||
"Remember preferences, routines, relationships, plans, and life context.",
|
||||
prompt: `Supermemory personal assistant. The user saves conversations, notes, and daily context.
|
||||
|
||||
EXTRACT:
|
||||
- Preferences: "prefers morning meetings", "allergic to peanuts"
|
||||
- Routines: "works out every Tuesday and Thursday"
|
||||
- Relationships: "Sarah is their manager", "lives with roommate Jake"
|
||||
- Plans: "planning a trip to Japan in March"
|
||||
- Life events: "moved to Austin last month", "started a new job"
|
||||
|
||||
SKIP:
|
||||
- Generic assistant suggestions the user did not confirm
|
||||
- Pleasantries and small talk without factual content
|
||||
- Repeated scheduling details already captured`,
|
||||
},
|
||||
{
|
||||
id: "personal-productivity",
|
||||
label: "Productivity Assistant",
|
||||
description:
|
||||
"Focus on tasks, decisions, deadlines, workflows, and blockers.",
|
||||
prompt: `Supermemory productivity tool. The user saves meeting notes, task updates, and project context.
|
||||
|
||||
EXTRACT:
|
||||
- Action items: "needs to send proposal to client by Friday"
|
||||
- Decisions: "team decided to use Figma for design handoff"
|
||||
- Deadlines: "Q3 review due September 15th"
|
||||
- Workflows: "deploys happen every Wednesday via CI pipeline"
|
||||
- Blockers: "waiting on legal approval before launch"
|
||||
|
||||
SKIP:
|
||||
- Meeting filler ("let's circle back", "good point")
|
||||
- Status updates that repeat previously captured information
|
||||
- Agenda items with no outcome or decision`,
|
||||
},
|
||||
]
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
variant = "default",
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
variant?: "default" | "danger" | "primary"
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"bg-[#0D121A] text-[#FAFAFA] hover:opacity-80",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
variant === "primary"
|
||||
? "border-transparent"
|
||||
: variant === "danger"
|
||||
? "border-transparent"
|
||||
: "border-transparent",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrgContext() {
|
||||
const { data: settings, isLoading, isError } = useOrgSettings()
|
||||
const updateSettings = useUpdateOrgSettings()
|
||||
const [confirmDialog, setConfirmDialog] = useState<
|
||||
"enable" | "disable" | null
|
||||
>(null)
|
||||
const [isManaging, setIsManaging] = useState(false)
|
||||
const [prompt, setPrompt] = useState("")
|
||||
|
||||
const enabled = settings?.shouldLLMFilter ?? false
|
||||
const savedPrompt = settings?.filterPrompt ?? ""
|
||||
const dirty = prompt.trim() !== savedPrompt.trim()
|
||||
const settingsReady = !isLoading && !isError
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt(settings?.filterPrompt ?? "")
|
||||
}, [settings?.filterPrompt])
|
||||
|
||||
const selectedTemplateId = useMemo(() => {
|
||||
const normalized = prompt.trim()
|
||||
return CONTEXT_TEMPLATES.find(
|
||||
(template) => template.prompt.trim() === normalized,
|
||||
)?.id
|
||||
}, [prompt])
|
||||
|
||||
const handleConfirmToggle = () => {
|
||||
const newEnabled = confirmDialog === "enable"
|
||||
updateSettings.mutate(
|
||||
newEnabled
|
||||
? { shouldLLMFilter: true }
|
||||
: { shouldLLMFilter: false, filterPrompt: null },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setConfirmDialog(null)
|
||||
if (!newEnabled) {
|
||||
setIsManaging(false)
|
||||
setPrompt("")
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateSettings.mutate(
|
||||
{
|
||||
shouldLLMFilter: true,
|
||||
filterPrompt: prompt.trim() ? prompt.trim() : null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsManaging(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setPrompt(savedPrompt)
|
||||
setIsManaging(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="organization-context" className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<SectionTitle>Organization Context</SectionTitle>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373] px-2",
|
||||
)}
|
||||
>
|
||||
Guide how Nova processes and remembers your content.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsCard>
|
||||
<div className={cn(dmSansClassName(), "flex flex-col gap-5")}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[15px] font-medium text-[#FAFAFA]">
|
||||
{enabled ? "Context is enabled" : "Context is disabled"}
|
||||
</p>
|
||||
<p className="text-[13px] leading-snug text-[#737373]">
|
||||
{enabled
|
||||
? "New content will use your guidance when Nova creates memories."
|
||||
: "Turn this on to tell Nova what matters most when it learns."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<PillButton
|
||||
onClick={() => setConfirmDialog(enabled ? "disable" : "enable")}
|
||||
disabled={!settingsReady || updateSettings.isPending}
|
||||
variant={enabled ? "danger" : "primary"}
|
||||
>
|
||||
{enabled ? "DISABLE" : "ENABLE"}
|
||||
</PillButton>
|
||||
{enabled && (
|
||||
<PillButton
|
||||
onClick={() => setIsManaging(true)}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
MANAGE
|
||||
</PillButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enabled && !isManaging && savedPrompt && (
|
||||
<div className="rounded-[12px] border border-white/[0.08] bg-[#0D121A] p-4">
|
||||
<p className="line-clamp-6 whitespace-pre-wrap text-[13px] leading-relaxed text-[#A3A3A3]">
|
||||
{savedPrompt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && !isManaging && !savedPrompt && (
|
||||
<p className="text-[13px] italic text-[#737373]">
|
||||
No organization context configured.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsManaging(true)}
|
||||
className="text-[#4BA0FA] transition-colors hover:text-[#7BB8FF] cursor-pointer"
|
||||
>
|
||||
Set up now
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{enabled && isManaging && (
|
||||
<div className="overflow-hidden rounded-[18px] border border-white/[0.08] bg-[#1B1F24]">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-medium text-[#FAFAFA]">
|
||||
What should Nova focus on?
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="Describe what Nova should extract, skip, and prioritize when turning your content into memories..."
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"min-h-[180px] w-full resize-y rounded-[14px] border border-white/[0.08] bg-[#0D121A] p-4 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.14] focus:outline-none",
|
||||
)}
|
||||
maxLength={750}
|
||||
/>
|
||||
<span className="self-end text-[11px] text-[#737373]">
|
||||
{prompt.length}/750
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{CONTEXT_TEMPLATES.map((template) => {
|
||||
const isSelected = selectedTemplateId === template.id
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => setPrompt(template.prompt)}
|
||||
className={cn(
|
||||
"rounded-[14px] border p-4 text-left transition-colors cursor-pointer",
|
||||
"bg-[#14161A] hover:bg-[#121820]",
|
||||
isSelected
|
||||
? "border-white/[0.16] bg-[#171B22]"
|
||||
: "border-white/[0.08]",
|
||||
)}
|
||||
>
|
||||
<p className="text-[14px] font-medium text-[#FAFAFA]">
|
||||
{template.label}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] leading-snug text-[#737373]">
|
||||
{template.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-white/[0.08] bg-[#171B22] px-4 py-3">
|
||||
<PillButton onClick={handleCancel}>CANCEL</PillButton>
|
||||
<PillButton
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || updateSettings.isPending}
|
||||
variant="primary"
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
)}
|
||||
SAVE
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<Dialog
|
||||
open={!!confirmDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmDialog(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
boxShadow: SURFACE_SHADOW,
|
||||
}}
|
||||
className={cn(
|
||||
"sm:max-w-[440px] border border-white/[0.12] bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{confirmDialog === "enable"
|
||||
? "Enable Organization Context?"
|
||||
: "Disable Organization Context?"}
|
||||
</DialogTitle>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] leading-relaxed text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{confirmDialog === "enable"
|
||||
? "Nova will use your guidance when processing new content across this organization."
|
||||
: "Nova will stop using this guidance for new content, and the saved context will be cleared."}
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-opacity hover:opacity-80 focus:outline-hidden cursor-pointer">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<PillButton onClick={() => setConfirmDialog(null)}>
|
||||
CANCEL
|
||||
</PillButton>
|
||||
<PillButton
|
||||
onClick={handleConfirmToggle}
|
||||
disabled={updateSettings.isPending}
|
||||
variant={confirmDialog === "disable" ? "danger" : "primary"}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{confirmDialog === "enable" ? "ENABLE" : "DISABLE"}
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
39
apps/web/components/space-profile-modal.tsx
Normal file
39
apps/web/components/space-profile-modal.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use client"
|
||||
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { SpaceProfileContent } from "@/components/space-profile-panel"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
||||
|
||||
type SpaceProfileModalProps = {
|
||||
containerTag: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function SpaceProfileModal({
|
||||
containerTag,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SpaceProfileModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"w-[94%]! max-w-[440px]! max-h-[82dvh] border border-white/[0.08] bg-[#1B1F24] p-4 rounded-[22px]",
|
||||
"shadow-[0_2.842px_14.211px_rgba(0,0,0,0.25),inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.10)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">Space Profile</DialogTitle>
|
||||
<div className="flex min-h-[420px] flex-col">
|
||||
<SpaceProfileContent
|
||||
containerTag={containerTag}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
168
apps/web/components/space-profile-panel.tsx
Normal file
168
apps/web/components/space-profile-panel.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"use client"
|
||||
|
||||
import { Brain, X } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { useSpaceProfile } from "@/hooks/use-space-profile"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
type SpaceProfilePanelProps = {
|
||||
containerTag: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type SpaceProfileContentProps = {
|
||||
containerTag: string
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
function CountBadge({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex h-[18px] min-w-[22px] items-center justify-center rounded-[4px] bg-white/[0.05] px-1.5 text-[10px] font-semibold text-[#A3A3A3]">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-10 w-full animate-pulse rounded-[10px] bg-white/[0.04]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex min-h-[180px] flex-col items-center justify-center rounded-[14px] border border-white/[0.08] bg-[#14161A] px-5 py-8 text-center">
|
||||
<div className="mb-3 flex size-10 items-center justify-center rounded-full bg-[#0D121A] shadow-inside-out">
|
||||
<Brain className="size-4 text-[#A3A3A3]" />
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[#FAFAFA]">No profile yet</p>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-[#737373]">
|
||||
Add more memories and Nova will learn about this space.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileSection({ label, items }: { label: string; items: string[] }) {
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] font-semibold uppercase tracking-[0.12em] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<CountBadge>{items.length}</CountBadge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={`${label}-${index}-${item}`}
|
||||
className="rounded-[10px] px-2 py-1.5 transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Brain className="mt-1 size-3 shrink-0 text-[#A3A3A3]" />
|
||||
<p className="line-clamp-3 text-[12px] leading-relaxed text-[#D4D4D4]">
|
||||
{item}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpaceProfileContent({
|
||||
containerTag,
|
||||
onClose,
|
||||
}: SpaceProfileContentProps) {
|
||||
const { data, isLoading, error } = useSpaceProfile(containerTag)
|
||||
const keyFacts = data?.static ?? []
|
||||
const recentContext = data?.dynamic ?? []
|
||||
const totalCount = keyFacts.length + recentContext.length
|
||||
|
||||
return (
|
||||
<div className={cn(dmSansClassName(), "flex min-h-0 flex-1 flex-col")}>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-white/[0.08] pb-3">
|
||||
<div>
|
||||
<h3
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[15px] font-semibold tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Space Profile
|
||||
</h3>
|
||||
<p className="mt-0.5 text-[12px] text-[#737373]">
|
||||
What Nova knows in this space
|
||||
</p>
|
||||
</div>
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close space profile"
|
||||
onClick={onClose}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-opacity hover:opacity-80 cursor-pointer"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-4 scrollbar-thin">
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<p className="rounded-[12px] border border-white/[0.08] bg-[#14161A] p-3 text-[13px] text-[#A3A3A3]">
|
||||
Failed to load space profile.
|
||||
</p>
|
||||
) : totalCount === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
<ProfileSection label="Key Facts" items={keyFacts} />
|
||||
<ProfileSection label="Recent Context" items={recentContext} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpaceProfilePanel({
|
||||
containerTag,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: SpaceProfilePanelProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
initial={{ opacity: 0, width: 0, x: 12 }}
|
||||
animate={{ opacity: 1, width: 300, x: 0 }}
|
||||
exit={{ opacity: 0, width: 0, x: 12 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
"hidden h-full w-[300px] shrink-0 flex-col rounded-[18px] border border-white/[0.08] bg-[#1B1F24] p-4 md:flex",
|
||||
"shadow-[0_2.842px_14.211px_rgba(0,0,0,0.25),inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.10)]",
|
||||
)}
|
||||
>
|
||||
<SpaceProfileContent containerTag={containerTag} onClose={onClose} />
|
||||
</motion.aside>
|
||||
)
|
||||
}
|
||||
80
apps/web/hooks/use-org-settings.ts
Normal file
80
apps/web/hooks/use-org-settings.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
const API_BASE = `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/v3`
|
||||
|
||||
export type OrgSettings = {
|
||||
shouldLLMFilter: boolean
|
||||
filterPrompt: string | null
|
||||
includeItems?: string[] | null
|
||||
excludeItems?: string[] | null
|
||||
}
|
||||
|
||||
type OrgSettingsResponse = {
|
||||
settings?: Partial<OrgSettings>
|
||||
} & Partial<OrgSettings>
|
||||
|
||||
export function useOrgSettings() {
|
||||
const { org } = useAuth()
|
||||
const orgId = org?.id ?? ""
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["settings", "org", orgId],
|
||||
queryFn: async (): Promise<OrgSettings> => {
|
||||
const response = await $fetch("@get/settings", {
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || "Failed to load settings")
|
||||
}
|
||||
const data = response.data as OrgSettingsResponse | null
|
||||
const settings = data?.settings ?? data ?? {}
|
||||
return {
|
||||
shouldLLMFilter: settings.shouldLLMFilter ?? false,
|
||||
filterPrompt: settings.filterPrompt ?? null,
|
||||
includeItems: settings.includeItems ?? null,
|
||||
excludeItems: settings.excludeItems ?? null,
|
||||
}
|
||||
},
|
||||
enabled: !!orgId,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateOrgSettings() {
|
||||
const { org } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const orgId = org?.id ?? ""
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (settings: Partial<OrgSettings>) => {
|
||||
const res = await fetch(`${API_BASE}/settings`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-App-Source": "nova",
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
throw new Error(body?.message || "Failed to save settings")
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings", "org", orgId] })
|
||||
toast.success("Settings saved")
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save settings",
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
37
apps/web/hooks/use-space-profile.ts
Normal file
37
apps/web/hooks/use-space-profile.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
export type SpaceProfile = {
|
||||
static: string[]
|
||||
dynamic: string[]
|
||||
}
|
||||
|
||||
export function useSpaceProfile(containerTag: string) {
|
||||
const { org } = useAuth()
|
||||
const orgId = org?.id ?? ""
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["space-profile", orgId, containerTag],
|
||||
queryFn: async (): Promise<SpaceProfile> => {
|
||||
const response = await $fetch(
|
||||
"@get/container-tags/:containerTag/profile",
|
||||
{
|
||||
params: { containerTag },
|
||||
},
|
||||
)
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error.message || "Failed to load space profile",
|
||||
)
|
||||
}
|
||||
const profile = response.data.profile
|
||||
return {
|
||||
static: profile.static ?? [],
|
||||
dynamic: profile.dynamic ?? [],
|
||||
}
|
||||
},
|
||||
enabled: !!orgId && !!containerTag,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
|
@ -164,7 +164,7 @@ export const apiSchema = createSchema({
|
|||
|
||||
// Settings operations
|
||||
"@get/settings": {
|
||||
output: z.object({ settings: z.object({}).passthrough() }),
|
||||
output: z.object({}).passthrough(),
|
||||
},
|
||||
"@patch/settings": {
|
||||
input: SettingsRequestSchema,
|
||||
|
|
@ -254,6 +254,17 @@ export const apiSchema = createSchema({
|
|||
"@get/container-tags/list": {
|
||||
output: ListContainerTagsResponseSchema,
|
||||
},
|
||||
"@get/container-tags/:containerTag/profile": {
|
||||
output: z.object({
|
||||
profile: z.object({
|
||||
static: z.array(z.string()).optional(),
|
||||
dynamic: z.array(z.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
params: z.object({
|
||||
containerTag: z.string(),
|
||||
}),
|
||||
},
|
||||
"@patch/container-tags/:containerTag": {
|
||||
input: UpdateContainerTagSettingsRequestSchema,
|
||||
output: ContainerTagSettingsUpdateSchema,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue