mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-23 05:35:27 +00:00
fix(web): harden workspace persona settings
This commit is contained in:
parent
7647888b4f
commit
d895df1a92
6 changed files with 144 additions and 85 deletions
|
|
@ -39,6 +39,7 @@ import { useQueryState } from "nuqs"
|
|||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { PopoverAnchor } from "@ui/components/popover"
|
||||
import { WorkspacePersona } from "@/components/settings/workspace-persona"
|
||||
import { OrgPlanBadge } from "@/components/org-plan-badge"
|
||||
|
|
@ -135,6 +136,7 @@ function isPendingInvitation(invitation: {
|
|||
export default function Account() {
|
||||
const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { currentPlan, searchesUsed } = useTokenUsage(autumn)
|
||||
const { data: orgSummaries } = useOrgSummaries()
|
||||
const orgSummary = orgSummaries?.find((s) => s.orgId === org?.id)
|
||||
|
|
@ -614,7 +616,7 @@ export default function Account() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{canManageTeam && <WorkspacePersona />}
|
||||
{canManageTeam && isCompanyBrain && <WorkspacePersona />}
|
||||
|
||||
<DigestPreferences />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,70 @@
|
|||
"use client"
|
||||
|
||||
import { LoaderIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
const DESCRIPTION_ID = "workspace-persona-description"
|
||||
const COUNTER_ID = "workspace-persona-counter"
|
||||
const HEADING_ID = "workspace-persona-heading"
|
||||
|
||||
function SectionHeading({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
<h2
|
||||
id={HEADING_ID}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
function PersonaHeader() {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<SectionHeading>Workspace Persona</SectionHeading>
|
||||
<p
|
||||
id={DESCRIPTION_ID}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Guide how Company Brain communicates and works with your workspace.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspacePersona() {
|
||||
const { data: settings, isLoading, isError } = useOrgSettings()
|
||||
const { org } = useAuth()
|
||||
const settingsQuery = useOrgSettings()
|
||||
const updateSettings = useUpdateOrgSettings()
|
||||
const [persona, setPersona] = useState("")
|
||||
const syncedPersona = useRef<{ orgId: string; value: string } | null>(null)
|
||||
|
||||
const savedPersona = settings?.workspacePersona ?? ""
|
||||
const savedPersona = settingsQuery.data?.workspacePersona ?? ""
|
||||
const dirty = persona.trim() !== savedPersona.trim()
|
||||
const settingsReady = !isLoading && !isError
|
||||
|
||||
useEffect(() => {
|
||||
setPersona(settings?.workspacePersona ?? "")
|
||||
}, [settings?.workspacePersona])
|
||||
if (!settingsQuery.data) return
|
||||
|
||||
const nextPersona = settingsQuery.data.workspacePersona ?? ""
|
||||
const orgId = org?.id ?? ""
|
||||
const previous = syncedPersona.current
|
||||
|
||||
setPersona((current) => {
|
||||
if (!previous || previous.orgId !== orgId) return nextPersona
|
||||
return current === previous.value ? nextPersona : current
|
||||
})
|
||||
syncedPersona.current = { orgId, value: nextPersona }
|
||||
}, [org?.id, settingsQuery.data])
|
||||
|
||||
const handleSave = () => {
|
||||
updateSettings.mutate({
|
||||
|
|
@ -39,63 +73,92 @@ export function WorkspacePersona() {
|
|||
}
|
||||
|
||||
return (
|
||||
<section id="workspace-persona" className="flex flex-col gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<SectionTitle>Workspace Persona</SectionTitle>
|
||||
<p
|
||||
<section
|
||||
id="workspace-persona"
|
||||
aria-labelledby={HEADING_ID}
|
||||
className="flex flex-col gap-3 px-1"
|
||||
>
|
||||
<PersonaHeader />
|
||||
|
||||
{settingsQuery.isLoading ? (
|
||||
<div
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
dmSansClassName(),
|
||||
"flex min-h-[96px] items-center justify-center gap-2 rounded-[12px] border border-white/[0.08] bg-[#0D121A] text-[12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Guide how Company Brain communicates and works with your workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn(dmSansClassName(), "flex flex-col gap-2")}>
|
||||
<textarea
|
||||
value={persona}
|
||||
onChange={(event) => setPersona(event.target.value)}
|
||||
placeholder="Describe your workspace's goals, communication style, and how you prefer Company Brain to collaborate with your team..."
|
||||
maxLength={1500}
|
||||
disabled={!settingsReady || updateSettings.isPending}
|
||||
className="min-h-[96px] w-full resize-y rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-3.5 py-3 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.16] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-[#737373] tabular-nums">
|
||||
{persona.length}/1500
|
||||
</span>
|
||||
{dirty && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPersona(savedPersona)}
|
||||
disabled={updateSettings.isPending}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={updateSettings.isPending}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
Loading workspace persona…
|
||||
</div>
|
||||
</div>
|
||||
) : settingsQuery.isError ? (
|
||||
<div
|
||||
className={cn(dmSansClassName(), "flex flex-col items-start gap-2")}
|
||||
>
|
||||
<p className="text-[13px] text-[#A3A3A3]">
|
||||
Workspace persona could not be loaded.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void settingsQuery.refetch()}
|
||||
disabled={settingsQuery.isFetching}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{settingsQuery.isFetching && (
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
)}
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(dmSansClassName(), "flex flex-col gap-2")}>
|
||||
<textarea
|
||||
aria-labelledby={HEADING_ID}
|
||||
aria-describedby={`${DESCRIPTION_ID} ${COUNTER_ID}`}
|
||||
value={persona}
|
||||
onChange={(event) => setPersona(event.target.value)}
|
||||
placeholder="Describe your workspace's goals, communication style, and how you prefer Company Brain to collaborate with your team..."
|
||||
maxLength={1500}
|
||||
className="min-h-[96px] w-full resize-y rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-3.5 py-3 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.16] focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
id={COUNTER_ID}
|
||||
className="text-[11px] text-[#737373] tabular-nums"
|
||||
>
|
||||
{persona.length}/1500
|
||||
</span>
|
||||
{dirty && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPersona(savedPersona)}
|
||||
disabled={updateSettings.isPending}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={updateSettings.isPending}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ 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
|
||||
|
|
@ -52,25 +50,19 @@ export function useUpdateOrgSettings() {
|
|||
|
||||
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),
|
||||
const response = await $fetch("@patch/settings", {
|
||||
body: settings,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
throw new Error(body?.message || "Failed to save settings")
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || "Failed to save settings")
|
||||
}
|
||||
return res.json()
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings", "org", orgId] })
|
||||
onSuccess: (_data, settings) => {
|
||||
queryClient.setQueryData<OrgSettings>(
|
||||
["settings", "org", orgId],
|
||||
(current) => (current ? { ...current, ...settings } : current),
|
||||
)
|
||||
toast.success("Settings saved")
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
|
|||
|
|
@ -33,10 +33,11 @@ import {
|
|||
const SettingsResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
settings: z.object({
|
||||
excludeItems: z.array(z.string().min(1).max(20)).optional(),
|
||||
filterPrompt: z.string().min(1).max(750).optional(),
|
||||
includeItems: z.array(z.string().min(1).max(20)).optional(),
|
||||
excludeItems: z.array(z.string().min(1).max(20)).nullable().optional(),
|
||||
filterPrompt: z.string().min(1).max(750).nullable().optional(),
|
||||
includeItems: z.array(z.string().min(1).max(20)).nullable().optional(),
|
||||
shouldLLMFilter: z.boolean().optional(),
|
||||
workspacePersona: z.string().max(1500).nullable().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -801,7 +801,7 @@ export const SettingsRequestSchema = OrganizationSettingsSchema.omit({
|
|||
id: true,
|
||||
orgId: true,
|
||||
updatedAt: true,
|
||||
})
|
||||
}).partial()
|
||||
|
||||
export const ConnectionResponseSchema = z.object({
|
||||
createdAt: z.string().datetime(),
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@ export const OrganizationSettingsSchema = z.object({
|
|||
filterPrompt: z.string().nullable().optional(),
|
||||
includeItems: z.array(z.string()).nullable().optional(),
|
||||
excludeItems: z.array(z.string()).nullable().optional(),
|
||||
workspacePersona: z.string().max(1500).nullable().optional(),
|
||||
|
||||
// Google Drive custom keys
|
||||
googleDriveCustomKeyEnabled: z.boolean().default(false),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue