diff --git a/app/(main)/agents/actions.ts b/app/(main)/agents/actions.ts index c395d6ad..cd384b5e 100644 --- a/app/(main)/agents/actions.ts +++ b/app/(main)/agents/actions.ts @@ -1,6 +1,13 @@ "use server"; -import { agents, db } from "@/drizzle"; +import { agents, db, githubIntegrationSettings } from "@/drizzle"; +import { + ExternalServiceName, + VercelBlobOperation, + createLogger, + waitForTelemetryExport, + withCountMeasurement, +} from "@/lib/opentelemetry"; import { fetchCurrentUser } from "@/services/accounts"; import { fetchCurrentTeam } from "@/services/teams"; import { putGraph } from "@giselles-ai/actions"; @@ -13,7 +20,8 @@ import { } from "@giselles-ai/lib/utils"; import type { AgentId, Graph, Node } from "@giselles-ai/types"; import { createId } from "@paralleldrive/cuid2"; -import { copy, list } from "@vercel/blob"; +import { copy, del, list } from "@vercel/blob"; +import { eq } from "drizzle-orm"; interface AgentDuplicationSuccess { result: "success"; @@ -25,6 +33,10 @@ interface AgentDuplicationError { } type AgentDuplicationResult = AgentDuplicationSuccess | AgentDuplicationError; +type DeleteAgentResult = + | { result: "success"; message: string } + | { result: "error"; message: string }; + export async function copyAgent( agentId: AgentId, _formData: FormData, @@ -39,101 +51,269 @@ export async function copyAgent( if (agent === undefined || agent.graphUrl === null) { return { result: "error", message: `${agentId} is not found.` }; } - const [user, team, graph] = await Promise.all([ - fetchCurrentUser(), - fetchCurrentTeam(), - fetch(agent.graphUrl).then((res) => res.json() as unknown as Graph), - ]); + + try { + const startTime = Date.now(); + const logger = createLogger("copyAgent"); + + const [user, team, graph] = await Promise.all([ + fetchCurrentUser(), + fetchCurrentTeam(), + fetch(agent.graphUrl).then((res) => res.json() as unknown as Graph), + ]); + if (agent.teamDbId !== team.dbId) { + return { + result: "error", + message: "You are not allowed to duplicate this agent", + }; + } + + const newNodes = await Promise.all( + graph.nodes.map(async (node) => { + if (node.content.type !== "files") { + return node; + } + const newData = await Promise.all( + node.content.data.map(async (fileData) => { + if (fileData.status !== "completed") { + return null; + } + const newFileId = createFileId(); + const { blobList } = await withCountMeasurement( + logger, + async () => { + const result = await list({ + prefix: buildFileFolderPath(fileData.id), + }); + const size = result.blobs.reduce( + (sum, blob) => sum + blob.size, + 0, + ); + return { + blobList: result, + size, + }; + }, + ExternalServiceName.VercelBlob, + startTime, + VercelBlobOperation.List, + ); + + let newFileBlobUrl = ""; + let newTextDataUrl = ""; + + await Promise.all( + blobList.blobs.map(async (blob) => { + const { url: copyUrl } = await withCountMeasurement( + logger, + async () => { + const copyResult = await copy( + blob.url, + pathJoin( + buildFileFolderPath(newFileId), + pathnameToFilename(blob.pathname), + ), + { + addRandomSuffix: true, + access: "public", + }, + ); + return { + url: copyResult.url, + size: blob.size, + }; + }, + ExternalServiceName.VercelBlob, + startTime, + VercelBlobOperation.Copy, + ); + + if (blob.url === fileData.fileBlobUrl) { + newFileBlobUrl = copyUrl; + } + if (blob.url === fileData.textDataUrl) { + newTextDataUrl = copyUrl; + } + }), + ); + + return { + ...fileData, + id: newFileId, + fileBlobUrl: newFileBlobUrl, + textDataUrl: newTextDataUrl, + }; + }), + ).then((data) => data.filter((d) => d !== null)); + return { + ...node, + content: { + ...node.content, + data: newData, + }, + } as Node; + }), + ); + + const newGraphId = createGraphId(); + const { url } = await putGraph({ + ...graph, + id: newGraphId, + nodes: newNodes, + }); + + const newAgentId = `agnt_${createId()}` as AgentId; + const insertResult = await db + .insert(agents) + .values({ + id: newAgentId, + name: `Copy of ${agent.name ?? agentId}`, + teamDbId: team.dbId, + creatorDbId: user.dbId, + graphUrl: url, + graphv2: { + agentId: newAgentId, + nodes: [], + xyFlow: { + nodes: [], + edges: [], + }, + connectors: [], + artifacts: [], + webSearches: [], + mode: "edit", + flowIndexes: [], + }, + }) + .returning({ id: agents.id }); + + if (insertResult.length === 0) { + return { + result: "error", + message: "Failed to save the duplicated agent", + }; + } + + waitForTelemetryExport(); + const newAgent = insertResult[0]; + return { result: "success", agentId: newAgent.id }; + } catch (error) { + console.error("Failed to copy agent:", error); + return { + result: "error", + message: `Failed to copy agent: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } +} + +export async function deleteAgent( + agentId: string, + formData: FormData, +): Promise { + if (typeof agentId !== "string" || agentId.length === 0) { + return { result: "error", message: "Invalid agent id" }; + } + + const agent = await db.query.agents.findFirst({ + where: (agents, { eq }) => eq(agents.id, agentId as AgentId), + }); + + if (agent === undefined || agent.graphUrl === null) { + return { result: "error", message: `Agent ${agentId} not found` }; + } + + const team = await fetchCurrentTeam(); if (agent.teamDbId !== team.dbId) { return { result: "error", - message: "You are not allowed to duplicate this agent", + message: "You are not allowed to delete this agent", }; } - const newNodes = await Promise.all( - graph.nodes.map(async (node) => { - if (node.content.type !== "files") { - return node; - } - const newData = await Promise.all( - node.content.data.map(async (fileData) => { - if (fileData.status !== "completed") { - return null; + + try { + const startTime = Date.now(); + const logger = createLogger("deleteAgent"); + + // Fetch graph data + const graph = await fetch(agent.graphUrl).then( + (res) => res.json() as unknown as Graph, + ); + + // Collect all blob URLs that need to be deleted + const blobUrlsToDelete = new Set(); + blobUrlsToDelete.add(agent.graphUrl); + let toDeleteBlobSize = 0; + toDeleteBlobSize += new TextEncoder().encode(JSON.stringify(graph)).length; + + // Handle file nodes and their blobs + for (const node of graph.nodes) { + if (node.content.type === "files") { + for (const fileData of node.content.data) { + if (fileData.status === "completed") { + // Get all blobs in the file folder + const { blobList } = await withCountMeasurement( + logger, + async () => { + const result = await list({ + prefix: buildFileFolderPath(fileData.id), + }); + const size = result.blobs.reduce( + (sum, blob) => sum + blob.size, + 0, + ); + return { + blobList: result, + size, + }; + }, + ExternalServiceName.VercelBlob, + startTime, + VercelBlobOperation.List, + ); + for (const blob of blobList.blobs) { + blobUrlsToDelete.add(blob.url); + toDeleteBlobSize += blob.size; + } } - const newFileId = createFileId(); - const blobList = await list({ - prefix: buildFileFolderPath(fileData.id), - }); - let newFileBlobUrl = ""; - let newTextDataUrl = ""; - await Promise.all( - blobList.blobs.map(async (blob) => { - const copyResult = await copy( - blob.url, - pathJoin( - buildFileFolderPath(newFileId), - pathnameToFilename(blob.pathname), - ), - { - addRandomSuffix: true, - access: "public", - }, - ); - if (blob.url === fileData.fileBlobUrl) { - newFileBlobUrl = copyResult.url; - } - if (blob.url === fileData.textDataUrl) { - newTextDataUrl = copyResult.url; - } - }), - ); + } + } + } + + // Delete all collected blobs + if (blobUrlsToDelete.size > 0) { + await withCountMeasurement( + logger, + async () => { + const urls = Array.from(blobUrlsToDelete); + await del(urls); return { - ...fileData, - id: newFileId, - fileBlobUrl: newFileBlobUrl, - textDataUrl: newTextDataUrl, + size: toDeleteBlobSize, }; - }), - ).then((data) => data.filter((d) => d !== null)); - return { - ...node, - content: { - ...node.content, - data: newData, }, - } as Node; - }), - ); - const newGraphId = createGraphId(); - const { url } = await putGraph({ ...graph, id: newGraphId, nodes: newNodes }); - - const newAgentId = `agnt_${createId()}` as AgentId; - const insertResult = await db - .insert(agents) - .values({ - id: newAgentId, - name: `Copy of ${agent.name ?? agentId}`, - teamDbId: team.dbId, - creatorDbId: user.dbId, - graphUrl: url, - graphv2: { - agentId: newAgentId, - nodes: [], - xyFlow: { - nodes: [], - edges: [], - }, - connectors: [], - artifacts: [], - webSearches: [], - mode: "edit", - flowIndexes: [], - }, - }) - .returning({ id: agents.id }); - if (insertResult.length === 0) { - return { result: "error", message: "Failed to save the duplicated agent" }; + ExternalServiceName.VercelBlob, + startTime, + VercelBlobOperation.Del, + ); + waitForTelemetryExport(); + } + + // Delete the agent from database + await db.transaction(async (tx) => { + await tx + .delete(githubIntegrationSettings) + .where(eq(githubIntegrationSettings.agentDbId, agent.dbId)); + await tx.delete(agents).where(eq(agents.id, agentId as AgentId)); + }); + + return { + result: "success", + message: "Agent deleted successfully", + }; + } catch (error) { + console.error("Failed to delete agent:", error); + return { + result: "error", + message: `Failed to delete agent: ${error instanceof Error ? error.message : "Unknown error"}`, + }; } - const newAgent = insertResult[0]; - return { result: "success", agentId: newAgent.id }; } diff --git a/app/(main)/agents/components.tsx b/app/(main)/agents/components.tsx index a93752d8..b5472c6b 100644 --- a/app/(main)/agents/components.tsx +++ b/app/(main)/agents/components.tsx @@ -21,11 +21,11 @@ import { import type { AgentId } from "@/services/agents"; import { Toast } from "@giselles-ai/components/toast"; import { useToast } from "@giselles-ai/contexts/toast"; -import { CopyIcon, LoaderCircleIcon } from "lucide-react"; -import { redirect } from "next/navigation"; +import { CopyIcon, LoaderCircleIcon, TrashIcon } from "lucide-react"; +import { redirect, useRouter } from "next/navigation"; import { useRef, useTransition } from "react"; import { useFormStatus } from "react-dom"; -import { copyAgent } from "./actions"; +import { copyAgent, deleteAgent } from "./actions"; export function CreateAgentButton() { const { pending } = useFormStatus(); @@ -117,7 +117,98 @@ export function DuplicateAgentButton({ Cancel - Copy + + Duplicate + + + + + ); +} + +export function DeleteAgentButton({ + agentId, + agentName, +}: { + agentId: AgentId; + agentName: string | null; +}) { + const action = deleteAgent.bind(null, agentId); + const { addToast } = useToast(); + const [isPending, startTransition] = useTransition(); + const formRef = useRef(null); + const router = useRouter(); + const handleConfirm = () => { + formRef.current?.requestSubmit(); + }; + + const formAction = async (formData: FormData) => { + startTransition(async () => { + const res = await action(formData); + switch (res.result) { + case "success": + router.refresh(); + break; + case "error": + addToast({ message: res.message, type: "error" }); + break; + } + }); + }; + + return ( + +
+ + + + + + + + +

Delete Agent

+
+
+
+
+ + + + + Are you sure you want to delete this agent? + + {agentName && ( + + This action cannot be undone. This will permanently delete the + agent "{agentName}". + + )} + + + + Cancel + + + Delete +
diff --git a/app/(main)/agents/page.tsx b/app/(main)/agents/page.tsx index 84e0053f..1e685b8a 100644 --- a/app/(main)/agents/page.tsx +++ b/app/(main)/agents/page.tsx @@ -5,7 +5,7 @@ import { formatTimestamp } from "@giselles-ai/lib/utils"; import { and, eq, isNotNull } from "drizzle-orm"; import Link from "next/link"; import { type ReactNode, Suspense } from "react"; -import { DuplicateAgentButton, Toasts } from "./components"; +import { DeleteAgentButton, DuplicateAgentButton, Toasts } from "./components"; function DataList({ label, children }: { label: string; children: ReactNode }) { return ( @@ -58,6 +58,7 @@ async function AgentList() { + ))} diff --git a/lib/opentelemetry/wrapper.ts b/lib/opentelemetry/wrapper.ts index 556874fd..b32416ed 100644 --- a/lib/opentelemetry/wrapper.ts +++ b/lib/opentelemetry/wrapper.ts @@ -2,8 +2,7 @@ import { getCurrentMeasurementScope, isRoute06User } from "@/app/(auth)/lib"; import { db } from "@/drizzle"; import type { AgentId } from "@giselles-ai/types"; import { waitUntil } from "@vercel/functions"; -import type { LanguageModelUsage } from "ai"; -import type { LanguageModelV1 } from "ai"; +import type { LanguageModelUsage, LanguageModelV1 } from "ai"; import type { Strategy } from "unstructured-client/sdk/models/shared"; import { captureError } from "./log"; import type { LogSchema, OtelLoggerWrapper } from "./types"; @@ -101,6 +100,12 @@ const APICallBasedService = { } as const; export const VercelBlobOperation = { + Copy: { + type: "copy" as const, + measure: (result: { size: number }) => ({ + blobSizeStored: result.size, + }), + }, Put: { type: "put" as const, measure: (result: { size: number }) => ({