mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(brain): Company Brain Models settings tab (#1292)
Adds an admin-gated Models tab (main/triage/research pickers) that reads/writes the mono /brain/models endpoint, shown only for Company Brain orgs. Extracts a shared useOrgMemberRole hook so the brain settings sections dedupe the getActiveMember call. Fixes ENG-1054
This commit is contained in:
parent
2cebe81512
commit
ef0026a23c
7 changed files with 322 additions and 23 deletions
|
|
@ -1,6 +1,5 @@
|
|||
"use client"
|
||||
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
|
@ -43,6 +42,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@ui/components/tooltip"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
const BACKEND =
|
||||
|
|
@ -873,16 +873,7 @@ export default function CompanyBrainAutomations() {
|
|||
const removeDraft = (key: number) =>
|
||||
setDrafts((d) => d.filter((x) => x.key !== key))
|
||||
|
||||
const roleQuery = useQuery({
|
||||
queryKey: ["company-brain-automations", "role"],
|
||||
queryFn: async () =>
|
||||
(await authClient.organization.getActiveMember()).data?.role ?? null,
|
||||
staleTime: 60_000,
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
const isAdmin = ["owner", "admin"].includes(
|
||||
(roleQuery.data ?? "").toLowerCase(),
|
||||
)
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["company-brain-automations", "list", org?.id],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Loader2, Lock } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -223,15 +222,7 @@ export default function CompanyBrainConnections() {
|
|||
const [customName, setCustomName] = useState("")
|
||||
const [customServerUrl, setCustomServerUrl] = useState("")
|
||||
|
||||
const roleQuery = useQuery({
|
||||
queryKey: ["company-brain-connections", "role"],
|
||||
queryFn: async () =>
|
||||
(await authClient.organization.getActiveMember()).data?.role ?? null,
|
||||
staleTime: 60_000,
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
const role = (roleQuery.data ?? "").toLowerCase()
|
||||
const isAdmin = role === "owner" || role === "admin"
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [catRes, connRes, slackRes] = await Promise.all([
|
||||
|
|
|
|||
223
apps/web/components/settings/company-brain-models.tsx
Normal file
223
apps/web/components/settings/company-brain-models.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { Loader2, Lock } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select"
|
||||
import {
|
||||
type BrainModelRole,
|
||||
useBrainModels,
|
||||
useUpdateBrainModels,
|
||||
} from "@/hooks/use-brain-models"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
"claude-sonnet-5": "Sonnet 5",
|
||||
"claude-opus-4.8": "Opus 4.8",
|
||||
"claude-sonnet-4.6": "Sonnet 4.6",
|
||||
"claude-haiku-4.5": "Haiku 4.5",
|
||||
"grok-4.5": "Grok 4.5",
|
||||
"grok-4.3": "Grok 4.3",
|
||||
"grok-4-fast": "Grok 4 Fast",
|
||||
"gpt-5.6": "GPT-5.6",
|
||||
"gpt-5.5": "GPT-5.5",
|
||||
}
|
||||
|
||||
const labelFor = (id: string) => MODEL_LABELS[id] ?? id
|
||||
|
||||
const ROWS: { role: BrainModelRole; title: string; help: string }[] = [
|
||||
{
|
||||
role: "main",
|
||||
title: "Main model",
|
||||
help: "Reasoning, tool use, and the final Slack answer.",
|
||||
},
|
||||
{
|
||||
role: "triage",
|
||||
title: "Triage model",
|
||||
help: "Decides whether and how the brain replies to a message.",
|
||||
},
|
||||
{
|
||||
role: "research",
|
||||
title: "Research model",
|
||||
help: "Grounded web research during company research.",
|
||||
},
|
||||
]
|
||||
|
||||
const controlClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-3 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50",
|
||||
)
|
||||
const selectContentClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"rounded-[10px] border-white/[0.08] bg-[#1B1F24] text-[#FAFAFA] shadow-[0px_8px_24px_rgba(0,0,0,0.5)]",
|
||||
)
|
||||
const selectItemClass =
|
||||
"cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white"
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CompanyBrainModels() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
|
||||
const modelsQuery = useBrainModels(isCompanyBrain)
|
||||
const update = useUpdateBrainModels()
|
||||
|
||||
const [draft, setDraft] = useState<Partial<Record<BrainModelRole, string>>>(
|
||||
{},
|
||||
)
|
||||
|
||||
const resolved = modelsQuery.data?.resolved
|
||||
const defaults = modelsQuery.data?.defaults
|
||||
const choices = modelsQuery.data?.choices
|
||||
|
||||
const valueFor = (role: BrainModelRole): string =>
|
||||
draft[role] ?? resolved?.[role] ?? ""
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!resolved) return false
|
||||
return ROWS.some(
|
||||
({ role }) => draft[role] && draft[role] !== resolved[role],
|
||||
)
|
||||
}, [draft, resolved])
|
||||
|
||||
if (!isCompanyBrain) return null
|
||||
|
||||
const disabled = !isAdmin || modelsQuery.isLoading || update.isPending
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 px-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SectionTitle>Models</SectionTitle>
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#9A9A9A]")}
|
||||
>
|
||||
Choose which models Company Brain uses. Applies to this organization
|
||||
only.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{modelsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-[13px] text-[#9A9A9A]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
) : modelsQuery.isError ? (
|
||||
<p className={cn(dmSans125ClassName(), "text-[13px] text-red-400")}>
|
||||
Couldn't load models.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{ROWS.map(({ role, title, help }) => {
|
||||
const options = choices?.[role] ?? []
|
||||
const current = valueFor(role)
|
||||
return (
|
||||
<div key={role} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{defaults?.[role] === current ? (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Default
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Select
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => setDraft((d) => ({ ...d, [role]: v }))}
|
||||
>
|
||||
<SelectTrigger className={controlClass}>
|
||||
<SelectValue placeholder="Select a model…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={selectContentClass}>
|
||||
{options.map((id) => (
|
||||
<SelectItem
|
||||
key={id}
|
||||
value={id}
|
||||
className={selectItemClass}
|
||||
>
|
||||
{labelFor(id)}
|
||||
{defaults?.[role] === id ? " (default)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#9A9A9A]",
|
||||
)}
|
||||
>
|
||||
{help}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isAdmin ? (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[#737373]">
|
||||
<Lock className="size-3.5" />
|
||||
Only organization admins can change these.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || !dirty}
|
||||
onClick={() => {
|
||||
const patch: Partial<Record<BrainModelRole, string>> = {}
|
||||
for (const { role } of ROWS) {
|
||||
if (draft[role] && draft[role] !== resolved?.[role]) {
|
||||
patch[role] = draft[role]
|
||||
}
|
||||
}
|
||||
update.mutate(patch, { onSuccess: () => setDraft({}) })
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-full bg-white px-4 text-[12px] font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-40",
|
||||
)}
|
||||
>
|
||||
{update.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import ConnectionsMCP from "@/components/settings/connections-mcp"
|
|||
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
|
||||
import Proactiveness from "@/components/settings/proactiveness"
|
||||
import CompanyBrainModels from "@/components/settings/company-brain-models"
|
||||
import Support from "@/components/settings/support"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
|
@ -30,6 +31,7 @@ import {
|
|||
Zap,
|
||||
HelpCircle,
|
||||
CreditCard,
|
||||
Cpu,
|
||||
ShieldAlert,
|
||||
ChevronRight,
|
||||
ArrowUpRight,
|
||||
|
|
@ -55,6 +57,7 @@ export const TABS = [
|
|||
"integrations",
|
||||
"connections",
|
||||
"company-brain",
|
||||
"company-brain-models",
|
||||
"proactiveness",
|
||||
"support",
|
||||
] as const
|
||||
|
|
@ -98,6 +101,12 @@ const NAV_ITEMS: NavItem[] = [
|
|||
description: "Connect apps to your brain — org and personal",
|
||||
icon: <Building2 className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "company-brain-models",
|
||||
label: "Models",
|
||||
description: "Choose the models your brain uses",
|
||||
icon: <Cpu className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "proactiveness",
|
||||
label: "Proactiveness",
|
||||
|
|
@ -171,7 +180,7 @@ export function SettingsContent({
|
|||
? NAV_ITEMS.filter(
|
||||
(item) => item.id !== "integrations" && item.id !== "connections",
|
||||
)
|
||||
: NAV_ITEMS
|
||||
: NAV_ITEMS.filter((item) => item.id !== "company-brain-models")
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const localStorageUsername = useLocalStorageUsername()
|
||||
|
|
@ -499,6 +508,7 @@ export function SettingsContent({
|
|||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "company-brain" && <CompanyBrainConnections />}
|
||||
{activeTab === "company-brain-models" && <CompanyBrainModels />}
|
||||
{activeTab === "proactiveness" && <Proactiveness />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
|
|
|
|||
64
apps/web/hooks/use-brain-models.ts
Normal file
64
apps/web/hooks/use-brain-models.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const BASE = `${BACKEND}/brain/models`
|
||||
|
||||
export type BrainModelRole = "main" | "triage" | "research"
|
||||
|
||||
export type BrainModelConfig = Record<BrainModelRole, string>
|
||||
|
||||
export type BrainModelsResponse = {
|
||||
resolved: BrainModelConfig
|
||||
defaults: BrainModelConfig
|
||||
choices: Record<BrainModelRole, string[]>
|
||||
}
|
||||
|
||||
export function useBrainModels(enabled: boolean) {
|
||||
const { org } = useAuth()
|
||||
return useQuery({
|
||||
queryKey: ["brain", "models", org?.id],
|
||||
queryFn: async (): Promise<BrainModelsResponse> => {
|
||||
const res = await fetch(`${BASE}/`, { credentials: "include" })
|
||||
if (!res.ok) throw new Error("Failed to load models")
|
||||
return res.json()
|
||||
},
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateBrainModels() {
|
||||
const { org } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (patch: Partial<BrainModelConfig>) => {
|
||||
const res = await fetch(`${BASE}/`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-App-Source": "nova" },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
if (res.status === 403)
|
||||
throw new Error("Only admins can change brain models.")
|
||||
if (!res.ok) {
|
||||
const b = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
throw new Error(b.message ?? b.error ?? "Failed to save models")
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["brain", "models", org?.id],
|
||||
})
|
||||
toast.success("Brain models saved")
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save models"),
|
||||
})
|
||||
}
|
||||
19
apps/web/hooks/use-org-member-role.ts
Normal file
19
apps/web/hooks/use-org-member-role.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
// Shared active-member role for the current org. Single queryKey so the
|
||||
// company-brain settings sections dedupe the getActiveMember call.
|
||||
export function useOrgMemberRole(enabled = true) {
|
||||
const { org } = useAuth()
|
||||
const query = useQuery({
|
||||
queryKey: ["org", "member-role", org?.id],
|
||||
queryFn: async () =>
|
||||
(await authClient.organization.getActiveMember()).data?.role ?? null,
|
||||
staleTime: 60_000,
|
||||
enabled: enabled && !!org?.id,
|
||||
})
|
||||
const role = (query.data ?? "").toLowerCase()
|
||||
const isAdmin = role === "owner" || role === "admin"
|
||||
return { role, isAdmin, query }
|
||||
}
|
||||
|
|
@ -228,6 +228,7 @@ export const analytics = {
|
|||
| "integrations"
|
||||
| "connections"
|
||||
| "company-brain"
|
||||
| "company-brain-models"
|
||||
| "proactiveness"
|
||||
| "support"
|
||||
}) => safeCapture("settings_tab_changed", props),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue