From e1648bc1cdb53e9f03aa5bea5ad2758a2a2dec3c Mon Sep 17 00:00:00 2001 From: Daishan Peng Date: Fri, 22 Nov 2024 14:20:37 -0700 Subject: [PATCH] Feat: Add file tree, add include and exclude prefix for auto ingestion (#639) * Feat: Add knowledge file tree, add include and exclude list Signed-off-by: Daishan Peng --- apiclient/types/knowledgesource.go | 8 +- .../handlers/knowledgefile/knowledgefile.go | 38 +- .../app/components/knowledge/FileTree.tsx | 499 +++++++++++++ .../knowledge/KnowledgeSourceDetail.tsx | 691 +++--------------- ui/admin/app/components/ui/collapsible.tsx | 9 + ui/admin/app/lib/model/knowledge.ts | 22 + ui/admin/package.json | 1 + ui/admin/pnpm-lock.yaml | 3 + 8 files changed, 681 insertions(+), 590 deletions(-) create mode 100644 ui/admin/app/components/knowledge/FileTree.tsx create mode 100644 ui/admin/app/components/ui/collapsible.tsx diff --git a/apiclient/types/knowledgesource.go b/apiclient/types/knowledgesource.go index fe588d5c..e31370fa 100644 --- a/apiclient/types/knowledgesource.go +++ b/apiclient/types/knowledgesource.go @@ -39,9 +39,11 @@ type KnowledgeSource struct { } type KnowledgeSourceManifest struct { - SyncSchedule string `json:"syncSchedule,omitempty"` - AutoApprove *bool `json:"autoApprove,omitempty"` - KnowledgeSourceInput `json:",inline"` + SyncSchedule string `json:"syncSchedule,omitempty"` + AutoApprove *bool `json:"autoApprove,omitempty"` + FilePathPrefixInclude []string `json:"filePathPrefixInclude,omitempty"` + FilePathPrefixExclude []string `json:"filePathPrefixExclude,omitempty"` + KnowledgeSourceInput `json:",inline"` } type KnowledgeSourceList List[KnowledgeSource] diff --git a/pkg/controller/handlers/knowledgefile/knowledgefile.go b/pkg/controller/handlers/knowledgefile/knowledgefile.go index d88c5409..c6d6da84 100644 --- a/pkg/controller/handlers/knowledgefile/knowledgefile.go +++ b/pkg/controller/handlers/knowledgefile/knowledgefile.go @@ -99,6 +99,24 @@ func (h *Handler) IngestFile(req router.Request, _ router.Response) error { } } + // Check approval + if file.Spec.Approved == nil { + switch { + case source.Spec.Manifest.AutoApprove != nil && *source.Spec.Manifest.AutoApprove: + file.Spec.Approved = typed.Pointer(true) + case isFileMatchPrefixPattern(file.Spec.FileName, source.Spec.Manifest.FilePathPrefixExclude): + file.Spec.Approved = typed.Pointer(false) + case isFileMatchPrefixPattern(file.Spec.FileName, source.Spec.Manifest.FilePathPrefixInclude): + file.Spec.Approved = typed.Pointer(true) + } + + if file.Spec.Approved != nil { + if err := req.Client.Update(req.Ctx, file); err != nil { + return err + } + } + } + if file.Status.State.IsTerminal() && !shouldReIngest(file) { return nil } @@ -111,16 +129,6 @@ func (h *Handler) IngestFile(req router.Request, _ router.Response) error { } } - // Check approval - if file.Spec.Approved == nil { - if source.Spec.Manifest.AutoApprove != nil && *source.Spec.Manifest.AutoApprove { - file.Spec.Approved = typed.Pointer(true) - if err := req.Client.Update(req.Ctx, file); err != nil { - return err - } - } - } - if file.Spec.Approved == nil || !*file.Spec.Approved { // Not approved, wait for user action return nil @@ -395,3 +403,13 @@ func (h *Handler) Cleanup(req router.Request, _ router.Response) error { } return nil } + +func isFileMatchPrefixPattern(filePath string, patterns []string) bool { + for _, pattern := range patterns { + if strings.HasPrefix(filePath, pattern) { + return true + } + } + + return false +} diff --git a/ui/admin/app/components/knowledge/FileTree.tsx b/ui/admin/app/components/knowledge/FileTree.tsx new file mode 100644 index 00000000..f7730009 --- /dev/null +++ b/ui/admin/app/components/knowledge/FileTree.tsx @@ -0,0 +1,499 @@ +import { + CheckIcon, + ChevronDown, + ChevronRight, + CircleX, + Eye, + File, + FileClock, + Folder, + FolderOpen, + MinusIcon, + Plus, + RefreshCcw, + ShieldAlert, +} from "lucide-react"; +import { useState } from "react"; + +import { + KnowledgeFile, + KnowledgeFileState, + KnowledgeSource, + getKnowledgeFileDisplayName, +} from "~/lib/model/knowledge"; +import { cn } from "~/lib/utils"; + +import { LoadingSpinner } from "~/components/ui/LoadingSpinner"; +import { Button } from "~/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "~/components/ui/collapsible"; +import { Label } from "~/components/ui/label"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; + +export type FileNode = { + name: string; + path: string; + file: KnowledgeFile | null; + children?: FileNode[]; +}; + +const getAllFiles = (node: FileNode): KnowledgeFile[] => { + if (node.file) return [node.file]; + return [...node.children!.flatMap(getAllFiles)]; +}; + +export default function FileTreeNode({ + node, + level, + source, + onApproveFile, + onReingestFile, + setErrorDialogError, + updateKnowledgeSource, +}: { + node: FileNode; + level: number; + source: KnowledgeSource; + onApproveFile: (approved: boolean, fileNode: FileNode) => Promise; + onReingestFile: (file: KnowledgeFile) => void; + setErrorDialogError: (error: string) => void; + updateKnowledgeSource: (source: KnowledgeSource) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const hasChildren = node.children && node.children.length > 0; + + const allFiles = getAllFiles(node); + const totalFiles = allFiles.length; + const ingestingFiles = allFiles.filter( + (file) => file.state === KnowledgeFileState.Ingesting + ).length; + const ingestedFiles = allFiles.filter( + (file) => file.state === KnowledgeFileState.Ingested + ).length; + const excludedFiles = allFiles.filter( + (file) => file.state === KnowledgeFileState.Unapproved + ).length; + const selectedFiles = allFiles.filter((file) => file.approved).length; + const errorFiles = allFiles.filter( + (file) => file.state === KnowledgeFileState.Error + ).length; + const totalSize = allFiles.reduce( + (acc, file) => acc + (file.sizeInBytes || 0), + 0 + ); + + const isFile = node.file !== null; + const file = node.file!; + + const included = + source.filePathPrefixInclude?.some((prefix) => + node.path.startsWith(prefix) + ) && + !source.filePathPrefixExclude?.some((prefix) => + node.path.startsWith(prefix) + ); + + const excluded = source.filePathPrefixExclude?.some((prefix) => + node.path.startsWith(prefix) + ); + + // We shouldn't allow user to toggle include button if its parent folder has been excluded. This is against the design from backend which is built from whitelist + blacklist where whitelist is preferred. + // So if a folder is excluded, all its children should be excluded by default and the only way to include it is to remove the parent folder from the blacklist. + const disableToggleButton = + excluded && !source.filePathPrefixExclude?.includes(node.path); + + const toggleIncludeExcludeList = async () => { + // We should manually approve/unapprove all files in the folder at once so that we don't rely on backend reconciliation logic as it will cause delay in updating the UI. + try { + await onApproveFile(!included, node); + } catch (e) { + console.error("failed to approve files", e); + } + + // After files are approved/unapproved, we need to update the include/exclude list so that new files will be included/excluded from future syncs. + let filePathPrefixInclude = source.filePathPrefixInclude; + let filePathPrefixExclude = source.filePathPrefixExclude; + if (included) { + filePathPrefixInclude = source.filePathPrefixInclude?.filter( + (path) => !path.startsWith(node.path) + ); + filePathPrefixExclude = source.filePathPrefixExclude?.includes( + node.path + ) + ? source.filePathPrefixExclude + : [...(source.filePathPrefixExclude ?? []), node.path]; + } else { + filePathPrefixInclude = source.filePathPrefixInclude?.includes( + node.path + ) + ? source.filePathPrefixInclude + : [...(source.filePathPrefixInclude ?? []), node.path]; + filePathPrefixExclude = source.filePathPrefixExclude?.filter( + (path) => !path.startsWith(node.path) + ); + } + + updateKnowledgeSource({ + ...source, + filePathPrefixInclude, + filePathPrefixExclude, + }); + }; + + return ( +
0 && "ml-4")}> + + +
+
+
+ {hasChildren ? ( + isOpen ? ( + <> + + + + ) : ( + <> + + + + ) + ) : ( + + )} + {isFile ? ( + + + + + { + getKnowledgeFileDisplayName( + file, + source + ).displayName + } + + + + + { + getKnowledgeFileDisplayName( + file, + source + ).displayName + } + + + ) : ( + + + + {node.name} + + + + {node.name} + + + )} + {isFile ? ( +
+ {file.state === + KnowledgeFileState.Ingesting ? ( + + ) : file.state === + KnowledgeFileState.Ingested ? ( + + ) : file.state === + KnowledgeFileState.Pending ? ( + + ) : file.state === + KnowledgeFileState.Error ? ( + + ) : file.state === + KnowledgeFileState.PendingApproval || + file.state === + KnowledgeFileState.Unapproved ? null : file.state === + KnowledgeFileState.Unsupported ? ( + + ) : null} +
+ ) : ( +
+ {included ? ( + + Included + + ) : excluded ? ( + + Excluded + + ) : null} +
+ )} +
+ {!disableToggleButton && ( +
+ +
+ )} +
+ {node.file ? ( +
+
+ {node.file.state === + KnowledgeFileState.PendingApproval ? null : node + .file.state === + KnowledgeFileState.Unapproved ? ( + + Excluded + + ) : node.file.state === + KnowledgeFileState.Ingesting ? ( +
+ +
+ ) : node.file.state === + KnowledgeFileState.Pending ? ( +
+ +
+ ) : node.file.state === + KnowledgeFileState.Error ? ( +
+ + + + + + + + Reingest + + + + + + + + View Error + + +
+ ) : node.file.state === + KnowledgeFileState.Ingested ? ( +
+ +
+ ) : node.file.state === + KnowledgeFileState.Unsupported ? ( +
+ + + + + + {node.file.error} + + +
+ ) : null} +
+ + {node.file.sizeInBytes + ? node.file.sizeInBytes > 1000000 + ? ( + node.file.sizeInBytes / + 1000000 + ).toFixed(2) + " MB" + : node.file.sizeInBytes > 1000 + ? ( + node.file.sizeInBytes / 1000 + ).toFixed(2) + " KB" + : node.file.sizeInBytes + " Bytes" + : "0 Bytes"} + +
+ ) : ( +
+
+ + {ingestingFiles > 0 && ( + <> + {`${ingestingFiles}`} + {` Ingesting, `} + + )} + {ingestedFiles > 0 && ( + <> + {`${ingestedFiles}`} + {` Ingested, `} + + )} + {errorFiles > 0 && ( + <> + {`${errorFiles}`} + {` Err, `} + + )} + {selectedFiles > 0 && ( + {`${selectedFiles} Inc, `} + )} + {excludedFiles > 0 && ( + {`${excludedFiles} Exc, `} + )} + {`${totalFiles} Total`} + +
+
+ {totalSize > 1000000 + ? (totalSize / 1000000).toFixed(2) + + " MB" + : totalSize > 1000 + ? (totalSize / 1000).toFixed(2) + + " KB" + : totalSize + " Bytes"} +
+
+ )} +
+
+ {hasChildren && ( + + {node + .children!.sort((a, b) => { + if (a.file === null && b.file !== null) + return -1; + if (a.file !== null && b.file === null) + return 1; + return a.path.localeCompare(b.path); + }) + .map((child, index) => ( + + ))} + + )} +
+
+ ); +} diff --git a/ui/admin/app/components/knowledge/KnowledgeSourceDetail.tsx b/ui/admin/app/components/knowledge/KnowledgeSourceDetail.tsx index 029f9abb..2e29ab91 100644 --- a/ui/admin/app/components/knowledge/KnowledgeSourceDetail.tsx +++ b/ui/admin/app/components/knowledge/KnowledgeSourceDetail.tsx @@ -1,20 +1,6 @@ import cronstrue from "cronstrue"; -import { - ArrowDownUp, - ArrowUpDown, - CheckIcon, - CircleX, - EditIcon, - Eye, - FileClock, - InfoIcon, - MinusIcon, - Plus, - RefreshCcw, - ShieldAlert, - Trash, -} from "lucide-react"; -import { FC, useEffect, useMemo, useRef, useState } from "react"; +import { EditIcon, Eye, InfoIcon, Trash } from "lucide-react"; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import useSWR, { SWRResponse } from "swr"; import { @@ -23,29 +9,18 @@ import { KnowledgeSource, KnowledgeSourceStatus, KnowledgeSourceType, - getKnowledgeFileDisplayName, + getKnowledgeFilePathNameForFileTree, getKnowledgeSourceDisplayName, getKnowledgeSourceType, } from "~/lib/model/knowledge"; import { KnowledgeService } from "~/lib/service/api/knowledgeService"; -import { TypographyP } from "~/components/Typography"; import CronDialog from "~/components/knowledge/CronDialog"; import ErrorDialog from "~/components/knowledge/ErrorDialog"; +import FileTreeNode, { FileNode } from "~/components/knowledge/FileTree"; import KnowledgeSourceAvatar from "~/components/knowledge/KnowledgeSourceAvatar"; import OauthSignDialog from "~/components/knowledge/OAuthSignDialog"; import { LoadingSpinner } from "~/components/ui/LoadingSpinner"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "~/components/ui/alert-dialog"; import { Button } from "~/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "~/components/ui/dialog"; import { @@ -56,14 +31,6 @@ import { DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; import { Label } from "~/components/ui/label"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "~/components/ui/table"; import { Tooltip, TooltipContent, @@ -93,15 +60,10 @@ const KnowledgeSourceDetail: FC = ({ const [syncSchedule, setSyncSchedule] = useState( knowledgeSource.syncSchedule ); - const [autoApprove, setAutoApprove] = useState(knowledgeSource.autoApprove); const [isCronDialogOpen, setIsCronDialogOpen] = useState(false); const [cronDescription, setCronDescription] = useState(""); const [errorDialogError, setErrorDialogError] = useState(""); - const [sortingOrder, setSortingOrder] = useState<"asc" | "desc">("asc"); - const [sortingColumn, setSortingColumn] = useState<"fileName" | "state">( - "fileName" - ); const sourceType = getKnowledgeSourceType(knowledgeSource); const tableContainerRef = useRef(null); @@ -109,7 +71,6 @@ const KnowledgeSourceDetail: FC = ({ useEffect(() => { setSyncSchedule(knowledgeSource.syncSchedule); - setAutoApprove(knowledgeSource.autoApprove); }, [knowledgeSource]); useEffect(() => { @@ -140,50 +101,13 @@ const KnowledgeSourceDetail: FC = ({ } ); - const files = useMemo(() => { - if (!getFiles.data) return []; - if (!sortingOrder) return getFiles.data; - - return [...getFiles.data].sort((a, b) => { - const stateOrder = { - [KnowledgeFileState.Ingesting]: 1, - [KnowledgeFileState.Ingested]: 2, - [KnowledgeFileState.Pending]: 3, - [KnowledgeFileState.Error]: 4, - [KnowledgeFileState.Unsupported]: 5, - [KnowledgeFileState.Unapproved]: 6, - [KnowledgeFileState.PendingApproval]: 7, - }; - const { displayName: aDisplayName } = getKnowledgeFileDisplayName( - a, - knowledgeSource - ); - const { displayName: bDisplayName } = getKnowledgeFileDisplayName( - b, - knowledgeSource - ); - - if (sortingColumn === "state") { - const stateA = stateOrder[a.state]; - const stateB = stateOrder[b.state]; - - if (stateA !== stateB) { - return sortingOrder === "asc" - ? stateA - stateB - : stateB - stateA; - } - return sortingOrder === "asc" - ? (aDisplayName?.localeCompare(bDisplayName ?? "") ?? 0) - : ((bDisplayName ?? "").localeCompare(aDisplayName ?? "") ?? - 0); - } else { - return sortingOrder === "asc" - ? (aDisplayName?.localeCompare(bDisplayName ?? "") ?? 0) - : ((bDisplayName ?? "").localeCompare(aDisplayName ?? "") ?? - 0); - } - }); - }, [getFiles.data, sortingOrder, sortingColumn, knowledgeSource]); + const files = useMemo( + () => + getFiles.data?.sort((a, b) => + a.fileName.localeCompare(b.fileName) + ) ?? [], + [getFiles.data] + ); useEffect(() => { if (files.length === 0) { @@ -234,17 +158,13 @@ const KnowledgeSourceDetail: FC = ({ } }, [knowledgeSource, getFiles]); - const onSourceUpdate = async ( - syncSchedule: string, - autoApprove: boolean - ) => { + const onSourceUpdate = async (syncSchedule: string) => { const updatedSource = await KnowledgeService.updateKnowledgeSource( agentId, knowledgeSource.id, { ...knowledgeSource, syncSchedule: syncSchedule, - autoApprove: autoApprove, } ); onSave(updatedSource); @@ -261,9 +181,19 @@ const KnowledgeSourceDetail: FC = ({ ); }; - const onApproveAllFiles = async (approved: boolean) => { - for (const file of files) { - await onApproveFile(file, approved); + const onApproveFileNode = async (approved: boolean, fileNode: FileNode) => { + if (fileNode.file) { + try { + await onApproveFile(fileNode.file, approved); + } catch (e) { + console.error("failed to approve file", fileNode.file, e); + } + return; + } + if (fileNode.children) { + for (const child of fileNode.children) { + await onApproveFileNode(approved, child); + } } }; @@ -278,38 +208,71 @@ const KnowledgeSourceDetail: FC = ({ ); }; - const renderFileElement = ( - file: KnowledgeFile, - source: KnowledgeSource - ) => { - const { displayName, subTitle } = getKnowledgeFileDisplayName( - file, - source - ); + const constructFileTree = useCallback( + (files: KnowledgeFile[]): FileNode[] => { + const roots: FileNode[] = []; + + function addPathToTree( + parts: string[], + file: KnowledgeFile, + currentNode: FileNode + ) { + if (parts.length === 0) return; + + const currentPart = parts[0]; + const isFile = parts.length === 1; + let childNode = currentNode.children?.find( + (child) => child.name === currentPart + ); + + if (!childNode) { + childNode = { + name: currentPart, + file: isFile ? file : null, + children: isFile ? [] : [], + path: currentNode.path + currentPart + "/", + }; + currentNode.children?.push(childNode); + } - return ( - - ); + addPathToTree(parts.slice(1), file, childNode); + } - return null; - }; + for (const file of files) { + const pathName = getKnowledgeFilePathNameForFileTree( + file, + knowledgeSource + ); + const pathParts = pathName.split("/"); + let root = roots.find((r) => r.name === pathParts[0]); + if (!root) { + root = { + name: pathParts[0], + file: null, + children: [], + path: pathParts[0] + "/", + }; + if (pathParts.length === 1) { + root.file = file; + root.path = pathParts[0]; + } + roots.push(root); + } + addPathToTree(pathParts.slice(1), file, root); + } + + return roots.sort((a, b) => { + if (a.file === null && b.file !== null) return -1; + if (a.file !== null && b.file === null) return 1; + return a.path.localeCompare(b.path); + }); + }, + [knowledgeSource] + ); + + const fileNodes = useMemo(() => { + return constructFileTree(files); + }, [files, constructFileTree]); return ( @@ -455,10 +418,7 @@ const KnowledgeSourceDetail: FC = ({ className="cursor-pointer" onClick={() => { setSyncSchedule(""); - onSourceUpdate( - "", - autoApprove ?? false - ); + onSourceUpdate(""); }} > On-Demand @@ -470,10 +430,7 @@ const KnowledgeSourceDetail: FC = ({ className="cursor-pointer" onClick={() => { setSyncSchedule("0 * * * *"); - onSourceUpdate( - "0 * * * *", - autoApprove ?? false - ); + onSourceUpdate("0 * * * *"); }} > Hourly @@ -488,10 +445,7 @@ const KnowledgeSourceDetail: FC = ({ className="cursor-pointer" onClick={() => { setSyncSchedule("0 0 * * *"); - onSourceUpdate( - "0 0 * * *", - autoApprove ?? false - ); + onSourceUpdate("0 0 * * *"); }} > Daily @@ -506,10 +460,7 @@ const KnowledgeSourceDetail: FC = ({ className="cursor-pointer" onClick={() => { setSyncSchedule("0 0 * * 0"); - onSourceUpdate( - "0 0 * * 0", - autoApprove ?? false - ); + onSourceUpdate("0 0 * * 0"); }} > Weekly @@ -541,55 +492,6 @@ const KnowledgeSourceDetail: FC = ({ -
- - - -
- - -
-
- - { - setAutoApprove(false); - onSourceUpdate( - syncSchedule ?? "", - false - ); - }} - > - Manual - - - { - setAutoApprove(true); - onSourceUpdate( - syncSchedule ?? "", - true - ); - }} - > - Automatic - - - -
-
{knowledgeSource.state === @@ -652,389 +554,27 @@ const KnowledgeSourceDetail: FC = ({ )}
- -
- - - - -
- - - - - - - - Include All Files - - - This will immediately - ingest all files in the - knowledge base - - - - - Cancel - - { - onApproveAllFiles( - true - ); - }} - > - Continue - - - - -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
-
-
- - - {files.map((file) => ( - - -
- -
-
- -
- {renderFileElement( - file, - knowledgeSource - )} -
-
- -
- {file.state === - KnowledgeFileState.PendingApproval || - file.state === - KnowledgeFileState.Unapproved ? ( -
- -
- ) : file.state === - KnowledgeFileState.Ingesting ? ( -
- -
- ) : file.state === - KnowledgeFileState.Pending ? ( -
- -
- ) : file.state === - KnowledgeFileState.Error ? ( -
- <> - - - - - - - - Reingest - - - - - - - - View Error - - - -
- ) : file.state === - KnowledgeFileState.Ingested ? ( -
- -
- ) : file.state === - KnowledgeFileState.Unsupported ? ( -
- - - - - - {file.error} - - -
- ) : null} -
-
- -
- {file.lastIngestionEndTime && - file.lastIngestionStartTime - ? (new Date( - file.lastIngestionEndTime - ).getTime() - - new Date( - file.lastIngestionStartTime - ).getTime()) / - 1000 + - " seconds" - : ""} -
-
- -
- {file.sizeInBytes - ? file.sizeInBytes > 1000000 - ? ( - file.sizeInBytes / - 1000000 - ).toFixed(2) + " MB" - : file.sizeInBytes > 1000 - ? ( - file.sizeInBytes / - 1000 - ).toFixed(2) + " KB" - : file.sizeInBytes + - " Bytes" - : ""} -
-
-
- ))} -
-
+
+ {fileNodes.map((node) => ( + { + const res = + await KnowledgeService.updateKnowledgeSource( + agentId, + knowledgeSource.id, + source + ); + onSave(res); + }} + /> + ))}
= ({ cronExpression={syncSchedule || ""} setCronExpression={setSyncSchedule} onSubmit={() => { - onSourceUpdate( - syncSchedule ?? "", - autoApprove ?? false - ); + onSourceUpdate(syncSchedule ?? ""); }} /> 2) { + parts.splice(-2, 1); + } else if (parts.length === 2) { + return parts[1]; + } + return parts.join("/"); + } + + return file.fileName.replace(/^\//, ""); +} diff --git a/ui/admin/package.json b/ui/admin/package.json index 685f09cc..5ad3c1bf 100644 --- a/ui/admin/package.json +++ b/ui/admin/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", diff --git a/ui/admin/pnpm-lock.yaml b/ui/admin/pnpm-lock.yaml index 05fe5201..68e5723d 100644 --- a/ui/admin/pnpm-lock.yaml +++ b/ui/admin/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collapsible': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)