From 23f9b0efd138e5031991b4c7d371a94fc63b890e Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:02:00 -0700 Subject: [PATCH 01/25] Update schema to support the foundation for skills, both personal and for the organization --- .../migration.sql | 38 +++++++++++++++++++ packages/db/prisma/schema.prisma | 34 +++++++++++++++++ packages/db/src/index.ts | 14 ++++++- 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 packages/db/prisma/migrations/20260609000000_add_agent_skills/migration.sql diff --git a/packages/db/prisma/migrations/20260609000000_add_agent_skills/migration.sql b/packages/db/prisma/migrations/20260609000000_add_agent_skills/migration.sql new file mode 100644 index 000000000..cdb8ab4ec --- /dev/null +++ b/packages/db/prisma/migrations/20260609000000_add_agent_skills/migration.sql @@ -0,0 +1,38 @@ +-- CreateEnum +CREATE TYPE "AgentSkillScope" AS ENUM ('PERSONAL', 'ORG'); + +-- CreateTable +CREATE TABLE "AgentSkill" ( + "id" TEXT NOT NULL, + "scope" "AgentSkillScope" NOT NULL, + "namespaceKey" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "instructions" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "orgId" INTEGER, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AgentSkill_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AgentSkill_namespaceKey_slug_key" ON "AgentSkill"("namespaceKey", "slug"); + +-- CreateIndex +CREATE INDEX "AgentSkill_createdById_idx" ON "AgentSkill"("createdById"); + +-- CreateIndex +CREATE INDEX "AgentSkill_orgId_idx" ON "AgentSkill"("orgId"); + +-- CreateIndex +CREATE INDEX "AgentSkill_scope_idx" ON "AgentSkill"("scope"); + +-- AddForeignKey +ALTER TABLE "AgentSkill" ADD CONSTRAINT "AgentSkill_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentSkill" ADD CONSTRAINT "AgentSkill_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f18889a5f..5c44a2994 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -334,6 +334,7 @@ model Org { repoVisits RepoVisit[] mcpServers McpServer[] + agentSkills AgentSkill[] license License? servicePingEvents ServicePingEvent[] @@ -402,6 +403,11 @@ enum McpServerToolPermission { DISABLED } +enum AgentSkillScope { + PERSONAL + ORG +} + model UserToOrg { joinedAt DateTime @default(now()) @@ -519,6 +525,7 @@ model User { sessionVersion Int @default(0) userMcpServers UserMcpServer[] + createdAgentSkills AgentSkill[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -741,6 +748,33 @@ model ChatAccess { @@unique([chatId, userId]) } +model AgentSkill { + id String @id @default(cuid()) + + scope AgentSkillScope + namespaceKey String + slug String + + name String + description String + instructions String + + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdById String + + org Org? @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int? + + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([namespaceKey, slug]) + @@index([createdById]) + @@index([orgId]) + @@index([scope]) +} + // OAuth2 Authorization Server models // @see: https://datatracker.ietf.org/doc/html/rfc6749 diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 245206d9d..9815f91d9 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,3 +1,15 @@ import type { User, Account } from ".prisma/client"; export type UserWithAccounts = User & { accounts: Account[] }; -export * from ".prisma/client"; \ No newline at end of file +export * from ".prisma/client"; + +type AgentSkillNamespaceInput = + | { scope: "PERSONAL"; userId: string } + | { scope: "ORG"; orgId: number }; + +export const getAgentSkillNamespaceKey = (input: AgentSkillNamespaceInput) => { + if (input.scope === "PERSONAL") { + return `user:${input.userId}`; + } + + return `org:${input.orgId}`; +}; From 05ae0bd73310155797728451537da9716df024e7 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:27:48 -0700 Subject: [PATCH 02/25] Add initial UI and actions to support personal skills --- .../migration.sql | 7 - packages/db/prisma/schema.prisma | 9 +- packages/db/src/index.ts | 8 + packages/web/package.json | 1 + .../(app)/settings/accountAskAgent/page.tsx | 8 + .../accountAskAgent/skills/[skillId]/page.tsx | 34 ++ .../accountAskAgent/skills/new/page.tsx | 14 + .../components/settingsContentFrame.tsx | 27 ++ .../web/src/app/(app)/settings/layout.tsx | 1 + .../mcp/components/accountAskAgentPage.tsx | 199 +++++++- .../src/ee/features/chat/mcp/mcpToolSets.ts | 5 +- .../src/ee/features/chat/skills/actions.ts | 188 ++++++++ .../components/personalSkillEditorPage.tsx | 449 ++++++++++++++++++ .../src/ee/features/chat/skills/types.test.ts | 146 ++++++ .../web/src/ee/features/chat/skills/types.ts | 145 ++++++ packages/web/src/ee/features/oauth/server.ts | 4 +- packages/web/src/lib/errorCodes.ts | 2 + packages/web/src/lib/prismaErrors.ts | 21 + yarn.lock | 1 + 19 files changed, 1249 insertions(+), 20 deletions(-) create mode 100644 packages/web/src/app/(app)/settings/accountAskAgent/skills/[skillId]/page.tsx create mode 100644 packages/web/src/app/(app)/settings/accountAskAgent/skills/new/page.tsx create mode 100644 packages/web/src/app/(app)/settings/components/settingsContentFrame.tsx create mode 100644 packages/web/src/ee/features/chat/skills/actions.ts create mode 100644 packages/web/src/ee/features/chat/skills/components/personalSkillEditorPage.tsx create mode 100644 packages/web/src/ee/features/chat/skills/types.test.ts create mode 100644 packages/web/src/ee/features/chat/skills/types.ts create mode 100644 packages/web/src/lib/prismaErrors.ts diff --git a/packages/db/prisma/migrations/20260609000000_add_agent_skills/migration.sql b/packages/db/prisma/migrations/20260609000000_add_agent_skills/migration.sql index cdb8ab4ec..0581017b7 100644 --- a/packages/db/prisma/migrations/20260609000000_add_agent_skills/migration.sql +++ b/packages/db/prisma/migrations/20260609000000_add_agent_skills/migration.sql @@ -1,10 +1,6 @@ --- CreateEnum -CREATE TYPE "AgentSkillScope" AS ENUM ('PERSONAL', 'ORG'); - -- CreateTable CREATE TABLE "AgentSkill" ( "id" TEXT NOT NULL, - "scope" "AgentSkillScope" NOT NULL, "namespaceKey" TEXT NOT NULL, "slug" TEXT NOT NULL, "name" TEXT NOT NULL, @@ -28,9 +24,6 @@ CREATE INDEX "AgentSkill_createdById_idx" ON "AgentSkill"("createdById"); -- CreateIndex CREATE INDEX "AgentSkill_orgId_idx" ON "AgentSkill"("orgId"); --- CreateIndex -CREATE INDEX "AgentSkill_scope_idx" ON "AgentSkill"("scope"); - -- AddForeignKey ALTER TABLE "AgentSkill" ADD CONSTRAINT "AgentSkill_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 5c44a2994..67bb3467d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -403,11 +403,6 @@ enum McpServerToolPermission { DISABLED } -enum AgentSkillScope { - PERSONAL - ORG -} - model UserToOrg { joinedAt DateTime @default(now()) @@ -751,7 +746,8 @@ model ChatAccess { model AgentSkill { id String @id @default(cuid()) - scope AgentSkillScope + // Encodes both the scope and owner of the skill, e.g. "user:" (personal) + // or "org:" (organization). See getAgentSkillNamespaceKey in @sourcebot/db. namespaceKey String slug String @@ -772,7 +768,6 @@ model AgentSkill { @@unique([namespaceKey, slug]) @@index([createdById]) @@index([orgId]) - @@index([scope]) } // OAuth2 Authorization Server models diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9815f91d9..60250009f 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -2,10 +2,15 @@ import type { User, Account } from ".prisma/client"; export type UserWithAccounts = User & { accounts: Account[] }; export * from ".prisma/client"; +export type AgentSkillScope = "PERSONAL" | "ORG"; + type AgentSkillNamespaceInput = | { scope: "PERSONAL"; userId: string } | { scope: "ORG"; orgId: number }; +// The namespaceKey is the single source of truth for a skill's scope and owner. +// The "user:" / "org:" prefix is disjoint, so the scope is fully derivable from it +// and (namespaceKey, slug) is a robust, collision-proof unique key on its own. export const getAgentSkillNamespaceKey = (input: AgentSkillNamespaceInput) => { if (input.scope === "PERSONAL") { return `user:${input.userId}`; @@ -13,3 +18,6 @@ export const getAgentSkillNamespaceKey = (input: AgentSkillNamespaceInput) => { return `org:${input.orgId}`; }; + +export const getAgentSkillScopeFromNamespaceKey = (namespaceKey: string): AgentSkillScope => + namespaceKey.startsWith("org:") ? "ORG" : "PERSONAL"; diff --git a/packages/web/package.json b/packages/web/package.json index 397d0df21..4f25dfcbc 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -205,6 +205,7 @@ "usehooks-ts": "^3.1.0", "uuid": "^14.0.0", "vscode-icons-js": "^11.6.1", + "yaml": "^2.8.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.5" }, diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/page.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/page.tsx index 369e43793..cad97cc30 100644 --- a/packages/web/src/app/(app)/settings/accountAskAgent/page.tsx +++ b/packages/web/src/app/(app)/settings/accountAskAgent/page.tsx @@ -1,6 +1,9 @@ import { AccountAskAgentPage } from "@/ee/features/chat/mcp/components/accountAskAgentPage"; import { AccountAskAgentEntitlementMessage } from "./accountAskAgentEntitlementMessage"; +import { listPersonalAgentSkills } from "@/ee/features/chat/skills/actions"; import { hasEntitlement } from "@/lib/entitlements"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; import { authenticatedPage } from "@/middleware/authenticatedPage"; import { OrgRole } from "@sourcebot/db"; @@ -21,6 +24,10 @@ export default authenticatedPage(async ({ role }, { searchParams }) = } const { status, server, message } = await searchParams; + const personalSkills = await listPersonalAgentSkills(); + if (isServiceError(personalSkills)) { + throw new ServiceErrorException(personalSkills); + } return ( (async ({ role }, { searchParams }) = callbackServer={server} callbackMessage={message} canManageConnectors={role === OrgRole.OWNER} + initialPersonalSkills={personalSkills} /> ); }); diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/skills/[skillId]/page.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/skills/[skillId]/page.tsx new file mode 100644 index 000000000..d364ed796 --- /dev/null +++ b/packages/web/src/app/(app)/settings/accountAskAgent/skills/[skillId]/page.tsx @@ -0,0 +1,34 @@ +import { notFound } from "next/navigation"; +import { getPersonalAgentSkill } from "@/ee/features/chat/skills/actions"; +import { PersonalSkillEditorPage } from "@/ee/features/chat/skills/components/personalSkillEditorPage"; +import { hasEntitlement } from "@/lib/entitlements"; +import { ErrorCode } from "@/lib/errorCodes"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { AccountAskAgentEntitlementMessage } from "../../accountAskAgentEntitlementMessage"; + +interface PageProps extends Record { + params: Promise<{ + skillId: string; + }>; +} + +export default authenticatedPage(async (_context, { params }) => { + // Skills are part of Ask Sourcebot. Gate the EE skill editor behind the + // `ask` entitlement so it never renders on a non-entitled deployment. + if (!(await hasEntitlement('ask'))) { + return ; + } + + const { skillId } = await params; + const skill = await getPersonalAgentSkill(skillId); + if (isServiceError(skill)) { + if (skill.errorCode === ErrorCode.AGENT_SKILL_NOT_FOUND) { + return notFound(); + } + throw new ServiceErrorException(skill); + } + + return ; +}); diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/skills/new/page.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/skills/new/page.tsx new file mode 100644 index 000000000..e99900868 --- /dev/null +++ b/packages/web/src/app/(app)/settings/accountAskAgent/skills/new/page.tsx @@ -0,0 +1,14 @@ +import { PersonalSkillEditorPage } from "@/ee/features/chat/skills/components/personalSkillEditorPage"; +import { hasEntitlement } from "@/lib/entitlements"; +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { AccountAskAgentEntitlementMessage } from "../../accountAskAgentEntitlementMessage"; + +export default authenticatedPage(async () => { + // Skills are part of Ask Sourcebot. Gate the EE skill editor behind the + // `ask` entitlement so it never renders on a non-entitled deployment. + if (!(await hasEntitlement('ask'))) { + return ; + } + + return ; +}); diff --git a/packages/web/src/app/(app)/settings/components/settingsContentFrame.tsx b/packages/web/src/app/(app)/settings/components/settingsContentFrame.tsx new file mode 100644 index 000000000..6375d253b --- /dev/null +++ b/packages/web/src/app/(app)/settings/components/settingsContentFrame.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { usePathname } from "next/navigation"; + +// Routes that opt out of the centered settings column and render edge-to-edge +// (e.g. the full-bleed skill editor). Everything else stays in the constrained, +// centered column shared by the rest of settings. +const FULL_BLEED_ROUTE_PATTERNS = [ + /^\/settings\/accountAskAgent\/skills(\/|$)/, +]; + +export function SettingsContentFrame({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const isFullBleed = FULL_BLEED_ROUTE_PATTERNS.some((pattern) => pattern.test(pathname)); + + if (isFullBleed) { + return
{children}
; + } + + return ( +
+
+
{children}
+
+
+ ); +} diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index 422785ced..a776d32ad 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -87,6 +87,7 @@ export const getSidebarNavGroups = async () => { title: "Ask Sourcebot", href: `/settings/accountAskAgent`, + hrefRegex: `/settings/accountAskAgent(/.*)?$`, icon: "bot" as const, } ] : []), diff --git a/packages/web/src/ee/features/chat/mcp/components/accountAskAgentPage.tsx b/packages/web/src/ee/features/chat/mcp/components/accountAskAgentPage.tsx index e80ea4183..ff049fa12 100644 --- a/packages/web/src/ee/features/chat/mcp/components/accountAskAgentPage.tsx +++ b/packages/web/src/ee/features/chat/mcp/components/accountAskAgentPage.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { CableIcon, ExternalLink, MoreHorizontal, SearchIcon, Settings2Icon, Unplug } from "lucide-react"; +import { BookOpenIcon, CableIcon, ExternalLink, MoreHorizontal, PencilIcon, PlusIcon, SearchIcon, Settings2Icon, Trash2Icon, Unplug } from "lucide-react"; import { getMcpServersWithStatus } from "@/app/api/(client)/client"; import { useToast } from "@/components/hooks/use-toast"; import { @@ -25,6 +25,8 @@ import { ConnectorToolTrigger } from "@/ee/features/chat/mcp/components/connecto import { useConnectMcp } from "@/ee/features/chat/mcp/hooks/useConnectMcp"; import { useMcpToolMetadata } from "@/ee/features/chat/mcp/hooks/useMcpToolMetadata"; import { disconnectMcpServer } from "@/ee/features/chat/mcp/actions"; +import { deletePersonalAgentSkill } from "@/ee/features/chat/skills/actions"; +import { sortAgentSkillListItems, type AgentSkillListItem } from "@/ee/features/chat/skills/types"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/chat/mcp/queryKeys"; import { pluralize } from "@/features/chat/mcp/utils"; import { cn, isServiceError } from "@/lib/utils"; @@ -46,6 +48,87 @@ interface AccountAskAgentPageProps { callbackServer?: string; callbackMessage?: string; canManageConnectors: boolean; + initialPersonalSkills: AgentSkillListItem[]; +} + +const newSkillHref = "/settings/accountAskAgent/skills/new"; +const editSkillHref = (skill: AgentSkillListItem) => `/settings/accountAskAgent/skills/${skill.id}`; + +function PersonalSkillCard({ + skill, + onDelete, +}: { + skill: AgentSkillListItem; + onDelete: (skill: AgentSkillListItem) => void; +}) { + return ( + + +
+ +
+
+
+

{skill.name}

+ + /{skill.slug} + +
+ {skill.description && ( +

+ {skill.description} +

+ )} +
+ + + + + + + + + Edit + + + onDelete(skill)} + > + + Delete + + + +
+
+ ); +} + +function PersonalSkillsEmptyState() { + return ( + + +
+ +
+

+ No skills yet +

+

+ Create a personal slash command for prompts you reuse in Ask Sourcebot. +

+ +
+
+ ); } export function AccountAskAgentEmptyState({ canManageConnectors }: { canManageConnectors: boolean }) { @@ -125,7 +208,7 @@ function AccountConnectedConnectorCard({ } actionButtons={ - + + )} + + + {personalSkills.length === 0 ? ( + + ) : ( +
+
+

+ Personal +

+

+ {personalSkills.length} {pluralize(personalSkills.length, "skill")} +

+
+ {personalSkills.map((skill) => ( + + ))} +
+ )} + + ); + + const skillDialogs = ( + <> + { + if (!open && !isDeletingConfirmedSkill) { + setConfirmDeleteSkill(null); + } + }} + > + + + Delete Skill + + Are you sure you want to delete {confirmDeleteSkill?.name}? This will remove the /{confirmDeleteSkill?.slug} command. + + + + Cancel + { + event.preventDefault(); + if (confirmDeleteSkill) { + void handleDeleteSkill(confirmDeleteSkill); + } + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeletingConfirmedSkill ? "Deleting..." : "Delete"} + + + + + + ); + if (isError) { return
Error loading connectors
; } @@ -299,6 +487,8 @@ export function AccountAskAgentPage({

+ {skillsSection} +

Connectors

@@ -308,6 +498,7 @@ export function AccountAskAgentPage({
+ {skillDialogs} ); } @@ -321,6 +512,8 @@ export function AccountAskAgentPage({

+ + {skillsSection}
@@ -492,6 +685,8 @@ export function AccountAskAgentPage({ + + {skillDialogs}
); } diff --git a/packages/web/src/ee/features/chat/mcp/mcpToolSets.ts b/packages/web/src/ee/features/chat/mcp/mcpToolSets.ts index 193c08ff2..b474e7919 100644 --- a/packages/web/src/ee/features/chat/mcp/mcpToolSets.ts +++ b/packages/web/src/ee/features/chat/mcp/mcpToolSets.ts @@ -8,8 +8,9 @@ import { createHash } from 'crypto'; import { getExternalMcpErrorLogFields } from './externalMcpError'; import { getMcpFaviconUrl } from '@/features/chat/mcp/utils'; import { __unsafePrisma } from '@/prisma'; -import { McpServerToolPermission, Prisma } from '@sourcebot/db'; +import { McpServerToolPermission } from '@sourcebot/db'; import { captureEvent } from '@/lib/posthog'; +import { isUniqueConstraintError } from '@/lib/prismaErrors'; import type { AskMcpAnalyticsSource } from '@/lib/posthogEvents'; import { getRedisClient } from '@/lib/redis'; import { @@ -49,7 +50,7 @@ async function incrementMcpToolCallCounter(serverId: string, toolName: string) { }, }); } catch (error) { - if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') { + if (!isUniqueConstraintError(error)) { throw error; } diff --git a/packages/web/src/ee/features/chat/skills/actions.ts b/packages/web/src/ee/features/chat/skills/actions.ts new file mode 100644 index 000000000..c5c99e82e --- /dev/null +++ b/packages/web/src/ee/features/chat/skills/actions.ts @@ -0,0 +1,188 @@ +'use server'; + +import { checkAskEntitlement } from "@/features/chat/utils.server"; +import { ErrorCode } from "@/lib/errorCodes"; +import { isUniqueConstraintError } from "@/lib/prismaErrors"; +import { requestBodySchemaValidationError, ServiceError } from "@/lib/serviceError"; +import { sew } from "@/middleware/sew"; +import { withAuth } from "@/middleware/withAuth"; +import { getAgentSkillNamespaceKey } from "@sourcebot/db"; +import { StatusCodes } from "http-status-codes"; +import { + agentSkillInputSchema, + agentSkillOrderBy, + toAgentSkillListItem, + updateAgentSkillInputSchema, + type AgentSkillInput, + type AgentSkillListItem, + type UpdateAgentSkillInput, +} from "./types"; + +const personalNamespaceKey = (userId: string) => + getAgentSkillNamespaceKey({ scope: "PERSONAL", userId }); + +const skillAlreadyExists = (slug: string): ServiceError => ({ + statusCode: StatusCodes.CONFLICT, + errorCode: ErrorCode.AGENT_SKILL_ALREADY_EXISTS, + message: `A skill with command /${slug} already exists.`, +}); + +const skillNotFound = (): ServiceError => ({ + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.AGENT_SKILL_NOT_FOUND, + message: "Skill not found.", +}); + +export const listPersonalAgentSkills = async (): Promise => sew(() => + withAuth(async ({ user, prisma }) => { + const askError = await checkAskEntitlement(); + if (askError) { + return askError; + } + + const skills = await prisma.agentSkill.findMany({ + where: { + namespaceKey: personalNamespaceKey(user.id), + }, + orderBy: agentSkillOrderBy, + }); + + return skills.map(toAgentSkillListItem); + })); + +export const getPersonalAgentSkill = async ( + skillId: string, +): Promise => sew(() => + withAuth(async ({ user, prisma }) => { + const askError = await checkAskEntitlement(); + if (askError) { + return askError; + } + + const skill = await prisma.agentSkill.findFirst({ + where: { + id: skillId, + namespaceKey: personalNamespaceKey(user.id), + }, + }); + + if (!skill) { + return skillNotFound(); + } + + return toAgentSkillListItem(skill); + })); + +export const createPersonalAgentSkill = async ( + input: AgentSkillInput, +): Promise => { + const parsed = agentSkillInputSchema.safeParse(input); + if (!parsed.success) { + return requestBodySchemaValidationError(parsed.error); + } + + return sew(() => + withAuth(async ({ user, prisma }) => { + const askError = await checkAskEntitlement(); + if (askError) { + return askError; + } + + const namespaceKey = personalNamespaceKey(user.id); + + try { + const skill = await prisma.agentSkill.create({ + data: { + namespaceKey, + slug: parsed.data.slug, + name: parsed.data.name, + description: parsed.data.description, + instructions: parsed.data.instructions, + createdById: user.id, + orgId: null, + }, + }); + + return toAgentSkillListItem(skill); + } catch (error) { + if (isUniqueConstraintError(error)) { + return skillAlreadyExists(parsed.data.slug); + } + + throw error; + } + })); +}; + +export const updatePersonalAgentSkill = async ( + input: UpdateAgentSkillInput, +): Promise => { + const parsed = updateAgentSkillInputSchema.safeParse(input); + if (!parsed.success) { + return requestBodySchemaValidationError(parsed.error); + } + + return sew(() => + withAuth(async ({ user, prisma }) => { + const askError = await checkAskEntitlement(); + if (askError) { + return askError; + } + + const namespaceKey = personalNamespaceKey(user.id); + const existingSkill = await prisma.agentSkill.findFirst({ + where: { + id: parsed.data.id, + namespaceKey, + }, + select: { id: true }, + }); + + if (!existingSkill) { + return skillNotFound(); + } + + try { + const skill = await prisma.agentSkill.update({ + where: { id: existingSkill.id }, + data: { + slug: parsed.data.slug, + name: parsed.data.name, + description: parsed.data.description, + instructions: parsed.data.instructions, + }, + }); + + return toAgentSkillListItem(skill); + } catch (error) { + if (isUniqueConstraintError(error)) { + return skillAlreadyExists(parsed.data.slug); + } + + throw error; + } + })); +}; + +export const deletePersonalAgentSkill = async ( + skillId: string, +): Promise<{ success: true } | ServiceError> => sew(() => + withAuth(async ({ user, prisma }) => { + const askError = await checkAskEntitlement(); + if (askError) { + return askError; + } + + const result = await prisma.agentSkill.deleteMany({ + where: { + id: skillId, + namespaceKey: personalNamespaceKey(user.id), + }, + }); + + if (result.count === 0) { + return skillNotFound(); + } + + return { success: true }; + })); diff --git a/packages/web/src/ee/features/chat/skills/components/personalSkillEditorPage.tsx b/packages/web/src/ee/features/chat/skills/components/personalSkillEditorPage.tsx new file mode 100644 index 000000000..10d5076d1 --- /dev/null +++ b/packages/web/src/ee/features/chat/skills/components/personalSkillEditorPage.tsx @@ -0,0 +1,449 @@ +'use client'; + +import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { NavigationGuardProvider, useNavigationGuard } from "next-navigation-guard"; +import { ArrowLeftIcon, PanelRightCloseIcon, PanelRightOpenIcon, PencilIcon, PlusIcon, UploadIcon } from "lucide-react"; +import { useToast } from "@/components/hooks/use-toast"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { createPersonalAgentSkill, updatePersonalAgentSkill } from "@/ee/features/chat/skills/actions"; +import { normalizeAgentSkillSlug, parseAgentSkillMarkdown, type AgentSkillInput, type AgentSkillListItem } from "@/ee/features/chat/skills/types"; +import { isServiceError } from "@/lib/utils"; + +const INSTRUCTIONS_MAX_LENGTH = 20000; +const DETAILS_COLLAPSED_STORAGE_KEY = "sb.skillEditor.detailsCollapsed"; + +const emptySkillForm: AgentSkillInput = { + name: "", + slug: "", + description: "", + instructions: "", +}; + +interface PersonalSkillEditorPageProps { + skill: AgentSkillListItem | null; +} + +// The NavigationGuardProvider must be an ancestor of the component that calls +// useNavigationGuard, so it patches the router / intercepts the in-editor links +// (Cancel, Back) used by SkillEditor. +export function PersonalSkillEditorPage(props: PersonalSkillEditorPageProps) { + return ( + + + + ); +} + +function SkillEditor({ skill }: PersonalSkillEditorPageProps) { + const router = useRouter(); + const { toast } = useToast(); + const markdownFileInputRef = useRef(null); + const initialForm: AgentSkillInput = skill + ? { + name: skill.name, + slug: skill.slug, + description: skill.description, + instructions: skill.instructions, + } + : emptySkillForm; + const [form, setForm] = useState(initialForm); + const [isSlugTouched, setIsSlugTouched] = useState(skill !== null); + const [isSaving, setIsSaving] = useState(false); + const [isDetailsCollapsed, setIsDetailsCollapsed] = useState(false); + const isEditing = skill !== null; + + const isDirty = + form.name !== initialForm.name || + form.slug !== initialForm.slug || + form.description !== initialForm.description || + form.instructions !== initialForm.instructions; + + // Set just before a deliberate save-then-navigate so the discard guard does + // not prompt on the post-save redirect (the form is still "dirty" vs. its + // initial values at that point). + const bypassGuardRef = useRef(false); + + // Intercept in-app navigation (the Cancel button, the Back link, and the + // browser back button) while there are unsaved changes, and resolve it + // through a themed dialog. `beforeunload` (refresh / tab close) is left to + // the native handler below since browsers won't render custom dialogs there. + // The settings sidebar lives in a separate parallel-route slot outside this + // provider, so navigating away via a sidebar link is not currently guarded. + const navGuard = useNavigationGuard({ + enabled: ({ type }) => { + if (bypassGuardRef.current) { + return false; + } + if (type === "beforeunload") { + return false; + } + return isDirty; + }, + }); + + // Each time the guard re-activates, reset the one-shot decision latch that + // keeps accept()/reject() from both firing (the action buttons close the + // dialog, which would otherwise also trigger the onOpenChange path). + const guardDecisionMadeRef = useRef(false); + useEffect(() => { + if (navGuard.active) { + guardDecisionMadeRef.current = false; + } + }, [navGuard.active]); + + const resolveNavGuard = (discard: boolean) => { + if (guardDecisionMadeRef.current) { + return; + } + guardDecisionMadeRef.current = true; + if (discard) { + navGuard.accept(); + } else { + navGuard.reject(); + } + }; + + // Warn before a full-page unload (refresh, tab close, external navigation) + // when there are unsaved changes. The themed guard above covers in-app + // navigation; this native prompt is the unavoidable browser fallback. + useEffect(() => { + if (!isDirty) { + return; + } + + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [isDirty]); + + // Restore the panel preference after mount to avoid an SSR hydration mismatch. + // Creating a skill always starts with details expanded so name/command are + // front-and-center; editing restores the user's last preference. + useEffect(() => { + if (isEditing && window.localStorage.getItem(DETAILS_COLLAPSED_STORAGE_KEY) === "true") { + setIsDetailsCollapsed(true); + } + }, [isEditing]); + + const setDetailsCollapsed = (collapsed: boolean) => { + setIsDetailsCollapsed(collapsed); + window.localStorage.setItem(DETAILS_COLLAPSED_STORAGE_KEY, String(collapsed)); + }; + + const handleNameChange = (name: string) => { + setForm((current) => ({ + ...current, + name, + slug: isSlugTouched ? current.slug : normalizeAgentSkillSlug(name), + })); + }; + + const handleMarkdownFileChange = async (event: ChangeEvent) => { + const input = event.currentTarget; + const file = input.files?.[0]; + input.value = ""; + + if (!file) { + return; + } + + if (!/\.(md|markdown)$/i.test(file.name)) { + toast({ title: "Unsupported file", description: "Choose a markdown file ending in .md or .markdown.", variant: "destructive" }); + return; + } + + try { + const text = await file.text(); + const parsed = parseAgentSkillMarkdown(text, file.name); + setForm((current) => ({ + name: parsed.name ?? current.name, + slug: parsed.slug ?? current.slug, + description: parsed.description ?? current.description, + instructions: parsed.instructions, + })); + + if (parsed.slug || parsed.name) { + setIsSlugTouched(true); + } + + toast({ + title: parsed.frontmatterError ? "Front matter not parsed" : undefined, + description: parsed.frontmatterError + ? "The markdown body was imported, but front matter could not be parsed." + : "Markdown skill imported.", + variant: parsed.frontmatterError ? "destructive" : undefined, + }); + } catch { + toast({ title: "Error", description: "Failed to import markdown file.", variant: "destructive" }); + } + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSaving(true); + try { + const result = isEditing + ? await updatePersonalAgentSkill({ id: skill.id, ...form }) + : await createPersonalAgentSkill(form); + + if (isServiceError(result)) { + toast({ title: "Error", description: result.message, variant: "destructive" }); + return; + } + + toast({ description: isEditing ? "Skill updated." : "Skill created." }); + bypassGuardRef.current = true; + router.push("/settings/accountAskAgent"); + } catch { + toast({ title: "Error", description: "Failed to save skill.", variant: "destructive" }); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + router.push("/settings/accountAskAgent"); + }; + + const triggerMarkdownImport = () => { + markdownFileInputRef.current?.click(); + }; + + return ( + <> +
+ + + {/* Top bar */} +
+
+ + + Back to Ask Sourcebot + + + handleNameChange(event.target.value)} + placeholder={isEditing ? "Untitled skill" : "New skill"} + size={Math.max(form.name.length || (isEditing ? "Untitled skill".length : "New skill".length), 4)} + maxLength={80} + aria-label="Skill name" + className="min-w-0 bg-transparent text-sm font-medium text-foreground outline-none placeholder:text-muted-foreground placeholder:font-normal" + /> + {form.slug && ( + /{form.slug} + )} + +
+
+ + +
+
+ + {/* Body: instructions canvas + details panel */} +
+ {/* Instructions canvas */} +
+
+ + + {isDetailsCollapsed + ? "Focus mode — open details to edit name, command & description" + : "Markdown"} + +
+
+