mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-16 12:03:58 +00:00
feat(web): add Company Brain skills settings (#1322)
## Stack Context This is the frontend half of the Company Brain Skills feature. The harness is implemented in supermemoryai/mono#2611. ## What? Add Skills settings with separate Org-wide and Personal sections, Markdown upload autofill, scoped creation and editing, approval controls, and server-driven permissions. ## Why? Members need a focused way to manage their private playbooks while admins create and approve organization-wide guidance. ## Related - Harness: https://github.com/supermemoryai/mono/pull/2611 --- **Session Details** - Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/d8c77451-8f55-47ab-a182-5c98da616263) - Requested by: Sreeram Sreedhar (sreeram@supermemory.com) - Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
This commit is contained in:
parent
af9c6b74e8
commit
f14cdd7a4c
8 changed files with 1535 additions and 2 deletions
|
|
@ -1,12 +1,30 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { Blocks, CalendarClock, Cpu, ScrollText } from "lucide-react"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@ui/components/alert-dialog"
|
||||
import {
|
||||
Blocks,
|
||||
BookOpenText,
|
||||
CalendarClock,
|
||||
Cpu,
|
||||
ScrollText,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||
import CompanyBrainModels from "@/components/settings/company-brain-models"
|
||||
import CompanyBrainProactivity from "@/components/settings/company-brain-proactivity"
|
||||
import CompanyBrainSkills from "@/components/settings/company-brain-skills"
|
||||
import Proactiveness from "@/components/settings/proactiveness"
|
||||
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
|
||||
import { WorkspacePrompt } from "@/components/settings/workspace-prompt"
|
||||
|
|
@ -61,16 +79,43 @@ const SECTIONS: {
|
|||
"Read-only scheduled summaries posted to Slack channels or DMs. You manage the ones you create.",
|
||||
icon: CalendarClock,
|
||||
},
|
||||
{
|
||||
id: "skills",
|
||||
label: "Skills",
|
||||
description:
|
||||
"Teach Company Brain your team's repeatable processes, formats, and voice with reusable Markdown playbooks.",
|
||||
icon: BookOpenText,
|
||||
},
|
||||
]
|
||||
|
||||
export function ConfigureView() {
|
||||
const { org } = useAuth()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
// Reachable via ?view=configure too, where the path carries no section.
|
||||
const activeSection =
|
||||
pathToConfigureSection(pathname) ?? DEFAULT_CONFIGURE_SECTION
|
||||
const [skillsDirty, setSkillsDirty] = useState(false)
|
||||
const [pendingSection, setPendingSection] = useState<ConfigureSection | null>(
|
||||
null,
|
||||
)
|
||||
const active = SECTIONS.find((section) => section.id === activeSection)
|
||||
if (!active) return null
|
||||
const requestSection = (
|
||||
event: React.MouseEvent<HTMLAnchorElement>,
|
||||
section: ConfigureSection,
|
||||
) => {
|
||||
if (section === activeSection) return
|
||||
if (activeSection !== "skills" || !skillsDirty) return
|
||||
event.preventDefault()
|
||||
setPendingSection(section)
|
||||
}
|
||||
const confirmSectionChange = () => {
|
||||
if (!pendingSection) return
|
||||
setSkillsDirty(false)
|
||||
router.push(configureSectionToPath(pendingSection))
|
||||
setPendingSection(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -96,6 +141,7 @@ export function ConfigureView() {
|
|||
key={section.id}
|
||||
href={configureSectionToPath(section.id)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={(event) => requestSection(event, section.id)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2.5 rounded-[8px] px-3 py-2 text-left text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
|
|
@ -144,6 +190,8 @@ export function ConfigureView() {
|
|||
<WorkspacePrompt key={org?.id} showHeading={false} />
|
||||
) : activeSection === "proactivity" ? (
|
||||
<CompanyBrainProactivity />
|
||||
) : activeSection === "skills" ? (
|
||||
<CompanyBrainSkills onUnsavedChangesChange={setSkillsDirty} />
|
||||
) : (
|
||||
<Proactiveness />
|
||||
)}
|
||||
|
|
@ -151,6 +199,31 @@ export function ConfigureView() {
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingSection !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingSection(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="border-white/[0.08] bg-[#191D24] text-[#FAFAFA]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[#8B929E]">
|
||||
Leaving Skills will discard your unsaved skill changes.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep editing</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmSectionChange}
|
||||
className="bg-red-600 text-white hover:bg-red-500"
|
||||
>
|
||||
Discard and leave
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
446
apps/web/components/settings/company-brain-skills.tsx
Normal file
446
apps/web/components/settings/company-brain-skills.tsx
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@ui/components/alert-dialog"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from "@ui/components/sheet"
|
||||
import { FileUp, Loader2, Plus, RotateCcw } from "lucide-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
type BrainSkill,
|
||||
useBrainSkills,
|
||||
useUploadBrainSkill,
|
||||
} from "@/hooks/use-brain-skills"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import {
|
||||
emptySkillDraft,
|
||||
parseSkillMarkdown,
|
||||
skillDraftForRole,
|
||||
type SkillDraft,
|
||||
type SkillScope,
|
||||
} from "./company-brain-skills/domain"
|
||||
import { SkillEditor } from "./company-brain-skills/skill-editor"
|
||||
import { SkillRow } from "./company-brain-skills/skill-row"
|
||||
|
||||
type EditorTarget =
|
||||
| { mode: "existing"; skillId: string }
|
||||
| { mode: "new" | "upload"; key: number; draft: SkillDraft }
|
||||
|
||||
type PendingIntent = { type: "close" } | { type: "open"; target: EditorTarget }
|
||||
|
||||
type ScopeFilter = "all" | "org" | "personal"
|
||||
|
||||
const SCOPE_HINTS: Record<ScopeFilter, string> = {
|
||||
all: "Org-wide skills are shared with the workspace and managed by admins and owners. Personal skills stay private to you.",
|
||||
org: "Shared with the workspace and managed by admins and owners.",
|
||||
personal: "Private to you and managed only by you.",
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGES: Record<ScopeFilter, string> = {
|
||||
all: "No skills yet. Create one or upload a .md playbook to get started.",
|
||||
org: "No org-wide skills yet.",
|
||||
personal: "No personal skills yet.",
|
||||
}
|
||||
|
||||
function draftFromSkill(skill: BrainSkill): SkillDraft {
|
||||
return {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
body: skill.body,
|
||||
scope: skill.scope,
|
||||
}
|
||||
}
|
||||
|
||||
function uploadedDraft(
|
||||
local: ReturnType<typeof parseSkillMarkdown>,
|
||||
server: Partial<SkillDraft>,
|
||||
): SkillDraft {
|
||||
return {
|
||||
name: typeof server.name === "string" ? server.name : local.name,
|
||||
description:
|
||||
typeof server.description === "string"
|
||||
? server.description
|
||||
: local.description,
|
||||
body: typeof server.body === "string" ? server.body : local.body,
|
||||
scope: "personal",
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
|
||||
export default function CompanyBrainSkills({
|
||||
onUnsavedChangesChange,
|
||||
}: {
|
||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void
|
||||
}) {
|
||||
const { org, user } = useAuth()
|
||||
return (
|
||||
<CompanyBrainSkillsContent
|
||||
key={`${org?.id ?? "no-org"}:${user?.id ?? "no-user"}`}
|
||||
userId={user?.id ?? ""}
|
||||
onUnsavedChangesChange={onUnsavedChangesChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CompanyBrainSkillsContent({
|
||||
userId,
|
||||
onUnsavedChangesChange,
|
||||
}: {
|
||||
userId: string
|
||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void
|
||||
}) {
|
||||
const skillsQuery = useBrainSkills()
|
||||
const upload = useUploadBrainSkill()
|
||||
const [editing, setEditing] = useState<EditorTarget | null>(null)
|
||||
const [editorDirty, setEditorDirty] = useState(false)
|
||||
const [pending, setPending] = useState<PendingIntent | null>(null)
|
||||
const [scopeFilter, setScopeFilter] = useState<ScopeFilter>("all")
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const draftKey = useRef(0)
|
||||
// An uploaded draft holds real content the user hasn't saved, so it guards too.
|
||||
const hasUnsavedChanges = editorDirty || editing?.mode === "upload"
|
||||
|
||||
useEffect(() => {
|
||||
onUnsavedChangesChange?.(hasUnsavedChanges)
|
||||
}, [hasUnsavedChanges, onUnsavedChangesChange])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
onUnsavedChangesChange?.(false)
|
||||
},
|
||||
[onUnsavedChangesChange],
|
||||
)
|
||||
|
||||
const data = skillsQuery.data
|
||||
const viewerId = data?.viewerId ?? userId
|
||||
const isAdmin = data?.isAdmin ?? false
|
||||
const openEditor = (target: EditorTarget) => {
|
||||
setEditing(target)
|
||||
setEditorDirty(false)
|
||||
}
|
||||
const closeEditor = () => {
|
||||
setEditing(null)
|
||||
setEditorDirty(false)
|
||||
setPending(null)
|
||||
}
|
||||
const requestOpen = (target: EditorTarget) => {
|
||||
if (editing && hasUnsavedChanges) {
|
||||
setPending({ type: "open", target })
|
||||
return
|
||||
}
|
||||
openEditor(target)
|
||||
}
|
||||
const requestClose = () => {
|
||||
if (editing && hasUnsavedChanges) {
|
||||
setPending({ type: "close" })
|
||||
return
|
||||
}
|
||||
closeEditor()
|
||||
}
|
||||
const confirmPending = () => {
|
||||
if (!pending) return
|
||||
if (pending.type === "close") closeEditor()
|
||||
else openEditor(pending.target)
|
||||
setPending(null)
|
||||
}
|
||||
const openDraft = (kind: "new" | "upload", draft: SkillDraft) => {
|
||||
requestOpen({
|
||||
mode: kind,
|
||||
key: draftKey.current++,
|
||||
draft: skillDraftForRole(draft, isAdmin),
|
||||
})
|
||||
}
|
||||
|
||||
const onUpload = async (file: File | undefined) => {
|
||||
if (!file || upload.isPending) return
|
||||
setUploadError(null)
|
||||
upload.reset()
|
||||
if (!file.name.toLowerCase().endsWith(".md")) {
|
||||
setUploadError("Choose a Markdown file ending in .md.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const content = await file.text()
|
||||
// Client parsing gives immediate, deterministic feedback; the harness runs
|
||||
// the same validation and remains authoritative before the draft opens.
|
||||
const local = parseSkillMarkdown(content)
|
||||
const result = await upload.mutateAsync(content)
|
||||
openDraft("upload", uploadedDraft(local, result.draft))
|
||||
toast.success("Skill file is ready to review.")
|
||||
} catch (error) {
|
||||
setUploadError(errorMessage(error, "Couldn't read this skill file."))
|
||||
}
|
||||
}
|
||||
|
||||
const orgSkills = (data?.skills ?? []).filter(
|
||||
(skill) => skill.scope === "org",
|
||||
)
|
||||
const personalSkills = (data?.skills ?? []).filter(
|
||||
(skill) => skill.scope === "personal" && skill.creatorUserId === viewerId,
|
||||
)
|
||||
const visibleSkills =
|
||||
scopeFilter === "org"
|
||||
? orgSkills
|
||||
: scopeFilter === "personal"
|
||||
? personalSkills
|
||||
: [...orgSkills, ...personalSkills]
|
||||
const newSkillScope: SkillScope =
|
||||
scopeFilter === "org" && isAdmin ? "org" : "personal"
|
||||
const target = editing
|
||||
const editingSkill =
|
||||
target?.mode === "existing"
|
||||
? ((data?.skills ?? []).find((skill) => skill.id === target.skillId) ??
|
||||
null)
|
||||
: null
|
||||
const renderSkill = (skill: BrainSkill) => (
|
||||
<SkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onOpen={() => requestOpen({ mode: "existing", skillId: skill.id })}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className={cn(dmSans125ClassName(), "flex flex-col gap-3")}>
|
||||
{skillsQuery.isLoading ? (
|
||||
<div className="flex min-h-32 items-center justify-center gap-2 text-[13px] text-[#7E8794]">
|
||||
<Loader2 className="size-4 animate-spin" /> Loading skills…
|
||||
</div>
|
||||
) : skillsQuery.isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex min-h-32 flex-col items-center justify-center gap-3 rounded-[12px] border border-red-400/10 bg-red-400/[0.025] px-4 text-center"
|
||||
>
|
||||
<p className="text-[13px] text-red-300">
|
||||
{errorMessage(skillsQuery.error, "Couldn't load skills.")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={skillsQuery.isFetching}
|
||||
onClick={() => void skillsQuery.refetch()}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-full border border-white/10 px-3 text-[12px] text-[#B5BDC9] hover:bg-white/[0.04] disabled:opacity-45"
|
||||
>
|
||||
<RotateCcw
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
skillsQuery.isFetching && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Filter skills by scope"
|
||||
className="flex items-center gap-0.5 rounded-full bg-white/[0.04] p-0.5"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{
|
||||
id: "all",
|
||||
label: "All",
|
||||
count: orgSkills.length + personalSkills.length,
|
||||
},
|
||||
{ id: "org", label: "Org-wide", count: orgSkills.length },
|
||||
{
|
||||
id: "personal",
|
||||
label: "Personal",
|
||||
count: personalSkills.length,
|
||||
},
|
||||
] as const
|
||||
).map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={scopeFilter === tab.id}
|
||||
onClick={() => setScopeFilter(tab.id)}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium transition-colors",
|
||||
scopeFilter === tab.id
|
||||
? "bg-white/[0.09] text-[#FAFAFA]"
|
||||
: "text-[#8B929E] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count > 0 ? (
|
||||
<span className="tabular-nums text-[11px] text-[#6B7482]">
|
||||
{tab.count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".md,text/markdown,text/plain"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0]
|
||||
event.currentTarget.value = ""
|
||||
void onUpload(file)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={upload.isPending}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-full border border-white/[0.09] px-3.5 text-[12px] font-medium text-[#9AA3B2] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<FileUp className="size-4" />
|
||||
)}
|
||||
{upload.isPending ? "Reading…" : "Upload .md"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openDraft("new", {
|
||||
...emptySkillDraft(),
|
||||
scope: newSkillScope,
|
||||
})
|
||||
}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-full bg-white/[0.09] px-3.5 text-[12px] font-medium text-[#FAFAFA] transition-colors hover:bg-white/[0.14]"
|
||||
>
|
||||
<Plus className="size-4" /> New skill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-5 text-[#596270]">
|
||||
{SCOPE_HINTS[scopeFilter]}
|
||||
</p>
|
||||
|
||||
{uploadError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 rounded-[9px] border border-red-400/15 bg-red-400/[0.05] px-3 py-2 text-[12px] text-red-300"
|
||||
>
|
||||
<span>{uploadError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadError(null)}
|
||||
className="shrink-0 text-[11px] text-red-200 hover:text-white"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleSkills.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-[#14161A]">
|
||||
<div className="divide-y divide-white/[0.05]">
|
||||
{visibleSkills.map(renderSkill)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-xl border border-white/[0.06] border-dashed px-4 py-5 text-center text-[12px] text-[#596270]">
|
||||
{EMPTY_MESSAGES[scopeFilter]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Sheet
|
||||
open={target !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) requestClose()
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 border-white/[0.06] bg-[#14161A] p-0 sm:max-w-[720px]"
|
||||
>
|
||||
<SheetTitle className="sr-only">Skill editor</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Edit the name, scope, description and Markdown instructions for this
|
||||
skill.
|
||||
</SheetDescription>
|
||||
{target?.mode === "existing" ? (
|
||||
editingSkill ? (
|
||||
<SkillEditor
|
||||
key={`${editingSkill.id}:${editingSkill.version}:${editingSkill.updatedAt}`}
|
||||
skill={editingSkill}
|
||||
initialDraft={draftFromSkill(editingSkill)}
|
||||
isAdmin={isAdmin}
|
||||
viewerId={viewerId}
|
||||
onClose={closeEditor}
|
||||
onDirtyChange={setEditorDirty}
|
||||
/>
|
||||
) : (
|
||||
<p className="p-5 text-[13px] text-[#8B929E]">
|
||||
This skill is no longer available.
|
||||
</p>
|
||||
)
|
||||
) : target ? (
|
||||
<SkillEditor
|
||||
key={target.key}
|
||||
skill={null}
|
||||
initialDraft={target.draft}
|
||||
isAdmin={isAdmin}
|
||||
viewerId={viewerId}
|
||||
draftKind={target.mode}
|
||||
createOrigin={target.mode === "upload" ? "upload" : "web"}
|
||||
onClose={closeEditor}
|
||||
onDirtyChange={setEditorDirty}
|
||||
/>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog
|
||||
open={pending !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPending(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="border-white/[0.08] bg-[#191D24] text-[#FAFAFA]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[#8B929E]">
|
||||
{pending?.type === "open"
|
||||
? "Opening another skill will discard the edits in this skill."
|
||||
: "Closing the editor will discard the edits in this skill."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep editing</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmPending}
|
||||
className="bg-red-600 text-white hover:bg-red-500"
|
||||
>
|
||||
{pending?.type === "open" ? "Discard and open" : "Discard"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
246
apps/web/components/settings/company-brain-skills/domain.ts
Normal file
246
apps/web/components/settings/company-brain-skills/domain.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
export const SKILL_NAME_MAX_LENGTH = 64
|
||||
export const SKILL_DESCRIPTION_MAX_LENGTH = 200
|
||||
export const SKILL_BODY_MAX_LENGTH = 16 * 1024
|
||||
|
||||
export type SkillScope = "personal" | "org"
|
||||
|
||||
export type SkillDraft = {
|
||||
name: string
|
||||
description: string
|
||||
body: string
|
||||
scope: SkillScope
|
||||
}
|
||||
|
||||
export type SkillMarkdown = Pick<SkillDraft, "name" | "description" | "body">
|
||||
export type NewSkillOrigin = "web" | "upload"
|
||||
|
||||
export type SkillAuthoringTarget = {
|
||||
canEdit: boolean
|
||||
creatorUserId: string
|
||||
scope: SkillScope
|
||||
}
|
||||
|
||||
export function canSelectSkillScope(
|
||||
skill: SkillAuthoringTarget | null,
|
||||
viewerId: string,
|
||||
isAdmin: boolean,
|
||||
targetScope: SkillScope,
|
||||
): boolean {
|
||||
if (!skill) return targetScope === "personal" || isAdmin
|
||||
|
||||
const isCreator = skill.creatorUserId === viewerId
|
||||
if (targetScope === skill.scope) return skill.canEdit
|
||||
return skill.canEdit && isAdmin && isCreator
|
||||
}
|
||||
|
||||
export function skillDraftForRole(
|
||||
draft: SkillDraft,
|
||||
isAdmin: boolean,
|
||||
): SkillDraft {
|
||||
return isAdmin || draft.scope === "personal"
|
||||
? draft
|
||||
: { ...draft, scope: "personal" }
|
||||
}
|
||||
|
||||
export function emptySkillDraft(): SkillDraft {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
body: "",
|
||||
scope: "personal",
|
||||
}
|
||||
}
|
||||
|
||||
export function setSkillDraftScope(
|
||||
draft: SkillDraft,
|
||||
scope: SkillScope,
|
||||
): SkillDraft {
|
||||
return {
|
||||
...draft,
|
||||
scope,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSkillDraft(draft: SkillDraft): SkillDraft {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim(),
|
||||
body: draft.body.trim(),
|
||||
scope: draft.scope,
|
||||
}
|
||||
}
|
||||
|
||||
export function skillDraftsEqual(left: SkillDraft, right: SkillDraft): boolean {
|
||||
const normalizedLeft = normalizeSkillDraft(left)
|
||||
const normalizedRight = normalizeSkillDraft(right)
|
||||
return (
|
||||
normalizedLeft.name === normalizedRight.name &&
|
||||
normalizedLeft.description === normalizedRight.description &&
|
||||
normalizedLeft.body === normalizedRight.body &&
|
||||
normalizedLeft.scope === normalizedRight.scope
|
||||
)
|
||||
}
|
||||
|
||||
export function skillDraftPayload(draft: SkillDraft): SkillDraft {
|
||||
const normalized = normalizeSkillDraft(draft)
|
||||
const { name, description, body } = normalized
|
||||
|
||||
if (!name) throw new Error("Give the skill a name.")
|
||||
if (name.length > SKILL_NAME_MAX_LENGTH) {
|
||||
throw new Error(`Keep the name to ${SKILL_NAME_MAX_LENGTH} characters.`)
|
||||
}
|
||||
if (!description) throw new Error("Add a short description.")
|
||||
if (description.length > SKILL_DESCRIPTION_MAX_LENGTH) {
|
||||
throw new Error(
|
||||
`Keep the description to ${SKILL_DESCRIPTION_MAX_LENGTH} characters.`,
|
||||
)
|
||||
}
|
||||
if (!body) throw new Error("Add the skill instructions.")
|
||||
if (new TextEncoder().encode(body).length > SKILL_BODY_MAX_LENGTH) {
|
||||
throw new Error("Keep the skill instructions under 16 KB.")
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function skillSaveRequestBody(
|
||||
draft: SkillDraft,
|
||||
id: string | null,
|
||||
createOrigin?: NewSkillOrigin,
|
||||
expectedVersion?: number,
|
||||
): SkillDraft & { origin?: "upload"; expectedVersion?: number } {
|
||||
if (id !== null) {
|
||||
if (!Number.isInteger(expectedVersion) || (expectedVersion ?? 0) < 1) {
|
||||
throw new Error("Refresh this skill before saving your changes.")
|
||||
}
|
||||
return { ...draft, expectedVersion }
|
||||
}
|
||||
return createOrigin === "upload" ? { ...draft, origin: "upload" } : draft
|
||||
}
|
||||
|
||||
function parseQuotedScalar(value: string, key: string): string {
|
||||
if (value.startsWith('"')) {
|
||||
let closingQuote = -1
|
||||
let escaped = false
|
||||
for (let index = 1; index < value.length; index += 1) {
|
||||
const character = value[index]
|
||||
if (character === '"' && !escaped) {
|
||||
closingQuote = index
|
||||
break
|
||||
}
|
||||
escaped = character === "\\" ? !escaped : false
|
||||
}
|
||||
if (closingQuote < 0) {
|
||||
throw new Error(`Invalid quoted ${key} in skill frontmatter.`)
|
||||
}
|
||||
const suffix = value.slice(closingQuote + 1).trim()
|
||||
if (suffix && !suffix.startsWith("#")) {
|
||||
throw new Error(`Invalid quoted ${key} in skill frontmatter.`)
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value.slice(0, closingQuote + 1)) as unknown
|
||||
if (typeof parsed !== "string") throw new Error("not a string")
|
||||
return parsed
|
||||
} catch {
|
||||
throw new Error(`Invalid quoted ${key} in skill frontmatter.`)
|
||||
}
|
||||
}
|
||||
if (value.startsWith("'")) {
|
||||
let parsed = ""
|
||||
for (let index = 1; index < value.length; index += 1) {
|
||||
const character = value[index]
|
||||
if (character !== "'") {
|
||||
parsed += character
|
||||
continue
|
||||
}
|
||||
if (value[index + 1] === "'") {
|
||||
parsed += "'"
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
const suffix = value.slice(index + 1).trim()
|
||||
if (suffix && !suffix.startsWith("#")) {
|
||||
throw new Error(`Invalid quoted ${key} in skill frontmatter.`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
throw new Error(`Invalid quoted ${key} in skill frontmatter.`)
|
||||
}
|
||||
return value.replace(/\s+#.*$/, "").trimEnd()
|
||||
}
|
||||
|
||||
function parseFrontmatter(source: string): {
|
||||
attributes: Record<string, string>
|
||||
body: string
|
||||
} {
|
||||
const normalized = source.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n")
|
||||
const lines = normalized.split("\n")
|
||||
if (lines[0]?.trim() !== "---") {
|
||||
throw new Error("Skill files must start with YAML frontmatter.")
|
||||
}
|
||||
|
||||
const end = lines.findIndex(
|
||||
(line, index) => index > 0 && /^---[\t ]*$/.test(line),
|
||||
)
|
||||
if (end < 0) throw new Error("Skill frontmatter is missing its closing ---.")
|
||||
|
||||
const attributes: Record<string, string> = {}
|
||||
for (let index = 1; index < end; index += 1) {
|
||||
const line = lines[index] ?? ""
|
||||
if (!line.trim() || line.trimStart().startsWith("#")) continue
|
||||
// Extra SKILL.md metadata may contain nested maps/lists. Only name and
|
||||
// description are used here, so valid indented metadata can be ignored.
|
||||
if (/^\s/.test(line)) continue
|
||||
const match = /^([A-Za-z][\w-]*):(?:\s*(.*))?$/.exec(line)
|
||||
if (!match) throw new Error("Skill frontmatter is malformed.")
|
||||
const key = match[1]?.toLowerCase()
|
||||
let rawValue = match[2] ?? ""
|
||||
if (!key) throw new Error("Skill frontmatter is malformed.")
|
||||
if (attributes[key] !== undefined) {
|
||||
throw new Error(`Skill frontmatter contains duplicate ${key} fields.`)
|
||||
}
|
||||
|
||||
if ([">", "|", ">-", "|-"].includes(rawValue)) {
|
||||
const blockLines: string[] = []
|
||||
while (index + 1 < end) {
|
||||
const next = lines[index + 1] ?? ""
|
||||
if (next && !/^\s/.test(next)) break
|
||||
index += 1
|
||||
blockLines.push(next.replace(/^\s{1,2}/, ""))
|
||||
}
|
||||
rawValue = rawValue.startsWith(">")
|
||||
? blockLines.join(" ").replace(/\s+/g, " ").trim()
|
||||
: blockLines.join("\n")
|
||||
}
|
||||
|
||||
attributes[key] = parseQuotedScalar(rawValue.trim(), key)
|
||||
}
|
||||
|
||||
return { attributes, body: lines.slice(end + 1).join("\n") }
|
||||
}
|
||||
|
||||
export function parseSkillMarkdown(source: string): SkillMarkdown {
|
||||
const { attributes, body } = parseFrontmatter(source)
|
||||
const name = attributes.name?.trim() ?? ""
|
||||
const description = attributes.description?.trim() ?? ""
|
||||
if (!name) throw new Error("Skill frontmatter needs a name.")
|
||||
if (name.length > SKILL_NAME_MAX_LENGTH) {
|
||||
throw new Error(`Keep the name to ${SKILL_NAME_MAX_LENGTH} characters.`)
|
||||
}
|
||||
if (!description) throw new Error("Skill frontmatter needs a description.")
|
||||
if (description.length > SKILL_DESCRIPTION_MAX_LENGTH) {
|
||||
throw new Error(
|
||||
`Keep the description to ${SKILL_DESCRIPTION_MAX_LENGTH} characters.`,
|
||||
)
|
||||
}
|
||||
return { name, description, body }
|
||||
}
|
||||
|
||||
export function serializeSkillMarkdown(skill: SkillMarkdown): string {
|
||||
return [
|
||||
"---",
|
||||
`name: ${JSON.stringify(skill.name)}`,
|
||||
`description: ${JSON.stringify(skill.description)}`,
|
||||
"---",
|
||||
skill.body,
|
||||
].join("\n")
|
||||
}
|
||||
|
|
@ -0,0 +1,511 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@ui/components/alert-dialog"
|
||||
import { Building2, Loader2, Trash2, UserRound } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
type BrainSkill,
|
||||
useDeleteBrainSkill,
|
||||
useSaveBrainSkill,
|
||||
} from "@/hooks/use-brain-skills"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import {
|
||||
canSelectSkillScope,
|
||||
SKILL_BODY_MAX_LENGTH,
|
||||
SKILL_DESCRIPTION_MAX_LENGTH,
|
||||
SKILL_NAME_MAX_LENGTH,
|
||||
setSkillDraftScope,
|
||||
skillDraftPayload,
|
||||
skillDraftForRole,
|
||||
skillDraftsEqual,
|
||||
type NewSkillOrigin,
|
||||
type SkillDraft,
|
||||
type SkillScope,
|
||||
} from "./domain"
|
||||
|
||||
const inputClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-3 text-[13px] text-[#FAFAFA] outline-none transition-colors focus:border-white/[0.16] read-only:cursor-default read-only:text-[#B5BDC9] disabled:opacity-50",
|
||||
)
|
||||
const labelClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] font-medium uppercase tracking-[0.06em] text-[#687282]",
|
||||
)
|
||||
const secondaryButtonClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full border border-white/10 px-4 text-[13px] font-medium text-[#9AA3B2] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-45",
|
||||
)
|
||||
const primaryButtonClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820] disabled:cursor-not-allowed disabled:opacity-45",
|
||||
)
|
||||
|
||||
function messageFor(error: unknown): string | null {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: error
|
||||
? "Something went wrong."
|
||||
: null
|
||||
}
|
||||
|
||||
function ScopeButton({
|
||||
value,
|
||||
label,
|
||||
icon,
|
||||
selected,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: {
|
||||
value: SkillScope
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
selected: boolean
|
||||
disabled: boolean
|
||||
onSelect: (scope: SkillScope) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(value)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
selected
|
||||
? "border-[#3B82F6]/40 bg-[#2563EB]/10 text-[#BFDBFE]"
|
||||
: "border-white/[0.08] text-[#7E8794] hover:border-white/[0.14] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownPreview({ body }: { body: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"prose prose-invert prose-sm min-h-[280px] max-w-none overflow-auto rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-4 py-3 text-[#C6CDD7]",
|
||||
"prose-headings:text-[#FAFAFA] prose-a:text-[#93C5FD] prose-code:text-[#D8B4FE] prose-strong:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{body.trim() ? (
|
||||
<ReactMarkdown>{body}</ReactMarkdown>
|
||||
) : (
|
||||
<p className="not-prose text-[13px] text-[#596270]">
|
||||
Nothing to preview yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkillEditor({
|
||||
skill,
|
||||
initialDraft,
|
||||
isAdmin,
|
||||
viewerId,
|
||||
draftKind,
|
||||
createOrigin,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
skill: BrainSkill | null
|
||||
initialDraft: SkillDraft
|
||||
isAdmin: boolean
|
||||
viewerId: string
|
||||
draftKind?: "new" | "upload"
|
||||
createOrigin?: NewSkillOrigin
|
||||
onClose: () => void
|
||||
onDirtyChange?: (dirty: boolean) => void
|
||||
}) {
|
||||
const privateSkillUnavailable =
|
||||
!!skill && skill.scope === "personal" && skill.creatorUserId !== viewerId
|
||||
const [draft, setDraft] = useState(() =>
|
||||
privateSkillUnavailable
|
||||
? { name: "", description: "", body: "", scope: "personal" as const }
|
||||
: skill
|
||||
? initialDraft
|
||||
: skillDraftForRole(initialDraft, isAdmin),
|
||||
)
|
||||
const [preview, setPreview] = useState(false)
|
||||
const [clientError, setClientError] = useState<string | null>(null)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const save = useSaveBrainSkill()
|
||||
const remove = useDeleteBrainSkill()
|
||||
const isDirty =
|
||||
!privateSkillUnavailable && !skillDraftsEqual(draft, initialDraft)
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty)
|
||||
}, [isDirty, onDirtyChange])
|
||||
|
||||
if (privateSkillUnavailable) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-40 flex-col items-center justify-center gap-3 rounded-[14px] bg-[#14161A] px-5 py-8 text-center",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-[#FAFAFA]">
|
||||
Personal skill unavailable
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-[#737B87]">
|
||||
This private skill is only available to its creator.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={secondaryButtonClass}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isCreator = !skill || skill.creatorUserId === viewerId
|
||||
const canManage = skill
|
||||
? skill.canEdit
|
||||
: isAdmin || draft.scope === "personal"
|
||||
const canDelete = skill?.canDelete ?? false
|
||||
const canSelectScope = (scope: SkillScope) =>
|
||||
canSelectSkillScope(skill, viewerId, isAdmin, scope)
|
||||
const showSharedScopeOptions = isAdmin
|
||||
const busy = save.isPending || remove.isPending
|
||||
const bodyBytes = new TextEncoder().encode(draft.body).length
|
||||
const mutationError = messageFor(save.error) ?? messageFor(remove.error)
|
||||
const visibleError = clientError ?? mutationError
|
||||
const scopePermissionCopy =
|
||||
!skill && !isAdmin
|
||||
? "New member skills are personal. Only admins and owners can create org-wide skills."
|
||||
: skill?.scope === "personal" && isCreator && !isAdmin
|
||||
? "This skill stays personal. Only creators who are also admins or owners can make personal skills org-wide."
|
||||
: skill?.scope !== "personal" && isAdmin && !isCreator
|
||||
? "Only the original creator can convert this org-wide skill to Personal."
|
||||
: null
|
||||
|
||||
const resetErrors = () => {
|
||||
setClientError(null)
|
||||
save.reset()
|
||||
remove.reset()
|
||||
}
|
||||
const set = <K extends keyof SkillDraft>(key: K, value: SkillDraft[K]) => {
|
||||
resetErrors()
|
||||
setDraft((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
const saveDraft = () => {
|
||||
if (busy || !canManage) return
|
||||
let payload: SkillDraft
|
||||
try {
|
||||
payload = skillDraftPayload(draft)
|
||||
} catch (error) {
|
||||
setClientError(messageFor(error))
|
||||
return
|
||||
}
|
||||
save.mutate(
|
||||
{
|
||||
id: skill?.id ?? null,
|
||||
draft: payload,
|
||||
createOrigin,
|
||||
expectedVersion: skill?.version,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Skill saved.")
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-0 flex-1 flex-col bg-[#14161A]",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 shrink-0 flex-col border-white/[0.05] border-b px-5 py-4 pr-14">
|
||||
<p className="text-[13px] font-semibold text-[#FAFAFA]">
|
||||
{draftKind === "upload"
|
||||
? "Review uploaded skill"
|
||||
: skill
|
||||
? canManage
|
||||
? "Edit skill"
|
||||
: "View skill"
|
||||
: "New skill"}
|
||||
</p>
|
||||
{draftKind === "upload" ? (
|
||||
<p className="mt-0.5 text-[11px] text-[#687282]">Not saved yet</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||
{!canManage ? (
|
||||
<p className="rounded-[9px] border border-white/[0.07] bg-white/[0.025] px-3 py-2 text-[12px] text-[#8B929E]">
|
||||
{skill?.scope === "personal"
|
||||
? "Only this skill’s creator can edit or delete it. Admin access does not override personal ownership. You can still read the full playbook below."
|
||||
: isAdmin
|
||||
? "You can view this org-wide skill, but it cannot be edited or deleted in its current state."
|
||||
: "Only admins and owners can edit or delete org-wide skills. You can still read the full playbook below."}
|
||||
</p>
|
||||
) : null}
|
||||
{skill?.status === "disabled" ? (
|
||||
<p className="rounded-[9px] border border-red-400/15 bg-red-400/[0.05] px-3 py-2 text-[12px] text-red-200">
|
||||
This skill is disabled.
|
||||
{skill.rejectionReason ? ` Reason: ${skill.rejectionReason}` : null}
|
||||
</p>
|
||||
) : null}
|
||||
<label className="flex min-w-0 shrink-0 flex-col gap-1.5">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className={labelClass}>Name</span>
|
||||
<span className="text-[10px] text-[#596270]">
|
||||
{draft.name.length} / {SKILL_NAME_MAX_LENGTH}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(event) => set("name", event.target.value)}
|
||||
readOnly={!canManage}
|
||||
disabled={busy}
|
||||
maxLength={SKILL_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. Incident status updates"
|
||||
className={cn(inputClass, "h-9")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{canManage ? (
|
||||
<div className="flex shrink-0 flex-col gap-2">
|
||||
<span className={labelClass}>Who can use it</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ScopeButton
|
||||
value="personal"
|
||||
label="Personal"
|
||||
icon={<UserRound className="size-3.5" />}
|
||||
selected={draft.scope === "personal"}
|
||||
disabled={!canSelectScope("personal") || busy}
|
||||
onSelect={(scope) => {
|
||||
resetErrors()
|
||||
setDraft((current) => setSkillDraftScope(current, scope))
|
||||
}}
|
||||
/>
|
||||
{showSharedScopeOptions ? (
|
||||
<ScopeButton
|
||||
value="org"
|
||||
label="Org-wide"
|
||||
icon={<Building2 className="size-3.5" />}
|
||||
selected={draft.scope === "org"}
|
||||
disabled={!canSelectScope("org") || busy}
|
||||
onSelect={(scope) => {
|
||||
resetErrors()
|
||||
setDraft((current) => setSkillDraftScope(current, scope))
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{scopePermissionCopy ? (
|
||||
<p className="text-[11px] leading-5 text-[#687282]">
|
||||
{scopePermissionCopy}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="flex min-w-0 shrink-0 flex-col gap-1.5">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className={labelClass}>Description</span>
|
||||
<span className="text-[10px] text-[#596270]">
|
||||
{draft.description.length} / {SKILL_DESCRIPTION_MAX_LENGTH}
|
||||
</span>
|
||||
</span>
|
||||
<textarea
|
||||
value={draft.description}
|
||||
onChange={(event) => set("description", event.target.value)}
|
||||
readOnly={!canManage}
|
||||
disabled={busy}
|
||||
maxLength={SKILL_DESCRIPTION_MAX_LENGTH}
|
||||
rows={3}
|
||||
placeholder="When should the brain use this playbook?"
|
||||
className={cn(inputClass, "resize-y py-2 leading-5")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={labelClass}>Instructions (Markdown)</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
bodyBytes > SKILL_BODY_MAX_LENGTH
|
||||
? "text-red-300"
|
||||
: "text-[#596270]",
|
||||
)}
|
||||
>
|
||||
{Math.ceil(bodyBytes / 1024)} / 16 KB
|
||||
</span>
|
||||
{canManage ? (
|
||||
<div className="inline-flex rounded-full border border-white/[0.08] bg-[#0D0F14] p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview(false)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-[11px] transition-colors",
|
||||
!preview
|
||||
? "bg-white/[0.08] text-[#FAFAFA]"
|
||||
: "text-[#687282]",
|
||||
)}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview(true)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-[11px] transition-colors",
|
||||
preview
|
||||
? "bg-white/[0.08] text-[#FAFAFA]"
|
||||
: "text-[#687282]",
|
||||
)}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{preview || !canManage ? (
|
||||
<MarkdownPreview body={draft.body} />
|
||||
) : (
|
||||
<textarea
|
||||
value={draft.body}
|
||||
onChange={(event) => set("body", event.target.value)}
|
||||
disabled={busy}
|
||||
maxLength={SKILL_BODY_MAX_LENGTH}
|
||||
spellCheck={false}
|
||||
placeholder={
|
||||
"# Process\n\nDescribe the steps, format, and voice to follow."
|
||||
}
|
||||
className="min-h-[240px] w-full flex-1 resize-y rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-4 py-3 font-mono text-[12px] leading-5 text-[#D3D8E0] outline-none transition-colors focus:border-white/[0.16] disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="shrink-0 rounded-[9px] border border-red-400/15 bg-red-400/[0.05] px-3 py-2 text-[12px] text-red-300"
|
||||
>
|
||||
{visibleError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-white/[0.05] border-t px-5 py-4">
|
||||
<div>
|
||||
{canDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-full px-3 text-[12px] text-[#A7685B] transition-colors hover:bg-red-400/[0.05] hover:text-red-300 disabled:opacity-45"
|
||||
>
|
||||
<Trash2 className="size-4" /> Delete
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onClose}
|
||||
className={secondaryButtonClass}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || (!!skill && !isDirty)}
|
||||
onClick={saveDraft}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{save.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{save.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent className="border-white/[0.08] bg-[#191D24] text-[#FAFAFA]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete “{skill?.name}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[#8B929E]">
|
||||
{skill?.scope === "org"
|
||||
? "This removes the playbook for the workspace."
|
||||
: "This removes your private playbook."}{" "}
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{remove.error ? (
|
||||
<p role="alert" className="text-[12px] text-red-300">
|
||||
{messageFor(remove.error)}
|
||||
</p>
|
||||
) : null}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={remove.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={remove.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (!skill || !canDelete || remove.isPending) return
|
||||
remove.mutate(
|
||||
{ id: skill.id, expectedVersion: skill.version },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Skill deleted.")
|
||||
setDeleteOpen(false)
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
)
|
||||
}}
|
||||
className="bg-red-600 text-white hover:bg-red-500"
|
||||
>
|
||||
{remove.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
Delete skill
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import type { BrainSkill } from "@/hooks/use-brain-skills"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
function updatedLabel(updatedAt: number) {
|
||||
const date = new Date(updatedAt)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return formatDistanceToNow(date, { addSuffix: true })
|
||||
}
|
||||
|
||||
function ScopeBadge({ scope }: { scope: BrainSkill["scope"] }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-[18px] shrink-0 items-center rounded-full px-1.5 text-[10px] font-medium uppercase tracking-[0.04em]",
|
||||
scope === "org"
|
||||
? "bg-[#2A3140] text-[#A9B4C6]"
|
||||
: "bg-white/[0.05] text-[#7E8794]",
|
||||
)}
|
||||
>
|
||||
{scope === "org" ? "Org-wide" : "Personal"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkillRow({
|
||||
skill,
|
||||
onOpen,
|
||||
}: {
|
||||
skill: BrainSkill
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const updated = updatedLabel(skill.updatedAt)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
aria-label={`${skill.canEdit ? "Edit" : "View"} ${skill.name}`}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"group flex w-full items-center gap-4 px-4 py-3 text-left transition-colors hover:bg-white/[0.03]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="truncate text-[13px] font-semibold tracking-[-0.1px] text-[#FAFAFA]">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<ScopeBadge scope={skill.scope} />
|
||||
{skill.status === "disabled" ? (
|
||||
<span className="inline-flex h-[18px] shrink-0 items-center rounded-full border border-red-400/20 bg-red-400/[0.07] px-1.5 text-[10px] font-medium text-red-300">
|
||||
Disabled
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[12px] leading-5 text-[#737B87]">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden shrink-0 text-[11px] tabular-nums text-[#596270] sm:block">
|
||||
v{skill.version}
|
||||
{updated ? ` · ${updated}` : ""}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-[#4A5260] transition-colors group-hover:text-[#8B929E]" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
182
apps/web/hooks/use-brain-skills.impl.ts
Normal file
182
apps/web/hooks/use-brain-skills.impl.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import type {
|
||||
NewSkillOrigin,
|
||||
SkillDraft,
|
||||
SkillScope,
|
||||
} from "@/components/settings/company-brain-skills/domain"
|
||||
import { skillSaveRequestBody } from "@/components/settings/company-brain-skills/domain"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const BASE = `${BACKEND}/brain/skills`
|
||||
|
||||
export type BrainSkillStatus = "active" | "disabled"
|
||||
|
||||
export type BrainSkill = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
body: string
|
||||
scope: SkillScope
|
||||
status: BrainSkillStatus
|
||||
creatorUserId: string
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
version: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
rejectionReason: string | null
|
||||
}
|
||||
|
||||
export type BrainSkillsResponse = {
|
||||
skills: BrainSkill[]
|
||||
isAdmin: boolean
|
||||
viewerId: string
|
||||
}
|
||||
|
||||
export const brainSkillKeys = {
|
||||
all: (orgId: string | undefined, userId: string | undefined) =>
|
||||
["brain", "skills", orgId, userId] as const,
|
||||
list: (orgId: string | undefined, userId: string | undefined) =>
|
||||
[...brainSkillKeys.all(orgId, userId), "list"] as const,
|
||||
}
|
||||
|
||||
class BrainSkillRequestError extends Error {
|
||||
readonly status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = "BrainSkillRequestError"
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function responseError(response: Response, fallback: string) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string | { message?: string }
|
||||
message?: string
|
||||
} | null
|
||||
const nested =
|
||||
typeof body?.error === "object" ? body.error.message : undefined
|
||||
const message =
|
||||
body?.message ??
|
||||
(typeof body?.error === "string" ? body.error : undefined) ??
|
||||
nested ??
|
||||
fallback
|
||||
return new BrainSkillRequestError(message, response.status)
|
||||
}
|
||||
|
||||
async function jsonRequest<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
fallback: string,
|
||||
) {
|
||||
const response = await fetch(url, { credentials: "include", ...init })
|
||||
if (!response.ok) throw await responseError(response, fallback)
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export function useBrainSkills() {
|
||||
const { org, user } = useAuth()
|
||||
return useQuery({
|
||||
queryKey: brainSkillKeys.list(org?.id, user?.id),
|
||||
queryFn: () =>
|
||||
jsonRequest<BrainSkillsResponse>(`${BASE}/`, {}, "Couldn't load skills."),
|
||||
enabled: !!org?.id && !!user?.id,
|
||||
})
|
||||
}
|
||||
|
||||
function useInvalidateBrainSkills() {
|
||||
const { org, user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
return () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: brainSkillKeys.all(org?.id, user?.id),
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveBrainSkill() {
|
||||
const invalidate = useInvalidateBrainSkills()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
draft,
|
||||
createOrigin,
|
||||
expectedVersion,
|
||||
}: {
|
||||
id: string | null
|
||||
draft: SkillDraft
|
||||
createOrigin?: NewSkillOrigin
|
||||
expectedVersion?: number
|
||||
}) =>
|
||||
jsonRequest<{ skill: BrainSkill }>(
|
||||
id ? `${BASE}/${id}` : `${BASE}/`,
|
||||
{
|
||||
method: id ? "PUT" : "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
skillSaveRequestBody(draft, id, createOrigin, expectedVersion),
|
||||
),
|
||||
},
|
||||
"Couldn't save this skill.",
|
||||
),
|
||||
onSuccess: () => {
|
||||
void invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof BrainSkillRequestError && error.status === 409) {
|
||||
void invalidate()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteBrainSkill() {
|
||||
const invalidate = useInvalidateBrainSkills()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
expectedVersion,
|
||||
}: {
|
||||
id: string
|
||||
expectedVersion: number
|
||||
}) =>
|
||||
jsonRequest<{ ok?: boolean }>(
|
||||
`${BASE}/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ expectedVersion }),
|
||||
},
|
||||
"Couldn't delete this skill.",
|
||||
),
|
||||
onSuccess: () => {
|
||||
void invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof BrainSkillRequestError && error.status === 409) {
|
||||
void invalidate()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUploadBrainSkill() {
|
||||
return useMutation({
|
||||
mutationFn: (content: string) =>
|
||||
jsonRequest<{
|
||||
draft: Partial<SkillDraft> & { origin?: "upload" }
|
||||
}>(
|
||||
`${BASE}/upload`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
"Couldn't read this skill file.",
|
||||
),
|
||||
})
|
||||
}
|
||||
3
apps/web/hooks/use-brain-skills.ts
Normal file
3
apps/web/hooks/use-brain-skills.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"use client"
|
||||
|
||||
export * from "./use-brain-skills.impl"
|
||||
|
|
@ -6,6 +6,7 @@ export const CONFIGURE_SECTIONS = [
|
|||
"workspace-prompt",
|
||||
"proactivity",
|
||||
"automations",
|
||||
"skills",
|
||||
] as const
|
||||
|
||||
export type ConfigureSection = (typeof CONFIGURE_SECTIONS)[number]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue